Actualizar docker-compose.yml con sintaxis YAML corregida y puerto 80 para frontend

This commit is contained in:
Administrator
2026-08-29 13:00:33 -05:00
parent 54581c21ab
commit 15c577207d
63 changed files with 11740 additions and 2864 deletions

View File

@@ -1,35 +1,67 @@
import { useState } from "react";
// src/App.jsx
import { useState, useEffect } from "react";
import { useAuth } from "./lib/auth";
import Sidebar from "./components/Sidebar";
import Leads from "./pages/Leads";
import OtrosGeneral from "./pages/OtrosGeneral";
import Vendedores from "./pages/Vendedores";
import Roas from "./pages/Roas";
import CampanitaAlertas from "./components/CampanitaAlertas";
import ConfigModal from "./components/ConfigModal";
import ErrorBoundary from "./components/ErrorBoundary";
import IndicadorActualizacion from "./components/IndicadorActualizacion";
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 [pagina, setPagina] = useState("leads");
const [config, setConfig] = useState(false);
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 "leads": return <Leads />;
case "otros": return <OtrosGeneral />;
case "vendedores": return <Vendedores />;
case "roas": return <Roas />;
default: return null;
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} onConfig={() => setConfig(true)} />
<main className="main">
<ErrorBoundary key={pagina}>{render()}</ErrorBoundary>
</main>
<CampanitaAlertas />
{config && <ConfigModal onClose={() => setConfig(false)} />}
<Sidebar active={pagina} onChange={setPagina} />
<main className="main">{render()}</main>
<IndicadorActualizacion />
</div>
);
}
function SinAcceso() {
return (
<div style={{ padding:40, textAlign:"center", color:"#94a3b8" }}>
🔒 No tienes acceso a este módulo.
</div>
);
}

View File

@@ -0,0 +1,97 @@
// src/components/IndicadorActualizacion.jsx
// Indicador (arriba-derecha) con la hora de la última actualización + botón de
// recarga. Al pulsar: re-lee las bases para la vista actual y recachea el resto.
import { useState, useEffect, useCallback } from "react";
import { api } from "../lib/api";
import { getVistaActual, emitirRefresco } from "../lib/vistaActual";
function formatearHora(iso) {
if (!iso) return "—";
try {
const d = new Date(iso);
return d.toLocaleString("es-PE", {
day: "2-digit", month: "2-digit",
hour: "2-digit", minute: "2-digit",
});
} catch {
return "—";
}
}
export default function IndicadorActualizacion() {
const [hora, setHora] = useState(null);
const [cargando, setCargando] = useState(false);
const consultar = useCallback(async () => {
try {
const r = await api.ultimaActualizacion();
setHora(r?.hora || null);
} catch { /* silencioso */ }
}, []);
useEffect(() => {
consultar();
const id = setInterval(consultar, 60000);
return () => clearInterval(id);
}, [consultar]);
async function recargar() {
if (cargando) return;
setCargando(true);
try {
const r = await api.cacheRefreshManual(getVistaActual());
setHora(r?.hora || null);
emitirRefresco();
} catch { /* silencioso */ }
finally { setCargando(false); }
}
return (
<>
<style>{`
@keyframes spin-actualiza { to { transform: rotate(360deg); } }
.ind-actualiza-btn { transition: color .15s, transform .15s; }
.ind-actualiza-btn:hover:not(:disabled) { color:#7dd3fc; transform: scale(1.15); }
.ind-actualiza-spin { animation: spin-actualiza .9s linear infinite; }
`}</style>
<div
style={{
position: "fixed", right: 16, top: 14, zIndex: 60,
display: "flex", alignItems: "center", gap: 8,
background: "rgba(15,23,42,0.9)",
color: cargando ? "#7dd3fc" : "#cbd5e1",
border: "1px solid " + (cargando ? "#0ea5e9" : "#334155"),
borderRadius: 20, padding: "6px 12px",
fontSize: 12, lineHeight: 1, fontWeight: 500,
boxShadow: "0 2px 8px rgba(0,0,0,0.28)",
backdropFilter: "blur(4px)",
userSelect: "none",
}}
title="Última actualización de las bases de datos"
>
<span style={{ opacity: cargando ? 1 : 0.85 }}>
{cargando ? "Actualizando…" : formatearHora(hora)}
</span>
<button
onClick={recargar}
disabled={cargando}
className={"ind-actualiza-btn" + (cargando ? " ind-actualiza-spin" : "")}
title={cargando ? "Actualizando datos…" : "Actualizar ahora"}
style={{
background: "transparent", border: "none",
cursor: cargando ? "default" : "pointer",
color: "#38bdf8", fontSize: 15, padding: 0, lineHeight: 1,
display: "flex", alignItems: "center",
}}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.4"
strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 3v6h-6" />
</svg>
</button>
</div>
</>
);
}

