Estructura inicial del frontend
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Credenciales públicas de Supabase para el login (frontend)
|
||||||
|
# La URL es la misma de tu proyecto.
|
||||||
|
# La ANON KEY es la PÚBLICA (anon/public), NO la service_role.
|
||||||
|
VITE_SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co
|
||||||
|
VITE_SUPABASE_ANON_KEY=pega_aqui_tu_anon_public_key
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
29
README.md
Normal file
29
README.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Frontend — Dashboard React
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
- Node.js 18+ (descarga: https://nodejs.org)
|
||||||
|
|
||||||
|
## Instalación (una sola vez)
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ejecutar
|
||||||
|
```
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
Abre: http://localhost:3000
|
||||||
|
|
||||||
|
⚠️ El backend FastAPI debe estar corriendo en http://localhost:8000
|
||||||
|
(en otra terminal: `py -3.12 main.py`)
|
||||||
|
|
||||||
|
## Estado
|
||||||
|
- ✅ Sidebar + navegación
|
||||||
|
- ✅ Ocupabilidad (conectado al backend)
|
||||||
|
- 🚧 Ventas, Cobranza, Rentabilidad, Saldo Pendiente, Asesores (próximas sesiones)
|
||||||
|
|
||||||
|
## Cambiar IP del backend
|
||||||
|
Si el backend corre en otra máquina, edita `src/lib/api.js` línea 3:
|
||||||
|
```js
|
||||||
|
const BASE_URL = "http://IP_DEL_SERVIDOR:8000";
|
||||||
|
```
|
||||||
5
frontend/.env.example
Normal file
5
frontend/.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Credenciales públicas de Supabase para el login (frontend)
|
||||||
|
# La URL es la misma de tu proyecto.
|
||||||
|
# La ANON KEY es la PÚBLICA (anon/public), NO la service_role.
|
||||||
|
VITE_SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co
|
||||||
|
VITE_SUPABASE_ANON_KEY=pega_aqui_tu_anon_public_key
|
||||||
4
frontend/.gitignore
vendored
Normal file
4
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
29
frontend/README.md
Normal file
29
frontend/README.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Frontend — Dashboard React
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
- Node.js 18+ (descarga: https://nodejs.org)
|
||||||
|
|
||||||
|
## Instalación (una sola vez)
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ejecutar
|
||||||
|
```
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
Abre: http://localhost:3000
|
||||||
|
|
||||||
|
⚠️ El backend FastAPI debe estar corriendo en http://localhost:8000
|
||||||
|
(en otra terminal: `py -3.12 main.py`)
|
||||||
|
|
||||||
|
## Estado
|
||||||
|
- ✅ Sidebar + navegación
|
||||||
|
- ✅ Ocupabilidad (conectado al backend)
|
||||||
|
- 🚧 Ventas, Cobranza, Rentabilidad, Saldo Pendiente, Asesores (próximas sesiones)
|
||||||
|
|
||||||
|
## Cambiar IP del backend
|
||||||
|
Si el backend corre en otra máquina, edita `src/lib/api.js` línea 3:
|
||||||
|
```js
|
||||||
|
const BASE_URL = "http://IP_DEL_SERVIDOR:8000";
|
||||||
|
```
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Dashboard — Escuela de Refrigeración</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1980
frontend/package-lock.json
generated
Normal file
1980
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "dashboard-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.107.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
|
"xlsx-js-style": "^1.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
65
frontend/src/App.jsx
Normal file
65
frontend/src/App.jsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// src/App.jsx
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useAuth } from "./lib/auth";
|
||||||
|
import Sidebar from "./components/Sidebar";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
import Ocupabilidad from "./pages/Ocupabilidad";
|
||||||
|
import Ventas from "./pages/Ventas";
|
||||||
|
import Comisiones from "./pages/Comisiones";
|
||||||
|
import Cobranza from "./pages/Cobranza";
|
||||||
|
import Rentabilidad from "./pages/Rentabilidad";
|
||||||
|
import Asesores from "./pages/Asesores";
|
||||||
|
import Usuarios from "./pages/Usuarios";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { autenticado, cargando, permisos, puedeVer } = useAuth();
|
||||||
|
const [pagina, setPagina] = useState(null);
|
||||||
|
|
||||||
|
// Al entrar, seleccionar el primer módulo permitido
|
||||||
|
useEffect(() => {
|
||||||
|
if (autenticado && permisos.length > 0 && !pagina) {
|
||||||
|
setPagina(permisos[0]);
|
||||||
|
}
|
||||||
|
if (!autenticado) setPagina(null);
|
||||||
|
}, [autenticado, permisos, pagina]);
|
||||||
|
|
||||||
|
if (cargando) {
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight:"100vh", display:"flex", alignItems:"center", justifyContent:"center", background:"#0f172a", color:"#fff" }}>
|
||||||
|
Cargando...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!autenticado) return <Login />;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
// Seguridad: si la página actual no está permitida, no renderizar
|
||||||
|
if (pagina && !puedeVer(pagina)) return <SinAcceso />;
|
||||||
|
switch (pagina) {
|
||||||
|
case "ocupabilidad": return <Ocupabilidad />;
|
||||||
|
case "ventas": return <Ventas />;
|
||||||
|
case "comisiones": return <Comisiones />;
|
||||||
|
case "cobranza": return <Cobranza />;
|
||||||
|
case "rentabilidad": return <Rentabilidad />;
|
||||||
|
case "asesores": return <Asesores />;
|
||||||
|
case "usuarios": return <Usuarios />;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<Sidebar active={pagina} onChange={setPagina} />
|
||||||
|
<main className="main">{render()}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SinAcceso() {
|
||||||
|
return (
|
||||||
|
<div style={{ padding:40, textAlign:"center", color:"#94a3b8" }}>
|
||||||
|
🔒 No tienes acceso a este módulo.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
frontend/src/components/Modal.jsx
Normal file
34
frontend/src/components/Modal.jsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// src/components/Modal.jsx
|
||||||
|
export default function Modal({ title, onClose, children, width = 900 }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, background: "rgba(15,23,42,0.55)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
zIndex: 1000, padding: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: "#fff", borderRadius: 16, width: "100%", maxWidth: width,
|
||||||
|
maxHeight: "90vh", overflow: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||||
|
padding: "16px 22px", borderBottom: "1px solid #e2e8f0", position: "sticky",
|
||||||
|
top: 0, background: "#fff", zIndex: 2,
|
||||||
|
}}>
|
||||||
|
<h3 style={{ fontSize: 16, fontWeight: 700, color: "#1e40af" }}>{title}</h3>
|
||||||
|
<button onClick={onClose} style={{
|
||||||
|
border: "none", background: "#f1f5f9", borderRadius: 8, width: 32, height: 32,
|
||||||
|
cursor: "pointer", fontSize: 18, color: "#475569",
|
||||||
|
}}>×</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 22 }}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
frontend/src/components/Sidebar.jsx
Normal file
54
frontend/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// src/components/Sidebar.jsx
|
||||||
|
import { useAuth } from "../lib/auth";
|
||||||
|
|
||||||
|
const MENU = [
|
||||||
|
{ id: "ocupabilidad", ico: "📊", label: "Ocupabilidad" },
|
||||||
|
{ id: "ventas", ico: "💰", label: "Ventas" },
|
||||||
|
{ id: "comisiones", ico: "🧮", label: "Comisiones" },
|
||||||
|
{ id: "cobranza", ico: "📋", label: "Cobranza" },
|
||||||
|
{ id: "rentabilidad", ico: "📈", label: "Rentabilidad" },
|
||||||
|
{ id: "asesores", ico: "👥", label: "Asesores" },
|
||||||
|
{ id: "usuarios", ico: "🔐", label: "Usuarios" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Sidebar({ active, onChange }) {
|
||||||
|
const { perfil, permisos, logout } = useAuth();
|
||||||
|
const visibles = MENU.filter((m) => permisos.includes(m.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-logo">Escuela <span>Refrigeración</span></div>
|
||||||
|
|
||||||
|
<nav className="nav" style={{ flex: 1 }}>
|
||||||
|
{visibles.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
className={`nav-item ${active === m.id ? "active" : ""}`}
|
||||||
|
onClick={() => onChange(m.id)}
|
||||||
|
>
|
||||||
|
<span className="ico">{m.ico}</span> {m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div style={{ padding: 14, borderTop: "1px solid rgba(255,255,255,0.08)" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#94a3b8", marginBottom: 2 }}>
|
||||||
|
{perfil?.nombre || "Usuario"}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#60a5fa", fontWeight: 600, marginBottom: 10 }}>
|
||||||
|
{perfil?.rol || ""}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "8px", border: "1px solid rgba(255,255,255,0.15)",
|
||||||
|
borderRadius: 8, background: "transparent", color: "#cbd5e1", fontSize: 12,
|
||||||
|
cursor: "pointer", fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🚪 Cerrar sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/components/UI.jsx
Normal file
51
frontend/src/components/UI.jsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// src/components/UI.jsx
|
||||||
|
export function Loader({ text = "Cargando..." }) {
|
||||||
|
return (
|
||||||
|
<div className="loader-wrap">
|
||||||
|
<div className="spinner" />
|
||||||
|
<div className="loader-text">{text}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBox({ msg }) {
|
||||||
|
return <div className="error-box">⚠️ {msg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function colorSemaforo(pct) {
|
||||||
|
if (pct >= 80) return { bg: "#d1fae5", fg: "#065f46", fill: "#10b981" };
|
||||||
|
if (pct >= 60) return { bg: "#fef9c3", fg: "#854d0e", fill: "#f59e0b" };
|
||||||
|
return { bg: "#fee2e2", fg: "#991b1b", fill: "#ef4444" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBar({ pct }) {
|
||||||
|
const c = colorSemaforo(pct);
|
||||||
|
const w = Math.min(Math.max(pct, 0), 100);
|
||||||
|
return (
|
||||||
|
<div className="pbar">
|
||||||
|
<span className="pct" style={{ color: c.fg }}>{pct.toFixed(1)}%</span>
|
||||||
|
<div className="track">
|
||||||
|
<div className="fill" style={{ width: `${w}%`, background: c.fill }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Filters({ children }) {
|
||||||
|
return <div className="filters">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({ label, value, options, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="filter-group">
|
||||||
|
{label && <label>{label}</label>}
|
||||||
|
<select value={value} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
{options.map((o) => {
|
||||||
|
const val = typeof o === "object" ? o.value : o;
|
||||||
|
const txt = typeof o === "object" ? o.label : o;
|
||||||
|
return <option key={val} value={val}>{txt}</option>;
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
frontend/src/lib/api.js
Normal file
73
frontend/src/lib/api.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// src/lib/api.js
|
||||||
|
// Cliente para el backend FastAPI. Cambia BASE_URL si el backend corre en otra IP.
|
||||||
|
const BASE_URL = "http://localhost:8000";
|
||||||
|
|
||||||
|
async function get(path, params = {}) {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`;
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error(`Error ${r.status} en ${path}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson(path, body) {
|
||||||
|
const r = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`Error ${r.status} en ${path}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(path, params = {}) {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`;
|
||||||
|
const r = await fetch(url, { method: "POST" });
|
||||||
|
if (!r.ok) throw new Error(`Error ${r.status} en ${path}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
periodoActual: () => get("/api/periodo-actual"),
|
||||||
|
ocupabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") =>
|
||||||
|
get("/api/ocupabilidad", { ano, mes, sede, programa }),
|
||||||
|
ventas: (ano, mes, sede = "TODOS", programa = "TODOS") => get("/api/ventas", { ano, mes, sede, programa }),
|
||||||
|
ventasDetalle: (vendedor, ano, mes, tipo, sede = "TODOS", programa = "TODOS") =>
|
||||||
|
get("/api/ventas/detalle", { vendedor, ano, mes, tipo, sede, programa }),
|
||||||
|
cobranza: (ano, mes, sectorista = "TODOS", agrupacion = "SEDE") =>
|
||||||
|
get("/api/cobranza", { ano, mes, sectorista, agrupacion }),
|
||||||
|
cobranzaDetalle: (grupo, ano, mes, sectorista = "TODOS", agrupacion = "PROGRAMA") =>
|
||||||
|
get("/api/cobranza/detalle", { grupo, ano, mes, sectorista, agrupacion }),
|
||||||
|
rentabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") =>
|
||||||
|
get("/api/rentabilidad", { ano, mes, sede, programa }),
|
||||||
|
rentabilidadDetalle: (programa, ano, mes) =>
|
||||||
|
get("/api/rentabilidad/detalle", { programa, ano, mes }),
|
||||||
|
rentabilidadCostos: (programa, ano, mes) =>
|
||||||
|
get("/api/rentabilidad/costos", { programa, ano, mes }),
|
||||||
|
saldoPendiente: (tipo_cuota) => get("/api/saldo-pendiente", { tipo_cuota }),
|
||||||
|
asesores: () => get("/api/asesores"),
|
||||||
|
asesorEstado: (agent_id, online) =>
|
||||||
|
post("/api/asesores/estado", { agent_id, online }),
|
||||||
|
guardarCostos: (num_indice, inicial, actual) =>
|
||||||
|
postJson("/api/rentabilidad/costos/guardar", { num_indice, inicial, actual }),
|
||||||
|
clasificarSede: (programas) =>
|
||||||
|
postJson("/api/cobranza/clasificar-sede", { programas }),
|
||||||
|
cobranzaDetalleTodos: (ano, mes, sectorista = "TODOS") =>
|
||||||
|
get("/api/cobranza/detalle-todos", { ano, mes, sectorista }),
|
||||||
|
usuariosListar: () => get("/api/usuarios"),
|
||||||
|
usuariosCrear: (email, password, nombre, rol) =>
|
||||||
|
postJson("/api/usuarios/crear", { email, password, nombre, rol }),
|
||||||
|
usuariosActualizarRol: (id, rol) => postJson("/api/usuarios/rol", { id, rol }),
|
||||||
|
usuariosEliminar: (id) => postJson("/api/usuarios/eliminar", { id }),
|
||||||
|
vendedoresManualesListar: (ano, mes) => get("/api/comisiones/vendedores", { ano, mes }),
|
||||||
|
vendedoresManualesCrear: (datos) => postJson("/api/comisiones/vendedores/crear", datos),
|
||||||
|
vendedoresManualesEliminar: (id) => postJson("/api/comisiones/vendedores/eliminar", { id }),
|
||||||
|
vendedoresManualesActualizar: (datos) => postJson("/api/comisiones/vendedores/actualizar", datos),
|
||||||
|
vendedoresManualesLote: (datos) => postJson("/api/comisiones/vendedores/lote", datos),
|
||||||
|
comisionesOverrideGuardar: (registros) => postJson("/api/comisiones/override/guardar", { registros }),
|
||||||
|
comisionesOverrideRestaurar: (num_matricula) => postJson("/api/comisiones/override/restaurar", { num_matricula }),
|
||||||
|
comisionesDetalleTodos: (ano, mes) => get("/api/comisiones/detalle-todos", { ano, mes }),
|
||||||
|
comisionesConfig: (ano, mes) => get("/api/comisiones/config", { ano, mes }),
|
||||||
|
comisionesConfigGuardar: (datos) => postJson("/api/comisiones/config/guardar", datos),
|
||||||
|
};
|
||||||
106
frontend/src/lib/auth.jsx
Normal file
106
frontend/src/lib/auth.jsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// src/lib/auth.jsx
|
||||||
|
import { createContext, useContext, useEffect, useState, useCallback } from "react";
|
||||||
|
import { supabase } from "./supabase";
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [session, setSession] = useState(null);
|
||||||
|
const [perfil, setPerfil] = useState(null); // { nombre, rol }
|
||||||
|
const [permisos, setPermisos] = useState([]); // ["ocupabilidad", "cobranza", ...]
|
||||||
|
const [cargando, setCargando] = useState(true);
|
||||||
|
|
||||||
|
// Cargar perfil (rol) y permisos del usuario logueado
|
||||||
|
const cargarPerfil = useCallback(async (userId) => {
|
||||||
|
// 1) Traer el rol del usuario desde la tabla "perfiles"
|
||||||
|
const { data: perf, error: e1 } = await supabase
|
||||||
|
.from("perfiles")
|
||||||
|
.select("nombre, rol, activo")
|
||||||
|
.eq("id", userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (e1 || !perf || perf.activo === false) {
|
||||||
|
setPerfil(null);
|
||||||
|
setPermisos([]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Traer los módulos permitidos para ese rol desde "permisos"
|
||||||
|
const { data: perms } = await supabase
|
||||||
|
.from("permisos")
|
||||||
|
.select("modulo, puede_ver")
|
||||||
|
.eq("rol", perf.rol);
|
||||||
|
|
||||||
|
const modulos = (perms || [])
|
||||||
|
.filter((p) => p.puede_ver)
|
||||||
|
.map((p) => p.modulo);
|
||||||
|
|
||||||
|
setPerfil({ nombre: perf.nombre, rol: perf.rol });
|
||||||
|
setPermisos(modulos);
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Al iniciar: revisar si ya hay sesión activa
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
supabase.auth.getSession().then(async ({ data }) => {
|
||||||
|
if (!activo) return;
|
||||||
|
const s = data?.session || null;
|
||||||
|
setSession(s);
|
||||||
|
if (s?.user) await cargarPerfil(s.user.id);
|
||||||
|
setCargando(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escuchar cambios de sesión (login/logout)
|
||||||
|
const { data: sub } = supabase.auth.onAuthStateChange(async (_evt, s) => {
|
||||||
|
setSession(s);
|
||||||
|
if (s?.user) await cargarPerfil(s.user.id);
|
||||||
|
else { setPerfil(null); setPermisos([]); }
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { activo = false; sub?.subscription?.unsubscribe(); };
|
||||||
|
}, [cargarPerfil]);
|
||||||
|
|
||||||
|
const login = async (email, password) => {
|
||||||
|
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
|
||||||
|
if (error) return { ok: false, msg: traducirError(error.message) };
|
||||||
|
// Verificar que tenga perfil/rol válido
|
||||||
|
const ok = await cargarPerfil(data.user.id);
|
||||||
|
if (!ok) {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
return { ok: false, msg: "Tu usuario no tiene un perfil asignado. Contacta al administrador." };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
setPerfil(null);
|
||||||
|
setPermisos([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
session,
|
||||||
|
perfil,
|
||||||
|
permisos,
|
||||||
|
cargando,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
autenticado: !!session && !!perfil,
|
||||||
|
puedeVer: (modulo) => permisos.includes(modulo),
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth debe usarse dentro de AuthProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function traducirError(msg) {
|
||||||
|
if (/invalid login credentials/i.test(msg)) return "Correo o contraseña incorrectos.";
|
||||||
|
if (/email not confirmed/i.test(msg)) return "El correo no está confirmado.";
|
||||||
|
return msg || "Error al iniciar sesión.";
|
||||||
|
}
|
||||||
10
frontend/src/lib/supabase.js
Normal file
10
frontend/src/lib/supabase.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// src/lib/supabase.js
|
||||||
|
// Cliente de Supabase para autenticación (login).
|
||||||
|
// IMPORTANTE: aquí va la clave PÚBLICA (anon), nunca la secreta (service_role).
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
// Estos valores se leen de frontend/.env (ver .env.example)
|
||||||
|
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || "";
|
||||||
|
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || "";
|
||||||
|
|
||||||
|
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
||||||
63
frontend/src/lib/useColumnasAjustables.jsx
Normal file
63
frontend/src/lib/useColumnasAjustables.jsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// src/lib/useColumnasAjustables.jsx
|
||||||
|
// Hook reutilizable para columnas redimensionables (estilo Excel) en cualquier tabla.
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useColumnasAjustables(anchosIniciales) {
|
||||||
|
const [anchos, setAnchos] = useState(anchosIniciales);
|
||||||
|
|
||||||
|
// Si cambia el número de columnas (ej. Cobranza Sede↔Programa), reiniciar anchos
|
||||||
|
useEffect(() => {
|
||||||
|
if (anchos.length !== anchosIniciales.length) {
|
||||||
|
setAnchos(anchosIniciales);
|
||||||
|
}
|
||||||
|
}, [anchosIniciales.length]);
|
||||||
|
|
||||||
|
function iniciarResize(e, i) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const xInicial = e.clientX;
|
||||||
|
const anchoInicial = anchos[i];
|
||||||
|
function onMove(ev) {
|
||||||
|
const nuevo = Math.max(50, anchoInicial + (ev.clientX - xInicial));
|
||||||
|
setAnchos((prev) => { const c = [...prev]; c[i] = nuevo; return c; });
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
document.removeEventListener("mousemove", onMove);
|
||||||
|
document.removeEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousemove", onMove);
|
||||||
|
document.addEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devuelve los elementos <col> para el <colgroup> de la tabla
|
||||||
|
function ColGroup() {
|
||||||
|
return (
|
||||||
|
<colgroup>
|
||||||
|
{anchos.map((w, i) => <col key={i} style={{ width: w, minWidth: w }} />)}
|
||||||
|
</colgroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devuelve el divisor arrastrable para poner dentro de cada <th>
|
||||||
|
function Resizer({ index }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
onMouseDown={(e) => iniciarResize(e, index)}
|
||||||
|
className="col-resizer"
|
||||||
|
style={{ position: "absolute", top: 0, right: 0, width: 8, height: "100%",
|
||||||
|
cursor: "col-resize", userSelect: "none", zIndex: 5 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchoTotal = anchos.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
// width:100% → llena el contenedor desde el inicio (sin huecos a la derecha).
|
||||||
|
// minWidth:anchoTotal → si las columnas crecen más que el contenedor, aparece scroll.
|
||||||
|
// tableLayout:fixed + colgroup → encabezados y celdas alineados.
|
||||||
|
const tableProps = {
|
||||||
|
style: { tableLayout: "fixed", width: "100%", minWidth: anchoTotal },
|
||||||
|
};
|
||||||
|
|
||||||
|
return { anchos, ColGroup, Resizer, anchoTotal, tableProps };
|
||||||
|
}
|
||||||
13
frontend/src/main.jsx
Normal file
13
frontend/src/main.jsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import { AuthProvider } from "./lib/auth";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
105
frontend/src/pages/Asesores.jsx
Normal file
105
frontend/src/pages/Asesores.jsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// src/pages/Asesores.jsx
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox } from "../components/UI";
|
||||||
|
|
||||||
|
const ESTADO_INFO = {
|
||||||
|
online: { txt: "En Línea", color: "#10b981", bg: "#d1fae5", fg: "#065f46" },
|
||||||
|
busy: { txt: "Ocupado", color: "#f59e0b", bg: "#fef9c3", fg: "#854d0e" },
|
||||||
|
offline:{ txt: "Fuera de Línea", color: "#94a3b8", bg: "#f1f5f9", fg: "#475569" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Asesores() {
|
||||||
|
const [agentes, setAgentes] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [cambiando, setCambiando] = useState(null);
|
||||||
|
|
||||||
|
const cargar = useCallback(() => {
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.asesores()
|
||||||
|
.then((res)=>{ setAgentes(res.agentes||[]); setLoading(false); })
|
||||||
|
.catch((e)=>{ setError(e.message); setLoading(false); });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { cargar(); }, [cargar]);
|
||||||
|
|
||||||
|
async function toggle(ag) {
|
||||||
|
const online = ag.availability_status !== "online";
|
||||||
|
setCambiando(ag.id);
|
||||||
|
try {
|
||||||
|
await api.asesorEstado(ag.id, online);
|
||||||
|
setAgentes((prev)=>prev.map((a)=>a.id===ag.id ? {...a, availability_status: online?"online":"offline"} : a));
|
||||||
|
} catch (e) { /* noop */ }
|
||||||
|
setCambiando(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function todos(online) {
|
||||||
|
setCambiando("all");
|
||||||
|
for (const ag of agentes) {
|
||||||
|
try { await api.asesorEstado(ag.id, online); } catch (e) {}
|
||||||
|
}
|
||||||
|
setAgentes((prev)=>prev.map((a)=>({...a, availability_status: online?"online":"offline"})));
|
||||||
|
setCambiando(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = agentes.length;
|
||||||
|
const enLinea = agentes.filter((a)=>a.availability_status==="online").length;
|
||||||
|
const ocupados = agentes.filter((a)=>a.availability_status==="busy").length;
|
||||||
|
const offline = total - enLinea - ocupados;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">👥 Asesores</h1>
|
||||||
|
|
||||||
|
{loading ? <Loader text="Cargando asesores de Chatwoot..." /> :
|
||||||
|
error ? <ErrorBox msg={error} /> :
|
||||||
|
<>
|
||||||
|
<div className="kpis" style={{gridTemplateColumns:"repeat(4,1fr)"}}>
|
||||||
|
<div className="kpi"><div className="ico">👥</div><div className="label">Total Asesores</div><div className="value">{total}</div></div>
|
||||||
|
<div className="kpi" style={{background:"#d1fae5"}}><div className="ico">🟢</div><div className="label">En Línea</div><div className="value" style={{color:"#065f46"}}>{enLinea}</div></div>
|
||||||
|
<div className="kpi" style={{background:"#fef9c3"}}><div className="ico">🟡</div><div className="label">Ocupados</div><div className="value" style={{color:"#854d0e"}}>{ocupados}</div></div>
|
||||||
|
<div className="kpi" style={{background:"#fee2e2"}}><div className="ico">🔴</div><div className="label">Fuera de Línea</div><div className="value" style={{color:"#991b1b"}}>{offline}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{display:"flex",gap:10,marginBottom:16}}>
|
||||||
|
<button className="btn btn-primary" disabled={cambiando==="all"} onClick={()=>todos(true)}>🟢 Encender todos</button>
|
||||||
|
<button className="btn btn-ghost" disabled={cambiando==="all"} onClick={()=>todos(false)}>🔴 Apagar todos</button>
|
||||||
|
<button className="btn btn-ghost" title="Refrescar" style={{fontSize:18}} onClick={cargar}>🔄</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>ASESOR</th><th>EMAIL</th><th>ROL</th><th>ESTADO</th><th>ACCIÓN</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{agentes.map((ag)=>{
|
||||||
|
const est = ESTADO_INFO[ag.availability_status] || ESTADO_INFO.offline;
|
||||||
|
const isOnline = ag.availability_status==="online";
|
||||||
|
return (
|
||||||
|
<tr key={ag.id}>
|
||||||
|
<td className="col-name">{ag.name || ag.available_name || "—"}</td>
|
||||||
|
<td>{ag.email || "—"}</td>
|
||||||
|
<td>{ag.role || "agent"}</td>
|
||||||
|
<td>
|
||||||
|
<span style={{background:est.bg,color:est.fg,padding:"3px 10px",borderRadius:20,
|
||||||
|
fontSize:11,fontWeight:700}}>● {est.txt}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn" disabled={cambiando===ag.id}
|
||||||
|
style={{padding:"5px 12px",fontSize:11,
|
||||||
|
background:isOnline?"#fee2e2":"#d1fae5",
|
||||||
|
color:isOnline?"#991b1b":"#065f46"}}
|
||||||
|
onClick={()=>toggle(ag)}>
|
||||||
|
{cambiando===ag.id ? "..." : (isOnline?"Apagar":"Encender")}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
660
frontend/src/pages/Cobranza.jsx
Normal file
660
frontend/src/pages/Cobranza.jsx
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
// src/pages/Cobranza.jsx
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox, Filters, Select, colorSemaforo } from "../components/UI";
|
||||||
|
import Modal from "../components/Modal";
|
||||||
|
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||||||
|
|
||||||
|
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||||||
|
const ANOS = [2024, 2025, 2026];
|
||||||
|
const AGRUPACIONES = [{value:"SEDE",label:"Por Sede"},{value:"PROGRAMA",label:"Por Programa"},{value:"ASESOR",label:"Por Asesor"}];
|
||||||
|
const SEDES = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"];
|
||||||
|
const FRECS = ["TODOS","DOM","SAB","NOC","MAN","VIR","TAR"];
|
||||||
|
|
||||||
|
// Colores por cartera — diferenciados y legibles (sin azul, reservado para TOTAL)
|
||||||
|
// ANTERIOR = ámbar suave | MES = verde menta | TOTAL = lila/violeta suave
|
||||||
|
const BG_ANT = "#fef3c7", BG_MES = "#d1fae5", BG_TOT = "#ede9fe";
|
||||||
|
// Versiones más oscuras para hover de cada cartera
|
||||||
|
const HV_ANT = "#fde68a", HV_MES = "#a7f3d0", HV_TOT = "#ddd6fe";
|
||||||
|
|
||||||
|
function toMonto(s){ const n = parseFloat(String(s).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; }
|
||||||
|
function toPct(s){ const n = parseFloat(String(s).replace("%","").trim()); return isNaN(n)?0:n; }
|
||||||
|
// Formato moneda SIN decimales (solo visualización)
|
||||||
|
function fmtMoneda(s){
|
||||||
|
if (s == null) return "";
|
||||||
|
if (!String(s).includes("S/")) return s; // no es monto, devolver tal cual
|
||||||
|
const n = toMonto(s);
|
||||||
|
return "S/ " + n.toLocaleString("es-PE", { maximumFractionDigits: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniBar({ valStr }) {
|
||||||
|
if (!valStr || !String(valStr).includes("%")) return <span style={{color:"#cbd5e1"}}>—</span>;
|
||||||
|
const pct = toPct(valStr);
|
||||||
|
const c = colorSemaforo(pct);
|
||||||
|
const w = Math.min(Math.max(pct,0),100);
|
||||||
|
return (
|
||||||
|
<div style={{display:"flex",alignItems:"center",gap:5}}>
|
||||||
|
<span style={{fontSize:10,fontWeight:700,color:c.fg,minWidth:30,textAlign:"right"}}>{pct.toFixed(0)}%</span>
|
||||||
|
<div style={{flex:1,background:"#e2e8f0",borderRadius:4,height:5,overflow:"hidden",minWidth:30}}>
|
||||||
|
<div style={{width:`${w}%`,background:c.fill,height:"100%"}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Cobranza() {
|
||||||
|
const hoy = new Date();
|
||||||
|
const [ano, setAno] = useState(hoy.getFullYear());
|
||||||
|
const [mes, setMes] = useState(hoy.getMonth() + 1);
|
||||||
|
const [agrupacion, setAgrupacion] = useState("SEDE");
|
||||||
|
const [sectorista, setSectorista] = useState("TODOS");
|
||||||
|
const [sede, setSede] = useState("TODOS");
|
||||||
|
const [frec, setFrec] = useState("TODOS");
|
||||||
|
const [filas, setFilas] = useState([]);
|
||||||
|
const [sectoristas, setSectoristas] = useState(["TODOS"]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [modal, setModal] = useState(null);
|
||||||
|
const [sedeMap, setSedeMap] = useState({}); // programa -> sede (sede.json)
|
||||||
|
const [exportando, setExportando] = useState(false);
|
||||||
|
const [alumnos, setAlumnos] = useState([]); // todos los alumnos (1 sola consulta)
|
||||||
|
const [buscadorAbierto, setBuscadorAbierto] = useState(false);
|
||||||
|
|
||||||
|
const esPrograma = agrupacion === "PROGRAMA";
|
||||||
|
// índices según agrupación
|
||||||
|
const IDX = esPrograma
|
||||||
|
? { saldo:13, ant:[4,5,6], mes:[7,8,9], tot:[10,11,12] }
|
||||||
|
: { saldo:10, ant:[1,2,3], mes:[4,5,6], tot:[7,8,9] };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.cobranza(ano, mes, sectorista, agrupacion)
|
||||||
|
.then((res) => {
|
||||||
|
if (!activo) return;
|
||||||
|
setFilas(res.filas || []);
|
||||||
|
setSectoristas(res.sectoristas || ["TODOS"]);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||||
|
return () => { activo = false; };
|
||||||
|
}, [ano, mes, agrupacion, sectorista]);
|
||||||
|
|
||||||
|
// Cargar TODOS los alumnos una sola vez (para exportar y buscar) — 1 consulta cacheada
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
api.cobranzaDetalleTodos(ano, mes, sectorista)
|
||||||
|
.then((res) => { if (activo) setAlumnos(res.alumnos || []); })
|
||||||
|
.catch(() => { if (activo) setAlumnos([]); });
|
||||||
|
return () => { activo = false; };
|
||||||
|
}, [ano, mes, sectorista]);
|
||||||
|
|
||||||
|
// Cargar clasificación de sede (sede.json) para los programas visibles
|
||||||
|
useEffect(() => {
|
||||||
|
if (!esPrograma) return;
|
||||||
|
const nombres = filas
|
||||||
|
.filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL")
|
||||||
|
.map((f) => String(f[0]));
|
||||||
|
if (nombres.length === 0) return;
|
||||||
|
let activo = true;
|
||||||
|
api.clasificarSede(nombres)
|
||||||
|
.then((res) => { if (activo) setSedeMap(res.mapa || {}); })
|
||||||
|
.catch(() => { if (activo) setSedeMap({}); });
|
||||||
|
return () => { activo = false; };
|
||||||
|
}, [filas, esPrograma]);
|
||||||
|
|
||||||
|
// Filtro sede/frecuencia (solo PROGRAMA) en cliente — usa sede.json
|
||||||
|
const datos = useMemo(() => {
|
||||||
|
let arr = filas.filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL");
|
||||||
|
if (esPrograma) {
|
||||||
|
if (sede !== "TODOS") {
|
||||||
|
arr = arr.filter((f) => {
|
||||||
|
const s = (sedeMap[String(f[0])] || "LIMA").toUpperCase();
|
||||||
|
return s === sede.toUpperCase();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (frec !== "TODOS") arr = arr.filter((f) => String(f[1]).toUpperCase() === frec);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, [filas, esPrograma, sede, frec, sedeMap]);
|
||||||
|
|
||||||
|
// KPIs: 3 tarjetas (recalculadas con datos filtrados)
|
||||||
|
const kpis = useMemo(() => {
|
||||||
|
let cAnt=0,obAnt=0,cMes=0,obMes=0,cTot=0,obTot=0;
|
||||||
|
datos.forEach((f) => {
|
||||||
|
cAnt += toMonto(f[IDX.ant[0]]); obAnt += toMonto(f[IDX.ant[1]]);
|
||||||
|
cMes += toMonto(f[IDX.mes[0]]); obMes += toMonto(f[IDX.mes[1]]);
|
||||||
|
cTot += toMonto(f[IDX.tot[0]]); obTot += toMonto(f[IDX.tot[1]]);
|
||||||
|
});
|
||||||
|
return { cAnt,obAnt,cMes,obMes,cTot,obTot, saldo: cTot-obTot };
|
||||||
|
}, [datos, IDX]);
|
||||||
|
|
||||||
|
const headers = esPrograma
|
||||||
|
? ["PROGRAMA","FREC.","N° CUOTA","VENCIMIENTO","CTA X COB. MES ANT.","COB. MES ANT.","% ANT","CTA X COB EN CURSO","COB. EN CURSO","% MES","CTA X COB TOTAL","COB. TOTAL","% TOTAL","SALDO","OPC."]
|
||||||
|
: ["GRUPO","CTA X COB. MES ANT.","COB. MES ANT.","% ANT","CTA X COB EN CURSO","COB. EN CURSO","% MES","CTA X COB TOTAL","COB. TOTAL","% TOTAL","SALDO","OPC."];
|
||||||
|
|
||||||
|
// Anchos para columnas redimensionables (1ª columna más ancha, resto estándar)
|
||||||
|
const anchosCob = headers.map((h, i) => i === 0 ? (esPrograma ? 280 : 200) : (h === "FREC." || h.startsWith("%") || h === "OPC." ? 80 : 120));
|
||||||
|
const cols = useColumnasAjustables(anchosCob);
|
||||||
|
|
||||||
|
function cartClase(ci) {
|
||||||
|
if (IDX.ant.includes(ci)) return "cart-ant";
|
||||||
|
if (IDX.mes.includes(ci)) return "cart-mes";
|
||||||
|
if (IDX.tot.includes(ci)) return "cart-tot";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alumnos visibles según filtros activos (mismo dataset que Ver / Estado de Cuenta)
|
||||||
|
const alumnosFiltrados = useMemo(() => {
|
||||||
|
let arr = alumnos;
|
||||||
|
if (esPrograma) {
|
||||||
|
if (sede !== "TODOS") arr = arr.filter((a) => String(a.sede).toUpperCase() === sede.toUpperCase());
|
||||||
|
if (frec !== "TODOS") arr = arr.filter((a) => String(a.frecuencia).toUpperCase() === frec);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, [alumnos, esPrograma, sede, frec]);
|
||||||
|
|
||||||
|
function grupoDe(a) {
|
||||||
|
if (agrupacion === "SEDE") return a.sede;
|
||||||
|
if (agrupacion === "ASESOR") return ""; // el sectorista ya filtra; no hay sub-grupo por alumno
|
||||||
|
return a.programa; // PROGRAMA
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exportar el DETALLE DE ALUMNOS (rápido: usa el dataset ya cargado, sin N consultas)
|
||||||
|
async function exportarDetalle() {
|
||||||
|
setExportando(true);
|
||||||
|
try {
|
||||||
|
const base = alumnosFiltrados;
|
||||||
|
if (base.length === 0) { alert("No hay alumnos para exportar con los filtros actuales."); return; }
|
||||||
|
|
||||||
|
const toDate = (v) => {
|
||||||
|
if (!v || v === "-") return "";
|
||||||
|
const s = String(v).trim().replace(/\//g,"-");
|
||||||
|
const p = s.split("-");
|
||||||
|
if (p.length === 3 && p[2].length === 4) return new Date(+p[2], +p[1]-1, +p[0]);
|
||||||
|
return String(v);
|
||||||
|
};
|
||||||
|
const pct = (cob, cta) => cta > 0 ? cob/cta : null;
|
||||||
|
|
||||||
|
const XLSX = await import("xlsx-js-style");
|
||||||
|
const COLS = ["GRUPO","MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"];
|
||||||
|
|
||||||
|
// Colores suaves por cartera (igual que el dashboard): ANT ámbar, MES verde, TOT lila
|
||||||
|
const CART = { ant:"FEF3C7", mes:"D1FAE5", tot:"EDE9FE" };
|
||||||
|
const cartFill = (c) => {
|
||||||
|
if ([5,6,7].includes(c)) return CART.ant;
|
||||||
|
if ([8,9,10].includes(c)) return CART.mes;
|
||||||
|
if ([11,12,13].includes(c)) return CART.tot;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const headerStyle = { font:{bold:true,color:{rgb:"FFFFFF"},sz:11}, fill:{fgColor:{rgb:"1E40AF"}}, alignment:{horizontal:"center",vertical:"center"} };
|
||||||
|
const moneyFmt = '"S/" #,##0';
|
||||||
|
const pctFmt = '0%';
|
||||||
|
const dateFmt = "dd/mm/yyyy";
|
||||||
|
const cellStyle = (c) => { const f = cartFill(c); return f ? { fill:{fgColor:{rgb:f}} } : undefined; };
|
||||||
|
|
||||||
|
const ws = {};
|
||||||
|
COLS.forEach((h,c)=>{ ws[XLSX.utils.encode_cell({r:0,c})] = {v:h,t:"s",s:headerStyle}; });
|
||||||
|
|
||||||
|
base.forEach((a, ri) => {
|
||||||
|
const r = ri + 1;
|
||||||
|
const vals = [
|
||||||
|
grupoDe(a), a.matricula, a.alumno, a.num_cuota, toDate(a.fch_venc),
|
||||||
|
a.cta_ant, a.cob_ant, pct(a.cob_ant,a.cta_ant),
|
||||||
|
a.cta_cur, a.cob_cur, pct(a.cob_cur,a.cta_cur),
|
||||||
|
a.cta_tot, a.cob_tot, pct(a.cob_tot,a.cta_tot),
|
||||||
|
a.saldo,
|
||||||
|
];
|
||||||
|
vals.forEach((v, c) => {
|
||||||
|
const ref = XLSX.utils.encode_cell({r,c});
|
||||||
|
const st = cellStyle(c);
|
||||||
|
if ([5,6,8,9,11,12,14].includes(c)) ws[ref] = { v:Number(v)||0, t:"n", z:moneyFmt, ...(st?{s:st}:{}) };
|
||||||
|
else if ([7,10,13].includes(c)) ws[ref] = (v===null) ? { v:"-", t:"s", ...(st?{s:st}:{}) } : { v:Number(v), t:"n", z:pctFmt, ...(st?{s:st}:{}) };
|
||||||
|
else if (c===4 && v instanceof Date) ws[ref] = { v, t:"d", z:dateFmt };
|
||||||
|
else ws[ref] = { v: v ?? "", t:"s" };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// TOTAL GENERAL (suma de montos)
|
||||||
|
const rTot = base.length + 1;
|
||||||
|
const totalStyle = { font:{bold:true}, fill:{fgColor:{rgb:"EFF6FF"}} };
|
||||||
|
const sum = (k) => base.reduce((s,a)=>s+(Number(a[k])||0),0);
|
||||||
|
const totVals = { 5:sum("cta_ant"),6:sum("cob_ant"),8:sum("cta_cur"),9:sum("cob_cur"),11:sum("cta_tot"),12:sum("cob_tot"),14:sum("saldo") };
|
||||||
|
COLS.forEach((col,c)=>{
|
||||||
|
const ref = XLSX.utils.encode_cell({r:rTot,c});
|
||||||
|
if (c===0) ws[ref] = {v:"TOTAL GENERAL",t:"s",s:totalStyle};
|
||||||
|
else if (totVals[c]!==undefined) ws[ref] = {v:totVals[c],t:"n",z:moneyFmt,s:totalStyle};
|
||||||
|
else ws[ref] = {v:"",t:"s",s:totalStyle};
|
||||||
|
});
|
||||||
|
|
||||||
|
ws["!ref"] = XLSX.utils.encode_range({ s:{r:0,c:0}, e:{r:rTot,c:COLS.length-1} });
|
||||||
|
ws["!cols"] = [{wch:26},{wch:12},{wch:28},{wch:9},{wch:12},{wch:12},{wch:12},{wch:8},{wch:12},{wch:12},{wch:8},{wch:12},{wch:12},{wch:8},{wch:13}];
|
||||||
|
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, "Cobranza");
|
||||||
|
const sufijo = agrupacion + (esPrograma && sede!=="TODOS" ? `_${sede}` : "") + (sectorista!=="TODOS" ? `_${sectorista}` : "");
|
||||||
|
XLSX.writeFile(wb, `cobranza_${sufijo}_${ano}_${mes}.xlsx`);
|
||||||
|
} finally {
|
||||||
|
setExportando(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">📋 Cobranza</h1>
|
||||||
|
|
||||||
|
<Filters>
|
||||||
|
<Select label="Año" value={ano} options={ANOS} onChange={(v)=>setAno(+v)} />
|
||||||
|
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} />
|
||||||
|
<Select label="Agrupación" value={agrupacion} options={AGRUPACIONES} onChange={(v)=>{setAgrupacion(v);setSede("TODOS");setFrec("TODOS");}} />
|
||||||
|
<Select label="Sectorista" value={sectorista} options={sectoristas} onChange={setSectorista} />
|
||||||
|
{esPrograma && <Select label="Sede" value={sede} options={SEDES} onChange={setSede} />}
|
||||||
|
{esPrograma && <Select label="Frecuencia" value={frec} options={FRECS} onChange={setFrec} />}
|
||||||
|
<div style={{display:"flex",gap:8,alignItems:"flex-end",marginLeft:"auto"}}>
|
||||||
|
<button className="btn btn-ghost" style={{height:38}} onClick={()=>setBuscadorAbierto(true)}>🔍 Buscar</button>
|
||||||
|
<button className="btn btn-primary" style={{height:38}} disabled={exportando || !alumnosFiltrados.length} onClick={exportarDetalle}>
|
||||||
|
{exportando ? "Exportando..." : "⬇️ Exportar"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Filters>
|
||||||
|
|
||||||
|
{loading ? <Loader text="Calculando cobranza..." /> :
|
||||||
|
error ? <ErrorBox msg={error} /> :
|
||||||
|
<>
|
||||||
|
<Tarjetas kpis={kpis} />
|
||||||
|
|
||||||
|
<div className="table-wrap" style={{marginTop:16, overflowX:"auto"}}>
|
||||||
|
<table className="cob-table" {...cols.tableProps}>
|
||||||
|
<cols.ColGroup />
|
||||||
|
<thead><tr>{headers.map((h, i)=><th key={h} style={{position:"relative"}}>{h}<cols.Resizer index={i} /></th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{datos.map((f, ri) => {
|
||||||
|
// En la tabla principal SIEMPRE son agrupaciones (Sede/Programa/Asesor),
|
||||||
|
// nunca alumnos individuales → NO aplicar rojo por deuda aquí.
|
||||||
|
// Regla "-": si % del grupo está vacío, CTA y COB del grupo → "-"
|
||||||
|
const antVacio = !String(f[IDX.ant[2]]||"").trim() || ["-","—"].includes(String(f[IDX.ant[2]]).trim());
|
||||||
|
const mesVacio = !String(f[IDX.mes[2]]||"").trim() || ["-","—"].includes(String(f[IDX.mes[2]]).trim());
|
||||||
|
const totVacio = !String(f[IDX.tot[2]]||"").trim() || ["-","—"].includes(String(f[IDX.tot[2]]).trim());
|
||||||
|
return (
|
||||||
|
<tr key={ri}>
|
||||||
|
{f.slice(0, headers.length-1).map((v, ci) => {
|
||||||
|
const isPct = IDX.ant[2]===ci || IDX.mes[2]===ci || IDX.tot[2]===ci;
|
||||||
|
let val = v;
|
||||||
|
if ((ci===IDX.ant[0]||ci===IDX.ant[1]||ci===IDX.ant[2]) && antVacio) val = "—";
|
||||||
|
if ((ci===IDX.mes[0]||ci===IDX.mes[1]||ci===IDX.mes[2]) && mesVacio) val = "—";
|
||||||
|
if ((ci===IDX.tot[0]||ci===IDX.tot[1]||ci===IDX.tot[2]) && totVacio) val = "—";
|
||||||
|
const cartCls = cartClase(ci);
|
||||||
|
const clss = (ci===0?"col-name":"") + (cartCls?` ${cartCls}`:"");
|
||||||
|
return (
|
||||||
|
<td key={ci} className={clss.trim()}
|
||||||
|
style={{textAlign: ci===0?"left":"center",
|
||||||
|
minWidth: ci===0?(esPrograma?300:200):undefined,
|
||||||
|
maxWidth: ci===0?500:undefined,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: ci===0?"hidden":undefined,
|
||||||
|
textOverflow: ci===0?"ellipsis":undefined}}>
|
||||||
|
{isPct ? (val==="—" ? <span style={{color:"#cbd5e1"}}>—</span> : <MiniBar valStr={val} />) : fmtMoneda(val)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td>
|
||||||
|
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
|
||||||
|
onClick={() => setModal({ grupo: f[0] })}>👁️ Ver</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{datos.length > 0 && (() => {
|
||||||
|
// Fila TOTAL GENERAL — suma de los datos visibles (respeta filtros)
|
||||||
|
const t = {};
|
||||||
|
[IDX.ant[0],IDX.ant[1],IDX.mes[0],IDX.mes[1],IDX.tot[0],IDX.tot[1],IDX.saldo]
|
||||||
|
.forEach((ci)=>{ t[ci] = datos.reduce((s,f)=>s+toMonto(f[ci]),0); });
|
||||||
|
const ratio = (cob,cta)=> cta>0 ? `${Math.round(cob/cta*100)}%` : "";
|
||||||
|
const cell = (ci) => {
|
||||||
|
if (ci===0) return "TOTAL GENERAL";
|
||||||
|
if (ci===IDX.ant[2]) return ratio(t[IDX.ant[1]],t[IDX.ant[0]]);
|
||||||
|
if (ci===IDX.mes[2]) return ratio(t[IDX.mes[1]],t[IDX.mes[0]]);
|
||||||
|
if (ci===IDX.tot[2]) return ratio(t[IDX.tot[1]],t[IDX.tot[0]]);
|
||||||
|
if (t[ci]!==undefined) return "S/ " + t[ci].toLocaleString("es-PE",{maximumFractionDigits:0});
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<tr className="total-row">
|
||||||
|
{Array.from({length: headers.length-1}).map((_,ci)=>{
|
||||||
|
const isPct = IDX.ant[2]===ci || IDX.mes[2]===ci || IDX.tot[2]===ci;
|
||||||
|
const val = cell(ci);
|
||||||
|
return (
|
||||||
|
<td key={ci} className={ci===0?"col-name":""}
|
||||||
|
style={{textAlign: ci===0?"left":"center",
|
||||||
|
whiteSpace:"nowrap"}}>
|
||||||
|
{isPct ? <MiniBar valStr={val} /> : val}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td>—</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{modal && (
|
||||||
|
<ModalEstadoCuenta grupo={modal.grupo} ano={ano} mes={mes}
|
||||||
|
sectorista={sectorista} agrupacion={agrupacion} onClose={() => setModal(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{buscadorAbierto && (
|
||||||
|
<ModalBuscador
|
||||||
|
alumnos={alumnosFiltrados}
|
||||||
|
agrupacion={agrupacion}
|
||||||
|
sectorista={sectorista}
|
||||||
|
onClose={() => setBuscadorAbierto(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tarjetas({ kpis }) {
|
||||||
|
const alDia = kpis.saldo <= 0.01;
|
||||||
|
if (alDia) {
|
||||||
|
return (
|
||||||
|
<div style={{background:"linear-gradient(135deg,#d1fae5,#a7f3d0)",border:"2px solid #10b981",
|
||||||
|
borderRadius:14,padding:18,textAlign:"center"}}>
|
||||||
|
<div style={{fontSize:32}}>✅</div>
|
||||||
|
<div style={{fontSize:18,fontWeight:800,color:"#065f46",letterSpacing:1}}>AL DÍA</div>
|
||||||
|
<div style={{fontSize:12,color:"#047857"}}>Sin deuda pendiente</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const cards = [
|
||||||
|
["⚠ MES ANTERIOR","#d97706",kpis.cAnt,kpis.obAnt],
|
||||||
|
["📅 MES EN CURSO","#059669",kpis.cMes,kpis.obMes],
|
||||||
|
["💰 CARTERA TOTAL","#7c3aed",kpis.cTot,kpis.obTot],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:14}}>
|
||||||
|
{cards.map(([titulo,color,cta,cob]) => {
|
||||||
|
const ratio = cta>0 ? (cob/cta*100) : 0;
|
||||||
|
const c = colorSemaforo(ratio);
|
||||||
|
const saldo = cta - cob;
|
||||||
|
return (
|
||||||
|
<div key={titulo} style={{background:"#fff",border:"1px solid #e2e8f0",borderRadius:12,overflow:"hidden"}}>
|
||||||
|
<div style={{background:color,color:"#fff",padding:"10px 14px",fontWeight:700,fontSize:13,textAlign:"center"}}>{titulo}</div>
|
||||||
|
<div style={{padding:14}}>
|
||||||
|
<Row k="Cta x Cobrar" v={`S/ ${cta.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
|
||||||
|
<Row k="Cobrado" v={`S/ ${cob.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
|
||||||
|
<Row k="Saldo" v={`S/ ${saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}`} bold color={saldo>0?"#dc2626":"#10b981"} />
|
||||||
|
{cta>0 && (
|
||||||
|
<div style={{marginTop:10}}>
|
||||||
|
<div style={{display:"flex",justifyContent:"space-between",marginBottom:4}}>
|
||||||
|
<span style={{fontSize:10,fontWeight:600,color:"#64748b",textTransform:"uppercase"}}>% de Pago</span>
|
||||||
|
<span style={{fontSize:14,fontWeight:700,color:c.fg}}>{ratio.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div style={{background:"#e2e8f0",borderRadius:6,height:8,overflow:"hidden"}}>
|
||||||
|
<div style={{width:`${Math.min(ratio,100)}%`,background:c.fill,height:"100%"}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ k, v, bold, color }) {
|
||||||
|
return (
|
||||||
|
<div style={{display:"flex",justifyContent:"space-between",fontSize:11,color:"#64748b",marginBottom:4,
|
||||||
|
...(bold?{paddingTop:6,borderTop:"1px solid #f1f5f9"}:{})}}>
|
||||||
|
<span>{k}</span>
|
||||||
|
<span style={{fontWeight:bold?700:600,color:color||"#0f172a"}}>{v}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TarjetasResumen({ r }) {
|
||||||
|
const cards = [
|
||||||
|
["⚠ MES ANTERIOR","#d97706",r.cAnt,r.obAnt],
|
||||||
|
["📅 MES EN CURSO","#059669",r.cMes,r.obMes],
|
||||||
|
["💰 CARTERA TOTAL","#7c3aed",r.cTot,r.obTot],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:14}}>
|
||||||
|
{cards.map(([titulo,color,cta,cob]) => {
|
||||||
|
const ratio = cta>0 ? (cob/cta*100) : 0;
|
||||||
|
const c = colorSemaforo(ratio);
|
||||||
|
const saldo = cta - cob;
|
||||||
|
return (
|
||||||
|
<div key={titulo} style={{background:"#fff",border:"1px solid #e2e8f0",borderRadius:12,overflow:"hidden"}}>
|
||||||
|
<div style={{background:color,color:"#fff",padding:"9px 12px",fontWeight:700,fontSize:12,textAlign:"center"}}>{titulo}</div>
|
||||||
|
<div style={{padding:12}}>
|
||||||
|
<Row k="Cta x Cobrar" v={`S/ ${cta.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
|
||||||
|
<Row k="Cobrado" v={`S/ ${cob.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
|
||||||
|
<Row k="Saldo" v={`S/ ${saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}`} bold color={saldo>0?"#dc2626":"#10b981"} />
|
||||||
|
{cta>0 && (
|
||||||
|
<div style={{marginTop:8}}>
|
||||||
|
<div style={{display:"flex",justifyContent:"space-between",marginBottom:4}}>
|
||||||
|
<span style={{fontSize:10,fontWeight:600,color:"#64748b",textTransform:"uppercase"}}>% de Pago</span>
|
||||||
|
<span style={{fontSize:13,fontWeight:700,color:c.fg}}>{ratio.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div style={{background:"#e2e8f0",borderRadius:6,height:7,overflow:"hidden"}}>
|
||||||
|
<div style={{width:`${Math.min(ratio,100)}%`,background:c.fill,height:"100%"}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
|
||||||
|
const [texto, setTexto] = useState("");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const resultados = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return [];
|
||||||
|
return alumnos.filter((a) =>
|
||||||
|
String(a.alumno).toLowerCase().includes(q) ||
|
||||||
|
String(a.programa).toLowerCase().includes(q) ||
|
||||||
|
String(a.matricula).toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [alumnos, query]);
|
||||||
|
|
||||||
|
const COLS = ["GRUPO","MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.",
|
||||||
|
"CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"];
|
||||||
|
|
||||||
|
const pctStr = (cob, cta) => cta > 0 ? `${Math.round(cob/cta*100)}%` : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="🔍 Buscar Alumno" onClose={onClose} width={1200}>
|
||||||
|
<div style={{display:"flex",gap:8,marginBottom:16}}>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={texto}
|
||||||
|
onChange={(e)=>setTexto(e.target.value)}
|
||||||
|
onKeyDown={(e)=>{ if(e.key==="Enter") setQuery(texto); }}
|
||||||
|
placeholder="Escribe nombre, programa o matrícula (ej: Aro) y presiona Enter"
|
||||||
|
style={{flex:1,padding:"10px 14px",border:"1px solid #cbd5e1",borderRadius:8,fontSize:14}}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" onClick={()=>setQuery(texto)}>🔍 Buscar</button>
|
||||||
|
{query && <button className="btn btn-ghost" onClick={()=>{setTexto("");setQuery("");}}>Limpiar</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!query ? (
|
||||||
|
<div style={{color:"#94a3b8",padding:30,textAlign:"center",fontSize:14}}>
|
||||||
|
Escribe un texto y presiona Enter para buscar.
|
||||||
|
</div>
|
||||||
|
) : resultados.length === 0 ? (
|
||||||
|
<div style={{color:"#94a3b8",padding:30,textAlign:"center",fontSize:14}}>
|
||||||
|
Sin coincidencias para "{query}".
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{fontSize:13,color:"#1e40af",fontWeight:600,marginBottom:8}}>
|
||||||
|
{resultados.length} alumno(s) encontrado(s)
|
||||||
|
</div>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="cob-table" style={{minWidth:1100}}>
|
||||||
|
<thead><tr>{COLS.map((c)=><th key={c}>{c}</th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{resultados.map((a,i)=>{
|
||||||
|
const deuda = a.saldo > 0.01;
|
||||||
|
const grp = agrupacion==="SEDE" ? a.sede : (agrupacion==="ASESOR" ? sectorista : a.programa);
|
||||||
|
const celdas = [
|
||||||
|
{v:grp, cls:"col-name", al:"left"},
|
||||||
|
{v:a.matricula},
|
||||||
|
{v:a.alumno, cls:"col-name", al:"left"},
|
||||||
|
{v:a.num_cuota},
|
||||||
|
{v:a.fch_venc},
|
||||||
|
{v:fmtMoneda("S/ "+a.cta_ant), al:"right", cart:"cart-ant"},
|
||||||
|
{v:fmtMoneda("S/ "+a.cob_ant), al:"right", cart:"cart-ant"},
|
||||||
|
{pct:pctStr(a.cob_ant,a.cta_ant), cart:"cart-ant"},
|
||||||
|
{v:fmtMoneda("S/ "+a.cta_cur), al:"right", cart:"cart-mes"},
|
||||||
|
{v:fmtMoneda("S/ "+a.cob_cur), al:"right", cart:"cart-mes"},
|
||||||
|
{pct:pctStr(a.cob_cur,a.cta_cur), cart:"cart-mes"},
|
||||||
|
{v:fmtMoneda("S/ "+a.cta_tot), al:"right", cart:"cart-tot"},
|
||||||
|
{v:fmtMoneda("S/ "+a.cob_tot), al:"right", cart:"cart-tot"},
|
||||||
|
{pct:pctStr(a.cob_tot,a.cta_tot), cart:"cart-tot"},
|
||||||
|
{v:fmtMoneda("S/ "+a.saldo), al:"right", saldo:true},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<tr key={i} className={deuda?"row-deuda":""}>
|
||||||
|
{celdas.map((c,j)=>(
|
||||||
|
<td key={j} className={((c.cls||"")+" "+(c.cart||"")).trim()}
|
||||||
|
style={{textAlign:c.al||"center",
|
||||||
|
fontWeight:c.saldo?700:undefined,
|
||||||
|
color:c.saldo&&deuda?"#dc2626":undefined}}>
|
||||||
|
{c.pct!==undefined ? (c.pct ? <MiniBar valStr={c.pct} /> : "—") : c.v}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose }) {
|
||||||
|
const [filas, setFilas] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
setLoading(true);
|
||||||
|
api.cobranzaDetalle(grupo, ano, mes, sectorista, agrupacion)
|
||||||
|
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
||||||
|
.catch(() => { if (activo) { setFilas([]); setLoading(false); } });
|
||||||
|
return () => { activo = false; };
|
||||||
|
}, [grupo, ano, mes, sectorista, agrupacion]);
|
||||||
|
|
||||||
|
const COLS = ["MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"];
|
||||||
|
|
||||||
|
// Resumen para las 3 tarjetas (siempre visibles) — suma del detalle de alumnos
|
||||||
|
const resumen = useMemo(() => {
|
||||||
|
let cAnt=0,obAnt=0,cMes=0,obMes=0,cTot=0,obTot=0;
|
||||||
|
filas.forEach((f) => {
|
||||||
|
if (String(f[0]).toUpperCase() === "TOTAL") return;
|
||||||
|
cAnt += toMonto(f[4]); obAnt += toMonto(f[5]);
|
||||||
|
cMes += toMonto(f[7]); obMes += toMonto(f[8]);
|
||||||
|
cTot += toMonto(f[10]); obTot += toMonto(f[11]);
|
||||||
|
});
|
||||||
|
return { cAnt,obAnt,cMes,obMes,cTot,obTot };
|
||||||
|
}, [filas]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={`📊 Estado de Cuenta — ${grupo}`} onClose={onClose} width={1500}>
|
||||||
|
{loading ? <Loader text="Cargando estado de cuenta..." /> :
|
||||||
|
filas.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin datos.</div> :
|
||||||
|
<>
|
||||||
|
<div style={{marginBottom:16}}>
|
||||||
|
<TarjetasResumen r={resumen} />
|
||||||
|
</div>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="cob-table" style={{minWidth:1000}}>
|
||||||
|
<thead><tr>{COLS.map((c)=><th key={c}>{c}</th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL").map((f, i) => {
|
||||||
|
const saldo = toMonto(f[13]);
|
||||||
|
const deuda = saldo > 0.01;
|
||||||
|
// Regla "-": si %ANT vacío → CTA/COB ANT a "—" (igual MES y TOT)
|
||||||
|
const antVacio = !String(f[6]||"").trim() || ["-","—"].includes(String(f[6]).trim());
|
||||||
|
const mesVacio = !String(f[9]||"").trim() || ["-","—"].includes(String(f[9]).trim());
|
||||||
|
const totVacio = !String(f[12]||"").trim() || ["-","—"].includes(String(f[12]).trim());
|
||||||
|
return (
|
||||||
|
<tr key={i} className={deuda?"row-deuda":""}>
|
||||||
|
{f.map((v, j) => {
|
||||||
|
let val = v;
|
||||||
|
if ((j===4||j===5||j===6) && antVacio) val = "—";
|
||||||
|
if ((j===7||j===8||j===9) && mesVacio) val = "—";
|
||||||
|
if ((j===10||j===11||j===12) && totVacio) val = "—";
|
||||||
|
const isPct = [6,9,12].includes(j);
|
||||||
|
const isMonto = String(val).includes("S/");
|
||||||
|
let cart = "";
|
||||||
|
if ([4,5,6].includes(j)) cart = "cart-ant";
|
||||||
|
else if ([7,8,9].includes(j)) cart = "cart-mes";
|
||||||
|
else if ([10,11,12].includes(j)) cart = "cart-tot";
|
||||||
|
const clss = ((j===1?"col-name":"") + (cart?` ${cart}`:"")).trim();
|
||||||
|
return (
|
||||||
|
<td key={j} className={clss}
|
||||||
|
style={{textAlign:j===1?"left":(isMonto?"right":"center"),
|
||||||
|
whiteSpace:"nowrap",
|
||||||
|
color:j===13&&deuda?"#dc2626":undefined,
|
||||||
|
fontWeight:j===13?700:undefined}}>
|
||||||
|
{isPct ? (val==="—"?<span style={{color:"#cbd5e1"}}>—</span>:<MiniBar valStr={val} />) : fmtMoneda(val)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(() => {
|
||||||
|
// TOTAL GENERAL del modal — solo alumnos visibles (respeta la sede filtrada)
|
||||||
|
const alumnos = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL");
|
||||||
|
if (alumnos.length===0) return null;
|
||||||
|
const sum = (j)=>alumnos.reduce((s,f)=>s+toMonto(f[j]),0);
|
||||||
|
const t = {4:sum(4),5:sum(5),7:sum(7),8:sum(8),10:sum(10),11:sum(11),13:sum(13)};
|
||||||
|
const r = (cob,cta)=>cta>0?`${Math.round(cob/cta*100)}%`:"";
|
||||||
|
const celda = (j)=>{
|
||||||
|
if (j===0) return "TOTAL GENERAL";
|
||||||
|
if (j===1||j===2||j===3) return "";
|
||||||
|
if (j===6) return r(t[5],t[4]);
|
||||||
|
if (j===9) return r(t[8],t[7]);
|
||||||
|
if (j===12) return r(t[11],t[10]);
|
||||||
|
if (t[j]!==undefined) return "S/ "+t[j].toLocaleString("es-PE",{maximumFractionDigits:0});
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<tr className="total-row">
|
||||||
|
{Array.from({length:14}).map((_,j)=>{
|
||||||
|
const isPct=[6,9,12].includes(j);
|
||||||
|
const val=celda(j);
|
||||||
|
return (
|
||||||
|
<td key={j} className={j===0?"col-name":""}
|
||||||
|
style={{textAlign:j===0?"left":(String(val).includes("S/")?"right":"center"),whiteSpace:"nowrap"}}>
|
||||||
|
{isPct?<MiniBar valStr={val} />:val}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
1004
frontend/src/pages/Comisiones.jsx
Normal file
1004
frontend/src/pages/Comisiones.jsx
Normal file
File diff suppressed because it is too large
Load Diff
82
frontend/src/pages/Login.jsx
Normal file
82
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// src/pages/Login.jsx
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "../lib/auth";
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [cargando, setCargando] = useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setCargando(true);
|
||||||
|
const res = await login(email.trim(), password);
|
||||||
|
if (!res.ok) setError(res.msg);
|
||||||
|
setCargando(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
background: "linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%)", padding: 20,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: "#fff", borderRadius: 16, padding: "40px 36px", width: "100%", maxWidth: 400,
|
||||||
|
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||||
|
}}>
|
||||||
|
<div style={{ textAlign: "center", marginBottom: 28 }}>
|
||||||
|
<div style={{ fontSize: 38, marginBottom: 8 }}>❄️</div>
|
||||||
|
<h1 style={{ fontSize: 20, fontWeight: 800, color: "#0f172a" }}>Escuela de Refrigeración</h1>
|
||||||
|
<p style={{ fontSize: 13, color: "#64748b", marginTop: 4 }}>Dashboard — Inicia sesión</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: "#475569", display: "block", marginBottom: 6 }}>
|
||||||
|
Correo
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email" value={email} onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="usuario@escuela.com" required autoFocus
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: "#475569", display: "block", marginBottom: 6 }}>
|
||||||
|
Contraseña
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••" required
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
background: "#fee2e2", border: "1px solid #fca5a5", color: "#991b1b",
|
||||||
|
padding: "10px 12px", borderRadius: 8, fontSize: 13, marginBottom: 16,
|
||||||
|
}}>⚠️ {error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" disabled={cargando} style={{
|
||||||
|
width: "100%", padding: "12px", border: "none", borderRadius: 10,
|
||||||
|
background: cargando ? "#94a3b8" : "#2563eb", color: "#fff", fontSize: 15, fontWeight: 600,
|
||||||
|
cursor: cargando ? "default" : "pointer",
|
||||||
|
}}>
|
||||||
|
{cargando ? "Ingresando..." : "Iniciar sesión"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
width: "100%", padding: "11px 14px", border: "1px solid #cbd5e1", borderRadius: 10,
|
||||||
|
fontSize: 14, outline: "none", boxSizing: "border-box",
|
||||||
|
};
|
||||||
194
frontend/src/pages/Ocupabilidad.jsx
Normal file
194
frontend/src/pages/Ocupabilidad.jsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
// src/pages/Ocupabilidad.jsx
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox, ProgressBar, Filters, Select } from "../components/UI";
|
||||||
|
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||||||
|
|
||||||
|
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||||||
|
const ANOS = [2024, 2025, 2026];
|
||||||
|
const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"];
|
||||||
|
const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"];
|
||||||
|
|
||||||
|
const COLS = ["PROGRAMA","FECHA INICIO","DÍAS PARA INICIO","TOTAL INSCRITOS","RETIRADOS","INSCRITOS EN CURSO","META INSCRITOS","AVANCE INSCRITOS","INSCRITOS MES","INSCRITOS P.C","INSCRITOS REFRIPERU","INSCRITOS CONTINUIDAD"];
|
||||||
|
|
||||||
|
function toNum(v) { const n = parseFloat(String(v).replace("%","").trim()); return isNaN(n) ? 0 : n; }
|
||||||
|
|
||||||
|
export default function Ocupabilidad() {
|
||||||
|
const hoy = new Date();
|
||||||
|
const [ano, setAno] = useState(hoy.getFullYear());
|
||||||
|
const [mes, setMes] = useState(hoy.getMonth() + 1);
|
||||||
|
const [sede, setSede] = useState("TODOS");
|
||||||
|
const [programa, setPrograma] = useState("TODOS");
|
||||||
|
const [mostrarRepro, setMostrarRepro] = useState(true);
|
||||||
|
const [datos, setDatos] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const cols = useColumnasAjustables([280, 110, 120, 110, 100, 120, 110, 120, 100, 100, 130, 140]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.ocupabilidad(ano, mes, sede, programa)
|
||||||
|
.then((res) => { if (activo) { setDatos(res.datos || []); setLoading(false); } })
|
||||||
|
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||||
|
return () => { activo = false; };
|
||||||
|
}, [ano, mes, sede, programa]);
|
||||||
|
|
||||||
|
// Toggle "Mostrar Reprogramados": si está apagado, ocultar filas de inicio
|
||||||
|
const datosVisibles = useMemo(() => {
|
||||||
|
if (mostrarRepro) return datos;
|
||||||
|
return datos.filter((d) => String(d.dias_para_inicio).trim().toUpperCase() !== "REPROGRAMADO");
|
||||||
|
}, [datos, mostrarRepro]);
|
||||||
|
|
||||||
|
const kpis = useMemo(() => {
|
||||||
|
if (!datosVisibles.length) return null;
|
||||||
|
const esRepro = (d) => String(d.dias_para_inicio).trim().toUpperCase() === "REPROGRAMADO";
|
||||||
|
// Las filas REPROGRAMADO (inicios) no entran en las métricas (igual que escritorio)
|
||||||
|
const validos = datosVisibles.filter((d) => !esRepro(d));
|
||||||
|
const insc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Activos) || 0), 0);
|
||||||
|
const totalInsc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Totales) || 0), 0);
|
||||||
|
const retirados = validos.reduce((s, d) => s + (parseInt(d.Retirados ?? d.Inscritos_Retirados) || 0), 0);
|
||||||
|
const meta = validos.reduce((s, d) => s + (parseInt(d.Meta_Curso) || 0), 0);
|
||||||
|
return {
|
||||||
|
cursos: validos.length, // solo programados (sin reprogramados)
|
||||||
|
reprogramados: datosVisibles.filter(esRepro).length, // inicios rojos (REPROGRAMADO)
|
||||||
|
inscritos: totalInsc, // columna TOTAL INSCRITOS
|
||||||
|
meta,
|
||||||
|
avance: meta > 0 ? (totalInsc / meta * 100) : 0,
|
||||||
|
desercion: totalInsc > 0 ? (retirados / totalInsc * 100) : 0,
|
||||||
|
sobreMeta: validos.filter((d) => (parseInt(d.Inscritos_Totales)||0) >= (parseInt(d.Meta_Curso)||1)).length,
|
||||||
|
pc: validos.reduce((s, d) => s + (parseInt(d.Inscritos_PC) || 0), 0),
|
||||||
|
};
|
||||||
|
}, [datosVisibles]);
|
||||||
|
|
||||||
|
const totales = useMemo(() => {
|
||||||
|
const t = { mes:0, total:0, pc:0, retirados:0, activos:0, refri:0, cont:0, meta:0 };
|
||||||
|
datosVisibles.forEach((d) => {
|
||||||
|
t.mes += parseInt(d.Inscritos_Mes)||0;
|
||||||
|
t.total += parseInt(d.Inscritos_Totales)||0;
|
||||||
|
t.pc += parseInt(d.Inscritos_PC)||0;
|
||||||
|
t.retirados += parseInt(d.Retirados ?? d.Inscritos_Retirados)||0;
|
||||||
|
t.activos += parseInt(d.Inscritos_Activos)||0;
|
||||||
|
t.refri += parseInt(d.Descuento)||0;
|
||||||
|
t.cont += parseInt(d.Inscritos_Continuidad)||0;
|
||||||
|
t.meta += parseInt(d.Meta_Curso)||0;
|
||||||
|
});
|
||||||
|
return t;
|
||||||
|
}, [datosVisibles]);
|
||||||
|
|
||||||
|
const KPI_CARDS = kpis ? [
|
||||||
|
["📚","Cursos Activos", kpis.cursos, "programas este mes"],
|
||||||
|
["🔁","Cursos Reprogramados", kpis.reprogramados, "inicios reprogramados"],
|
||||||
|
["👥","Total Inscritos", kpis.inscritos, "alumnos en curso"],
|
||||||
|
["🎯","Meta Total", kpis.meta, "inscritos objetivo"],
|
||||||
|
["📈","Avance Inscritos", `${kpis.avance.toFixed(1)}%`, "total inscritos / meta"],
|
||||||
|
["📉","Deserción", `${kpis.desercion.toFixed(1)}%`, "retirados / total inscritos"],
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
async function exportarExcel() {
|
||||||
|
if (!datosVisibles.length) return;
|
||||||
|
// Cargar SheetJS desde CDN solo cuando se necesita
|
||||||
|
const XLSX = await import("xlsx");
|
||||||
|
const filas = datosVisibles.map((d) => ({
|
||||||
|
"PROGRAMA": d.programa_frecuencia || d.dsc_programa || "",
|
||||||
|
"FECHA INICIO": d.fch_inicio || "",
|
||||||
|
"DÍAS PARA INICIO": d.dias_para_inicio ?? "",
|
||||||
|
"TOTAL INSCRITOS": parseInt(d.Inscritos_Totales)||0,
|
||||||
|
"RETIRADOS": parseInt(d.Retirados ?? d.Inscritos_Retirados)||0,
|
||||||
|
"INSCRITOS EN CURSO": parseInt(d.Inscritos_Activos)||0,
|
||||||
|
"META INSCRITOS": parseInt(d.Meta_Curso)||0,
|
||||||
|
"AVANCE INSCRITOS": d.Avance_Inscritos || "",
|
||||||
|
"INSCRITOS MES": parseInt(d.Inscritos_Mes)||0,
|
||||||
|
"INSCRITOS P.C": parseInt(d.Inscritos_PC)||0,
|
||||||
|
"INSCRITOS REFRIPERU": parseInt(d.Descuento)||0,
|
||||||
|
"INSCRITOS CONTINUIDAD": parseInt(d.Inscritos_Continuidad)||0,
|
||||||
|
}));
|
||||||
|
const ws = XLSX.utils.json_to_sheet(filas);
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, "Ocupabilidad");
|
||||||
|
XLSX.writeFile(wb, `cursos_${ano}_${mes}.xlsx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">📊 Ocupabilidad</h1>
|
||||||
|
|
||||||
|
<Filters>
|
||||||
|
<Select label="Año" value={ano} options={ANOS} onChange={(v) => setAno(+v)} />
|
||||||
|
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v) => setMes(+v)} />
|
||||||
|
<Select label="Sede" value={sede} options={SEDES} onChange={setSede} />
|
||||||
|
<Select label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} />
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Reprogramados</label>
|
||||||
|
<label style={{display:"flex",alignItems:"center",gap:6,height:38,cursor:"pointer",fontSize:13}}>
|
||||||
|
<input type="checkbox" checked={mostrarRepro} onChange={(e)=>setMostrarRepro(e.target.checked)} />
|
||||||
|
Mostrar
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" style={{height:38,marginLeft:"auto"}} onClick={exportarExcel} disabled={!datosVisibles.length}>
|
||||||
|
⬇️ Exportar Excel
|
||||||
|
</button>
|
||||||
|
</Filters>
|
||||||
|
|
||||||
|
{loading ? <Loader text="Calculando ocupabilidad..." /> :
|
||||||
|
error ? <ErrorBox msg={error} /> :
|
||||||
|
<>
|
||||||
|
<div className="kpis">
|
||||||
|
{KPI_CARDS.map(([ico,label,val,sub]) => (
|
||||||
|
<div className="kpi" key={label}>
|
||||||
|
<div className="ico">{ico}</div>
|
||||||
|
<div className="label" style={{fontSize:15}}>{label}</div>
|
||||||
|
<div className="value">{val}</div>
|
||||||
|
<div className="sub">{sub}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-wrap" style={{overflowX:"auto"}}>
|
||||||
|
<table {...cols.tableProps}>
|
||||||
|
<cols.ColGroup />
|
||||||
|
<thead><tr>{COLS.map((c, i) => <th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={i} /></th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{datosVisibles.map((d, i) => {
|
||||||
|
const prog = d.programa_frecuencia || d.dsc_programa || "";
|
||||||
|
const dias = parseInt(d.dias_para_inicio);
|
||||||
|
const pct = toNum(d.Avance_Inscritos);
|
||||||
|
// Inicio reprogramado (existe en Supabase pero no en SQL este mes) → fila roja
|
||||||
|
const esInicio = String(d.dias_para_inicio).trim().toUpperCase() === "REPROGRAMADO";
|
||||||
|
const cel = (v) => esInicio ? "-" : v;
|
||||||
|
return (
|
||||||
|
<tr key={i} className={esInicio ? "row-inicio" : ""}>
|
||||||
|
<td className="col-name" style={{minWidth:300,maxWidth:480,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"}}>{prog}</td>
|
||||||
|
<td>{d.fch_inicio || ""}</td>
|
||||||
|
<td style={!esInicio && dias < 0 ? {color:"#dc2626",fontWeight:700} : {}}>{d.dias_para_inicio ?? ""}</td>
|
||||||
|
<td>{cel(parseInt(d.Inscritos_Totales)||0)}</td>
|
||||||
|
<td>{cel(parseInt(d.Retirados ?? d.Inscritos_Retirados)||0)}</td>
|
||||||
|
<td>{cel(parseInt(d.Inscritos_Activos)||0)}</td>
|
||||||
|
<td>{cel(parseInt(d.Meta_Curso)||0)}</td>
|
||||||
|
<td>{esInicio ? "-" : <ProgressBar pct={pct} />}</td>
|
||||||
|
<td>{cel(parseInt(d.Inscritos_Mes)||0)}</td>
|
||||||
|
<td>{cel(parseInt(d.Inscritos_PC)||0)}</td>
|
||||||
|
<td>{cel(parseInt(d.Descuento)||0)}</td>
|
||||||
|
<td>{cel(parseInt(d.Inscritos_Continuidad)||0)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{datosVisibles.length > 0 && (
|
||||||
|
<tr className="total-row">
|
||||||
|
<td className="col-name">TOTAL GENERAL</td>
|
||||||
|
<td>—</td><td>—</td>
|
||||||
|
<td>{totales.total}</td>
|
||||||
|
<td>{totales.retirados}</td>
|
||||||
|
<td>{totales.activos}</td>
|
||||||
|
<td>{totales.meta}</td>
|
||||||
|
<td><ProgressBar pct={totales.meta>0 ? totales.total/totales.meta*100 : 0} /></td>
|
||||||
|
<td>{totales.mes}</td><td>{totales.pc}</td><td>{totales.refri}</td><td>{totales.cont}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
270
frontend/src/pages/Rentabilidad.jsx
Normal file
270
frontend/src/pages/Rentabilidad.jsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
// src/pages/Rentabilidad.jsx
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||||
|
import Modal from "../components/Modal";
|
||||||
|
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||||||
|
|
||||||
|
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||||||
|
const ANOS = [2024, 2025, 2026];
|
||||||
|
const SEDES = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"];
|
||||||
|
const PROGRAMAS = ["TODOS","SEMINARIOS","OTROS","TEAC","TERC"];
|
||||||
|
|
||||||
|
const COLS = ["PROGRAMA","FECHA INICIO","TOTAL","RETIRADOS","EN CURSO","CUOTA PROM.","PROM. DESC. E.","V. VENTA INICIAL","V. COSTO INICIAL","M.B. INICIAL %","V. VENTA ACTUAL","V. COSTO ACTUAL","M.B. ACTUAL %","OPC."];
|
||||||
|
// índices: inicial = 7,8,9 | actual = 10,11,12
|
||||||
|
const BG_INI = "#eef4ff", BG_ACT = "#edfff6";
|
||||||
|
|
||||||
|
function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").replace("%","").trim()); return isNaN(n)?0:n; }
|
||||||
|
|
||||||
|
export default function Rentabilidad() {
|
||||||
|
const hoy = new Date();
|
||||||
|
const [ano, setAno] = useState(hoy.getFullYear());
|
||||||
|
const [mes, setMes] = useState(hoy.getMonth() + 1);
|
||||||
|
const [sede, setSede] = useState("TODOS");
|
||||||
|
const [programa, setPrograma] = useState("TODOS");
|
||||||
|
const [mostrarInscritos, setMostrarInscritos] = useState(true); // TOTAL, RETIRADOS, EN CURSO
|
||||||
|
const [filas, setFilas] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [modalVer, setModalVer] = useState(null);
|
||||||
|
// Índices de columnas TOTAL(2), RETIRADOS(3), EN CURSO(4) — se ocultan si el check está apagado
|
||||||
|
const IDX_INSCRITOS = [2, 3, 4];
|
||||||
|
const ocultar = (i) => !mostrarInscritos && IDX_INSCRITOS.includes(i);
|
||||||
|
const ANCHOS_BASE = [260, 110, 80, 90, 90, 110, 120, 120, 120, 110, 120, 120, 110, 80];
|
||||||
|
const anchosVisibles = ANCHOS_BASE.filter((_, i) => !ocultar(i));
|
||||||
|
const cols = useColumnasAjustables(anchosVisibles);
|
||||||
|
const [modalCostos, setModalCostos] = useState(null);
|
||||||
|
|
||||||
|
function cargar() {
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.rentabilidad(ano, mes, sede, programa)
|
||||||
|
.then((res) => { setFilas(res.filas || []); setLoading(false); })
|
||||||
|
.catch((e) => { setError(e.message); setLoading(false); });
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.rentabilidad(ano, mes, sede, programa)
|
||||||
|
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
||||||
|
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||||
|
return () => { activo = false; };
|
||||||
|
}, [ano, mes, sede, programa]);
|
||||||
|
|
||||||
|
const datos = useMemo(() => filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"), [filas]);
|
||||||
|
const total = useMemo(() => filas.find((f)=>String(f[0]).toUpperCase()==="TOTAL GENERAL"), [filas]);
|
||||||
|
|
||||||
|
const kpis = useMemo(() => {
|
||||||
|
if (!total) return null;
|
||||||
|
return {
|
||||||
|
programas: datos.length,
|
||||||
|
enCurso: total[4],
|
||||||
|
ventaAct: total[10],
|
||||||
|
costoAct: total[11],
|
||||||
|
mbAct: total[12],
|
||||||
|
};
|
||||||
|
}, [datos, total]);
|
||||||
|
|
||||||
|
function bgCol(ci){ if([7,8,9].includes(ci))return BG_INI; if([10,11,12].includes(ci))return BG_ACT; return ""; }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">📈 Rentabilidad</h1>
|
||||||
|
|
||||||
|
<Filters>
|
||||||
|
<Select label="Año" value={ano} options={ANOS} onChange={(v)=>setAno(+v)} />
|
||||||
|
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} />
|
||||||
|
<Select label="Sede" value={sede} options={SEDES} onChange={setSede} />
|
||||||
|
<Select label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} />
|
||||||
|
<div className="filter-group" style={{marginLeft:"auto"}}>
|
||||||
|
<label>Inscritos</label>
|
||||||
|
<label style={{display:"flex",alignItems:"center",gap:6,height:38,cursor:"pointer",fontSize:13}}>
|
||||||
|
<input type="checkbox" checked={mostrarInscritos} onChange={(e)=>setMostrarInscritos(e.target.checked)} />
|
||||||
|
Total / Retirados / En curso
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Filters>
|
||||||
|
|
||||||
|
{loading ? <Loader text="Calculando rentabilidad..." /> :
|
||||||
|
error ? <ErrorBox msg={error} /> :
|
||||||
|
<>
|
||||||
|
{kpis && (
|
||||||
|
<div className="kpis" style={{gridTemplateColumns:"repeat(5,1fr)"}}>
|
||||||
|
{[["📚","Programas",kpis.programas,"este mes"],
|
||||||
|
["🎓","En Curso",kpis.enCurso,"alumnos activos"],
|
||||||
|
["💵","Venta Actual",kpis.ventaAct,"ingreso total"],
|
||||||
|
["💸","Costo Actual",kpis.costoAct,"gasto total"],
|
||||||
|
["📊","M.B. Actual",kpis.mbAct,"margen bruto"]].map(([ico,label,val,sub])=>(
|
||||||
|
<div className="kpi" key={label}>
|
||||||
|
<div className="ico">{ico}</div><div className="label">{label}</div>
|
||||||
|
<div className="value" style={{fontSize:18}}>{val}</div><div className="sub">{sub}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="table-wrap" style={{marginTop:16, overflowX:"auto"}}>
|
||||||
|
<table {...cols.tableProps}>
|
||||||
|
<cols.ColGroup />
|
||||||
|
<thead><tr>{COLS.filter((_,i)=>!ocultar(i)).map((c, vi)=><th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={vi} /></th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{datos.map((f, ri) => (
|
||||||
|
<tr key={ri}>
|
||||||
|
{f.slice(0,13).map((v,ci)=>{
|
||||||
|
if (ocultar(ci)) return null;
|
||||||
|
const isPct = ci===9||ci===12;
|
||||||
|
return (
|
||||||
|
<td key={ci} className={ci===0?"col-name":""}
|
||||||
|
style={{background:bgCol(ci)||undefined,
|
||||||
|
textAlign: ci===0?"left":"center",
|
||||||
|
fontWeight:isPct?700:undefined,
|
||||||
|
minWidth:ci===0?260:undefined}}>{v}</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td style={{whiteSpace:"nowrap"}}>
|
||||||
|
<button className="btn btn-ghost" style={{padding:"3px 8px",fontSize:11,marginRight:4}}
|
||||||
|
onClick={()=>setModalVer({programa:f[0]})}>👁️</button>
|
||||||
|
<button className="btn btn-ghost" style={{padding:"3px 8px",fontSize:11}}
|
||||||
|
onClick={()=>setModalCostos({programa:f[0]})}>✏️</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{total && (
|
||||||
|
<tr className="total-row">
|
||||||
|
{total.slice(0,13).map((v,ci)=>(
|
||||||
|
ocultar(ci) ? null :
|
||||||
|
<td key={ci} className={ci===0?"col-name":""}
|
||||||
|
style={{textAlign:ci===0?"left":"center"}}>{v}</td>
|
||||||
|
))}
|
||||||
|
<td>—</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{modalVer && <ModalVerDetalle programa={modalVer.programa} ano={ano} mes={mes} onClose={()=>setModalVer(null)} />}
|
||||||
|
{modalCostos && <ModalCostos programa={modalCostos.programa} ano={ano} mes={mes} onClose={()=>setModalCostos(null)} onGuardado={cargar} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalVerDetalle({ programa, ano, mes, onClose }) {
|
||||||
|
const [filas, setFilas] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true; setLoading(true);
|
||||||
|
api.rentabilidadDetalle(programa, ano, mes)
|
||||||
|
.then((res)=>{ if(activo){setFilas(res.filas||[]);setLoading(false);} })
|
||||||
|
.catch(()=>{ if(activo){setFilas([]);setLoading(false);} });
|
||||||
|
return ()=>{activo=false;};
|
||||||
|
}, [programa, ano, mes]);
|
||||||
|
|
||||||
|
const COLS_DET = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","VALOR CUOTA","PROM. DESC. E.","VALOR VENTA"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={`👁️ Detalle — ${programa}`} onClose={onClose} width={1000}>
|
||||||
|
{loading ? <Loader text="Cargando detalle..." /> :
|
||||||
|
filas.length===0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin alumnos.</div> :
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr>{COLS_DET.map((c)=><th key={c}>{c}</th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{filas.map((f,i)=>(
|
||||||
|
<tr key={i}>
|
||||||
|
{f.map((v,j)=>(
|
||||||
|
<td key={j} className={j===1?"col-name":""} style={{textAlign:j>=5?"right":(j===1?"left":"center")}}>{v}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalCostos({ programa, ano, mes, onClose, onGuardado }) {
|
||||||
|
const [costos, setCostos] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [guardando, setGuardando] = useState(false);
|
||||||
|
const [msg, setMsg] = useState(null);
|
||||||
|
// [keyGuardar, etiqueta, prefijoBackend]
|
||||||
|
const CAMPOS = [["epp","COSTO EPP","epp"],["certificado","COSTO CERTIFICADO","cert"],["docente","COSTO DOCENTE","doc"],["marketing","COSTO MARKETING","mkt"],["consumibles","COSTO CONSUMIBLES","cons"]];
|
||||||
|
|
||||||
|
const [ini, setIni] = useState({});
|
||||||
|
const [act, setAct] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true; setLoading(true);
|
||||||
|
api.rentabilidadCostos(programa, ano, mes)
|
||||||
|
.then((res)=>{
|
||||||
|
if(!activo) return;
|
||||||
|
const c = res.costos || {};
|
||||||
|
setCostos(c);
|
||||||
|
const gi={}, ga={};
|
||||||
|
CAMPOS.forEach(([k,,bp])=>{ gi[k]=+(c[`${bp}_inicial`]||0); ga[k]=+(c[`${bp}_actual`]||0); });
|
||||||
|
setIni(gi); setAct(ga); setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(()=>{ if(activo){setCostos(null);setLoading(false);} });
|
||||||
|
return ()=>{activo=false;};
|
||||||
|
}, [programa, ano, mes]);
|
||||||
|
|
||||||
|
const totIni = Object.values(ini).reduce((a,b)=>a+(+b||0),0);
|
||||||
|
const totAct = Object.values(act).reduce((a,b)=>a+(+b||0),0);
|
||||||
|
|
||||||
|
async function guardar() {
|
||||||
|
setGuardando(true); setMsg(null);
|
||||||
|
try {
|
||||||
|
await api.guardarCostos(costos.num_indice, ini, act);
|
||||||
|
if (onGuardado) onGuardado(); // recarga la tabla principal
|
||||||
|
onClose(); // cierra el modal
|
||||||
|
} catch (e) {
|
||||||
|
setMsg({ ok:false, txt:"❌ Error al guardar. Verifica Supabase." });
|
||||||
|
setGuardando(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={`✏️ Editar Costos — ${programa}`} onClose={onClose} width={760}>
|
||||||
|
{loading ? <Loader text="Cargando costos..." /> :
|
||||||
|
!costos ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin datos de costos.</div> :
|
||||||
|
<>
|
||||||
|
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:16}}>
|
||||||
|
<Columna titulo="INICIAL" color="#1e3a5f" campos={CAMPOS} valores={ini} setVal={setIni} total={totIni} />
|
||||||
|
<Columna titulo="ACTUAL" color="#1a5f4a" campos={CAMPOS} valores={act} setVal={setAct} total={totAct} />
|
||||||
|
</div>
|
||||||
|
{msg && <div style={{marginTop:14,padding:10,borderRadius:8,fontSize:13,
|
||||||
|
background:msg.ok?"#d1fae5":"#fee2e2",color:msg.ok?"#065f46":"#991b1b"}}>{msg.txt}</div>}
|
||||||
|
<div style={{display:"flex",gap:10,marginTop:16}}>
|
||||||
|
<button className="btn btn-primary" disabled={guardando} onClick={guardar}>
|
||||||
|
{guardando ? "Guardando..." : "💾 Guardar"}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost" onClick={onClose}>Cerrar</button>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Columna({ titulo, color, campos, valores, setVal, total }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{background:color,color:"#fff",borderRadius:8,padding:"8px 12px",
|
||||||
|
marginBottom:10,fontWeight:600,fontSize:12,textAlign:"center"}}>{titulo}</div>
|
||||||
|
{campos.map(([k,label])=>(
|
||||||
|
<div key={k} style={{marginBottom:8}}>
|
||||||
|
<label style={{fontSize:11,fontWeight:600,color:"#64748b",display:"block",marginBottom:3}}>{label}</label>
|
||||||
|
<input type="number" value={valores[k]??0} min="0" step="100"
|
||||||
|
onChange={(e)=>setVal({...valores,[k]:+e.target.value})}
|
||||||
|
style={{width:"100%",padding:"7px 10px",border:"1px solid #cbd5e1",borderRadius:8,fontSize:13}} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{background:"#f1f5f9",borderRadius:8,padding:10,textAlign:"center",
|
||||||
|
fontWeight:700,color,marginTop:8}}>
|
||||||
|
TOTAL: S/ {total.toLocaleString("es-PE",{maximumFractionDigits:0})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
frontend/src/pages/SaldoPendiente.jsx
Normal file
96
frontend/src/pages/SaldoPendiente.jsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// src/pages/SaldoPendiente.jsx
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||||
|
|
||||||
|
const CUOTAS = ["1° Cuota","2° Cuota","3° Cuota","4° Cuota","5° Cuota"];
|
||||||
|
|
||||||
|
function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; }
|
||||||
|
function fmt(v){ const n=toNum(v); return "S/ " + n.toLocaleString("es-PE",{maximumFractionDigits:0}); }
|
||||||
|
|
||||||
|
export default function SaldoPendiente() {
|
||||||
|
const [tipoCuota, setTipoCuota] = useState("1° Cuota");
|
||||||
|
const [datos, setDatos] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [vendedor, setVendedor] = useState("TODOS");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.saldoPendiente(tipoCuota)
|
||||||
|
.then((res)=>{ if(activo){setDatos(res.datos||[]);setLoading(false);} })
|
||||||
|
.catch((e)=>{ if(activo){setError(e.message);setLoading(false);} });
|
||||||
|
return ()=>{activo=false;};
|
||||||
|
}, [tipoCuota]);
|
||||||
|
|
||||||
|
const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`;
|
||||||
|
const vendedores = useMemo(() => {
|
||||||
|
const set = new Set(datos.map((d)=>String(d.VENDEDOR||"").trim()).filter(Boolean));
|
||||||
|
return ["TODOS", ...Array.from(set).sort()];
|
||||||
|
}, [datos]);
|
||||||
|
|
||||||
|
const filtrados = useMemo(() => {
|
||||||
|
let arr = datos.filter((d)=>toNum(d[colSaldo])>0.01);
|
||||||
|
if (vendedor!=="TODOS") arr = arr.filter((d)=>String(d.VENDEDOR||"").trim().toUpperCase()===vendedor.toUpperCase());
|
||||||
|
return arr;
|
||||||
|
}, [datos, vendedor, colSaldo]);
|
||||||
|
|
||||||
|
const totalSaldo = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d[colSaldo]),0),[filtrados,colSaldo]);
|
||||||
|
const totalMat = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d["SALDO MAT."]),0),[filtrados]);
|
||||||
|
|
||||||
|
const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||||
|
const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">⏳ Saldo Pendiente</h1>
|
||||||
|
|
||||||
|
<Filters>
|
||||||
|
<Select label="Tipo de cuota" value={tipoCuota} options={CUOTAS} onChange={setTipoCuota} />
|
||||||
|
<Select label="Vendedor" value={vendedor} options={vendedores} onChange={setVendedor} />
|
||||||
|
</Filters>
|
||||||
|
|
||||||
|
{loading ? <Loader text="Calculando saldos..." /> :
|
||||||
|
error ? <ErrorBox msg={error} /> :
|
||||||
|
<>
|
||||||
|
<div className="kpis" style={{gridTemplateColumns:"repeat(3,1fr)"}}>
|
||||||
|
<div className="kpi"><div className="ico">👥</div><div className="label">Alumnos con Saldo</div><div className="value">{filtrados.length}</div></div>
|
||||||
|
<div className="kpi"><div className="ico">⏳</div><div className="label">Saldo {tipoCuota}</div><div className="value" style={{fontSize:20}}>{fmt(totalSaldo)}</div></div>
|
||||||
|
<div className="kpi"><div className="ico">📋</div><div className="label">Saldo Matrícula</div><div className="value" style={{fontSize:20}}>{fmt(totalMat)}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-wrap" style={{marginTop:16}}>
|
||||||
|
<table style={{minWidth:1100}}>
|
||||||
|
<thead><tr>{COLS.map((c)=><th key={c}>{c}</th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{filtrados.map((d,i)=>(
|
||||||
|
<tr key={i}>
|
||||||
|
{KEYS.map((k,j)=>{
|
||||||
|
const isMonto = k.includes("SALDO")||k.includes("INV");
|
||||||
|
return (
|
||||||
|
<td key={j} className={k==="ALUMNO"||k==="PROGRAMA"?"col-name":""}
|
||||||
|
style={{textAlign:isMonto?"right":(k==="ALUMNO"||k==="PROGRAMA"?"left":"center"),
|
||||||
|
fontWeight:k===colSaldo?700:undefined,
|
||||||
|
color:k===colSaldo?"#dc2626":undefined}}>
|
||||||
|
{isMonto ? fmt(d[k]) : (d[k] ?? "")}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{filtrados.length>0 && (
|
||||||
|
<tr className="total-row">
|
||||||
|
<td colSpan={6} style={{textAlign:"left"}}>TOTAL GENERAL ({filtrados.length})</td>
|
||||||
|
<td style={{textAlign:"right"}}>{fmt(totalMat)}</td>
|
||||||
|
<td style={{textAlign:"right"}}>{fmt(totalSaldo)}</td>
|
||||||
|
<td>—</td><td>—</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
frontend/src/pages/Usuarios.jsx
Normal file
130
frontend/src/pages/Usuarios.jsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// src/pages/Usuarios.jsx
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||||
|
|
||||||
|
const ROLES = ["ADMINISTRADOR", "VENTAS", "COBRANZA"];
|
||||||
|
|
||||||
|
export default function Usuarios() {
|
||||||
|
const [usuarios, setUsuarios] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// form nuevo usuario
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [nombre, setNombre] = useState("");
|
||||||
|
const [rol, setRol] = useState("COBRANZA");
|
||||||
|
const [creando, setCreando] = useState(false);
|
||||||
|
const [msg, setMsg] = useState(null);
|
||||||
|
|
||||||
|
const cargar = useCallback(() => {
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.usuariosListar()
|
||||||
|
.then((res) => { setUsuarios(res.usuarios || []); setLoading(false); })
|
||||||
|
.catch((e) => { setError(e.message); setLoading(false); });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { cargar(); }, [cargar]);
|
||||||
|
|
||||||
|
async function crear() {
|
||||||
|
setMsg(null);
|
||||||
|
if (!email.trim() || !password.trim()) { setMsg({ ok:false, txt:"Correo y contraseña son obligatorios." }); return; }
|
||||||
|
setCreando(true);
|
||||||
|
try {
|
||||||
|
await api.usuariosCrear(email.trim(), password, nombre.trim(), rol);
|
||||||
|
setMsg({ ok:true, txt:`Usuario ${email} creado como ${rol}.` });
|
||||||
|
setEmail(""); setPassword(""); setNombre(""); setRol("COBRANZA");
|
||||||
|
cargar();
|
||||||
|
} catch (e) {
|
||||||
|
setMsg({ ok:false, txt:"Error: " + e.message });
|
||||||
|
}
|
||||||
|
setCreando(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cambiarRol(id, nuevoRol) {
|
||||||
|
try { await api.usuariosActualizarRol(id, nuevoRol); cargar(); }
|
||||||
|
catch (e) { alert("No se pudo cambiar el rol: " + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eliminar(id, correo) {
|
||||||
|
if (!confirm(`¿Eliminar al usuario ${correo}? Esta acción no se puede deshacer.`)) return;
|
||||||
|
try { await api.usuariosEliminar(id); cargar(); }
|
||||||
|
catch (e) { alert("No se pudo eliminar: " + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">🔐 Usuarios</h1>
|
||||||
|
|
||||||
|
{/* Crear nuevo usuario */}
|
||||||
|
<div style={{ background:"#fff", border:"1px solid #e2e8f0", borderRadius:12, padding:18, marginBottom:20 }}>
|
||||||
|
<h3 style={{ fontSize:15, fontWeight:700, color:"#0f172a", marginBottom:14 }}>➕ Crear nuevo usuario</h3>
|
||||||
|
<div style={{ display:"flex", gap:12, flexWrap:"wrap", alignItems:"flex-end" }}>
|
||||||
|
<Campo label="Correo">
|
||||||
|
<input value={email} onChange={(e)=>setEmail(e.target.value)} placeholder="usuario@escuela.com" style={inp} />
|
||||||
|
</Campo>
|
||||||
|
<Campo label="Contraseña">
|
||||||
|
<input type="text" value={password} onChange={(e)=>setPassword(e.target.value)} placeholder="mín. 6 caracteres" style={inp} />
|
||||||
|
</Campo>
|
||||||
|
<Campo label="Nombre">
|
||||||
|
<input value={nombre} onChange={(e)=>setNombre(e.target.value)} placeholder="Nombre completo" style={inp} />
|
||||||
|
</Campo>
|
||||||
|
<Campo label="Rol">
|
||||||
|
<Select value={rol} options={ROLES} onChange={setRol} />
|
||||||
|
</Campo>
|
||||||
|
<button className="btn btn-primary" style={{height:38}} disabled={creando} onClick={crear}>
|
||||||
|
{creando ? "Creando..." : "Crear usuario"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{msg && (
|
||||||
|
<div style={{ marginTop:12, padding:10, borderRadius:8, fontSize:13,
|
||||||
|
background: msg.ok ? "#d1fae5" : "#fee2e2", color: msg.ok ? "#065f46" : "#991b1b" }}>
|
||||||
|
{msg.ok ? "✅" : "⚠️"} {msg.txt}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista de usuarios */}
|
||||||
|
{loading ? <Loader text="Cargando usuarios..." /> :
|
||||||
|
error ? <ErrorBox msg={error} /> :
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>CORREO</th><th>NOMBRE</th><th>ROL</th><th>ACCIONES</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{usuarios.map((u) => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td className="col-name">{u.email}</td>
|
||||||
|
<td>{u.nombre || "—"}</td>
|
||||||
|
<td>
|
||||||
|
<select value={u.rol} onChange={(e)=>cambiarRol(u.id, e.target.value)}
|
||||||
|
style={{ padding:"5px 8px", border:"1px solid #cbd5e1", borderRadius:6, fontSize:12 }}>
|
||||||
|
{ROLES.map((r)=><option key={r} value={r}>{r}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn" style={{ padding:"4px 10px", fontSize:11, background:"#fee2e2", color:"#991b1b" }}
|
||||||
|
onClick={()=>eliminar(u.id, u.email)}>🗑️ Eliminar</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{usuarios.length === 0 && (
|
||||||
|
<tr><td colSpan={4} style={{ padding:20, textAlign:"center", color:"#94a3b8" }}>Sin usuarios.</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Campo({ label, children }) {
|
||||||
|
return (
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>{label}</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inp = { padding:"8px 12px", border:"1px solid #cbd5e1", borderRadius:8, fontSize:13, minWidth:180 };
|
||||||
266
frontend/src/pages/Ventas.jsx
Normal file
266
frontend/src/pages/Ventas.jsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
// src/pages/Ventas.jsx
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||||
|
import Modal from "../components/Modal";
|
||||||
|
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||||||
|
|
||||||
|
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||||||
|
const ANOS = [2024, 2025, 2026];
|
||||||
|
const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"];
|
||||||
|
const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"];
|
||||||
|
const COLS = ["VENDEDOR","INSCRITOS","VENTA TOTAL","INSCRITOS P.C DEL MES","VENTA P.C DEL MES","INSCRITOS MES ANTERIOR","VENTA MES ANTERIOR","AVANCE P.C TOTAL","OPC."];
|
||||||
|
const TIPOS = ["Venta Inscritos", "Venta P.C", "Venta Pendientes"];
|
||||||
|
const HEADERS_DETALLE = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","CUOTA PROM.","DESC. ESPECIAL","INV. NETA"];
|
||||||
|
|
||||||
|
export default function Ventas() {
|
||||||
|
const hoy = new Date();
|
||||||
|
const [ano, setAno] = useState(hoy.getFullYear());
|
||||||
|
const [mes, setMes] = useState(hoy.getMonth() + 1);
|
||||||
|
const [sede, setSede] = useState("TODOS");
|
||||||
|
const [programa, setPrograma] = useState("TODOS");
|
||||||
|
const [filas, setFilas] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [modal, setModal] = useState(null); // { vendedor }
|
||||||
|
const cols = useColumnasAjustables([200, 100, 130, 120, 130, 120, 130, 140, 100]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.ventas(ano, mes, sede, programa)
|
||||||
|
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
||||||
|
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||||
|
return () => { activo = false; };
|
||||||
|
}, [ano, mes, sede, programa]);
|
||||||
|
|
||||||
|
const total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]);
|
||||||
|
const datos = useMemo(() => filas.filter((f) => f[0] !== "TOTAL GENERAL"), [filas]);
|
||||||
|
|
||||||
|
const KPIS = total ? [
|
||||||
|
["👤","Vendedores", datos.length, "activos este mes"],
|
||||||
|
["🎓","Total Inscritos", total[1], "matriculados"],
|
||||||
|
["💵","Venta Total", total[2], "inversión neta"],
|
||||||
|
["✅","Venta P.C del Mes", total[4], "pagos completos"],
|
||||||
|
["🚀","Avance P.C Total", total[7], "P.C + Pendientes"],
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
// Exportar saldos por cuota — Excel con formato (anchos, encabezado de color,
|
||||||
|
// fechas reales, montos numéricos en soles y fila TOTAL GENERAL).
|
||||||
|
async function exportarCuota(tipoCuota) {
|
||||||
|
const toNum = (v) => { const n = parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; };
|
||||||
|
// Convierte "dd-mm-yyyy" o "dd/mm/yyyy" a Date; si no puede, devuelve el texto original
|
||||||
|
const toDate = (v) => {
|
||||||
|
if (!v) return "";
|
||||||
|
const s = String(v).trim().replace(/\//g,"-");
|
||||||
|
const p = s.split("-");
|
||||||
|
if (p.length === 3) {
|
||||||
|
let [d,m,y] = p;
|
||||||
|
if (y.length === 4) return new Date(+y, +m-1, +d);
|
||||||
|
}
|
||||||
|
return String(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await api.saldoPendiente(tipoCuota);
|
||||||
|
} catch (e) {
|
||||||
|
alert("No se pudo obtener los datos de saldos: " + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const datosS = res.datos || [];
|
||||||
|
const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`;
|
||||||
|
const filtrados = datosS.filter((d) => toNum(d[colSaldo]) > 0.01);
|
||||||
|
if (filtrados.length === 0) { alert(`No hay saldos pendientes para ${tipoCuota}.`); return; }
|
||||||
|
|
||||||
|
const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||||
|
const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||||
|
const COL_MONTO = ["SALDO MAT.", colSaldo, "INV. NETA"];
|
||||||
|
const COL_FECHA = ["F. INICIO","F. MATR.","VENCIMIENTO"];
|
||||||
|
|
||||||
|
const XLSX = await import("xlsx-js-style");
|
||||||
|
|
||||||
|
// Construir matriz de celdas (AOA) con tipos correctos
|
||||||
|
const headerStyle = {
|
||||||
|
font: { bold: true, color: { rgb: "FFFFFF" }, sz: 11 },
|
||||||
|
fill: { fgColor: { rgb: "1E40AF" } },
|
||||||
|
alignment: { horizontal: "center", vertical: "center" },
|
||||||
|
};
|
||||||
|
const moneyFmt = '"S/" #,##0';
|
||||||
|
const dateFmt = "dd/mm/yyyy";
|
||||||
|
|
||||||
|
const ws = {};
|
||||||
|
const range = { s: { r: 0, c: 0 }, e: { r: filtrados.length + 1, c: COLS.length - 1 } };
|
||||||
|
|
||||||
|
// Encabezado
|
||||||
|
COLS.forEach((h, c) => {
|
||||||
|
const ref = XLSX.utils.encode_cell({ r: 0, c });
|
||||||
|
ws[ref] = { v: h, t: "s", s: headerStyle };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filas de datos
|
||||||
|
filtrados.forEach((d, ri) => {
|
||||||
|
KEYS.forEach((k, c) => {
|
||||||
|
const ref = XLSX.utils.encode_cell({ r: ri + 1, c });
|
||||||
|
const col = COLS[c];
|
||||||
|
if (COL_MONTO.includes(col)) {
|
||||||
|
ws[ref] = { v: toNum(d[k]), t: "n", z: moneyFmt };
|
||||||
|
} else if (COL_FECHA.includes(col)) {
|
||||||
|
const dt = toDate(d[k]);
|
||||||
|
if (dt instanceof Date) ws[ref] = { v: dt, t: "d", z: dateFmt };
|
||||||
|
else ws[ref] = { v: dt, t: "s" };
|
||||||
|
} else {
|
||||||
|
ws[ref] = { v: d[k] ?? "", t: "s" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fila TOTAL GENERAL: promedio en SALDO MAT. y SALDO CUOTA, suma en INV. NETA
|
||||||
|
const rTot = filtrados.length + 1;
|
||||||
|
const sum = (k) => filtrados.reduce((s,d)=>s+toNum(d[k]),0);
|
||||||
|
const prom = (k) => filtrados.length ? sum(k)/filtrados.length : 0;
|
||||||
|
const totalStyle = { font: { bold: true }, fill: { fgColor: { rgb: "EFF6FF" } } };
|
||||||
|
COLS.forEach((col, c) => {
|
||||||
|
const ref = XLSX.utils.encode_cell({ r: rTot, c });
|
||||||
|
if (c === 0) ws[ref] = { v: "TOTAL GENERAL", t: "s", s: totalStyle };
|
||||||
|
else if (col === "SALDO MAT.") ws[ref] = { v: prom("SALDO MAT."), t: "n", z: moneyFmt, s: totalStyle };
|
||||||
|
else if (col === colSaldo) ws[ref] = { v: prom(colSaldo), t: "n", z: moneyFmt, s: totalStyle };
|
||||||
|
else if (col === "INV. NETA") ws[ref] = { v: sum("INV. NETA"), t: "n", z: moneyFmt, s: totalStyle };
|
||||||
|
else ws[ref] = { v: "", t: "s", s: totalStyle };
|
||||||
|
});
|
||||||
|
|
||||||
|
ws["!ref"] = XLSX.utils.encode_range(range);
|
||||||
|
ws["!cols"] = [
|
||||||
|
{ wch: 12 }, // MATRÍCULA
|
||||||
|
{ wch: 22 }, // VENDEDOR
|
||||||
|
{ wch: 28 }, // ALUMNO
|
||||||
|
{ wch: 32 }, // PROGRAMA
|
||||||
|
{ wch: 12 }, // F. INICIO
|
||||||
|
{ wch: 12 }, // F. MATR.
|
||||||
|
{ wch: 13 }, // SALDO MAT.
|
||||||
|
{ wch: 15 }, // SALDO CUOTA
|
||||||
|
{ wch: 13 }, // VENCIMIENTO
|
||||||
|
{ wch: 13 }, // INV. NETA
|
||||||
|
];
|
||||||
|
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, "Saldos");
|
||||||
|
const nombre = tipoCuota.replace("°","").replace(/\s/g,"_");
|
||||||
|
XLSX.writeFile(wb, `saldos_${nombre}.xlsx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">🛒 Ventas</h1>
|
||||||
|
|
||||||
|
<Filters>
|
||||||
|
<Select label="Año" value={ano} options={ANOS} onChange={(v) => setAno(+v)} />
|
||||||
|
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v) => setMes(+v)} />
|
||||||
|
<Select label="Sede" value={sede} options={SEDES} onChange={setSede} />
|
||||||
|
<Select label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} />
|
||||||
|
<div style={{display:"flex",gap:8,alignItems:"flex-end",marginLeft:"auto"}}>
|
||||||
|
<button className="btn btn-primary" style={{height:38}} onClick={() => exportarCuota("1° Cuota")}>
|
||||||
|
⬇️ Exportar 1° Cuota
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" style={{height:38}} onClick={() => exportarCuota("2° Cuota")}>
|
||||||
|
⬇️ Exportar 2° Cuota
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Filters>
|
||||||
|
|
||||||
|
{loading ? <Loader text="Calculando ventas..." /> :
|
||||||
|
error ? <ErrorBox msg={error} /> :
|
||||||
|
<>
|
||||||
|
<div className="kpis" style={{ gridTemplateColumns: "repeat(5,1fr)" }}>
|
||||||
|
{KPIS.map(([ico,label,val,sub]) => (
|
||||||
|
<div className="kpi" key={label}>
|
||||||
|
<div className="ico">{ico}</div>
|
||||||
|
<div className="label" style={{fontSize:15}}>{label}</div>
|
||||||
|
<div className="value" style={{fontSize:20}}>{val}</div>
|
||||||
|
<div className="sub">{sub}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-wrap" style={{overflowX:"auto"}}>
|
||||||
|
<table {...cols.tableProps}>
|
||||||
|
<cols.ColGroup />
|
||||||
|
<thead><tr>{COLS.map((c, i) => <th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={i} /></th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{datos.map((f, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="col-name">{f[0]}</td>
|
||||||
|
<td>{f[1]}</td><td>{f[2]}</td>
|
||||||
|
<td>{f[3]}</td><td>{f[4]}</td>
|
||||||
|
<td>{f[5]}</td><td>{f[6]}</td>
|
||||||
|
<td style={{fontWeight:600}}>{f[7]}</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
|
||||||
|
onClick={() => setModal({ vendedor: f[0] })}>👁️ Ver</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{total && (
|
||||||
|
<tr className="total-row">
|
||||||
|
<td className="col-name">TOTAL GENERAL</td>
|
||||||
|
<td>{total[1]}</td><td>{total[2]}</td>
|
||||||
|
<td>{total[3]}</td><td>{total[4]}</td>
|
||||||
|
<td>{total[5]}</td><td>{total[6]}</td>
|
||||||
|
<td>{total[7]}</td><td>—</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{modal && (
|
||||||
|
<ModalDetalle vendedor={modal.vendedor} ano={ano} mes={mes} onClose={() => setModal(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalDetalle({ vendedor, ano, mes, onClose }) {
|
||||||
|
const [tipo, setTipo] = useState(TIPOS[0]);
|
||||||
|
const [filas, setFilas] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
setLoading(true);
|
||||||
|
api.ventasDetalle(vendedor, ano, mes, tipo)
|
||||||
|
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
||||||
|
.catch(() => { if (activo) { setFilas([]); setLoading(false); } });
|
||||||
|
return () => { activo = false; };
|
||||||
|
}, [vendedor, ano, mes, tipo]);
|
||||||
|
|
||||||
|
// Columnas redimensionables (igual que las tablas principales)
|
||||||
|
const colsDet = useColumnasAjustables([200, 260, 120, 130, 120, 120, 130, 120]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={`👤 ${vendedor}`} onClose={onClose} width={1500}>
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<Select label="Tipo de venta" value={tipo} options={TIPOS} onChange={setTipo} />
|
||||||
|
</div>
|
||||||
|
{loading ? <Loader text="Cargando detalle..." /> :
|
||||||
|
filas.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin registros para esta categoría.</div> :
|
||||||
|
<div className="table-wrap" style={{overflowX:"auto"}}>
|
||||||
|
<table {...colsDet.tableProps}>
|
||||||
|
<colsDet.ColGroup />
|
||||||
|
<thead><tr>{HEADERS_DETALLE.map((h, i) => <th key={h} style={{position:"relative", whiteSpace:"nowrap"}}>{h}<colsDet.Resizer index={i} /></th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{filas.map((f, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{f.slice(0, HEADERS_DETALLE.length).map((v, j) => (
|
||||||
|
<td key={j} className={j === 1 ? "col-name" : ""}
|
||||||
|
style={{textAlign:"center", whiteSpace:"nowrap", overflow:"hidden", textOverflow:"ellipsis"}}>{v}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
frontend/src/styles.css
Normal file
127
frontend/src/styles.css
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/* src/styles.css */
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', -apple-system, Arial, sans-serif;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layout ── */
|
||||||
|
.app { display: flex; min-height: 100vh; }
|
||||||
|
|
||||||
|
/* ── Sidebar ── */
|
||||||
|
.sidebar {
|
||||||
|
width: 240px; background: #0f172a; color: #e2e8f0;
|
||||||
|
display: flex; flex-direction: column; padding: 0;
|
||||||
|
position: fixed; height: 100vh; left: 0; top: 0;
|
||||||
|
}
|
||||||
|
.sidebar-logo {
|
||||||
|
padding: 22px 20px; font-size: 16px; font-weight: 700; color: #fff;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.08); letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
.sidebar-logo span { color: #60a5fa; }
|
||||||
|
.nav { padding: 12px 10px; flex: 1; }
|
||||||
|
.nav-item {
|
||||||
|
display: flex; align-items: center; gap: 11px; padding: 11px 14px;
|
||||||
|
border-radius: 10px; cursor: pointer; font-size: 13.5px; font-weight: 500;
|
||||||
|
color: #cbd5e1; margin-bottom: 3px; transition: all 0.15s; border: none;
|
||||||
|
background: transparent; width: 100%; text-align: left;
|
||||||
|
}
|
||||||
|
.nav-item:hover { background: rgba(255,255,255,0.06); color: #fff; }
|
||||||
|
.nav-item.active { background: #2563eb; color: #fff; }
|
||||||
|
.nav-item .ico { font-size: 16px; }
|
||||||
|
|
||||||
|
/* ── Main content ── */
|
||||||
|
.main { margin-left: 240px; flex: 1; padding: 22px 28px; min-width: 0; overflow-x: hidden; }
|
||||||
|
.page-title { font-size: 22px; font-weight: 700; color: #0f172a; margin-bottom: 14px; }
|
||||||
|
|
||||||
|
/* ── Filters bar ── */
|
||||||
|
.filters { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 18px; align-items: flex-end; }
|
||||||
|
.filter-group { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.filter-group label {
|
||||||
|
font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.filter-group select {
|
||||||
|
padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 8px;
|
||||||
|
font-size: 13px; background: #fff; color: #0f172a; cursor: pointer; min-width: 130px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 9px 16px; border: none; border-radius: 8px; font-size: 13px; font-weight: 600;
|
||||||
|
cursor: pointer; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.btn-primary { background: #2563eb; color: #fff; }
|
||||||
|
.btn-primary:hover { background: #1d4ed8; }
|
||||||
|
.btn-ghost { background: #f1f5f9; color: #475569; border: 1px solid #cbd5e1; }
|
||||||
|
.btn-ghost:hover { background: #e2e8f0; }
|
||||||
|
|
||||||
|
/* ── KPI cards ── */
|
||||||
|
.kpis { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 18px; }
|
||||||
|
.kpi {
|
||||||
|
background: #fff; border-radius: 12px; padding: 16px; border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.kpi .ico { font-size: 18px; margin-bottom: 4px; }
|
||||||
|
.kpi .label { font-size: 10px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.6px; }
|
||||||
|
.kpi .value { font-size: 24px; font-weight: 700; color: #0f172a; line-height: 1.3; }
|
||||||
|
.kpi .sub { font-size: 11px; color: #94a3b8; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ── Table ── */
|
||||||
|
.table-wrap {
|
||||||
|
background: #fff; border-radius: 12px; border: 1px solid #e2e8f0;
|
||||||
|
overflow: hidden; overflow-x: auto; max-width: 100%;
|
||||||
|
}
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
thead th {
|
||||||
|
background: #334155; color: #f1f5f9; padding: 11px 10px; font-size: 11px;
|
||||||
|
font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px;
|
||||||
|
white-space: nowrap; text-align: center; position: sticky; top: 0;
|
||||||
|
border-right: 1px solid #475569;
|
||||||
|
}
|
||||||
|
thead th:last-child { border-right: none; }
|
||||||
|
tbody td { padding: 9px 8px; text-align: center; font-size: 12px; color: #374151; border-bottom: 1px solid #eef2f7; border-right: 1px solid #f1f5f9; }
|
||||||
|
tbody td:last-child { border-right: none; }
|
||||||
|
tbody tr:nth-child(even) { background: #f8fafc; }
|
||||||
|
tbody tr:hover td { background: #eff6ff; }
|
||||||
|
tr.total-row td { background: #eff6ff !important; font-weight: 700; color: #1e40af; }
|
||||||
|
.col-name { text-align: left !important; font-weight: 500; color: #111827; }
|
||||||
|
|
||||||
|
/* ── Progress bar ── */
|
||||||
|
.pbar { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.pbar .pct { font-size: 11px; font-weight: 700; min-width: 42px; text-align: right; }
|
||||||
|
.pbar .track { flex: 1; background: #e2e8f0; border-radius: 5px; height: 6px; overflow: hidden; min-width: 50px; }
|
||||||
|
.pbar .fill { height: 100%; border-radius: 5px; }
|
||||||
|
|
||||||
|
/* ── Loader ── */
|
||||||
|
.loader-wrap { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px; }
|
||||||
|
.spinner {
|
||||||
|
width: 44px; height: 44px; border: 4px solid #e2e8f0; border-top-color: #2563eb;
|
||||||
|
border-radius: 50%; animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.loader-text { margin-top: 14px; font-size: 13px; color: #64748b; }
|
||||||
|
|
||||||
|
/* ── Error ── */
|
||||||
|
.error-box {
|
||||||
|
background: #fee2e2; border: 1px solid #fca5a5; color: #991b1b;
|
||||||
|
padding: 16px; border-radius: 10px; font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cobranza: colores por cartera + hover por cartera ── */
|
||||||
|
.cob-table td.cart-ant { background: #fef3c7; }
|
||||||
|
.cob-table td.cart-mes { background: #d1fae5; }
|
||||||
|
.cob-table td.cart-tot { background: #ede9fe; }
|
||||||
|
.cob-table tbody tr:hover td.cart-ant { background: #fde68a !important; }
|
||||||
|
.cob-table tbody tr:hover td.cart-mes { background: #a7f3d0 !important; }
|
||||||
|
.cob-table tbody tr:hover td.cart-tot { background: #ddd6fe !important; }
|
||||||
|
/* Filas con deuda: rojo y su hover, prevalece sobre cartera */
|
||||||
|
.cob-table tbody tr.row-deuda td { background: #fee2e2 !important; }
|
||||||
|
.cob-table tbody tr.row-deuda:hover td { background: #fecaca !important; }
|
||||||
|
/* Hover normal de celdas sin cartera */
|
||||||
|
.cob-table tbody tr:hover td:not(.cart-ant):not(.cart-mes):not(.cart-tot) { background: #f1f5f9; }
|
||||||
|
|
||||||
|
/* ── Ocupabilidad: fila de Inicio reprogramado (desde Supabase) en rojo ── */
|
||||||
|
tbody tr.row-inicio td { background: #fecaca !important; color: #7f1d1d; font-weight: 600; }
|
||||||
|
tbody tr.row-inicio:hover td { background: #fca5a5 !important; }
|
||||||
|
|
||||||
|
/* Zona de redimensionado de columnas (popups con columnas ajustables) */
|
||||||
|
.col-resizer { transition: background 0.15s; }
|
||||||
|
.col-resizer:hover { background: #93c5fd; }
|
||||||
7
frontend/vite.config.js
Normal file
7
frontend/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: { port: 3000, host: true }
|
||||||
|
})
|
||||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Dashboard — Escuela de Refrigeración</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1980
package-lock.json
generated
Normal file
1980
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "dashboard-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.107.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
|
"xlsx-js-style": "^1.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/App.jsx
Normal file
65
src/App.jsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// src/App.jsx
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useAuth } from "./lib/auth";
|
||||||
|
import Sidebar from "./components/Sidebar";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
import Ocupabilidad from "./pages/Ocupabilidad";
|
||||||
|
import Ventas from "./pages/Ventas";
|
||||||
|
import Comisiones from "./pages/Comisiones";
|
||||||
|
import Cobranza from "./pages/Cobranza";
|
||||||
|
import Rentabilidad from "./pages/Rentabilidad";
|
||||||
|
import Asesores from "./pages/Asesores";
|
||||||
|
import Usuarios from "./pages/Usuarios";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { autenticado, cargando, permisos, puedeVer } = useAuth();
|
||||||
|
const [pagina, setPagina] = useState(null);
|
||||||
|
|
||||||
|
// Al entrar, seleccionar el primer módulo permitido
|
||||||
|
useEffect(() => {
|
||||||
|
if (autenticado && permisos.length > 0 && !pagina) {
|
||||||
|
setPagina(permisos[0]);
|
||||||
|
}
|
||||||
|
if (!autenticado) setPagina(null);
|
||||||
|
}, [autenticado, permisos, pagina]);
|
||||||
|
|
||||||
|
if (cargando) {
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight:"100vh", display:"flex", alignItems:"center", justifyContent:"center", background:"#0f172a", color:"#fff" }}>
|
||||||
|
Cargando...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!autenticado) return <Login />;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
// Seguridad: si la página actual no está permitida, no renderizar
|
||||||
|
if (pagina && !puedeVer(pagina)) return <SinAcceso />;
|
||||||
|
switch (pagina) {
|
||||||
|
case "ocupabilidad": return <Ocupabilidad />;
|
||||||
|
case "ventas": return <Ventas />;
|
||||||
|
case "comisiones": return <Comisiones />;
|
||||||
|
case "cobranza": return <Cobranza />;
|
||||||
|
case "rentabilidad": return <Rentabilidad />;
|
||||||
|
case "asesores": return <Asesores />;
|
||||||
|
case "usuarios": return <Usuarios />;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<Sidebar active={pagina} onChange={setPagina} />
|
||||||
|
<main className="main">{render()}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SinAcceso() {
|
||||||
|
return (
|
||||||
|
<div style={{ padding:40, textAlign:"center", color:"#94a3b8" }}>
|
||||||
|
🔒 No tienes acceso a este módulo.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/Modal.jsx
Normal file
34
src/components/Modal.jsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// src/components/Modal.jsx
|
||||||
|
export default function Modal({ title, onClose, children, width = 900 }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, background: "rgba(15,23,42,0.55)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
zIndex: 1000, padding: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: "#fff", borderRadius: 16, width: "100%", maxWidth: width,
|
||||||
|
maxHeight: "90vh", overflow: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||||
|
padding: "16px 22px", borderBottom: "1px solid #e2e8f0", position: "sticky",
|
||||||
|
top: 0, background: "#fff", zIndex: 2,
|
||||||
|
}}>
|
||||||
|
<h3 style={{ fontSize: 16, fontWeight: 700, color: "#1e40af" }}>{title}</h3>
|
||||||
|
<button onClick={onClose} style={{
|
||||||
|
border: "none", background: "#f1f5f9", borderRadius: 8, width: 32, height: 32,
|
||||||
|
cursor: "pointer", fontSize: 18, color: "#475569",
|
||||||
|
}}>×</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 22 }}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/components/Sidebar.jsx
Normal file
54
src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// src/components/Sidebar.jsx
|
||||||
|
import { useAuth } from "../lib/auth";
|
||||||
|
|
||||||
|
const MENU = [
|
||||||
|
{ id: "ocupabilidad", ico: "📊", label: "Ocupabilidad" },
|
||||||
|
{ id: "ventas", ico: "💰", label: "Ventas" },
|
||||||
|
{ id: "comisiones", ico: "🧮", label: "Comisiones" },
|
||||||
|
{ id: "cobranza", ico: "📋", label: "Cobranza" },
|
||||||
|
{ id: "rentabilidad", ico: "📈", label: "Rentabilidad" },
|
||||||
|
{ id: "asesores", ico: "👥", label: "Asesores" },
|
||||||
|
{ id: "usuarios", ico: "🔐", label: "Usuarios" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Sidebar({ active, onChange }) {
|
||||||
|
const { perfil, permisos, logout } = useAuth();
|
||||||
|
const visibles = MENU.filter((m) => permisos.includes(m.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-logo">Escuela <span>Refrigeración</span></div>
|
||||||
|
|
||||||
|
<nav className="nav" style={{ flex: 1 }}>
|
||||||
|
{visibles.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
className={`nav-item ${active === m.id ? "active" : ""}`}
|
||||||
|
onClick={() => onChange(m.id)}
|
||||||
|
>
|
||||||
|
<span className="ico">{m.ico}</span> {m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div style={{ padding: 14, borderTop: "1px solid rgba(255,255,255,0.08)" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#94a3b8", marginBottom: 2 }}>
|
||||||
|
{perfil?.nombre || "Usuario"}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#60a5fa", fontWeight: 600, marginBottom: 10 }}>
|
||||||
|
{perfil?.rol || ""}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "8px", border: "1px solid rgba(255,255,255,0.15)",
|
||||||
|
borderRadius: 8, background: "transparent", color: "#cbd5e1", fontSize: 12,
|
||||||
|
cursor: "pointer", fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🚪 Cerrar sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/components/UI.jsx
Normal file
51
src/components/UI.jsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// src/components/UI.jsx
|
||||||
|
export function Loader({ text = "Cargando..." }) {
|
||||||
|
return (
|
||||||
|
<div className="loader-wrap">
|
||||||
|
<div className="spinner" />
|
||||||
|
<div className="loader-text">{text}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBox({ msg }) {
|
||||||
|
return <div className="error-box">⚠️ {msg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function colorSemaforo(pct) {
|
||||||
|
if (pct >= 80) return { bg: "#d1fae5", fg: "#065f46", fill: "#10b981" };
|
||||||
|
if (pct >= 60) return { bg: "#fef9c3", fg: "#854d0e", fill: "#f59e0b" };
|
||||||
|
return { bg: "#fee2e2", fg: "#991b1b", fill: "#ef4444" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBar({ pct }) {
|
||||||
|
const c = colorSemaforo(pct);
|
||||||
|
const w = Math.min(Math.max(pct, 0), 100);
|
||||||
|
return (
|
||||||
|
<div className="pbar">
|
||||||
|
<span className="pct" style={{ color: c.fg }}>{pct.toFixed(1)}%</span>
|
||||||
|
<div className="track">
|
||||||
|
<div className="fill" style={{ width: `${w}%`, background: c.fill }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Filters({ children }) {
|
||||||
|
return <div className="filters">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({ label, value, options, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="filter-group">
|
||||||
|
{label && <label>{label}</label>}
|
||||||
|
<select value={value} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
{options.map((o) => {
|
||||||
|
const val = typeof o === "object" ? o.value : o;
|
||||||
|
const txt = typeof o === "object" ? o.label : o;
|
||||||
|
return <option key={val} value={val}>{txt}</option>;
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/lib/api.js
Normal file
73
src/lib/api.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// src/lib/api.js
|
||||||
|
// Cliente para el backend FastAPI. Cambia BASE_URL si el backend corre en otra IP.
|
||||||
|
const BASE_URL = "http://localhost:8000";
|
||||||
|
|
||||||
|
async function get(path, params = {}) {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`;
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error(`Error ${r.status} en ${path}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson(path, body) {
|
||||||
|
const r = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`Error ${r.status} en ${path}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(path, params = {}) {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
const url = `${BASE_URL}${path}${qs ? "?" + qs : ""}`;
|
||||||
|
const r = await fetch(url, { method: "POST" });
|
||||||
|
if (!r.ok) throw new Error(`Error ${r.status} en ${path}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
periodoActual: () => get("/api/periodo-actual"),
|
||||||
|
ocupabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") =>
|
||||||
|
get("/api/ocupabilidad", { ano, mes, sede, programa }),
|
||||||
|
ventas: (ano, mes, sede = "TODOS", programa = "TODOS") => get("/api/ventas", { ano, mes, sede, programa }),
|
||||||
|
ventasDetalle: (vendedor, ano, mes, tipo, sede = "TODOS", programa = "TODOS") =>
|
||||||
|
get("/api/ventas/detalle", { vendedor, ano, mes, tipo, sede, programa }),
|
||||||
|
cobranza: (ano, mes, sectorista = "TODOS", agrupacion = "SEDE") =>
|
||||||
|
get("/api/cobranza", { ano, mes, sectorista, agrupacion }),
|
||||||
|
cobranzaDetalle: (grupo, ano, mes, sectorista = "TODOS", agrupacion = "PROGRAMA") =>
|
||||||
|
get("/api/cobranza/detalle", { grupo, ano, mes, sectorista, agrupacion }),
|
||||||
|
rentabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") =>
|
||||||
|
get("/api/rentabilidad", { ano, mes, sede, programa }),
|
||||||
|
rentabilidadDetalle: (programa, ano, mes) =>
|
||||||
|
get("/api/rentabilidad/detalle", { programa, ano, mes }),
|
||||||
|
rentabilidadCostos: (programa, ano, mes) =>
|
||||||
|
get("/api/rentabilidad/costos", { programa, ano, mes }),
|
||||||
|
saldoPendiente: (tipo_cuota) => get("/api/saldo-pendiente", { tipo_cuota }),
|
||||||
|
asesores: () => get("/api/asesores"),
|
||||||
|
asesorEstado: (agent_id, online) =>
|
||||||
|
post("/api/asesores/estado", { agent_id, online }),
|
||||||
|
guardarCostos: (num_indice, inicial, actual) =>
|
||||||
|
postJson("/api/rentabilidad/costos/guardar", { num_indice, inicial, actual }),
|
||||||
|
clasificarSede: (programas) =>
|
||||||
|
postJson("/api/cobranza/clasificar-sede", { programas }),
|
||||||
|
cobranzaDetalleTodos: (ano, mes, sectorista = "TODOS") =>
|
||||||
|
get("/api/cobranza/detalle-todos", { ano, mes, sectorista }),
|
||||||
|
usuariosListar: () => get("/api/usuarios"),
|
||||||
|
usuariosCrear: (email, password, nombre, rol) =>
|
||||||
|
postJson("/api/usuarios/crear", { email, password, nombre, rol }),
|
||||||
|
usuariosActualizarRol: (id, rol) => postJson("/api/usuarios/rol", { id, rol }),
|
||||||
|
usuariosEliminar: (id) => postJson("/api/usuarios/eliminar", { id }),
|
||||||
|
vendedoresManualesListar: (ano, mes) => get("/api/comisiones/vendedores", { ano, mes }),
|
||||||
|
vendedoresManualesCrear: (datos) => postJson("/api/comisiones/vendedores/crear", datos),
|
||||||
|
vendedoresManualesEliminar: (id) => postJson("/api/comisiones/vendedores/eliminar", { id }),
|
||||||
|
vendedoresManualesActualizar: (datos) => postJson("/api/comisiones/vendedores/actualizar", datos),
|
||||||
|
vendedoresManualesLote: (datos) => postJson("/api/comisiones/vendedores/lote", datos),
|
||||||
|
comisionesOverrideGuardar: (registros) => postJson("/api/comisiones/override/guardar", { registros }),
|
||||||
|
comisionesOverrideRestaurar: (num_matricula) => postJson("/api/comisiones/override/restaurar", { num_matricula }),
|
||||||
|
comisionesDetalleTodos: (ano, mes) => get("/api/comisiones/detalle-todos", { ano, mes }),
|
||||||
|
comisionesConfig: (ano, mes) => get("/api/comisiones/config", { ano, mes }),
|
||||||
|
comisionesConfigGuardar: (datos) => postJson("/api/comisiones/config/guardar", datos),
|
||||||
|
};
|
||||||
106
src/lib/auth.jsx
Normal file
106
src/lib/auth.jsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// src/lib/auth.jsx
|
||||||
|
import { createContext, useContext, useEffect, useState, useCallback } from "react";
|
||||||
|
import { supabase } from "./supabase";
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [session, setSession] = useState(null);
|
||||||
|
const [perfil, setPerfil] = useState(null); // { nombre, rol }
|
||||||
|
const [permisos, setPermisos] = useState([]); // ["ocupabilidad", "cobranza", ...]
|
||||||
|
const [cargando, setCargando] = useState(true);
|
||||||
|
|
||||||
|
// Cargar perfil (rol) y permisos del usuario logueado
|
||||||
|
const cargarPerfil = useCallback(async (userId) => {
|
||||||
|
// 1) Traer el rol del usuario desde la tabla "perfiles"
|
||||||
|
const { data: perf, error: e1 } = await supabase
|
||||||
|
.from("perfiles")
|
||||||
|
.select("nombre, rol, activo")
|
||||||
|
.eq("id", userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (e1 || !perf || perf.activo === false) {
|
||||||
|
setPerfil(null);
|
||||||
|
setPermisos([]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Traer los módulos permitidos para ese rol desde "permisos"
|
||||||
|
const { data: perms } = await supabase
|
||||||
|
.from("permisos")
|
||||||
|
.select("modulo, puede_ver")
|
||||||
|
.eq("rol", perf.rol);
|
||||||
|
|
||||||
|
const modulos = (perms || [])
|
||||||
|
.filter((p) => p.puede_ver)
|
||||||
|
.map((p) => p.modulo);
|
||||||
|
|
||||||
|
setPerfil({ nombre: perf.nombre, rol: perf.rol });
|
||||||
|
setPermisos(modulos);
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Al iniciar: revisar si ya hay sesión activa
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
supabase.auth.getSession().then(async ({ data }) => {
|
||||||
|
if (!activo) return;
|
||||||
|
const s = data?.session || null;
|
||||||
|
setSession(s);
|
||||||
|
if (s?.user) await cargarPerfil(s.user.id);
|
||||||
|
setCargando(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escuchar cambios de sesión (login/logout)
|
||||||
|
const { data: sub } = supabase.auth.onAuthStateChange(async (_evt, s) => {
|
||||||
|
setSession(s);
|
||||||
|
if (s?.user) await cargarPerfil(s.user.id);
|
||||||
|
else { setPerfil(null); setPermisos([]); }
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { activo = false; sub?.subscription?.unsubscribe(); };
|
||||||
|
}, [cargarPerfil]);
|
||||||
|
|
||||||
|
const login = async (email, password) => {
|
||||||
|
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
|
||||||
|
if (error) return { ok: false, msg: traducirError(error.message) };
|
||||||
|
// Verificar que tenga perfil/rol válido
|
||||||
|
const ok = await cargarPerfil(data.user.id);
|
||||||
|
if (!ok) {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
return { ok: false, msg: "Tu usuario no tiene un perfil asignado. Contacta al administrador." };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
setPerfil(null);
|
||||||
|
setPermisos([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
session,
|
||||||
|
perfil,
|
||||||
|
permisos,
|
||||||
|
cargando,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
autenticado: !!session && !!perfil,
|
||||||
|
puedeVer: (modulo) => permisos.includes(modulo),
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth debe usarse dentro de AuthProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function traducirError(msg) {
|
||||||
|
if (/invalid login credentials/i.test(msg)) return "Correo o contraseña incorrectos.";
|
||||||
|
if (/email not confirmed/i.test(msg)) return "El correo no está confirmado.";
|
||||||
|
return msg || "Error al iniciar sesión.";
|
||||||
|
}
|
||||||
10
src/lib/supabase.js
Normal file
10
src/lib/supabase.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// src/lib/supabase.js
|
||||||
|
// Cliente de Supabase para autenticación (login).
|
||||||
|
// IMPORTANTE: aquí va la clave PÚBLICA (anon), nunca la secreta (service_role).
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
// Estos valores se leen de frontend/.env (ver .env.example)
|
||||||
|
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || "";
|
||||||
|
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || "";
|
||||||
|
|
||||||
|
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
||||||
63
src/lib/useColumnasAjustables.jsx
Normal file
63
src/lib/useColumnasAjustables.jsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// src/lib/useColumnasAjustables.jsx
|
||||||
|
// Hook reutilizable para columnas redimensionables (estilo Excel) en cualquier tabla.
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useColumnasAjustables(anchosIniciales) {
|
||||||
|
const [anchos, setAnchos] = useState(anchosIniciales);
|
||||||
|
|
||||||
|
// Si cambia el número de columnas (ej. Cobranza Sede↔Programa), reiniciar anchos
|
||||||
|
useEffect(() => {
|
||||||
|
if (anchos.length !== anchosIniciales.length) {
|
||||||
|
setAnchos(anchosIniciales);
|
||||||
|
}
|
||||||
|
}, [anchosIniciales.length]);
|
||||||
|
|
||||||
|
function iniciarResize(e, i) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const xInicial = e.clientX;
|
||||||
|
const anchoInicial = anchos[i];
|
||||||
|
function onMove(ev) {
|
||||||
|
const nuevo = Math.max(50, anchoInicial + (ev.clientX - xInicial));
|
||||||
|
setAnchos((prev) => { const c = [...prev]; c[i] = nuevo; return c; });
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
document.removeEventListener("mousemove", onMove);
|
||||||
|
document.removeEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousemove", onMove);
|
||||||
|
document.addEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devuelve los elementos <col> para el <colgroup> de la tabla
|
||||||
|
function ColGroup() {
|
||||||
|
return (
|
||||||
|
<colgroup>
|
||||||
|
{anchos.map((w, i) => <col key={i} style={{ width: w, minWidth: w }} />)}
|
||||||
|
</colgroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devuelve el divisor arrastrable para poner dentro de cada <th>
|
||||||
|
function Resizer({ index }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
onMouseDown={(e) => iniciarResize(e, index)}
|
||||||
|
className="col-resizer"
|
||||||
|
style={{ position: "absolute", top: 0, right: 0, width: 8, height: "100%",
|
||||||
|
cursor: "col-resize", userSelect: "none", zIndex: 5 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchoTotal = anchos.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
// width:100% → llena el contenedor desde el inicio (sin huecos a la derecha).
|
||||||
|
// minWidth:anchoTotal → si las columnas crecen más que el contenedor, aparece scroll.
|
||||||
|
// tableLayout:fixed + colgroup → encabezados y celdas alineados.
|
||||||
|
const tableProps = {
|
||||||
|
style: { tableLayout: "fixed", width: "100%", minWidth: anchoTotal },
|
||||||
|
};
|
||||||
|
|
||||||
|
return { anchos, ColGroup, Resizer, anchoTotal, tableProps };
|
||||||
|
}
|
||||||
13
src/main.jsx
Normal file
13
src/main.jsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import { AuthProvider } from "./lib/auth";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
105
src/pages/Asesores.jsx
Normal file
105
src/pages/Asesores.jsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// src/pages/Asesores.jsx
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox } from "../components/UI";
|
||||||
|
|
||||||
|
const ESTADO_INFO = {
|
||||||
|
online: { txt: "En Línea", color: "#10b981", bg: "#d1fae5", fg: "#065f46" },
|
||||||
|
busy: { txt: "Ocupado", color: "#f59e0b", bg: "#fef9c3", fg: "#854d0e" },
|
||||||
|
offline:{ txt: "Fuera de Línea", color: "#94a3b8", bg: "#f1f5f9", fg: "#475569" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Asesores() {
|
||||||
|
const [agentes, setAgentes] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [cambiando, setCambiando] = useState(null);
|
||||||
|
|
||||||
|
const cargar = useCallback(() => {
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.asesores()
|
||||||
|
.then((res)=>{ setAgentes(res.agentes||[]); setLoading(false); })
|
||||||
|
.catch((e)=>{ setError(e.message); setLoading(false); });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { cargar(); }, [cargar]);
|
||||||
|
|
||||||
|
async function toggle(ag) {
|
||||||
|
const online = ag.availability_status !== "online";
|
||||||
|
setCambiando(ag.id);
|
||||||
|
try {
|
||||||
|
await api.asesorEstado(ag.id, online);
|
||||||
|
setAgentes((prev)=>prev.map((a)=>a.id===ag.id ? {...a, availability_status: online?"online":"offline"} : a));
|
||||||
|
} catch (e) { /* noop */ }
|
||||||
|
setCambiando(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function todos(online) {
|
||||||
|
setCambiando("all");
|
||||||
|
for (const ag of agentes) {
|
||||||
|
try { await api.asesorEstado(ag.id, online); } catch (e) {}
|
||||||
|
}
|
||||||
|
setAgentes((prev)=>prev.map((a)=>({...a, availability_status: online?"online":"offline"})));
|
||||||
|
setCambiando(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = agentes.length;
|
||||||
|
const enLinea = agentes.filter((a)=>a.availability_status==="online").length;
|
||||||
|
const ocupados = agentes.filter((a)=>a.availability_status==="busy").length;
|
||||||
|
const offline = total - enLinea - ocupados;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">👥 Asesores</h1>
|
||||||
|
|
||||||
|
{loading ? <Loader text="Cargando asesores de Chatwoot..." /> :
|
||||||
|
error ? <ErrorBox msg={error} /> :
|
||||||
|
<>
|
||||||
|
<div className="kpis" style={{gridTemplateColumns:"repeat(4,1fr)"}}>
|
||||||
|
<div className="kpi"><div className="ico">👥</div><div className="label">Total Asesores</div><div className="value">{total}</div></div>
|
||||||
|
<div className="kpi" style={{background:"#d1fae5"}}><div className="ico">🟢</div><div className="label">En Línea</div><div className="value" style={{color:"#065f46"}}>{enLinea}</div></div>
|
||||||
|
<div className="kpi" style={{background:"#fef9c3"}}><div className="ico">🟡</div><div className="label">Ocupados</div><div className="value" style={{color:"#854d0e"}}>{ocupados}</div></div>
|
||||||
|
<div className="kpi" style={{background:"#fee2e2"}}><div className="ico">🔴</div><div className="label">Fuera de Línea</div><div className="value" style={{color:"#991b1b"}}>{offline}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{display:"flex",gap:10,marginBottom:16}}>
|
||||||
|
<button className="btn btn-primary" disabled={cambiando==="all"} onClick={()=>todos(true)}>🟢 Encender todos</button>
|
||||||
|
<button className="btn btn-ghost" disabled={cambiando==="all"} onClick={()=>todos(false)}>🔴 Apagar todos</button>
|
||||||
|
<button className="btn btn-ghost" title="Refrescar" style={{fontSize:18}} onClick={cargar}>🔄</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>ASESOR</th><th>EMAIL</th><th>ROL</th><th>ESTADO</th><th>ACCIÓN</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{agentes.map((ag)=>{
|
||||||
|
const est = ESTADO_INFO[ag.availability_status] || ESTADO_INFO.offline;
|
||||||
|
const isOnline = ag.availability_status==="online";
|
||||||
|
return (
|
||||||
|
<tr key={ag.id}>
|
||||||
|
<td className="col-name">{ag.name || ag.available_name || "—"}</td>
|
||||||
|
<td>{ag.email || "—"}</td>
|
||||||
|
<td>{ag.role || "agent"}</td>
|
||||||
|
<td>
|
||||||
|
<span style={{background:est.bg,color:est.fg,padding:"3px 10px",borderRadius:20,
|
||||||
|
fontSize:11,fontWeight:700}}>● {est.txt}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn" disabled={cambiando===ag.id}
|
||||||
|
style={{padding:"5px 12px",fontSize:11,
|
||||||
|
background:isOnline?"#fee2e2":"#d1fae5",
|
||||||
|
color:isOnline?"#991b1b":"#065f46"}}
|
||||||
|
onClick={()=>toggle(ag)}>
|
||||||
|
{cambiando===ag.id ? "..." : (isOnline?"Apagar":"Encender")}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
660
src/pages/Cobranza.jsx
Normal file
660
src/pages/Cobranza.jsx
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
// src/pages/Cobranza.jsx
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox, Filters, Select, colorSemaforo } from "../components/UI";
|
||||||
|
import Modal from "../components/Modal";
|
||||||
|
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||||||
|
|
||||||
|
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||||||
|
const ANOS = [2024, 2025, 2026];
|
||||||
|
const AGRUPACIONES = [{value:"SEDE",label:"Por Sede"},{value:"PROGRAMA",label:"Por Programa"},{value:"ASESOR",label:"Por Asesor"}];
|
||||||
|
const SEDES = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"];
|
||||||
|
const FRECS = ["TODOS","DOM","SAB","NOC","MAN","VIR","TAR"];
|
||||||
|
|
||||||
|
// Colores por cartera — diferenciados y legibles (sin azul, reservado para TOTAL)
|
||||||
|
// ANTERIOR = ámbar suave | MES = verde menta | TOTAL = lila/violeta suave
|
||||||
|
const BG_ANT = "#fef3c7", BG_MES = "#d1fae5", BG_TOT = "#ede9fe";
|
||||||
|
// Versiones más oscuras para hover de cada cartera
|
||||||
|
const HV_ANT = "#fde68a", HV_MES = "#a7f3d0", HV_TOT = "#ddd6fe";
|
||||||
|
|
||||||
|
function toMonto(s){ const n = parseFloat(String(s).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; }
|
||||||
|
function toPct(s){ const n = parseFloat(String(s).replace("%","").trim()); return isNaN(n)?0:n; }
|
||||||
|
// Formato moneda SIN decimales (solo visualización)
|
||||||
|
function fmtMoneda(s){
|
||||||
|
if (s == null) return "";
|
||||||
|
if (!String(s).includes("S/")) return s; // no es monto, devolver tal cual
|
||||||
|
const n = toMonto(s);
|
||||||
|
return "S/ " + n.toLocaleString("es-PE", { maximumFractionDigits: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniBar({ valStr }) {
|
||||||
|
if (!valStr || !String(valStr).includes("%")) return <span style={{color:"#cbd5e1"}}>—</span>;
|
||||||
|
const pct = toPct(valStr);
|
||||||
|
const c = colorSemaforo(pct);
|
||||||
|
const w = Math.min(Math.max(pct,0),100);
|
||||||
|
return (
|
||||||
|
<div style={{display:"flex",alignItems:"center",gap:5}}>
|
||||||
|
<span style={{fontSize:10,fontWeight:700,color:c.fg,minWidth:30,textAlign:"right"}}>{pct.toFixed(0)}%</span>
|
||||||
|
<div style={{flex:1,background:"#e2e8f0",borderRadius:4,height:5,overflow:"hidden",minWidth:30}}>
|
||||||
|
<div style={{width:`${w}%`,background:c.fill,height:"100%"}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Cobranza() {
|
||||||
|
const hoy = new Date();
|
||||||
|
const [ano, setAno] = useState(hoy.getFullYear());
|
||||||
|
const [mes, setMes] = useState(hoy.getMonth() + 1);
|
||||||
|
const [agrupacion, setAgrupacion] = useState("SEDE");
|
||||||
|
const [sectorista, setSectorista] = useState("TODOS");
|
||||||
|
const [sede, setSede] = useState("TODOS");
|
||||||
|
const [frec, setFrec] = useState("TODOS");
|
||||||
|
const [filas, setFilas] = useState([]);
|
||||||
|
const [sectoristas, setSectoristas] = useState(["TODOS"]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [modal, setModal] = useState(null);
|
||||||
|
const [sedeMap, setSedeMap] = useState({}); // programa -> sede (sede.json)
|
||||||
|
const [exportando, setExportando] = useState(false);
|
||||||
|
const [alumnos, setAlumnos] = useState([]); // todos los alumnos (1 sola consulta)
|
||||||
|
const [buscadorAbierto, setBuscadorAbierto] = useState(false);
|
||||||
|
|
||||||
|
const esPrograma = agrupacion === "PROGRAMA";
|
||||||
|
// índices según agrupación
|
||||||
|
const IDX = esPrograma
|
||||||
|
? { saldo:13, ant:[4,5,6], mes:[7,8,9], tot:[10,11,12] }
|
||||||
|
: { saldo:10, ant:[1,2,3], mes:[4,5,6], tot:[7,8,9] };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.cobranza(ano, mes, sectorista, agrupacion)
|
||||||
|
.then((res) => {
|
||||||
|
if (!activo) return;
|
||||||
|
setFilas(res.filas || []);
|
||||||
|
setSectoristas(res.sectoristas || ["TODOS"]);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||||
|
return () => { activo = false; };
|
||||||
|
}, [ano, mes, agrupacion, sectorista]);
|
||||||
|
|
||||||
|
// Cargar TODOS los alumnos una sola vez (para exportar y buscar) — 1 consulta cacheada
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
api.cobranzaDetalleTodos(ano, mes, sectorista)
|
||||||
|
.then((res) => { if (activo) setAlumnos(res.alumnos || []); })
|
||||||
|
.catch(() => { if (activo) setAlumnos([]); });
|
||||||
|
return () => { activo = false; };
|
||||||
|
}, [ano, mes, sectorista]);
|
||||||
|
|
||||||
|
// Cargar clasificación de sede (sede.json) para los programas visibles
|
||||||
|
useEffect(() => {
|
||||||
|
if (!esPrograma) return;
|
||||||
|
const nombres = filas
|
||||||
|
.filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL")
|
||||||
|
.map((f) => String(f[0]));
|
||||||
|
if (nombres.length === 0) return;
|
||||||
|
let activo = true;
|
||||||
|
api.clasificarSede(nombres)
|
||||||
|
.then((res) => { if (activo) setSedeMap(res.mapa || {}); })
|
||||||
|
.catch(() => { if (activo) setSedeMap({}); });
|
||||||
|
return () => { activo = false; };
|
||||||
|
}, [filas, esPrograma]);
|
||||||
|
|
||||||
|
// Filtro sede/frecuencia (solo PROGRAMA) en cliente — usa sede.json
|
||||||
|
const datos = useMemo(() => {
|
||||||
|
let arr = filas.filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL");
|
||||||
|
if (esPrograma) {
|
||||||
|
if (sede !== "TODOS") {
|
||||||
|
arr = arr.filter((f) => {
|
||||||
|
const s = (sedeMap[String(f[0])] || "LIMA").toUpperCase();
|
||||||
|
return s === sede.toUpperCase();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (frec !== "TODOS") arr = arr.filter((f) => String(f[1]).toUpperCase() === frec);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, [filas, esPrograma, sede, frec, sedeMap]);
|
||||||
|
|
||||||
|
// KPIs: 3 tarjetas (recalculadas con datos filtrados)
|
||||||
|
const kpis = useMemo(() => {
|
||||||
|
let cAnt=0,obAnt=0,cMes=0,obMes=0,cTot=0,obTot=0;
|
||||||
|
datos.forEach((f) => {
|
||||||
|
cAnt += toMonto(f[IDX.ant[0]]); obAnt += toMonto(f[IDX.ant[1]]);
|
||||||
|
cMes += toMonto(f[IDX.mes[0]]); obMes += toMonto(f[IDX.mes[1]]);
|
||||||
|
cTot += toMonto(f[IDX.tot[0]]); obTot += toMonto(f[IDX.tot[1]]);
|
||||||
|
});
|
||||||
|
return { cAnt,obAnt,cMes,obMes,cTot,obTot, saldo: cTot-obTot };
|
||||||
|
}, [datos, IDX]);
|
||||||
|
|
||||||
|
const headers = esPrograma
|
||||||
|
? ["PROGRAMA","FREC.","N° CUOTA","VENCIMIENTO","CTA X COB. MES ANT.","COB. MES ANT.","% ANT","CTA X COB EN CURSO","COB. EN CURSO","% MES","CTA X COB TOTAL","COB. TOTAL","% TOTAL","SALDO","OPC."]
|
||||||
|
: ["GRUPO","CTA X COB. MES ANT.","COB. MES ANT.","% ANT","CTA X COB EN CURSO","COB. EN CURSO","% MES","CTA X COB TOTAL","COB. TOTAL","% TOTAL","SALDO","OPC."];
|
||||||
|
|
||||||
|
// Anchos para columnas redimensionables (1ª columna más ancha, resto estándar)
|
||||||
|
const anchosCob = headers.map((h, i) => i === 0 ? (esPrograma ? 280 : 200) : (h === "FREC." || h.startsWith("%") || h === "OPC." ? 80 : 120));
|
||||||
|
const cols = useColumnasAjustables(anchosCob);
|
||||||
|
|
||||||
|
function cartClase(ci) {
|
||||||
|
if (IDX.ant.includes(ci)) return "cart-ant";
|
||||||
|
if (IDX.mes.includes(ci)) return "cart-mes";
|
||||||
|
if (IDX.tot.includes(ci)) return "cart-tot";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alumnos visibles según filtros activos (mismo dataset que Ver / Estado de Cuenta)
|
||||||
|
const alumnosFiltrados = useMemo(() => {
|
||||||
|
let arr = alumnos;
|
||||||
|
if (esPrograma) {
|
||||||
|
if (sede !== "TODOS") arr = arr.filter((a) => String(a.sede).toUpperCase() === sede.toUpperCase());
|
||||||
|
if (frec !== "TODOS") arr = arr.filter((a) => String(a.frecuencia).toUpperCase() === frec);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, [alumnos, esPrograma, sede, frec]);
|
||||||
|
|
||||||
|
function grupoDe(a) {
|
||||||
|
if (agrupacion === "SEDE") return a.sede;
|
||||||
|
if (agrupacion === "ASESOR") return ""; // el sectorista ya filtra; no hay sub-grupo por alumno
|
||||||
|
return a.programa; // PROGRAMA
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exportar el DETALLE DE ALUMNOS (rápido: usa el dataset ya cargado, sin N consultas)
|
||||||
|
async function exportarDetalle() {
|
||||||
|
setExportando(true);
|
||||||
|
try {
|
||||||
|
const base = alumnosFiltrados;
|
||||||
|
if (base.length === 0) { alert("No hay alumnos para exportar con los filtros actuales."); return; }
|
||||||
|
|
||||||
|
const toDate = (v) => {
|
||||||
|
if (!v || v === "-") return "";
|
||||||
|
const s = String(v).trim().replace(/\//g,"-");
|
||||||
|
const p = s.split("-");
|
||||||
|
if (p.length === 3 && p[2].length === 4) return new Date(+p[2], +p[1]-1, +p[0]);
|
||||||
|
return String(v);
|
||||||
|
};
|
||||||
|
const pct = (cob, cta) => cta > 0 ? cob/cta : null;
|
||||||
|
|
||||||
|
const XLSX = await import("xlsx-js-style");
|
||||||
|
const COLS = ["GRUPO","MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"];
|
||||||
|
|
||||||
|
// Colores suaves por cartera (igual que el dashboard): ANT ámbar, MES verde, TOT lila
|
||||||
|
const CART = { ant:"FEF3C7", mes:"D1FAE5", tot:"EDE9FE" };
|
||||||
|
const cartFill = (c) => {
|
||||||
|
if ([5,6,7].includes(c)) return CART.ant;
|
||||||
|
if ([8,9,10].includes(c)) return CART.mes;
|
||||||
|
if ([11,12,13].includes(c)) return CART.tot;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const headerStyle = { font:{bold:true,color:{rgb:"FFFFFF"},sz:11}, fill:{fgColor:{rgb:"1E40AF"}}, alignment:{horizontal:"center",vertical:"center"} };
|
||||||
|
const moneyFmt = '"S/" #,##0';
|
||||||
|
const pctFmt = '0%';
|
||||||
|
const dateFmt = "dd/mm/yyyy";
|
||||||
|
const cellStyle = (c) => { const f = cartFill(c); return f ? { fill:{fgColor:{rgb:f}} } : undefined; };
|
||||||
|
|
||||||
|
const ws = {};
|
||||||
|
COLS.forEach((h,c)=>{ ws[XLSX.utils.encode_cell({r:0,c})] = {v:h,t:"s",s:headerStyle}; });
|
||||||
|
|
||||||
|
base.forEach((a, ri) => {
|
||||||
|
const r = ri + 1;
|
||||||
|
const vals = [
|
||||||
|
grupoDe(a), a.matricula, a.alumno, a.num_cuota, toDate(a.fch_venc),
|
||||||
|
a.cta_ant, a.cob_ant, pct(a.cob_ant,a.cta_ant),
|
||||||
|
a.cta_cur, a.cob_cur, pct(a.cob_cur,a.cta_cur),
|
||||||
|
a.cta_tot, a.cob_tot, pct(a.cob_tot,a.cta_tot),
|
||||||
|
a.saldo,
|
||||||
|
];
|
||||||
|
vals.forEach((v, c) => {
|
||||||
|
const ref = XLSX.utils.encode_cell({r,c});
|
||||||
|
const st = cellStyle(c);
|
||||||
|
if ([5,6,8,9,11,12,14].includes(c)) ws[ref] = { v:Number(v)||0, t:"n", z:moneyFmt, ...(st?{s:st}:{}) };
|
||||||
|
else if ([7,10,13].includes(c)) ws[ref] = (v===null) ? { v:"-", t:"s", ...(st?{s:st}:{}) } : { v:Number(v), t:"n", z:pctFmt, ...(st?{s:st}:{}) };
|
||||||
|
else if (c===4 && v instanceof Date) ws[ref] = { v, t:"d", z:dateFmt };
|
||||||
|
else ws[ref] = { v: v ?? "", t:"s" };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// TOTAL GENERAL (suma de montos)
|
||||||
|
const rTot = base.length + 1;
|
||||||
|
const totalStyle = { font:{bold:true}, fill:{fgColor:{rgb:"EFF6FF"}} };
|
||||||
|
const sum = (k) => base.reduce((s,a)=>s+(Number(a[k])||0),0);
|
||||||
|
const totVals = { 5:sum("cta_ant"),6:sum("cob_ant"),8:sum("cta_cur"),9:sum("cob_cur"),11:sum("cta_tot"),12:sum("cob_tot"),14:sum("saldo") };
|
||||||
|
COLS.forEach((col,c)=>{
|
||||||
|
const ref = XLSX.utils.encode_cell({r:rTot,c});
|
||||||
|
if (c===0) ws[ref] = {v:"TOTAL GENERAL",t:"s",s:totalStyle};
|
||||||
|
else if (totVals[c]!==undefined) ws[ref] = {v:totVals[c],t:"n",z:moneyFmt,s:totalStyle};
|
||||||
|
else ws[ref] = {v:"",t:"s",s:totalStyle};
|
||||||
|
});
|
||||||
|
|
||||||
|
ws["!ref"] = XLSX.utils.encode_range({ s:{r:0,c:0}, e:{r:rTot,c:COLS.length-1} });
|
||||||
|
ws["!cols"] = [{wch:26},{wch:12},{wch:28},{wch:9},{wch:12},{wch:12},{wch:12},{wch:8},{wch:12},{wch:12},{wch:8},{wch:12},{wch:12},{wch:8},{wch:13}];
|
||||||
|
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, "Cobranza");
|
||||||
|
const sufijo = agrupacion + (esPrograma && sede!=="TODOS" ? `_${sede}` : "") + (sectorista!=="TODOS" ? `_${sectorista}` : "");
|
||||||
|
XLSX.writeFile(wb, `cobranza_${sufijo}_${ano}_${mes}.xlsx`);
|
||||||
|
} finally {
|
||||||
|
setExportando(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">📋 Cobranza</h1>
|
||||||
|
|
||||||
|
<Filters>
|
||||||
|
<Select label="Año" value={ano} options={ANOS} onChange={(v)=>setAno(+v)} />
|
||||||
|
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} />
|
||||||
|
<Select label="Agrupación" value={agrupacion} options={AGRUPACIONES} onChange={(v)=>{setAgrupacion(v);setSede("TODOS");setFrec("TODOS");}} />
|
||||||
|
<Select label="Sectorista" value={sectorista} options={sectoristas} onChange={setSectorista} />
|
||||||
|
{esPrograma && <Select label="Sede" value={sede} options={SEDES} onChange={setSede} />}
|
||||||
|
{esPrograma && <Select label="Frecuencia" value={frec} options={FRECS} onChange={setFrec} />}
|
||||||
|
<div style={{display:"flex",gap:8,alignItems:"flex-end",marginLeft:"auto"}}>
|
||||||
|
<button className="btn btn-ghost" style={{height:38}} onClick={()=>setBuscadorAbierto(true)}>🔍 Buscar</button>
|
||||||
|
<button className="btn btn-primary" style={{height:38}} disabled={exportando || !alumnosFiltrados.length} onClick={exportarDetalle}>
|
||||||
|
{exportando ? "Exportando..." : "⬇️ Exportar"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Filters>
|
||||||
|
|
||||||
|
{loading ? <Loader text="Calculando cobranza..." /> :
|
||||||
|
error ? <ErrorBox msg={error} /> :
|
||||||
|
<>
|
||||||
|
<Tarjetas kpis={kpis} />
|
||||||
|
|
||||||
|
<div className="table-wrap" style={{marginTop:16, overflowX:"auto"}}>
|
||||||
|
<table className="cob-table" {...cols.tableProps}>
|
||||||
|
<cols.ColGroup />
|
||||||
|
<thead><tr>{headers.map((h, i)=><th key={h} style={{position:"relative"}}>{h}<cols.Resizer index={i} /></th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{datos.map((f, ri) => {
|
||||||
|
// En la tabla principal SIEMPRE son agrupaciones (Sede/Programa/Asesor),
|
||||||
|
// nunca alumnos individuales → NO aplicar rojo por deuda aquí.
|
||||||
|
// Regla "-": si % del grupo está vacío, CTA y COB del grupo → "-"
|
||||||
|
const antVacio = !String(f[IDX.ant[2]]||"").trim() || ["-","—"].includes(String(f[IDX.ant[2]]).trim());
|
||||||
|
const mesVacio = !String(f[IDX.mes[2]]||"").trim() || ["-","—"].includes(String(f[IDX.mes[2]]).trim());
|
||||||
|
const totVacio = !String(f[IDX.tot[2]]||"").trim() || ["-","—"].includes(String(f[IDX.tot[2]]).trim());
|
||||||
|
return (
|
||||||
|
<tr key={ri}>
|
||||||
|
{f.slice(0, headers.length-1).map((v, ci) => {
|
||||||
|
const isPct = IDX.ant[2]===ci || IDX.mes[2]===ci || IDX.tot[2]===ci;
|
||||||
|
let val = v;
|
||||||
|
if ((ci===IDX.ant[0]||ci===IDX.ant[1]||ci===IDX.ant[2]) && antVacio) val = "—";
|
||||||
|
if ((ci===IDX.mes[0]||ci===IDX.mes[1]||ci===IDX.mes[2]) && mesVacio) val = "—";
|
||||||
|
if ((ci===IDX.tot[0]||ci===IDX.tot[1]||ci===IDX.tot[2]) && totVacio) val = "—";
|
||||||
|
const cartCls = cartClase(ci);
|
||||||
|
const clss = (ci===0?"col-name":"") + (cartCls?` ${cartCls}`:"");
|
||||||
|
return (
|
||||||
|
<td key={ci} className={clss.trim()}
|
||||||
|
style={{textAlign: ci===0?"left":"center",
|
||||||
|
minWidth: ci===0?(esPrograma?300:200):undefined,
|
||||||
|
maxWidth: ci===0?500:undefined,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: ci===0?"hidden":undefined,
|
||||||
|
textOverflow: ci===0?"ellipsis":undefined}}>
|
||||||
|
{isPct ? (val==="—" ? <span style={{color:"#cbd5e1"}}>—</span> : <MiniBar valStr={val} />) : fmtMoneda(val)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td>
|
||||||
|
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
|
||||||
|
onClick={() => setModal({ grupo: f[0] })}>👁️ Ver</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{datos.length > 0 && (() => {
|
||||||
|
// Fila TOTAL GENERAL — suma de los datos visibles (respeta filtros)
|
||||||
|
const t = {};
|
||||||
|
[IDX.ant[0],IDX.ant[1],IDX.mes[0],IDX.mes[1],IDX.tot[0],IDX.tot[1],IDX.saldo]
|
||||||
|
.forEach((ci)=>{ t[ci] = datos.reduce((s,f)=>s+toMonto(f[ci]),0); });
|
||||||
|
const ratio = (cob,cta)=> cta>0 ? `${Math.round(cob/cta*100)}%` : "";
|
||||||
|
const cell = (ci) => {
|
||||||
|
if (ci===0) return "TOTAL GENERAL";
|
||||||
|
if (ci===IDX.ant[2]) return ratio(t[IDX.ant[1]],t[IDX.ant[0]]);
|
||||||
|
if (ci===IDX.mes[2]) return ratio(t[IDX.mes[1]],t[IDX.mes[0]]);
|
||||||
|
if (ci===IDX.tot[2]) return ratio(t[IDX.tot[1]],t[IDX.tot[0]]);
|
||||||
|
if (t[ci]!==undefined) return "S/ " + t[ci].toLocaleString("es-PE",{maximumFractionDigits:0});
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<tr className="total-row">
|
||||||
|
{Array.from({length: headers.length-1}).map((_,ci)=>{
|
||||||
|
const isPct = IDX.ant[2]===ci || IDX.mes[2]===ci || IDX.tot[2]===ci;
|
||||||
|
const val = cell(ci);
|
||||||
|
return (
|
||||||
|
<td key={ci} className={ci===0?"col-name":""}
|
||||||
|
style={{textAlign: ci===0?"left":"center",
|
||||||
|
whiteSpace:"nowrap"}}>
|
||||||
|
{isPct ? <MiniBar valStr={val} /> : val}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td>—</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{modal && (
|
||||||
|
<ModalEstadoCuenta grupo={modal.grupo} ano={ano} mes={mes}
|
||||||
|
sectorista={sectorista} agrupacion={agrupacion} onClose={() => setModal(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{buscadorAbierto && (
|
||||||
|
<ModalBuscador
|
||||||
|
alumnos={alumnosFiltrados}
|
||||||
|
agrupacion={agrupacion}
|
||||||
|
sectorista={sectorista}
|
||||||
|
onClose={() => setBuscadorAbierto(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tarjetas({ kpis }) {
|
||||||
|
const alDia = kpis.saldo <= 0.01;
|
||||||
|
if (alDia) {
|
||||||
|
return (
|
||||||
|
<div style={{background:"linear-gradient(135deg,#d1fae5,#a7f3d0)",border:"2px solid #10b981",
|
||||||
|
borderRadius:14,padding:18,textAlign:"center"}}>
|
||||||
|
<div style={{fontSize:32}}>✅</div>
|
||||||
|
<div style={{fontSize:18,fontWeight:800,color:"#065f46",letterSpacing:1}}>AL DÍA</div>
|
||||||
|
<div style={{fontSize:12,color:"#047857"}}>Sin deuda pendiente</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const cards = [
|
||||||
|
["⚠ MES ANTERIOR","#d97706",kpis.cAnt,kpis.obAnt],
|
||||||
|
["📅 MES EN CURSO","#059669",kpis.cMes,kpis.obMes],
|
||||||
|
["💰 CARTERA TOTAL","#7c3aed",kpis.cTot,kpis.obTot],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:14}}>
|
||||||
|
{cards.map(([titulo,color,cta,cob]) => {
|
||||||
|
const ratio = cta>0 ? (cob/cta*100) : 0;
|
||||||
|
const c = colorSemaforo(ratio);
|
||||||
|
const saldo = cta - cob;
|
||||||
|
return (
|
||||||
|
<div key={titulo} style={{background:"#fff",border:"1px solid #e2e8f0",borderRadius:12,overflow:"hidden"}}>
|
||||||
|
<div style={{background:color,color:"#fff",padding:"10px 14px",fontWeight:700,fontSize:13,textAlign:"center"}}>{titulo}</div>
|
||||||
|
<div style={{padding:14}}>
|
||||||
|
<Row k="Cta x Cobrar" v={`S/ ${cta.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
|
||||||
|
<Row k="Cobrado" v={`S/ ${cob.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
|
||||||
|
<Row k="Saldo" v={`S/ ${saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}`} bold color={saldo>0?"#dc2626":"#10b981"} />
|
||||||
|
{cta>0 && (
|
||||||
|
<div style={{marginTop:10}}>
|
||||||
|
<div style={{display:"flex",justifyContent:"space-between",marginBottom:4}}>
|
||||||
|
<span style={{fontSize:10,fontWeight:600,color:"#64748b",textTransform:"uppercase"}}>% de Pago</span>
|
||||||
|
<span style={{fontSize:14,fontWeight:700,color:c.fg}}>{ratio.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div style={{background:"#e2e8f0",borderRadius:6,height:8,overflow:"hidden"}}>
|
||||||
|
<div style={{width:`${Math.min(ratio,100)}%`,background:c.fill,height:"100%"}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ k, v, bold, color }) {
|
||||||
|
return (
|
||||||
|
<div style={{display:"flex",justifyContent:"space-between",fontSize:11,color:"#64748b",marginBottom:4,
|
||||||
|
...(bold?{paddingTop:6,borderTop:"1px solid #f1f5f9"}:{})}}>
|
||||||
|
<span>{k}</span>
|
||||||
|
<span style={{fontWeight:bold?700:600,color:color||"#0f172a"}}>{v}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TarjetasResumen({ r }) {
|
||||||
|
const cards = [
|
||||||
|
["⚠ MES ANTERIOR","#d97706",r.cAnt,r.obAnt],
|
||||||
|
["📅 MES EN CURSO","#059669",r.cMes,r.obMes],
|
||||||
|
["💰 CARTERA TOTAL","#7c3aed",r.cTot,r.obTot],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:14}}>
|
||||||
|
{cards.map(([titulo,color,cta,cob]) => {
|
||||||
|
const ratio = cta>0 ? (cob/cta*100) : 0;
|
||||||
|
const c = colorSemaforo(ratio);
|
||||||
|
const saldo = cta - cob;
|
||||||
|
return (
|
||||||
|
<div key={titulo} style={{background:"#fff",border:"1px solid #e2e8f0",borderRadius:12,overflow:"hidden"}}>
|
||||||
|
<div style={{background:color,color:"#fff",padding:"9px 12px",fontWeight:700,fontSize:12,textAlign:"center"}}>{titulo}</div>
|
||||||
|
<div style={{padding:12}}>
|
||||||
|
<Row k="Cta x Cobrar" v={`S/ ${cta.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
|
||||||
|
<Row k="Cobrado" v={`S/ ${cob.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
|
||||||
|
<Row k="Saldo" v={`S/ ${saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}`} bold color={saldo>0?"#dc2626":"#10b981"} />
|
||||||
|
{cta>0 && (
|
||||||
|
<div style={{marginTop:8}}>
|
||||||
|
<div style={{display:"flex",justifyContent:"space-between",marginBottom:4}}>
|
||||||
|
<span style={{fontSize:10,fontWeight:600,color:"#64748b",textTransform:"uppercase"}}>% de Pago</span>
|
||||||
|
<span style={{fontSize:13,fontWeight:700,color:c.fg}}>{ratio.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div style={{background:"#e2e8f0",borderRadius:6,height:7,overflow:"hidden"}}>
|
||||||
|
<div style={{width:`${Math.min(ratio,100)}%`,background:c.fill,height:"100%"}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
|
||||||
|
const [texto, setTexto] = useState("");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const resultados = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return [];
|
||||||
|
return alumnos.filter((a) =>
|
||||||
|
String(a.alumno).toLowerCase().includes(q) ||
|
||||||
|
String(a.programa).toLowerCase().includes(q) ||
|
||||||
|
String(a.matricula).toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [alumnos, query]);
|
||||||
|
|
||||||
|
const COLS = ["GRUPO","MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.",
|
||||||
|
"CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"];
|
||||||
|
|
||||||
|
const pctStr = (cob, cta) => cta > 0 ? `${Math.round(cob/cta*100)}%` : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="🔍 Buscar Alumno" onClose={onClose} width={1200}>
|
||||||
|
<div style={{display:"flex",gap:8,marginBottom:16}}>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={texto}
|
||||||
|
onChange={(e)=>setTexto(e.target.value)}
|
||||||
|
onKeyDown={(e)=>{ if(e.key==="Enter") setQuery(texto); }}
|
||||||
|
placeholder="Escribe nombre, programa o matrícula (ej: Aro) y presiona Enter"
|
||||||
|
style={{flex:1,padding:"10px 14px",border:"1px solid #cbd5e1",borderRadius:8,fontSize:14}}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" onClick={()=>setQuery(texto)}>🔍 Buscar</button>
|
||||||
|
{query && <button className="btn btn-ghost" onClick={()=>{setTexto("");setQuery("");}}>Limpiar</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!query ? (
|
||||||
|
<div style={{color:"#94a3b8",padding:30,textAlign:"center",fontSize:14}}>
|
||||||
|
Escribe un texto y presiona Enter para buscar.
|
||||||
|
</div>
|
||||||
|
) : resultados.length === 0 ? (
|
||||||
|
<div style={{color:"#94a3b8",padding:30,textAlign:"center",fontSize:14}}>
|
||||||
|
Sin coincidencias para "{query}".
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{fontSize:13,color:"#1e40af",fontWeight:600,marginBottom:8}}>
|
||||||
|
{resultados.length} alumno(s) encontrado(s)
|
||||||
|
</div>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="cob-table" style={{minWidth:1100}}>
|
||||||
|
<thead><tr>{COLS.map((c)=><th key={c}>{c}</th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{resultados.map((a,i)=>{
|
||||||
|
const deuda = a.saldo > 0.01;
|
||||||
|
const grp = agrupacion==="SEDE" ? a.sede : (agrupacion==="ASESOR" ? sectorista : a.programa);
|
||||||
|
const celdas = [
|
||||||
|
{v:grp, cls:"col-name", al:"left"},
|
||||||
|
{v:a.matricula},
|
||||||
|
{v:a.alumno, cls:"col-name", al:"left"},
|
||||||
|
{v:a.num_cuota},
|
||||||
|
{v:a.fch_venc},
|
||||||
|
{v:fmtMoneda("S/ "+a.cta_ant), al:"right", cart:"cart-ant"},
|
||||||
|
{v:fmtMoneda("S/ "+a.cob_ant), al:"right", cart:"cart-ant"},
|
||||||
|
{pct:pctStr(a.cob_ant,a.cta_ant), cart:"cart-ant"},
|
||||||
|
{v:fmtMoneda("S/ "+a.cta_cur), al:"right", cart:"cart-mes"},
|
||||||
|
{v:fmtMoneda("S/ "+a.cob_cur), al:"right", cart:"cart-mes"},
|
||||||
|
{pct:pctStr(a.cob_cur,a.cta_cur), cart:"cart-mes"},
|
||||||
|
{v:fmtMoneda("S/ "+a.cta_tot), al:"right", cart:"cart-tot"},
|
||||||
|
{v:fmtMoneda("S/ "+a.cob_tot), al:"right", cart:"cart-tot"},
|
||||||
|
{pct:pctStr(a.cob_tot,a.cta_tot), cart:"cart-tot"},
|
||||||
|
{v:fmtMoneda("S/ "+a.saldo), al:"right", saldo:true},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<tr key={i} className={deuda?"row-deuda":""}>
|
||||||
|
{celdas.map((c,j)=>(
|
||||||
|
<td key={j} className={((c.cls||"")+" "+(c.cart||"")).trim()}
|
||||||
|
style={{textAlign:c.al||"center",
|
||||||
|
fontWeight:c.saldo?700:undefined,
|
||||||
|
color:c.saldo&&deuda?"#dc2626":undefined}}>
|
||||||
|
{c.pct!==undefined ? (c.pct ? <MiniBar valStr={c.pct} /> : "—") : c.v}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose }) {
|
||||||
|
const [filas, setFilas] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
setLoading(true);
|
||||||
|
api.cobranzaDetalle(grupo, ano, mes, sectorista, agrupacion)
|
||||||
|
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
||||||
|
.catch(() => { if (activo) { setFilas([]); setLoading(false); } });
|
||||||
|
return () => { activo = false; };
|
||||||
|
}, [grupo, ano, mes, sectorista, agrupacion]);
|
||||||
|
|
||||||
|
const COLS = ["MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"];
|
||||||
|
|
||||||
|
// Resumen para las 3 tarjetas (siempre visibles) — suma del detalle de alumnos
|
||||||
|
const resumen = useMemo(() => {
|
||||||
|
let cAnt=0,obAnt=0,cMes=0,obMes=0,cTot=0,obTot=0;
|
||||||
|
filas.forEach((f) => {
|
||||||
|
if (String(f[0]).toUpperCase() === "TOTAL") return;
|
||||||
|
cAnt += toMonto(f[4]); obAnt += toMonto(f[5]);
|
||||||
|
cMes += toMonto(f[7]); obMes += toMonto(f[8]);
|
||||||
|
cTot += toMonto(f[10]); obTot += toMonto(f[11]);
|
||||||
|
});
|
||||||
|
return { cAnt,obAnt,cMes,obMes,cTot,obTot };
|
||||||
|
}, [filas]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={`📊 Estado de Cuenta — ${grupo}`} onClose={onClose} width={1500}>
|
||||||
|
{loading ? <Loader text="Cargando estado de cuenta..." /> :
|
||||||
|
filas.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin datos.</div> :
|
||||||
|
<>
|
||||||
|
<div style={{marginBottom:16}}>
|
||||||
|
<TarjetasResumen r={resumen} />
|
||||||
|
</div>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="cob-table" style={{minWidth:1000}}>
|
||||||
|
<thead><tr>{COLS.map((c)=><th key={c}>{c}</th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL").map((f, i) => {
|
||||||
|
const saldo = toMonto(f[13]);
|
||||||
|
const deuda = saldo > 0.01;
|
||||||
|
// Regla "-": si %ANT vacío → CTA/COB ANT a "—" (igual MES y TOT)
|
||||||
|
const antVacio = !String(f[6]||"").trim() || ["-","—"].includes(String(f[6]).trim());
|
||||||
|
const mesVacio = !String(f[9]||"").trim() || ["-","—"].includes(String(f[9]).trim());
|
||||||
|
const totVacio = !String(f[12]||"").trim() || ["-","—"].includes(String(f[12]).trim());
|
||||||
|
return (
|
||||||
|
<tr key={i} className={deuda?"row-deuda":""}>
|
||||||
|
{f.map((v, j) => {
|
||||||
|
let val = v;
|
||||||
|
if ((j===4||j===5||j===6) && antVacio) val = "—";
|
||||||
|
if ((j===7||j===8||j===9) && mesVacio) val = "—";
|
||||||
|
if ((j===10||j===11||j===12) && totVacio) val = "—";
|
||||||
|
const isPct = [6,9,12].includes(j);
|
||||||
|
const isMonto = String(val).includes("S/");
|
||||||
|
let cart = "";
|
||||||
|
if ([4,5,6].includes(j)) cart = "cart-ant";
|
||||||
|
else if ([7,8,9].includes(j)) cart = "cart-mes";
|
||||||
|
else if ([10,11,12].includes(j)) cart = "cart-tot";
|
||||||
|
const clss = ((j===1?"col-name":"") + (cart?` ${cart}`:"")).trim();
|
||||||
|
return (
|
||||||
|
<td key={j} className={clss}
|
||||||
|
style={{textAlign:j===1?"left":(isMonto?"right":"center"),
|
||||||
|
whiteSpace:"nowrap",
|
||||||
|
color:j===13&&deuda?"#dc2626":undefined,
|
||||||
|
fontWeight:j===13?700:undefined}}>
|
||||||
|
{isPct ? (val==="—"?<span style={{color:"#cbd5e1"}}>—</span>:<MiniBar valStr={val} />) : fmtMoneda(val)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(() => {
|
||||||
|
// TOTAL GENERAL del modal — solo alumnos visibles (respeta la sede filtrada)
|
||||||
|
const alumnos = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL");
|
||||||
|
if (alumnos.length===0) return null;
|
||||||
|
const sum = (j)=>alumnos.reduce((s,f)=>s+toMonto(f[j]),0);
|
||||||
|
const t = {4:sum(4),5:sum(5),7:sum(7),8:sum(8),10:sum(10),11:sum(11),13:sum(13)};
|
||||||
|
const r = (cob,cta)=>cta>0?`${Math.round(cob/cta*100)}%`:"";
|
||||||
|
const celda = (j)=>{
|
||||||
|
if (j===0) return "TOTAL GENERAL";
|
||||||
|
if (j===1||j===2||j===3) return "";
|
||||||
|
if (j===6) return r(t[5],t[4]);
|
||||||
|
if (j===9) return r(t[8],t[7]);
|
||||||
|
if (j===12) return r(t[11],t[10]);
|
||||||
|
if (t[j]!==undefined) return "S/ "+t[j].toLocaleString("es-PE",{maximumFractionDigits:0});
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<tr className="total-row">
|
||||||
|
{Array.from({length:14}).map((_,j)=>{
|
||||||
|
const isPct=[6,9,12].includes(j);
|
||||||
|
const val=celda(j);
|
||||||
|
return (
|
||||||
|
<td key={j} className={j===0?"col-name":""}
|
||||||
|
style={{textAlign:j===0?"left":(String(val).includes("S/")?"right":"center"),whiteSpace:"nowrap"}}>
|
||||||
|
{isPct?<MiniBar valStr={val} />:val}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
1004
src/pages/Comisiones.jsx
Normal file
1004
src/pages/Comisiones.jsx
Normal file
File diff suppressed because it is too large
Load Diff
82
src/pages/Login.jsx
Normal file
82
src/pages/Login.jsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// src/pages/Login.jsx
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "../lib/auth";
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [cargando, setCargando] = useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setCargando(true);
|
||||||
|
const res = await login(email.trim(), password);
|
||||||
|
if (!res.ok) setError(res.msg);
|
||||||
|
setCargando(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
background: "linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%)", padding: 20,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: "#fff", borderRadius: 16, padding: "40px 36px", width: "100%", maxWidth: 400,
|
||||||
|
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||||
|
}}>
|
||||||
|
<div style={{ textAlign: "center", marginBottom: 28 }}>
|
||||||
|
<div style={{ fontSize: 38, marginBottom: 8 }}>❄️</div>
|
||||||
|
<h1 style={{ fontSize: 20, fontWeight: 800, color: "#0f172a" }}>Escuela de Refrigeración</h1>
|
||||||
|
<p style={{ fontSize: 13, color: "#64748b", marginTop: 4 }}>Dashboard — Inicia sesión</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: "#475569", display: "block", marginBottom: 6 }}>
|
||||||
|
Correo
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email" value={email} onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="usuario@escuela.com" required autoFocus
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: "#475569", display: "block", marginBottom: 6 }}>
|
||||||
|
Contraseña
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••" required
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
background: "#fee2e2", border: "1px solid #fca5a5", color: "#991b1b",
|
||||||
|
padding: "10px 12px", borderRadius: 8, fontSize: 13, marginBottom: 16,
|
||||||
|
}}>⚠️ {error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" disabled={cargando} style={{
|
||||||
|
width: "100%", padding: "12px", border: "none", borderRadius: 10,
|
||||||
|
background: cargando ? "#94a3b8" : "#2563eb", color: "#fff", fontSize: 15, fontWeight: 600,
|
||||||
|
cursor: cargando ? "default" : "pointer",
|
||||||
|
}}>
|
||||||
|
{cargando ? "Ingresando..." : "Iniciar sesión"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
width: "100%", padding: "11px 14px", border: "1px solid #cbd5e1", borderRadius: 10,
|
||||||
|
fontSize: 14, outline: "none", boxSizing: "border-box",
|
||||||
|
};
|
||||||
194
src/pages/Ocupabilidad.jsx
Normal file
194
src/pages/Ocupabilidad.jsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
// src/pages/Ocupabilidad.jsx
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox, ProgressBar, Filters, Select } from "../components/UI";
|
||||||
|
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||||||
|
|
||||||
|
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||||||
|
const ANOS = [2024, 2025, 2026];
|
||||||
|
const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"];
|
||||||
|
const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"];
|
||||||
|
|
||||||
|
const COLS = ["PROGRAMA","FECHA INICIO","DÍAS PARA INICIO","TOTAL INSCRITOS","RETIRADOS","INSCRITOS EN CURSO","META INSCRITOS","AVANCE INSCRITOS","INSCRITOS MES","INSCRITOS P.C","INSCRITOS REFRIPERU","INSCRITOS CONTINUIDAD"];
|
||||||
|
|
||||||
|
function toNum(v) { const n = parseFloat(String(v).replace("%","").trim()); return isNaN(n) ? 0 : n; }
|
||||||
|
|
||||||
|
export default function Ocupabilidad() {
|
||||||
|
const hoy = new Date();
|
||||||
|
const [ano, setAno] = useState(hoy.getFullYear());
|
||||||
|
const [mes, setMes] = useState(hoy.getMonth() + 1);
|
||||||
|
const [sede, setSede] = useState("TODOS");
|
||||||
|
const [programa, setPrograma] = useState("TODOS");
|
||||||
|
const [mostrarRepro, setMostrarRepro] = useState(true);
|
||||||
|
const [datos, setDatos] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const cols = useColumnasAjustables([280, 110, 120, 110, 100, 120, 110, 120, 100, 100, 130, 140]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.ocupabilidad(ano, mes, sede, programa)
|
||||||
|
.then((res) => { if (activo) { setDatos(res.datos || []); setLoading(false); } })
|
||||||
|
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||||
|
return () => { activo = false; };
|
||||||
|
}, [ano, mes, sede, programa]);
|
||||||
|
|
||||||
|
// Toggle "Mostrar Reprogramados": si está apagado, ocultar filas de inicio
|
||||||
|
const datosVisibles = useMemo(() => {
|
||||||
|
if (mostrarRepro) return datos;
|
||||||
|
return datos.filter((d) => String(d.dias_para_inicio).trim().toUpperCase() !== "REPROGRAMADO");
|
||||||
|
}, [datos, mostrarRepro]);
|
||||||
|
|
||||||
|
const kpis = useMemo(() => {
|
||||||
|
if (!datosVisibles.length) return null;
|
||||||
|
const esRepro = (d) => String(d.dias_para_inicio).trim().toUpperCase() === "REPROGRAMADO";
|
||||||
|
// Las filas REPROGRAMADO (inicios) no entran en las métricas (igual que escritorio)
|
||||||
|
const validos = datosVisibles.filter((d) => !esRepro(d));
|
||||||
|
const insc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Activos) || 0), 0);
|
||||||
|
const totalInsc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Totales) || 0), 0);
|
||||||
|
const retirados = validos.reduce((s, d) => s + (parseInt(d.Retirados ?? d.Inscritos_Retirados) || 0), 0);
|
||||||
|
const meta = validos.reduce((s, d) => s + (parseInt(d.Meta_Curso) || 0), 0);
|
||||||
|
return {
|
||||||
|
cursos: validos.length, // solo programados (sin reprogramados)
|
||||||
|
reprogramados: datosVisibles.filter(esRepro).length, // inicios rojos (REPROGRAMADO)
|
||||||
|
inscritos: totalInsc, // columna TOTAL INSCRITOS
|
||||||
|
meta,
|
||||||
|
avance: meta > 0 ? (totalInsc / meta * 100) : 0,
|
||||||
|
desercion: totalInsc > 0 ? (retirados / totalInsc * 100) : 0,
|
||||||
|
sobreMeta: validos.filter((d) => (parseInt(d.Inscritos_Totales)||0) >= (parseInt(d.Meta_Curso)||1)).length,
|
||||||
|
pc: validos.reduce((s, d) => s + (parseInt(d.Inscritos_PC) || 0), 0),
|
||||||
|
};
|
||||||
|
}, [datosVisibles]);
|
||||||
|
|
||||||
|
const totales = useMemo(() => {
|
||||||
|
const t = { mes:0, total:0, pc:0, retirados:0, activos:0, refri:0, cont:0, meta:0 };
|
||||||
|
datosVisibles.forEach((d) => {
|
||||||
|
t.mes += parseInt(d.Inscritos_Mes)||0;
|
||||||
|
t.total += parseInt(d.Inscritos_Totales)||0;
|
||||||
|
t.pc += parseInt(d.Inscritos_PC)||0;
|
||||||
|
t.retirados += parseInt(d.Retirados ?? d.Inscritos_Retirados)||0;
|
||||||
|
t.activos += parseInt(d.Inscritos_Activos)||0;
|
||||||
|
t.refri += parseInt(d.Descuento)||0;
|
||||||
|
t.cont += parseInt(d.Inscritos_Continuidad)||0;
|
||||||
|
t.meta += parseInt(d.Meta_Curso)||0;
|
||||||
|
});
|
||||||
|
return t;
|
||||||
|
}, [datosVisibles]);
|
||||||
|
|
||||||
|
const KPI_CARDS = kpis ? [
|
||||||
|
["📚","Cursos Activos", kpis.cursos, "programas este mes"],
|
||||||
|
["🔁","Cursos Reprogramados", kpis.reprogramados, "inicios reprogramados"],
|
||||||
|
["👥","Total Inscritos", kpis.inscritos, "alumnos en curso"],
|
||||||
|
["🎯","Meta Total", kpis.meta, "inscritos objetivo"],
|
||||||
|
["📈","Avance Inscritos", `${kpis.avance.toFixed(1)}%`, "total inscritos / meta"],
|
||||||
|
["📉","Deserción", `${kpis.desercion.toFixed(1)}%`, "retirados / total inscritos"],
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
async function exportarExcel() {
|
||||||
|
if (!datosVisibles.length) return;
|
||||||
|
// Cargar SheetJS desde CDN solo cuando se necesita
|
||||||
|
const XLSX = await import("xlsx");
|
||||||
|
const filas = datosVisibles.map((d) => ({
|
||||||
|
"PROGRAMA": d.programa_frecuencia || d.dsc_programa || "",
|
||||||
|
"FECHA INICIO": d.fch_inicio || "",
|
||||||
|
"DÍAS PARA INICIO": d.dias_para_inicio ?? "",
|
||||||
|
"TOTAL INSCRITOS": parseInt(d.Inscritos_Totales)||0,
|
||||||
|
"RETIRADOS": parseInt(d.Retirados ?? d.Inscritos_Retirados)||0,
|
||||||
|
"INSCRITOS EN CURSO": parseInt(d.Inscritos_Activos)||0,
|
||||||
|
"META INSCRITOS": parseInt(d.Meta_Curso)||0,
|
||||||
|
"AVANCE INSCRITOS": d.Avance_Inscritos || "",
|
||||||
|
"INSCRITOS MES": parseInt(d.Inscritos_Mes)||0,
|
||||||
|
"INSCRITOS P.C": parseInt(d.Inscritos_PC)||0,
|
||||||
|
"INSCRITOS REFRIPERU": parseInt(d.Descuento)||0,
|
||||||
|
"INSCRITOS CONTINUIDAD": parseInt(d.Inscritos_Continuidad)||0,
|
||||||
|
}));
|
||||||
|
const ws = XLSX.utils.json_to_sheet(filas);
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, "Ocupabilidad");
|
||||||
|
XLSX.writeFile(wb, `cursos_${ano}_${mes}.xlsx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">📊 Ocupabilidad</h1>
|
||||||
|
|
||||||
|
<Filters>
|
||||||
|
<Select label="Año" value={ano} options={ANOS} onChange={(v) => setAno(+v)} />
|
||||||
|
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v) => setMes(+v)} />
|
||||||
|
<Select label="Sede" value={sede} options={SEDES} onChange={setSede} />
|
||||||
|
<Select label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} />
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Reprogramados</label>
|
||||||
|
<label style={{display:"flex",alignItems:"center",gap:6,height:38,cursor:"pointer",fontSize:13}}>
|
||||||
|
<input type="checkbox" checked={mostrarRepro} onChange={(e)=>setMostrarRepro(e.target.checked)} />
|
||||||
|
Mostrar
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" style={{height:38,marginLeft:"auto"}} onClick={exportarExcel} disabled={!datosVisibles.length}>
|
||||||
|
⬇️ Exportar Excel
|
||||||
|
</button>
|
||||||
|
</Filters>
|
||||||
|
|
||||||
|
{loading ? <Loader text="Calculando ocupabilidad..." /> :
|
||||||
|
error ? <ErrorBox msg={error} /> :
|
||||||
|
<>
|
||||||
|
<div className="kpis">
|
||||||
|
{KPI_CARDS.map(([ico,label,val,sub]) => (
|
||||||
|
<div className="kpi" key={label}>
|
||||||
|
<div className="ico">{ico}</div>
|
||||||
|
<div className="label" style={{fontSize:15}}>{label}</div>
|
||||||
|
<div className="value">{val}</div>
|
||||||
|
<div className="sub">{sub}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-wrap" style={{overflowX:"auto"}}>
|
||||||
|
<table {...cols.tableProps}>
|
||||||
|
<cols.ColGroup />
|
||||||
|
<thead><tr>{COLS.map((c, i) => <th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={i} /></th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{datosVisibles.map((d, i) => {
|
||||||
|
const prog = d.programa_frecuencia || d.dsc_programa || "";
|
||||||
|
const dias = parseInt(d.dias_para_inicio);
|
||||||
|
const pct = toNum(d.Avance_Inscritos);
|
||||||
|
// Inicio reprogramado (existe en Supabase pero no en SQL este mes) → fila roja
|
||||||
|
const esInicio = String(d.dias_para_inicio).trim().toUpperCase() === "REPROGRAMADO";
|
||||||
|
const cel = (v) => esInicio ? "-" : v;
|
||||||
|
return (
|
||||||
|
<tr key={i} className={esInicio ? "row-inicio" : ""}>
|
||||||
|
<td className="col-name" style={{minWidth:300,maxWidth:480,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"}}>{prog}</td>
|
||||||
|
<td>{d.fch_inicio || ""}</td>
|
||||||
|
<td style={!esInicio && dias < 0 ? {color:"#dc2626",fontWeight:700} : {}}>{d.dias_para_inicio ?? ""}</td>
|
||||||
|
<td>{cel(parseInt(d.Inscritos_Totales)||0)}</td>
|
||||||
|
<td>{cel(parseInt(d.Retirados ?? d.Inscritos_Retirados)||0)}</td>
|
||||||
|
<td>{cel(parseInt(d.Inscritos_Activos)||0)}</td>
|
||||||
|
<td>{cel(parseInt(d.Meta_Curso)||0)}</td>
|
||||||
|
<td>{esInicio ? "-" : <ProgressBar pct={pct} />}</td>
|
||||||
|
<td>{cel(parseInt(d.Inscritos_Mes)||0)}</td>
|
||||||
|
<td>{cel(parseInt(d.Inscritos_PC)||0)}</td>
|
||||||
|
<td>{cel(parseInt(d.Descuento)||0)}</td>
|
||||||
|
<td>{cel(parseInt(d.Inscritos_Continuidad)||0)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{datosVisibles.length > 0 && (
|
||||||
|
<tr className="total-row">
|
||||||
|
<td className="col-name">TOTAL GENERAL</td>
|
||||||
|
<td>—</td><td>—</td>
|
||||||
|
<td>{totales.total}</td>
|
||||||
|
<td>{totales.retirados}</td>
|
||||||
|
<td>{totales.activos}</td>
|
||||||
|
<td>{totales.meta}</td>
|
||||||
|
<td><ProgressBar pct={totales.meta>0 ? totales.total/totales.meta*100 : 0} /></td>
|
||||||
|
<td>{totales.mes}</td><td>{totales.pc}</td><td>{totales.refri}</td><td>{totales.cont}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
270
src/pages/Rentabilidad.jsx
Normal file
270
src/pages/Rentabilidad.jsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
// src/pages/Rentabilidad.jsx
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||||
|
import Modal from "../components/Modal";
|
||||||
|
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||||||
|
|
||||||
|
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||||||
|
const ANOS = [2024, 2025, 2026];
|
||||||
|
const SEDES = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"];
|
||||||
|
const PROGRAMAS = ["TODOS","SEMINARIOS","OTROS","TEAC","TERC"];
|
||||||
|
|
||||||
|
const COLS = ["PROGRAMA","FECHA INICIO","TOTAL","RETIRADOS","EN CURSO","CUOTA PROM.","PROM. DESC. E.","V. VENTA INICIAL","V. COSTO INICIAL","M.B. INICIAL %","V. VENTA ACTUAL","V. COSTO ACTUAL","M.B. ACTUAL %","OPC."];
|
||||||
|
// índices: inicial = 7,8,9 | actual = 10,11,12
|
||||||
|
const BG_INI = "#eef4ff", BG_ACT = "#edfff6";
|
||||||
|
|
||||||
|
function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").replace("%","").trim()); return isNaN(n)?0:n; }
|
||||||
|
|
||||||
|
export default function Rentabilidad() {
|
||||||
|
const hoy = new Date();
|
||||||
|
const [ano, setAno] = useState(hoy.getFullYear());
|
||||||
|
const [mes, setMes] = useState(hoy.getMonth() + 1);
|
||||||
|
const [sede, setSede] = useState("TODOS");
|
||||||
|
const [programa, setPrograma] = useState("TODOS");
|
||||||
|
const [mostrarInscritos, setMostrarInscritos] = useState(true); // TOTAL, RETIRADOS, EN CURSO
|
||||||
|
const [filas, setFilas] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [modalVer, setModalVer] = useState(null);
|
||||||
|
// Índices de columnas TOTAL(2), RETIRADOS(3), EN CURSO(4) — se ocultan si el check está apagado
|
||||||
|
const IDX_INSCRITOS = [2, 3, 4];
|
||||||
|
const ocultar = (i) => !mostrarInscritos && IDX_INSCRITOS.includes(i);
|
||||||
|
const ANCHOS_BASE = [260, 110, 80, 90, 90, 110, 120, 120, 120, 110, 120, 120, 110, 80];
|
||||||
|
const anchosVisibles = ANCHOS_BASE.filter((_, i) => !ocultar(i));
|
||||||
|
const cols = useColumnasAjustables(anchosVisibles);
|
||||||
|
const [modalCostos, setModalCostos] = useState(null);
|
||||||
|
|
||||||
|
function cargar() {
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.rentabilidad(ano, mes, sede, programa)
|
||||||
|
.then((res) => { setFilas(res.filas || []); setLoading(false); })
|
||||||
|
.catch((e) => { setError(e.message); setLoading(false); });
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.rentabilidad(ano, mes, sede, programa)
|
||||||
|
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
||||||
|
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||||
|
return () => { activo = false; };
|
||||||
|
}, [ano, mes, sede, programa]);
|
||||||
|
|
||||||
|
const datos = useMemo(() => filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"), [filas]);
|
||||||
|
const total = useMemo(() => filas.find((f)=>String(f[0]).toUpperCase()==="TOTAL GENERAL"), [filas]);
|
||||||
|
|
||||||
|
const kpis = useMemo(() => {
|
||||||
|
if (!total) return null;
|
||||||
|
return {
|
||||||
|
programas: datos.length,
|
||||||
|
enCurso: total[4],
|
||||||
|
ventaAct: total[10],
|
||||||
|
costoAct: total[11],
|
||||||
|
mbAct: total[12],
|
||||||
|
};
|
||||||
|
}, [datos, total]);
|
||||||
|
|
||||||
|
function bgCol(ci){ if([7,8,9].includes(ci))return BG_INI; if([10,11,12].includes(ci))return BG_ACT; return ""; }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">📈 Rentabilidad</h1>
|
||||||
|
|
||||||
|
<Filters>
|
||||||
|
<Select label="Año" value={ano} options={ANOS} onChange={(v)=>setAno(+v)} />
|
||||||
|
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} />
|
||||||
|
<Select label="Sede" value={sede} options={SEDES} onChange={setSede} />
|
||||||
|
<Select label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} />
|
||||||
|
<div className="filter-group" style={{marginLeft:"auto"}}>
|
||||||
|
<label>Inscritos</label>
|
||||||
|
<label style={{display:"flex",alignItems:"center",gap:6,height:38,cursor:"pointer",fontSize:13}}>
|
||||||
|
<input type="checkbox" checked={mostrarInscritos} onChange={(e)=>setMostrarInscritos(e.target.checked)} />
|
||||||
|
Total / Retirados / En curso
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Filters>
|
||||||
|
|
||||||
|
{loading ? <Loader text="Calculando rentabilidad..." /> :
|
||||||
|
error ? <ErrorBox msg={error} /> :
|
||||||
|
<>
|
||||||
|
{kpis && (
|
||||||
|
<div className="kpis" style={{gridTemplateColumns:"repeat(5,1fr)"}}>
|
||||||
|
{[["📚","Programas",kpis.programas,"este mes"],
|
||||||
|
["🎓","En Curso",kpis.enCurso,"alumnos activos"],
|
||||||
|
["💵","Venta Actual",kpis.ventaAct,"ingreso total"],
|
||||||
|
["💸","Costo Actual",kpis.costoAct,"gasto total"],
|
||||||
|
["📊","M.B. Actual",kpis.mbAct,"margen bruto"]].map(([ico,label,val,sub])=>(
|
||||||
|
<div className="kpi" key={label}>
|
||||||
|
<div className="ico">{ico}</div><div className="label">{label}</div>
|
||||||
|
<div className="value" style={{fontSize:18}}>{val}</div><div className="sub">{sub}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="table-wrap" style={{marginTop:16, overflowX:"auto"}}>
|
||||||
|
<table {...cols.tableProps}>
|
||||||
|
<cols.ColGroup />
|
||||||
|
<thead><tr>{COLS.filter((_,i)=>!ocultar(i)).map((c, vi)=><th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={vi} /></th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{datos.map((f, ri) => (
|
||||||
|
<tr key={ri}>
|
||||||
|
{f.slice(0,13).map((v,ci)=>{
|
||||||
|
if (ocultar(ci)) return null;
|
||||||
|
const isPct = ci===9||ci===12;
|
||||||
|
return (
|
||||||
|
<td key={ci} className={ci===0?"col-name":""}
|
||||||
|
style={{background:bgCol(ci)||undefined,
|
||||||
|
textAlign: ci===0?"left":"center",
|
||||||
|
fontWeight:isPct?700:undefined,
|
||||||
|
minWidth:ci===0?260:undefined}}>{v}</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td style={{whiteSpace:"nowrap"}}>
|
||||||
|
<button className="btn btn-ghost" style={{padding:"3px 8px",fontSize:11,marginRight:4}}
|
||||||
|
onClick={()=>setModalVer({programa:f[0]})}>👁️</button>
|
||||||
|
<button className="btn btn-ghost" style={{padding:"3px 8px",fontSize:11}}
|
||||||
|
onClick={()=>setModalCostos({programa:f[0]})}>✏️</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{total && (
|
||||||
|
<tr className="total-row">
|
||||||
|
{total.slice(0,13).map((v,ci)=>(
|
||||||
|
ocultar(ci) ? null :
|
||||||
|
<td key={ci} className={ci===0?"col-name":""}
|
||||||
|
style={{textAlign:ci===0?"left":"center"}}>{v}</td>
|
||||||
|
))}
|
||||||
|
<td>—</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{modalVer && <ModalVerDetalle programa={modalVer.programa} ano={ano} mes={mes} onClose={()=>setModalVer(null)} />}
|
||||||
|
{modalCostos && <ModalCostos programa={modalCostos.programa} ano={ano} mes={mes} onClose={()=>setModalCostos(null)} onGuardado={cargar} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalVerDetalle({ programa, ano, mes, onClose }) {
|
||||||
|
const [filas, setFilas] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true; setLoading(true);
|
||||||
|
api.rentabilidadDetalle(programa, ano, mes)
|
||||||
|
.then((res)=>{ if(activo){setFilas(res.filas||[]);setLoading(false);} })
|
||||||
|
.catch(()=>{ if(activo){setFilas([]);setLoading(false);} });
|
||||||
|
return ()=>{activo=false;};
|
||||||
|
}, [programa, ano, mes]);
|
||||||
|
|
||||||
|
const COLS_DET = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","VALOR CUOTA","PROM. DESC. E.","VALOR VENTA"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={`👁️ Detalle — ${programa}`} onClose={onClose} width={1000}>
|
||||||
|
{loading ? <Loader text="Cargando detalle..." /> :
|
||||||
|
filas.length===0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin alumnos.</div> :
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr>{COLS_DET.map((c)=><th key={c}>{c}</th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{filas.map((f,i)=>(
|
||||||
|
<tr key={i}>
|
||||||
|
{f.map((v,j)=>(
|
||||||
|
<td key={j} className={j===1?"col-name":""} style={{textAlign:j>=5?"right":(j===1?"left":"center")}}>{v}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalCostos({ programa, ano, mes, onClose, onGuardado }) {
|
||||||
|
const [costos, setCostos] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [guardando, setGuardando] = useState(false);
|
||||||
|
const [msg, setMsg] = useState(null);
|
||||||
|
// [keyGuardar, etiqueta, prefijoBackend]
|
||||||
|
const CAMPOS = [["epp","COSTO EPP","epp"],["certificado","COSTO CERTIFICADO","cert"],["docente","COSTO DOCENTE","doc"],["marketing","COSTO MARKETING","mkt"],["consumibles","COSTO CONSUMIBLES","cons"]];
|
||||||
|
|
||||||
|
const [ini, setIni] = useState({});
|
||||||
|
const [act, setAct] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true; setLoading(true);
|
||||||
|
api.rentabilidadCostos(programa, ano, mes)
|
||||||
|
.then((res)=>{
|
||||||
|
if(!activo) return;
|
||||||
|
const c = res.costos || {};
|
||||||
|
setCostos(c);
|
||||||
|
const gi={}, ga={};
|
||||||
|
CAMPOS.forEach(([k,,bp])=>{ gi[k]=+(c[`${bp}_inicial`]||0); ga[k]=+(c[`${bp}_actual`]||0); });
|
||||||
|
setIni(gi); setAct(ga); setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(()=>{ if(activo){setCostos(null);setLoading(false);} });
|
||||||
|
return ()=>{activo=false;};
|
||||||
|
}, [programa, ano, mes]);
|
||||||
|
|
||||||
|
const totIni = Object.values(ini).reduce((a,b)=>a+(+b||0),0);
|
||||||
|
const totAct = Object.values(act).reduce((a,b)=>a+(+b||0),0);
|
||||||
|
|
||||||
|
async function guardar() {
|
||||||
|
setGuardando(true); setMsg(null);
|
||||||
|
try {
|
||||||
|
await api.guardarCostos(costos.num_indice, ini, act);
|
||||||
|
if (onGuardado) onGuardado(); // recarga la tabla principal
|
||||||
|
onClose(); // cierra el modal
|
||||||
|
} catch (e) {
|
||||||
|
setMsg({ ok:false, txt:"❌ Error al guardar. Verifica Supabase." });
|
||||||
|
setGuardando(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={`✏️ Editar Costos — ${programa}`} onClose={onClose} width={760}>
|
||||||
|
{loading ? <Loader text="Cargando costos..." /> :
|
||||||
|
!costos ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin datos de costos.</div> :
|
||||||
|
<>
|
||||||
|
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:16}}>
|
||||||
|
<Columna titulo="INICIAL" color="#1e3a5f" campos={CAMPOS} valores={ini} setVal={setIni} total={totIni} />
|
||||||
|
<Columna titulo="ACTUAL" color="#1a5f4a" campos={CAMPOS} valores={act} setVal={setAct} total={totAct} />
|
||||||
|
</div>
|
||||||
|
{msg && <div style={{marginTop:14,padding:10,borderRadius:8,fontSize:13,
|
||||||
|
background:msg.ok?"#d1fae5":"#fee2e2",color:msg.ok?"#065f46":"#991b1b"}}>{msg.txt}</div>}
|
||||||
|
<div style={{display:"flex",gap:10,marginTop:16}}>
|
||||||
|
<button className="btn btn-primary" disabled={guardando} onClick={guardar}>
|
||||||
|
{guardando ? "Guardando..." : "💾 Guardar"}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost" onClick={onClose}>Cerrar</button>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Columna({ titulo, color, campos, valores, setVal, total }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{background:color,color:"#fff",borderRadius:8,padding:"8px 12px",
|
||||||
|
marginBottom:10,fontWeight:600,fontSize:12,textAlign:"center"}}>{titulo}</div>
|
||||||
|
{campos.map(([k,label])=>(
|
||||||
|
<div key={k} style={{marginBottom:8}}>
|
||||||
|
<label style={{fontSize:11,fontWeight:600,color:"#64748b",display:"block",marginBottom:3}}>{label}</label>
|
||||||
|
<input type="number" value={valores[k]??0} min="0" step="100"
|
||||||
|
onChange={(e)=>setVal({...valores,[k]:+e.target.value})}
|
||||||
|
style={{width:"100%",padding:"7px 10px",border:"1px solid #cbd5e1",borderRadius:8,fontSize:13}} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{background:"#f1f5f9",borderRadius:8,padding:10,textAlign:"center",
|
||||||
|
fontWeight:700,color,marginTop:8}}>
|
||||||
|
TOTAL: S/ {total.toLocaleString("es-PE",{maximumFractionDigits:0})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
src/pages/SaldoPendiente.jsx
Normal file
96
src/pages/SaldoPendiente.jsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// src/pages/SaldoPendiente.jsx
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||||
|
|
||||||
|
const CUOTAS = ["1° Cuota","2° Cuota","3° Cuota","4° Cuota","5° Cuota"];
|
||||||
|
|
||||||
|
function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; }
|
||||||
|
function fmt(v){ const n=toNum(v); return "S/ " + n.toLocaleString("es-PE",{maximumFractionDigits:0}); }
|
||||||
|
|
||||||
|
export default function SaldoPendiente() {
|
||||||
|
const [tipoCuota, setTipoCuota] = useState("1° Cuota");
|
||||||
|
const [datos, setDatos] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [vendedor, setVendedor] = useState("TODOS");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.saldoPendiente(tipoCuota)
|
||||||
|
.then((res)=>{ if(activo){setDatos(res.datos||[]);setLoading(false);} })
|
||||||
|
.catch((e)=>{ if(activo){setError(e.message);setLoading(false);} });
|
||||||
|
return ()=>{activo=false;};
|
||||||
|
}, [tipoCuota]);
|
||||||
|
|
||||||
|
const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`;
|
||||||
|
const vendedores = useMemo(() => {
|
||||||
|
const set = new Set(datos.map((d)=>String(d.VENDEDOR||"").trim()).filter(Boolean));
|
||||||
|
return ["TODOS", ...Array.from(set).sort()];
|
||||||
|
}, [datos]);
|
||||||
|
|
||||||
|
const filtrados = useMemo(() => {
|
||||||
|
let arr = datos.filter((d)=>toNum(d[colSaldo])>0.01);
|
||||||
|
if (vendedor!=="TODOS") arr = arr.filter((d)=>String(d.VENDEDOR||"").trim().toUpperCase()===vendedor.toUpperCase());
|
||||||
|
return arr;
|
||||||
|
}, [datos, vendedor, colSaldo]);
|
||||||
|
|
||||||
|
const totalSaldo = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d[colSaldo]),0),[filtrados,colSaldo]);
|
||||||
|
const totalMat = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d["SALDO MAT."]),0),[filtrados]);
|
||||||
|
|
||||||
|
const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||||
|
const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">⏳ Saldo Pendiente</h1>
|
||||||
|
|
||||||
|
<Filters>
|
||||||
|
<Select label="Tipo de cuota" value={tipoCuota} options={CUOTAS} onChange={setTipoCuota} />
|
||||||
|
<Select label="Vendedor" value={vendedor} options={vendedores} onChange={setVendedor} />
|
||||||
|
</Filters>
|
||||||
|
|
||||||
|
{loading ? <Loader text="Calculando saldos..." /> :
|
||||||
|
error ? <ErrorBox msg={error} /> :
|
||||||
|
<>
|
||||||
|
<div className="kpis" style={{gridTemplateColumns:"repeat(3,1fr)"}}>
|
||||||
|
<div className="kpi"><div className="ico">👥</div><div className="label">Alumnos con Saldo</div><div className="value">{filtrados.length}</div></div>
|
||||||
|
<div className="kpi"><div className="ico">⏳</div><div className="label">Saldo {tipoCuota}</div><div className="value" style={{fontSize:20}}>{fmt(totalSaldo)}</div></div>
|
||||||
|
<div className="kpi"><div className="ico">📋</div><div className="label">Saldo Matrícula</div><div className="value" style={{fontSize:20}}>{fmt(totalMat)}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-wrap" style={{marginTop:16}}>
|
||||||
|
<table style={{minWidth:1100}}>
|
||||||
|
<thead><tr>{COLS.map((c)=><th key={c}>{c}</th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{filtrados.map((d,i)=>(
|
||||||
|
<tr key={i}>
|
||||||
|
{KEYS.map((k,j)=>{
|
||||||
|
const isMonto = k.includes("SALDO")||k.includes("INV");
|
||||||
|
return (
|
||||||
|
<td key={j} className={k==="ALUMNO"||k==="PROGRAMA"?"col-name":""}
|
||||||
|
style={{textAlign:isMonto?"right":(k==="ALUMNO"||k==="PROGRAMA"?"left":"center"),
|
||||||
|
fontWeight:k===colSaldo?700:undefined,
|
||||||
|
color:k===colSaldo?"#dc2626":undefined}}>
|
||||||
|
{isMonto ? fmt(d[k]) : (d[k] ?? "")}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{filtrados.length>0 && (
|
||||||
|
<tr className="total-row">
|
||||||
|
<td colSpan={6} style={{textAlign:"left"}}>TOTAL GENERAL ({filtrados.length})</td>
|
||||||
|
<td style={{textAlign:"right"}}>{fmt(totalMat)}</td>
|
||||||
|
<td style={{textAlign:"right"}}>{fmt(totalSaldo)}</td>
|
||||||
|
<td>—</td><td>—</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
src/pages/Usuarios.jsx
Normal file
130
src/pages/Usuarios.jsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// src/pages/Usuarios.jsx
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||||
|
|
||||||
|
const ROLES = ["ADMINISTRADOR", "VENTAS", "COBRANZA"];
|
||||||
|
|
||||||
|
export default function Usuarios() {
|
||||||
|
const [usuarios, setUsuarios] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// form nuevo usuario
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [nombre, setNombre] = useState("");
|
||||||
|
const [rol, setRol] = useState("COBRANZA");
|
||||||
|
const [creando, setCreando] = useState(false);
|
||||||
|
const [msg, setMsg] = useState(null);
|
||||||
|
|
||||||
|
const cargar = useCallback(() => {
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.usuariosListar()
|
||||||
|
.then((res) => { setUsuarios(res.usuarios || []); setLoading(false); })
|
||||||
|
.catch((e) => { setError(e.message); setLoading(false); });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { cargar(); }, [cargar]);
|
||||||
|
|
||||||
|
async function crear() {
|
||||||
|
setMsg(null);
|
||||||
|
if (!email.trim() || !password.trim()) { setMsg({ ok:false, txt:"Correo y contraseña son obligatorios." }); return; }
|
||||||
|
setCreando(true);
|
||||||
|
try {
|
||||||
|
await api.usuariosCrear(email.trim(), password, nombre.trim(), rol);
|
||||||
|
setMsg({ ok:true, txt:`Usuario ${email} creado como ${rol}.` });
|
||||||
|
setEmail(""); setPassword(""); setNombre(""); setRol("COBRANZA");
|
||||||
|
cargar();
|
||||||
|
} catch (e) {
|
||||||
|
setMsg({ ok:false, txt:"Error: " + e.message });
|
||||||
|
}
|
||||||
|
setCreando(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cambiarRol(id, nuevoRol) {
|
||||||
|
try { await api.usuariosActualizarRol(id, nuevoRol); cargar(); }
|
||||||
|
catch (e) { alert("No se pudo cambiar el rol: " + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eliminar(id, correo) {
|
||||||
|
if (!confirm(`¿Eliminar al usuario ${correo}? Esta acción no se puede deshacer.`)) return;
|
||||||
|
try { await api.usuariosEliminar(id); cargar(); }
|
||||||
|
catch (e) { alert("No se pudo eliminar: " + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">🔐 Usuarios</h1>
|
||||||
|
|
||||||
|
{/* Crear nuevo usuario */}
|
||||||
|
<div style={{ background:"#fff", border:"1px solid #e2e8f0", borderRadius:12, padding:18, marginBottom:20 }}>
|
||||||
|
<h3 style={{ fontSize:15, fontWeight:700, color:"#0f172a", marginBottom:14 }}>➕ Crear nuevo usuario</h3>
|
||||||
|
<div style={{ display:"flex", gap:12, flexWrap:"wrap", alignItems:"flex-end" }}>
|
||||||
|
<Campo label="Correo">
|
||||||
|
<input value={email} onChange={(e)=>setEmail(e.target.value)} placeholder="usuario@escuela.com" style={inp} />
|
||||||
|
</Campo>
|
||||||
|
<Campo label="Contraseña">
|
||||||
|
<input type="text" value={password} onChange={(e)=>setPassword(e.target.value)} placeholder="mín. 6 caracteres" style={inp} />
|
||||||
|
</Campo>
|
||||||
|
<Campo label="Nombre">
|
||||||
|
<input value={nombre} onChange={(e)=>setNombre(e.target.value)} placeholder="Nombre completo" style={inp} />
|
||||||
|
</Campo>
|
||||||
|
<Campo label="Rol">
|
||||||
|
<Select value={rol} options={ROLES} onChange={setRol} />
|
||||||
|
</Campo>
|
||||||
|
<button className="btn btn-primary" style={{height:38}} disabled={creando} onClick={crear}>
|
||||||
|
{creando ? "Creando..." : "Crear usuario"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{msg && (
|
||||||
|
<div style={{ marginTop:12, padding:10, borderRadius:8, fontSize:13,
|
||||||
|
background: msg.ok ? "#d1fae5" : "#fee2e2", color: msg.ok ? "#065f46" : "#991b1b" }}>
|
||||||
|
{msg.ok ? "✅" : "⚠️"} {msg.txt}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista de usuarios */}
|
||||||
|
{loading ? <Loader text="Cargando usuarios..." /> :
|
||||||
|
error ? <ErrorBox msg={error} /> :
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>CORREO</th><th>NOMBRE</th><th>ROL</th><th>ACCIONES</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{usuarios.map((u) => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td className="col-name">{u.email}</td>
|
||||||
|
<td>{u.nombre || "—"}</td>
|
||||||
|
<td>
|
||||||
|
<select value={u.rol} onChange={(e)=>cambiarRol(u.id, e.target.value)}
|
||||||
|
style={{ padding:"5px 8px", border:"1px solid #cbd5e1", borderRadius:6, fontSize:12 }}>
|
||||||
|
{ROLES.map((r)=><option key={r} value={r}>{r}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn" style={{ padding:"4px 10px", fontSize:11, background:"#fee2e2", color:"#991b1b" }}
|
||||||
|
onClick={()=>eliminar(u.id, u.email)}>🗑️ Eliminar</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{usuarios.length === 0 && (
|
||||||
|
<tr><td colSpan={4} style={{ padding:20, textAlign:"center", color:"#94a3b8" }}>Sin usuarios.</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Campo({ label, children }) {
|
||||||
|
return (
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>{label}</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inp = { padding:"8px 12px", border:"1px solid #cbd5e1", borderRadius:8, fontSize:13, minWidth:180 };
|
||||||
266
src/pages/Ventas.jsx
Normal file
266
src/pages/Ventas.jsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
// src/pages/Ventas.jsx
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||||
|
import Modal from "../components/Modal";
|
||||||
|
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||||||
|
|
||||||
|
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||||||
|
const ANOS = [2024, 2025, 2026];
|
||||||
|
const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"];
|
||||||
|
const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"];
|
||||||
|
const COLS = ["VENDEDOR","INSCRITOS","VENTA TOTAL","INSCRITOS P.C DEL MES","VENTA P.C DEL MES","INSCRITOS MES ANTERIOR","VENTA MES ANTERIOR","AVANCE P.C TOTAL","OPC."];
|
||||||
|
const TIPOS = ["Venta Inscritos", "Venta P.C", "Venta Pendientes"];
|
||||||
|
const HEADERS_DETALLE = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","CUOTA PROM.","DESC. ESPECIAL","INV. NETA"];
|
||||||
|
|
||||||
|
export default function Ventas() {
|
||||||
|
const hoy = new Date();
|
||||||
|
const [ano, setAno] = useState(hoy.getFullYear());
|
||||||
|
const [mes, setMes] = useState(hoy.getMonth() + 1);
|
||||||
|
const [sede, setSede] = useState("TODOS");
|
||||||
|
const [programa, setPrograma] = useState("TODOS");
|
||||||
|
const [filas, setFilas] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [modal, setModal] = useState(null); // { vendedor }
|
||||||
|
const cols = useColumnasAjustables([200, 100, 130, 120, 130, 120, 130, 140, 100]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
api.ventas(ano, mes, sede, programa)
|
||||||
|
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
||||||
|
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||||
|
return () => { activo = false; };
|
||||||
|
}, [ano, mes, sede, programa]);
|
||||||
|
|
||||||
|
const total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]);
|
||||||
|
const datos = useMemo(() => filas.filter((f) => f[0] !== "TOTAL GENERAL"), [filas]);
|
||||||
|
|
||||||
|
const KPIS = total ? [
|
||||||
|
["👤","Vendedores", datos.length, "activos este mes"],
|
||||||
|
["🎓","Total Inscritos", total[1], "matriculados"],
|
||||||
|
["💵","Venta Total", total[2], "inversión neta"],
|
||||||
|
["✅","Venta P.C del Mes", total[4], "pagos completos"],
|
||||||
|
["🚀","Avance P.C Total", total[7], "P.C + Pendientes"],
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
// Exportar saldos por cuota — Excel con formato (anchos, encabezado de color,
|
||||||
|
// fechas reales, montos numéricos en soles y fila TOTAL GENERAL).
|
||||||
|
async function exportarCuota(tipoCuota) {
|
||||||
|
const toNum = (v) => { const n = parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; };
|
||||||
|
// Convierte "dd-mm-yyyy" o "dd/mm/yyyy" a Date; si no puede, devuelve el texto original
|
||||||
|
const toDate = (v) => {
|
||||||
|
if (!v) return "";
|
||||||
|
const s = String(v).trim().replace(/\//g,"-");
|
||||||
|
const p = s.split("-");
|
||||||
|
if (p.length === 3) {
|
||||||
|
let [d,m,y] = p;
|
||||||
|
if (y.length === 4) return new Date(+y, +m-1, +d);
|
||||||
|
}
|
||||||
|
return String(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await api.saldoPendiente(tipoCuota);
|
||||||
|
} catch (e) {
|
||||||
|
alert("No se pudo obtener los datos de saldos: " + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const datosS = res.datos || [];
|
||||||
|
const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`;
|
||||||
|
const filtrados = datosS.filter((d) => toNum(d[colSaldo]) > 0.01);
|
||||||
|
if (filtrados.length === 0) { alert(`No hay saldos pendientes para ${tipoCuota}.`); return; }
|
||||||
|
|
||||||
|
const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||||
|
const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||||
|
const COL_MONTO = ["SALDO MAT.", colSaldo, "INV. NETA"];
|
||||||
|
const COL_FECHA = ["F. INICIO","F. MATR.","VENCIMIENTO"];
|
||||||
|
|
||||||
|
const XLSX = await import("xlsx-js-style");
|
||||||
|
|
||||||
|
// Construir matriz de celdas (AOA) con tipos correctos
|
||||||
|
const headerStyle = {
|
||||||
|
font: { bold: true, color: { rgb: "FFFFFF" }, sz: 11 },
|
||||||
|
fill: { fgColor: { rgb: "1E40AF" } },
|
||||||
|
alignment: { horizontal: "center", vertical: "center" },
|
||||||
|
};
|
||||||
|
const moneyFmt = '"S/" #,##0';
|
||||||
|
const dateFmt = "dd/mm/yyyy";
|
||||||
|
|
||||||
|
const ws = {};
|
||||||
|
const range = { s: { r: 0, c: 0 }, e: { r: filtrados.length + 1, c: COLS.length - 1 } };
|
||||||
|
|
||||||
|
// Encabezado
|
||||||
|
COLS.forEach((h, c) => {
|
||||||
|
const ref = XLSX.utils.encode_cell({ r: 0, c });
|
||||||
|
ws[ref] = { v: h, t: "s", s: headerStyle };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filas de datos
|
||||||
|
filtrados.forEach((d, ri) => {
|
||||||
|
KEYS.forEach((k, c) => {
|
||||||
|
const ref = XLSX.utils.encode_cell({ r: ri + 1, c });
|
||||||
|
const col = COLS[c];
|
||||||
|
if (COL_MONTO.includes(col)) {
|
||||||
|
ws[ref] = { v: toNum(d[k]), t: "n", z: moneyFmt };
|
||||||
|
} else if (COL_FECHA.includes(col)) {
|
||||||
|
const dt = toDate(d[k]);
|
||||||
|
if (dt instanceof Date) ws[ref] = { v: dt, t: "d", z: dateFmt };
|
||||||
|
else ws[ref] = { v: dt, t: "s" };
|
||||||
|
} else {
|
||||||
|
ws[ref] = { v: d[k] ?? "", t: "s" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fila TOTAL GENERAL: promedio en SALDO MAT. y SALDO CUOTA, suma en INV. NETA
|
||||||
|
const rTot = filtrados.length + 1;
|
||||||
|
const sum = (k) => filtrados.reduce((s,d)=>s+toNum(d[k]),0);
|
||||||
|
const prom = (k) => filtrados.length ? sum(k)/filtrados.length : 0;
|
||||||
|
const totalStyle = { font: { bold: true }, fill: { fgColor: { rgb: "EFF6FF" } } };
|
||||||
|
COLS.forEach((col, c) => {
|
||||||
|
const ref = XLSX.utils.encode_cell({ r: rTot, c });
|
||||||
|
if (c === 0) ws[ref] = { v: "TOTAL GENERAL", t: "s", s: totalStyle };
|
||||||
|
else if (col === "SALDO MAT.") ws[ref] = { v: prom("SALDO MAT."), t: "n", z: moneyFmt, s: totalStyle };
|
||||||
|
else if (col === colSaldo) ws[ref] = { v: prom(colSaldo), t: "n", z: moneyFmt, s: totalStyle };
|
||||||
|
else if (col === "INV. NETA") ws[ref] = { v: sum("INV. NETA"), t: "n", z: moneyFmt, s: totalStyle };
|
||||||
|
else ws[ref] = { v: "", t: "s", s: totalStyle };
|
||||||
|
});
|
||||||
|
|
||||||
|
ws["!ref"] = XLSX.utils.encode_range(range);
|
||||||
|
ws["!cols"] = [
|
||||||
|
{ wch: 12 }, // MATRÍCULA
|
||||||
|
{ wch: 22 }, // VENDEDOR
|
||||||
|
{ wch: 28 }, // ALUMNO
|
||||||
|
{ wch: 32 }, // PROGRAMA
|
||||||
|
{ wch: 12 }, // F. INICIO
|
||||||
|
{ wch: 12 }, // F. MATR.
|
||||||
|
{ wch: 13 }, // SALDO MAT.
|
||||||
|
{ wch: 15 }, // SALDO CUOTA
|
||||||
|
{ wch: 13 }, // VENCIMIENTO
|
||||||
|
{ wch: 13 }, // INV. NETA
|
||||||
|
];
|
||||||
|
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, "Saldos");
|
||||||
|
const nombre = tipoCuota.replace("°","").replace(/\s/g,"_");
|
||||||
|
XLSX.writeFile(wb, `saldos_${nombre}.xlsx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">🛒 Ventas</h1>
|
||||||
|
|
||||||
|
<Filters>
|
||||||
|
<Select label="Año" value={ano} options={ANOS} onChange={(v) => setAno(+v)} />
|
||||||
|
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v) => setMes(+v)} />
|
||||||
|
<Select label="Sede" value={sede} options={SEDES} onChange={setSede} />
|
||||||
|
<Select label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} />
|
||||||
|
<div style={{display:"flex",gap:8,alignItems:"flex-end",marginLeft:"auto"}}>
|
||||||
|
<button className="btn btn-primary" style={{height:38}} onClick={() => exportarCuota("1° Cuota")}>
|
||||||
|
⬇️ Exportar 1° Cuota
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" style={{height:38}} onClick={() => exportarCuota("2° Cuota")}>
|
||||||
|
⬇️ Exportar 2° Cuota
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Filters>
|
||||||
|
|
||||||
|
{loading ? <Loader text="Calculando ventas..." /> :
|
||||||
|
error ? <ErrorBox msg={error} /> :
|
||||||
|
<>
|
||||||
|
<div className="kpis" style={{ gridTemplateColumns: "repeat(5,1fr)" }}>
|
||||||
|
{KPIS.map(([ico,label,val,sub]) => (
|
||||||
|
<div className="kpi" key={label}>
|
||||||
|
<div className="ico">{ico}</div>
|
||||||
|
<div className="label" style={{fontSize:15}}>{label}</div>
|
||||||
|
<div className="value" style={{fontSize:20}}>{val}</div>
|
||||||
|
<div className="sub">{sub}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-wrap" style={{overflowX:"auto"}}>
|
||||||
|
<table {...cols.tableProps}>
|
||||||
|
<cols.ColGroup />
|
||||||
|
<thead><tr>{COLS.map((c, i) => <th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={i} /></th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{datos.map((f, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="col-name">{f[0]}</td>
|
||||||
|
<td>{f[1]}</td><td>{f[2]}</td>
|
||||||
|
<td>{f[3]}</td><td>{f[4]}</td>
|
||||||
|
<td>{f[5]}</td><td>{f[6]}</td>
|
||||||
|
<td style={{fontWeight:600}}>{f[7]}</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
|
||||||
|
onClick={() => setModal({ vendedor: f[0] })}>👁️ Ver</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{total && (
|
||||||
|
<tr className="total-row">
|
||||||
|
<td className="col-name">TOTAL GENERAL</td>
|
||||||
|
<td>{total[1]}</td><td>{total[2]}</td>
|
||||||
|
<td>{total[3]}</td><td>{total[4]}</td>
|
||||||
|
<td>{total[5]}</td><td>{total[6]}</td>
|
||||||
|
<td>{total[7]}</td><td>—</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{modal && (
|
||||||
|
<ModalDetalle vendedor={modal.vendedor} ano={ano} mes={mes} onClose={() => setModal(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalDetalle({ vendedor, ano, mes, onClose }) {
|
||||||
|
const [tipo, setTipo] = useState(TIPOS[0]);
|
||||||
|
const [filas, setFilas] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let activo = true;
|
||||||
|
setLoading(true);
|
||||||
|
api.ventasDetalle(vendedor, ano, mes, tipo)
|
||||||
|
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
||||||
|
.catch(() => { if (activo) { setFilas([]); setLoading(false); } });
|
||||||
|
return () => { activo = false; };
|
||||||
|
}, [vendedor, ano, mes, tipo]);
|
||||||
|
|
||||||
|
// Columnas redimensionables (igual que las tablas principales)
|
||||||
|
const colsDet = useColumnasAjustables([200, 260, 120, 130, 120, 120, 130, 120]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={`👤 ${vendedor}`} onClose={onClose} width={1500}>
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<Select label="Tipo de venta" value={tipo} options={TIPOS} onChange={setTipo} />
|
||||||
|
</div>
|
||||||
|
{loading ? <Loader text="Cargando detalle..." /> :
|
||||||
|
filas.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin registros para esta categoría.</div> :
|
||||||
|
<div className="table-wrap" style={{overflowX:"auto"}}>
|
||||||
|
<table {...colsDet.tableProps}>
|
||||||
|
<colsDet.ColGroup />
|
||||||
|
<thead><tr>{HEADERS_DETALLE.map((h, i) => <th key={h} style={{position:"relative", whiteSpace:"nowrap"}}>{h}<colsDet.Resizer index={i} /></th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{filas.map((f, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{f.slice(0, HEADERS_DETALLE.length).map((v, j) => (
|
||||||
|
<td key={j} className={j === 1 ? "col-name" : ""}
|
||||||
|
style={{textAlign:"center", whiteSpace:"nowrap", overflow:"hidden", textOverflow:"ellipsis"}}>{v}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/styles.css
Normal file
127
src/styles.css
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/* src/styles.css */
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', -apple-system, Arial, sans-serif;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layout ── */
|
||||||
|
.app { display: flex; min-height: 100vh; }
|
||||||
|
|
||||||
|
/* ── Sidebar ── */
|
||||||
|
.sidebar {
|
||||||
|
width: 240px; background: #0f172a; color: #e2e8f0;
|
||||||
|
display: flex; flex-direction: column; padding: 0;
|
||||||
|
position: fixed; height: 100vh; left: 0; top: 0;
|
||||||
|
}
|
||||||
|
.sidebar-logo {
|
||||||
|
padding: 22px 20px; font-size: 16px; font-weight: 700; color: #fff;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.08); letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
.sidebar-logo span { color: #60a5fa; }
|
||||||
|
.nav { padding: 12px 10px; flex: 1; }
|
||||||
|
.nav-item {
|
||||||
|
display: flex; align-items: center; gap: 11px; padding: 11px 14px;
|
||||||
|
border-radius: 10px; cursor: pointer; font-size: 13.5px; font-weight: 500;
|
||||||
|
color: #cbd5e1; margin-bottom: 3px; transition: all 0.15s; border: none;
|
||||||
|
background: transparent; width: 100%; text-align: left;
|
||||||
|
}
|
||||||
|
.nav-item:hover { background: rgba(255,255,255,0.06); color: #fff; }
|
||||||
|
.nav-item.active { background: #2563eb; color: #fff; }
|
||||||
|
.nav-item .ico { font-size: 16px; }
|
||||||
|
|
||||||
|
/* ── Main content ── */
|
||||||
|
.main { margin-left: 240px; flex: 1; padding: 22px 28px; min-width: 0; overflow-x: hidden; }
|
||||||
|
.page-title { font-size: 22px; font-weight: 700; color: #0f172a; margin-bottom: 14px; }
|
||||||
|
|
||||||
|
/* ── Filters bar ── */
|
||||||
|
.filters { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 18px; align-items: flex-end; }
|
||||||
|
.filter-group { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.filter-group label {
|
||||||
|
font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.filter-group select {
|
||||||
|
padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 8px;
|
||||||
|
font-size: 13px; background: #fff; color: #0f172a; cursor: pointer; min-width: 130px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 9px 16px; border: none; border-radius: 8px; font-size: 13px; font-weight: 600;
|
||||||
|
cursor: pointer; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.btn-primary { background: #2563eb; color: #fff; }
|
||||||
|
.btn-primary:hover { background: #1d4ed8; }
|
||||||
|
.btn-ghost { background: #f1f5f9; color: #475569; border: 1px solid #cbd5e1; }
|
||||||
|
.btn-ghost:hover { background: #e2e8f0; }
|
||||||
|
|
||||||
|
/* ── KPI cards ── */
|
||||||
|
.kpis { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 18px; }
|
||||||
|
.kpi {
|
||||||
|
background: #fff; border-radius: 12px; padding: 16px; border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.kpi .ico { font-size: 18px; margin-bottom: 4px; }
|
||||||
|
.kpi .label { font-size: 10px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.6px; }
|
||||||
|
.kpi .value { font-size: 24px; font-weight: 700; color: #0f172a; line-height: 1.3; }
|
||||||
|
.kpi .sub { font-size: 11px; color: #94a3b8; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ── Table ── */
|
||||||
|
.table-wrap {
|
||||||
|
background: #fff; border-radius: 12px; border: 1px solid #e2e8f0;
|
||||||
|
overflow: hidden; overflow-x: auto; max-width: 100%;
|
||||||
|
}
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
thead th {
|
||||||
|
background: #334155; color: #f1f5f9; padding: 11px 10px; font-size: 11px;
|
||||||
|
font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px;
|
||||||
|
white-space: nowrap; text-align: center; position: sticky; top: 0;
|
||||||
|
border-right: 1px solid #475569;
|
||||||
|
}
|
||||||
|
thead th:last-child { border-right: none; }
|
||||||
|
tbody td { padding: 9px 8px; text-align: center; font-size: 12px; color: #374151; border-bottom: 1px solid #eef2f7; border-right: 1px solid #f1f5f9; }
|
||||||
|
tbody td:last-child { border-right: none; }
|
||||||
|
tbody tr:nth-child(even) { background: #f8fafc; }
|
||||||
|
tbody tr:hover td { background: #eff6ff; }
|
||||||
|
tr.total-row td { background: #eff6ff !important; font-weight: 700; color: #1e40af; }
|
||||||
|
.col-name { text-align: left !important; font-weight: 500; color: #111827; }
|
||||||
|
|
||||||
|
/* ── Progress bar ── */
|
||||||
|
.pbar { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.pbar .pct { font-size: 11px; font-weight: 700; min-width: 42px; text-align: right; }
|
||||||
|
.pbar .track { flex: 1; background: #e2e8f0; border-radius: 5px; height: 6px; overflow: hidden; min-width: 50px; }
|
||||||
|
.pbar .fill { height: 100%; border-radius: 5px; }
|
||||||
|
|
||||||
|
/* ── Loader ── */
|
||||||
|
.loader-wrap { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px; }
|
||||||
|
.spinner {
|
||||||
|
width: 44px; height: 44px; border: 4px solid #e2e8f0; border-top-color: #2563eb;
|
||||||
|
border-radius: 50%; animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.loader-text { margin-top: 14px; font-size: 13px; color: #64748b; }
|
||||||
|
|
||||||
|
/* ── Error ── */
|
||||||
|
.error-box {
|
||||||
|
background: #fee2e2; border: 1px solid #fca5a5; color: #991b1b;
|
||||||
|
padding: 16px; border-radius: 10px; font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cobranza: colores por cartera + hover por cartera ── */
|
||||||
|
.cob-table td.cart-ant { background: #fef3c7; }
|
||||||
|
.cob-table td.cart-mes { background: #d1fae5; }
|
||||||
|
.cob-table td.cart-tot { background: #ede9fe; }
|
||||||
|
.cob-table tbody tr:hover td.cart-ant { background: #fde68a !important; }
|
||||||
|
.cob-table tbody tr:hover td.cart-mes { background: #a7f3d0 !important; }
|
||||||
|
.cob-table tbody tr:hover td.cart-tot { background: #ddd6fe !important; }
|
||||||
|
/* Filas con deuda: rojo y su hover, prevalece sobre cartera */
|
||||||
|
.cob-table tbody tr.row-deuda td { background: #fee2e2 !important; }
|
||||||
|
.cob-table tbody tr.row-deuda:hover td { background: #fecaca !important; }
|
||||||
|
/* Hover normal de celdas sin cartera */
|
||||||
|
.cob-table tbody tr:hover td:not(.cart-ant):not(.cart-mes):not(.cart-tot) { background: #f1f5f9; }
|
||||||
|
|
||||||
|
/* ── Ocupabilidad: fila de Inicio reprogramado (desde Supabase) en rojo ── */
|
||||||
|
tbody tr.row-inicio td { background: #fecaca !important; color: #7f1d1d; font-weight: 600; }
|
||||||
|
tbody tr.row-inicio:hover td { background: #fca5a5 !important; }
|
||||||
|
|
||||||
|
/* Zona de redimensionado de columnas (popups con columnas ajustables) */
|
||||||
|
.col-resizer { transition: background 0.15s; }
|
||||||
|
.col-resizer:hover { background: #93c5fd; }
|
||||||
7
vite.config.js
Normal file
7
vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: { port: 3000, host: true }
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user