View 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>
);
}

View File

@@ -1,48 +1,26 @@
import { useState, useEffect } from "react";
import { api } from "../lib/api";
// src/components/Sidebar.jsx
import { useAuth } from "../lib/auth";
const MENU = [
{ id: "leads", ico: "🎯", label: "Leads" },
{ id: "otros", ico: "📊", label: "Otros General" },
{ id: "vendedores", ico: "🧑‍💼", label: "Vendedores" },
{ id: "roas", ico: "📈", label: "ROAS" },
{ 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, onConfig }) {
const [menuUser, setMenuUser] = useState(false);
const [ultima, setUltima] = useState("");
const [actualizando, setActualizando] = useState(false);
const cargarHora = () => api.ultimaActualizacion()
.then((r) => setUltima(r.hora || "")).catch(() => {});
useEffect(() => {
cargarHora();
const id = setInterval(cargarHora, 60 * 1000); // refresca la hora cada minuto
return () => clearInterval(id);
}, []);
// Actualiza TODO manualmente (como si pasaran los 15 min, pero al instante).
async function actualizarTodo() {
if (actualizando) return;
setActualizando(true);
try {
const r = await api.refrescarTodo();
if (r && r.hora) setUltima(r.hora); // hora del refresco manual recien hecho
else await cargarHora();
window.dispatchEvent(new Event("datos-actualizar")); // recarga el apartado abierto
} catch (e) {
// silencioso: no romper la UI si falla
} finally {
setActualizando(false);
}
}
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 }}>
{MENU.map((m) => (
{visibles.map((m) => (
<button
key={m.id}
className={`nav-item ${active === m.id ? "active" : ""}`}
@@ -53,59 +31,22 @@ export default function Sidebar({ active, onChange, onConfig }) {
))}
</nav>
{/* Botón de actualizar manual + hora de última actualización */}
<div style={{ padding: "0 12px 8px" }}>
<button onClick={actualizarTodo} disabled={actualizando}
title="Actualizar todos los datos ahora (sin esperar los 15 min)"
style={{ width: "100%", display: "flex", alignItems: "center", justifyContent: "center",
gap: 8, padding: "9px 12px", background: actualizando ? "#1e293b" : "#2563eb",
border: "none", borderRadius: 9, color: "#fff", fontSize: 12.5, fontWeight: 600,
cursor: actualizando ? "default" : "pointer", opacity: actualizando ? 0.75 : 1,
transition: "all .15s ease" }}>
<span style={{ display: "inline-block",
animation: actualizando ? "spin 1s linear infinite" : "none" }}>🔄</span>
{actualizando ? "Actualizando..." : "Actualizar ahora"}
</button>
{actualizando ? (
<div style={{ marginTop: 6, fontSize: 10, color: "#93c5fd", textAlign: "center" }}>
Actualizando en segundo plano...
</div>
) : ultima ? (
<div style={{ marginTop: 6, fontSize: 10, color: "#64748b", textAlign: "center" }}>
Actualizado: {ultima}
</div>
) : null}
</div>
<div style={{ position: "relative", borderTop: "1px solid rgba(255,255,255,0.08)", padding: 12 }}>
{menuUser && (
<div style={{ position: "absolute", bottom: 64, left: 12, right: 12, background: "#1e293b",
border: "1px solid rgba(255,255,255,0.12)", borderRadius: 10, overflow: "hidden",
boxShadow: "0 8px 24px rgba(0,0,0,0.4)" }}>
<button onClick={() => { setMenuUser(false); onConfig && onConfig(); }}
style={{ width: "100%", textAlign: "left", padding: "11px 14px", background: "transparent",
border: "none", color: "#e2e8f0", cursor: "pointer", fontSize: 13,
display: "flex", alignItems: "center", gap: 9 }}>
⚙️ Configuración
</button>
<button onClick={() => setMenuUser(false)}
style={{ width: "100%", textAlign: "left", padding: "11px 14px", background: "transparent",
border: "none", borderTop: "1px solid rgba(255,255,255,0.08)", color: "#f87171",
cursor: "pointer", fontSize: 13, display: "flex", alignItems: "center", gap: 9 }}>
⎋ Cerrar sesión
</button>
</div>
)}
<button onClick={() => setMenuUser((v) => !v)}
style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, background: "transparent",
border: "none", cursor: "pointer", padding: "6px 4px", color: "#e2e8f0" }}>
<div style={{ width: 34, height: 34, borderRadius: "50%", background: "#2563eb", color: "#fff",
display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 700,
fontSize: 13, flexShrink: 0 }}>AS</div>
<div style={{ flex: 1, textAlign: "left", lineHeight: 1.2 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Aron</div>
<div style={{ fontSize: 11, color: "#94a3b8" }}>RP ERP</div>
</div>
<span style={{ fontSize: 11, color: "#94a3b8" }}>{menuUser ? "▲" : "▼"}</span>
<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>

View File

@@ -1,3 +1,4 @@
// src/components/UI.jsx
export function Loader({ text = "Cargando..." }) {
return (
<div className="loader-wrap">
@@ -11,15 +12,34 @@ 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 }) {
export function Select({ label, value, options, onChange, width }) {
return (
<div className="filter-group">
<div className="filter-group" style={width ? { width } : undefined}>
{label && <label>{label}</label>}
<select value={value} onChange={(e) => onChange(e.target.value)}>
<select value={value} onChange={(e) => onChange(e.target.value)} style={width ? { width: "100%" } : undefined}>
{options.map((o) => {
const val = typeof o === "object" ? o.value : o;
const txt = typeof o === "object" ? o.label : o;

View File

@@ -1,150 +1,86 @@
// Cliente del backend FastAPI de Leads.
// 1) Si existe VITE_API_URL (archivo .env del frontend), se usa esa.
// 2) Si no, se decide por el host desde el que se abrió la página:
// localhost -> backend local http://localhost:8001
// cualquier otro -> API publicada del servidor
// Así funciona en desarrollo y en el servidor sin necesitar ningún .env.
const API_LOCAL = "http://localhost:8001";
const API_SERVIDOR = "https://dashboardapi.escueladerefrigeracion.edu.pe";
// 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";
function _baseUrlPorDefecto() {
if (typeof window === "undefined") return API_LOCAL;
const h = window.location.hostname;
if (h === "localhost" || h === "127.0.0.1" || h === "::1") {
return API_LOCAL;
}
const proto = window.location.protocol || "http:";
return `${proto}//dashboardapi.escueladerefrigeracion.edu.pe`;
}
const BASE_URL = (
import.meta.env.VITE_API_URL || _baseUrlPorDefecto()
).replace(/\/+$/, "");
// ── Cache en memoria (frontend) con TTL 15 min y "stale-while-revalidate" ──
// Guarda la respuesta de cada GET (por path+params). Al volver a pedir:
// - si hay dato vigente (< 15 min) -> lo devuelve al instante, SIN fetch.
// - si venció -> devuelve el viejo al instante Y refresca en 2º plano.
// Así, al cambiar de apartado y volver, NO se recarga; y a los 15 min se
// actualiza solo sin mostrar loader. Los POST invalidan el cache.
const TTL = 15 * 60 * 1000; // 15 minutos
const _cache = new Map(); // key -> { ts, data }
const _enVuelo = new Map(); // key -> Promise (evita fetch duplicado)
function _key(path, params) {
return path + "?" + new URLSearchParams(params).toString();
}
async function _fetch(path, params) {
async function get(path, params = {}) {
const qs = new URLSearchParams(params).toString();
const r = await fetch(`${BASE_URL}${path}${qs ? "?" + qs : ""}`);
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();
}
// GET con cache. Devuelve SIEMPRE una promesa que resuelve rápido si hay cache.
function get(path, params = {}) {
const key = _key(path, params);
const hit = _cache.get(key);
const now = Date.now();
if (hit) {
// Hay algo cacheado -> devolverlo YA.
if (now - hit.ts >= TTL && !_enVuelo.has(key)) {
// Venció: refrescar en 2º plano (no bloquea, no muestra loader).
const p = _fetch(path, params)
.then((data) => { _cache.set(key, { ts: Date.now(), data }); return data; })
.finally(() => _enVuelo.delete(key));
_enVuelo.set(key, p);
}
return Promise.resolve(hit.data);
}
// No hay cache: si ya hay una petición en vuelo para esta key, reusarla.
if (_enVuelo.has(key)) return _enVuelo.get(key);
const p = _fetch(path, params)
.then((data) => { _cache.set(key, { ts: Date.now(), data }); return data; })
.finally(() => _enVuelo.delete(key));
_enVuelo.set(key, p);
return p;
}
// Invalida entradas del cache cuyo path empiece con alguno de los prefijos dados.
function invalidar(...prefijos) {
for (const k of _cache.keys()) {
if (prefijos.some((p) => k.startsWith(p))) _cache.delete(k);
}
}
// POST helper: hace el POST y luego invalida los caches afectados.
async function post(path, body, invalidarPrefijos = []) {
async function postJson(path, body) {
const r = await fetch(`${BASE_URL}${path}`, {
method: "POST", headers: { "Content-Type": "application/json" },
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`Error al guardar en ${path}`);
const data = await r.json();
if (invalidarPrefijos.length) invalidar(...invalidarPrefijos);
return data;
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 = {
leadsFiltros: () => get("/api/leads/filtros"),
leads: (ano = "TODOS", mes = "TODOS", dia = "TODOS", programa = "TODOS", sede = "TODOS") =>
get("/api/leads", { ano, mes, dia, programa, sede }),
otrosGeneral: (ano = "TODOS", mes = "TODOS", dia = "TODOS") =>
get("/api/otros-general", { ano, mes, dia }),
vendedores: (ano = "TODOS", mes = "TODOS", dia = "TODOS") =>
get("/api/vendedores", { ano, mes, dia }),
roas: (ano = "TODOS", mes = "TODOS", dia = "TODOS") =>
get("/api/roas", { ano, mes, dia }),
conjuntosSinSede: () => get("/api/conjuntos-sin-sede"),
guardarConjuntoSede: (cambios) =>
post("/api/conjuntos-sin-sede/guardar", { cambios }, ["/api/conjuntos-sin-sede", "/api/roas"]),
borrarConjuntoSede: (conjunto) =>
post("/api/conjuntos-sin-sede/borrar", { conjunto }, ["/api/conjuntos-sin-sede", "/api/roas"]),
periodoActual: () => get("/api/periodo-actual"),
ultimaActualizacion: () => get("/api/ultima-actualizacion"),
// Actualiza TODO manualmente (como si pasaran los 15 min), pero EN SEGUNDO
// PLANO: NO se toca el cache del cliente mientras el backend trabaja, así el
// usuario sigue viendo los datos viejos sin pantallas de carga durante los
// ~29s. Recien cuando el backend responde OK se limpia el cache del cliente,
// para que a partir de ahi todo muestre los datos nuevos de inmediato.
refrescarTodo: async () => {
const r = await fetch(`${BASE_URL}/api/cache/refresh`, { method: "POST" });
if (!r.ok) throw new Error("Error al actualizar");
await r.json(); // espera a que el backend TERMINE de rehacer todo
_cache.clear(); // solo AHORA se invalida el cache del cliente
// Trae la hora de actualizacion FRESCA (sin cache) y la deja cacheada, para
// que el sidebar muestre la hora del refresco manual recien hecho.
const hr = await _fetch("/api/ultima-actualizacion", {});
_cache.set(_key("/api/ultima-actualizacion", {}), { ts: Date.now(), data: hr });
return hr; // { hora: "..." }
},
conjuntosSinPauta: () => get("/api/conjuntos-sin-pauta"),
usoPauta: (pauta, excluir) => get("/api/pauta/uso", { pauta, excluir: excluir || "" }),
leyendaAnuncios: () => get("/api/leyenda-anuncios"),
programasDisponibles: () => get("/api/programas-disponibles"),
programasOcultos: () => get("/api/programas-ocultos"),
encenderProgramas: (num_indices) =>
post("/api/programas-ocultos/encender", { num_indices },
["/api/programas-ocultos", "/api/programas-disponibles", "/api/leads"]),
guardarLeyenda: (cambios) =>
post("/api/leyenda-anuncios/guardar", { cambios }, ["/api/leyenda-anuncios", "/api/roas"]),
guardarPauta: (num_indice, pauta, conjunto, contar) =>
post("/api/curso/guardar-pauta", { num_indice, pauta, conjunto, contar },
["/api/leads", "/api/conjuntos-sin-pauta", "/api/roas"]),
alertas: () => get("/api/leads/alertas"),
// Leyenda (campanias de Supabase). Al agregar/borrar, invalida TODOS los
// apartados que dependen de las leyendas para que el cambio se vea al instante.
campanias: () => get("/api/campanias"),
agregarCampania: (datos) =>
post("/api/campanias/agregar", datos,
["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general"]),
editarCampania: (id, datos) =>
post("/api/campanias/editar", { id, ...datos },
["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general"]),
borrarCampania: (id) =>
post("/api/campanias/borrar", { id },
["/api/campanias", "/api/leads", "/api/vendedores", "/api/roas", "/api/otros-general"]),
cacheRefresh: () => post("/api/cache/refresh"),
cacheRefreshManual: (clave) => post("/api/cache/refresh-manual", clave ? { clave } : {}),
precargaModulo: (modulo, ano) => post("/api/precarga/modulo", { modulo, ano }),
precargaEstado: (modulo) => get("/api/precarga/estado", { modulo }),
ocupabilidad: (ano, mes, sede = "TODOS", programa = "TODOS") =>
get("/api/ocupabilidad", { ano, mes, sede, programa }),
ocupabilidadAlumnos: (num_indice) =>
get("/api/ocupabilidad/alumnos", { num_indice }),
ventas: (ano, mes, sede = "TODOS", programa = "TODOS", con_overrides = false) => get("/api/ventas", { ano, mes, sede, programa, con_overrides }),
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", sede_filtro = "TODOS", prog_filtro = "TODOS", venc_hasta = "TODOS") =>
get("/api/cobranza", { ano, mes, sectorista, agrupacion, sede_filtro, prog_filtro, venc_hasta }),
cobranzaDetalle: (grupo, ano, mes, sectorista = "TODOS", agrupacion = "PROGRAMA", prog_filtro = "TODOS", sede_filtro = "TODOS", venc_hasta = "TODOS") =>
get("/api/cobranza/detalle", { grupo, ano, mes, sectorista, agrupacion, prog_filtro, sede_filtro, venc_hasta }),
alumnoEstado: (matricula) => get("/api/alumno/estado", { matricula }),
alumnosPorRetirar: () => get("/api/alumnos/por-retirar"),
alumnoEstadoGuardar: (matricula, estado, observaciones, usuario) =>
postJson("/api/alumno/estado/guardar", { matricula, estado, observaciones, usuario }),
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 }),
clasificarFull: (programas) =>
postJson("/api/cobranza/clasificar-full", { 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),
};

144
frontend/src/lib/auth.jsx Normal file
View File

@@ -0,0 +1,144 @@
// 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);
const [permisos, setPermisos] = useState([]);
const [cargando, setCargando] = useState(true);
const cargarPerfil = useCallback(async (userId) => {
try {
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;
}
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;
} catch (e) {
console.error("[auth] cargarPerfil falló:", e);
setPerfil(null);
setPermisos([]);
return false;
}
}, []);
useEffect(() => {
let activo = true;
let resuelto = false;
const terminar = () => {
if (activo) { setCargando(false); resuelto = true; sessionStorage.removeItem("auth_reload"); }
};
// Fuente principal: onAuthStateChange dispara INITIAL_SESSION al arrancar
// con la sesión persistida (renueva token solo). Maneja login/logout en vivo.
const { data: sub } = supabase.auth.onAuthStateChange(async (_evt, s) => {
try {
setSession(s);
if (s?.user) await cargarPerfil(s.user.id);
else { setPerfil(null); setPermisos([]); }
} catch (e) {
console.error("[auth] onAuthStateChange falló:", e);
} finally {
terminar();
}
});
// Respaldo: si el listener no disparó en 6s, intenta getSession una vez.
// Si trae sesión la usa; si no, NO fuerza login (deja que el listener resuelva).
const tRespaldo = setTimeout(async () => {
if (resuelto || !activo) return;
try {
const { data } = await supabase.auth.getSession();
const s = data?.session || null;
if (s?.user && activo) {
setSession(s);
await cargarPerfil(s.user.id);
}
} catch (e) {
console.error("[auth] getSession (respaldo) falló:", e);
} finally {
terminar();
}
}, 6000);
// Si a los 8s sigue sin resolver, recarga la página UNA vez (F5 automático)
// en vez de mostrar el login pegado. sessionStorage evita bucle.
const tSeguridad = setTimeout(() => {
if (!activo || resuelto) return;
if (!sessionStorage.getItem("auth_reload")) {
sessionStorage.setItem("auth_reload", "1");
window.location.reload();
} else {
setCargando(false);
}
}, 8000);
return () => {
activo = false;
clearTimeout(tRespaldo);
clearTimeout(tSeguridad);
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) };
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.";
}

View File

@@ -0,0 +1,15 @@
// 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";
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, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
},
});

View File

@@ -1,10 +1,11 @@
// src/lib/useColumnasAjustables.jsx
// Hook para columnas redimensionables (arrastrar el borde, estilo Excel).
// 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);
@@ -28,6 +29,7 @@ export function useColumnasAjustables(anchosIniciales) {
document.addEventListener("mouseup", onUp);
}
// Devuelve los elementos <col> para el <colgroup> de la tabla
function ColGroup() {
return (
<colgroup>
@@ -36,6 +38,7 @@ export function useColumnasAjustables(anchosIniciales) {
);
}
// Devuelve el divisor arrastrable para poner dentro de cada <th>
function Resizer({ index }) {
return (
<span
@@ -48,6 +51,10 @@ export function useColumnasAjustables(anchosIniciales) {
}
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 },
};

View File

@@ -0,0 +1,23 @@
// src/lib/vistaActual.js
// Registro de la "vista actual" (clave de caché del backend) + bus de eventos
// para que la página visible se recargue tras el refresco manual.
let _clave = null;
export function setVistaActual(clave) {
_clave = clave || null;
}
export function getVistaActual() {
return _clave;
}
const EVENTO = "datos-refrescados";
export function emitirRefresco() {
window.dispatchEvent(new Event(EVENTO));
}
export function onRefrescar(cb) {
window.addEventListener(EVENTO, cb);
return () => window.removeEventListener(EVENTO, cb);
}

View File

@@ -1,10 +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>
<App />
<AuthProvider>
<App />
</AuthProvider>
</React.StrictMode>
);

View 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>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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",
};

View File

@@ -0,0 +1,307 @@
// 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";
import Modal from "../components/Modal";
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
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","OPC."];
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 [modalCurso, setModalCurso] = useState(null);
const cols = useColumnasAjustables([280, 110, 120, 110, 100, 120, 110, 120, 100, 100, 130, 140, 80]);
useEffect(() => {
let activo = true;
setVistaActual(`ocupabilidad:${ano}|${mes}|${sede}|${programa}`);
const cargar = () => {
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); } });
};
cargar();
const off = onRefrescar(cargar);
return () => { activo = false; off(); };
}, [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>
<td>
{esInicio ? "-" : (
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
onClick={() => setModalCurso({ num_indice: d.num_indice, programa: prog })}>
👁️ Ver
</button>
)}
</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>
<td>—</td>
</tr>
)}
</tbody>
</table>
</div>
</>}
{modalCurso && (
<ModalAlumnosCurso
numIndice={modalCurso.num_indice}
programa={modalCurso.programa}
onClose={() => setModalCurso(null)}
/>
)}
</div>
);
}
function ModalAlumnosCurso({ numIndice, programa, onClose }) {
const [alumnos, setAlumnos] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let activo = true;
setLoading(true);
api.ocupabilidadAlumnos(numIndice)
.then((res) => { if (activo) { setAlumnos(res.alumnos || []); setLoading(false); } })
.catch(() => { if (activo) { setAlumnos([]); setLoading(false); } });
return () => { activo = false; };
}, [numIndice]);
const colorEstado = (e) => {
if (e === "ALU") return { bg:"#ecfdf5", fg:"#047857", bd:"#a7f3d0" };
if (e === "PRE") return { bg:"#eff6ff", fg:"#1e40af", bd:"#bfdbfe" };
if (e === "RET") return { bg:"#fef2f2", fg:"#b91c1c", bd:"#fecaca" };
return { bg:"#f1f5f9", fg:"#475569", bd:"#e2e8f0" };
};
const conteo = useMemo(() => {
const c = {};
alumnos.forEach((a) => { c[a.estado] = (c[a.estado] || 0) + 1; });
return c;
}, [alumnos]);
return (
<Modal title={`👥 Alumnos Inscritos — ${programa}`} onClose={onClose} width={1050}>
{loading ? <Loader text="Cargando alumnos..." /> :
alumnos.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin alumnos.</div> :
<>
<div style={{display:"flex",gap:10,marginBottom:14,flexWrap:"wrap"}}>
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#eff6ff",color:"#1e40af",fontSize:13,fontWeight:700,
border:"1px solid #bfdbfe"}}>
👥 Total: {alumnos.length} alumno{alumnos.length!==1?"s":""}
</span>
{["ALU","PRE","RET"].map((e)=> conteo[e] ? (
<span key={e} style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:colorEstado(e).bg,color:colorEstado(e).fg,fontSize:13,fontWeight:700,
border:`1px solid ${colorEstado(e).bd}`}}>
{e}: {conteo[e]}
</span>
) : null)}
</div>
<div className="table-wrap" style={{overflowX:"auto"}}>
<table className="cob-table" style={{minWidth:900}}>
<thead><tr>
<th style={{textAlign:"left"}}>ALUMNO</th>
<th>ESTADO</th>
<th style={{textAlign:"left"}}>CURSO ANTERIOR (TEAC/TERC)</th>
<th>ESTADO ANTERIOR</th>
</tr></thead>
<tbody>
{alumnos.map((a, i) => {
const c = colorEstado(a.estado);
const tieneAnt = a.curso_anterior && a.curso_anterior !== "-";
const cAnt = colorEstado(a.estado_anterior);
return (
<tr key={i}>
<td className="col-name" style={{textAlign:"left"}}>{a.alumno}</td>
<td style={{textAlign:"center"}}>
<span style={{display:"inline-block",padding:"2px 10px",borderRadius:999,
background:c.bg,color:c.fg,fontWeight:700,fontSize:12,border:`1px solid ${c.bd}`}}>
{a.estado}
</span>
</td>
<td className="col-name" style={{textAlign:"left",color:tieneAnt?undefined:"#cbd5e1"}}>
{a.curso_anterior || "-"}
</td>
<td style={{textAlign:"center"}}>
{tieneAnt ? (
<span style={{display:"inline-block",padding:"2px 10px",borderRadius:999,
background:cAnt.bg,color:cAnt.fg,fontWeight:700,fontSize:12,border:`1px solid ${cAnt.bd}`}}>
{a.estado_anterior}
</span>
) : <span style={{color:"#cbd5e1"}}>-</span>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>}
</Modal>
);
}

View File

@@ -0,0 +1,276 @@
// 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";
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
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;
setVistaActual(`rentabilidad:${ano}|${mes}|${sede}|${programa}`);
const cargarSeguro = () => {
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); } });
};
cargarSeguro();
const off = onRefrescar(cargarSeguro);
return () => { activo = false; off(); };
}, [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>
);
}

View 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>
);
}

View 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 };

View File

@@ -0,0 +1,272 @@
// 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";
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
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;
setVistaActual(`ventas:${ano}|${mes}|${sede}|${programa}|False`);
const cargar = () => {
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); } });
};
cargar();
const off = onRefrescar(cargar);
return () => { activo = false; off(); };
}, [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>
);
}

View File

@@ -1,74 +1,127 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #f1f5f9; color: #0f172a; }
/* 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;
}
.app { display: flex; height: 100vh; overflow: hidden; }
/* ── Layout ── */
.app { display: flex; min-height: 100vh; }
/* ── Sidebar fija ── */
/* ── Sidebar ── */
.sidebar {
width: 240px; background: #0f172a; color: #e2e8f0;
display: flex; flex-direction: column; flex-shrink: 0;
height: 100vh; position: sticky; top: 0;
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 { padding: 22px 18px; font-size: 18px; font-weight: 700; border-bottom: 1px solid rgba(255,255,255,0.08); }
.sidebar-logo span { color: #60a5fa; }
.nav { padding: 12px 8px; }
.nav { padding: 12px 10px; flex: 1; }
.nav-item {
width: 100%; text-align: left; padding: 11px 14px; margin-bottom: 4px;
background: transparent; border: none; color: #cbd5e1; border-radius: 8px;
cursor: pointer; font-size: 14px; display: flex; align-items: center; gap: 10px;
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); }
.nav-item.active { background: #2563eb; color: #fff; font-weight: 600; }
.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 { flex: 1; padding: 24px 28px; overflow-y: auto; height: 100vh; }
.page-title { font-size: 24px; font-weight: 700; margin-bottom: 18px; color: #0f172a; }
/* ── 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; }
/* ── Filtros ── */
.filters { display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 20px; }
/* ── 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: 700; color: #64748b; text-transform: uppercase; letter-spacing: .3px; }
.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: 14px;
background: #fff; min-width: 130px; cursor: pointer;
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;
}
/* ── KPIs ── */
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 18px; }
.kpi { background: #fff; border: 1px solid #e2e8f0; border-radius: 14px; padding: 16px; }
.kpi .ico { font-size: 22px; margin-bottom: 6px; }
.kpi .label { font-size: 13px; font-weight: 600; color: #475569; }
.kpi .value { font-size: 26px; font-weight: 800; color: #0f172a; margin: 4px 0; }
.kpi .sub { font-size: 11px; color: #94a3b8; }
/* ── 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; }
/* tarjeta compuesta (varios sub-kpis) */
.kpi-multi { background: #fff; border: 1px solid #e2e8f0; border-radius: 14px; padding: 14px 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
.kpi-row { display: flex; align-items: center; gap: 12px; padding: 9px 0; }
.kpi-row + .kpi-row { border-top: 1px solid #f1f5f9; }
.kpi-row .ico { font-size: 24px; width: 34px; text-align: center; flex-shrink: 0; }
.kpi-row .txt { line-height: 1.25; }
.kpi-row .txt .label { font-size: 12px; font-weight: 600; color: #64748b; }
.kpi-row .txt .value { font-size: 21px; font-weight: 800; color: #0f172a; }
/* ── 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; }
/* ── Tablas ── */
.table-wrap { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; overflow: auto; }
table { width: 100%; border-collapse: collapse; }
th { background: #1e3a5f; color: #f1f5f9; padding: 10px 12px; font-size: 12px; font-weight: 700; text-align: center; white-space: nowrap; border-right: 1px solid rgba(255,255,255,0.12); }
td { padding: 9px 12px; font-size: 13px; border-right: 1px solid #eef2f7; text-align: center; }
th:last-child, td:last-child { border-right: none; }
.total-row td { background: #eff6ff; font-weight: 700; }
.col-name { text-align: left !important; }
/* ── Loader / error ── */
.loader-wrap { display: flex; flex-direction: column; align-items: center; padding: 50px; color: #64748b; }
.spinner { width: 36px; height: 36px; border: 4px solid #e2e8f0; border-top-color: #2563eb; border-radius: 50%; animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.loader-text { margin-top: 12px; font-size: 14px; }
.error-box { background: #fee2e2; color: #991b1b; padding: 14px 18px; border-radius: 10px; font-size: 14px; }
.card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 16px; }
.card-title { font-size: 14px; font-weight: 700; color: #1e293b; margin-bottom: 12px; }
/* Matriz "Detalle por Curso": líneas verticales suaves entre columnas (solo cuerpo) */
.matriz-grid tbody td { border-right: 1px solid #eef2f7; }
.matriz-grid tbody td:last-child { border-right: none; }
/* Zona de redimensionado de columnas (popups con columnas ajustables) */
.col-resizer { transition: background 0.15s; }
.col-resizer:hover { background: #93c5fd; }