Actualizar frontend/src/pages/Cobranza.jsx

This commit is contained in:
2026-08-27 16:48:03 -05:00
parent 9ee3cc1e3b
commit 1f214cf8cd

View File

@@ -1,5 +1,5 @@
// src/pages/Cobranza.jsx // src/pages/Cobranza.jsx
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo, useRef, Fragment } from "react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select, colorSemaforo } from "../components/UI"; import { Loader, ErrorBox, Filters, Select, colorSemaforo } from "../components/UI";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
@@ -8,9 +8,10 @@ import { setVistaActual, onRefrescar } from "../lib/vistaActual";
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
const ANOS = [2024, 2025, 2026]; const ANOS = [2024, 2025, 2026];
const AGRUPACIONES = [{value:"SEDE",label:"Por Sede"},{value:"PROGRAMA",label:"Por Programa"},{value:"ASESOR",label:"Por Asesor"}]; const AGRUPACIONES = [{value:"SEDE",label:"Por Sede"},{value:"ASESOR",label:"Por Asesor"}];
const SEDES = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"]; const SEDES = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"];
const FRECS = ["TODOS","DOM","SAB","NOC","MAN","VIR","TAR"]; const FRECS = ["TODOS","DOM","SAB","NOC","MAN","VIR","TAR"];
const PROGRAMAS_FILTRO = ["TODOS","TEAC","TERC","SEMINARIOS","OTROS"];
// Colores por cartera — diferenciados y legibles (sin azul, reservado para TOTAL) // Colores por cartera — diferenciados y legibles (sin azul, reservado para TOTAL)
// ANTERIOR = ámbar suave | MES = verde menta | TOTAL = lila/violeta suave // ANTERIOR = ámbar suave | MES = verde menta | TOTAL = lila/violeta suave
@@ -51,6 +52,9 @@ export default function Cobranza() {
const [sectorista, setSectorista] = useState("TODOS"); const [sectorista, setSectorista] = useState("TODOS");
const [sede, setSede] = useState("TODOS"); const [sede, setSede] = useState("TODOS");
const [frec, setFrec] = useState("TODOS"); const [frec, setFrec] = useState("TODOS");
// Filtros extra para la vista "Por Sede" (usan JSON REPLACE_META)
const [sedeFiltro, setSedeFiltro] = useState("TODOS");
const [progFiltro, setProgFiltro] = useState("TODOS");
const [filas, setFilas] = useState([]); const [filas, setFilas] = useState([]);
const [sectoristas, setSectoristas] = useState(["TODOS"]); const [sectoristas, setSectoristas] = useState(["TODOS"]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -60,6 +64,12 @@ export default function Cobranza() {
const [exportando, setExportando] = useState(false); const [exportando, setExportando] = useState(false);
const [alumnos, setAlumnos] = useState([]); // todos los alumnos (1 sola consulta) const [alumnos, setAlumnos] = useState([]); // todos los alumnos (1 sola consulta)
const [buscadorAbierto, setBuscadorAbierto] = useState(false); const [buscadorAbierto, setBuscadorAbierto] = useState(false);
const [calAbierto, setCalAbierto] = useState(false);
const [vencHasta, setVencHasta] = useState("TODOS"); // filtro F. Vencimiento (dd/mm/yyyy)
// Expansión sede -> programas (solo en agrupación SEDE)
const [filasPrograma, setFilasPrograma] = useState([]); // vista "Por Programa" (para expandir)
const [sedeMapPrograma, setSedeMapPrograma] = useState({}); // programa -> sede
const [expandidas, setExpandidas] = useState(new Set()); // sedes abiertas
const esPrograma = agrupacion === "PROGRAMA"; const esPrograma = agrupacion === "PROGRAMA";
// índices según agrupación // índices según agrupación
@@ -69,10 +79,10 @@ export default function Cobranza() {
useEffect(() => { useEffect(() => {
let activo = true; let activo = true;
setVistaActual(`cobranza:${ano}|${mes}|${sectorista}|${agrupacion}`); setVistaActual(`cobranza:${ano}|${mes}|${sectorista}|${agrupacion}|${sedeFiltro}|${progFiltro}|${vencHasta}`);
const cargar = () => { const cargar = () => {
setLoading(true); setError(null); setLoading(true); setError(null);
api.cobranza(ano, mes, sectorista, agrupacion) api.cobranza(ano, mes, sectorista, agrupacion, sedeFiltro, progFiltro, vencHasta)
.then((res) => { .then((res) => {
if (!activo) return; if (!activo) return;
setFilas(res.filas || []); setFilas(res.filas || []);
@@ -84,7 +94,7 @@ export default function Cobranza() {
cargar(); cargar();
const off = onRefrescar(cargar); const off = onRefrescar(cargar);
return () => { activo = false; off(); }; return () => { activo = false; off(); };
}, [ano, mes, agrupacion, sectorista]); }, [ano, mes, agrupacion, sectorista, sedeFiltro, progFiltro, vencHasta]);
// Cargar TODOS los alumnos una sola vez (para exportar y buscar) — 1 consulta cacheada // Cargar TODOS los alumnos una sola vez (para exportar y buscar) — 1 consulta cacheada
useEffect(() => { useEffect(() => {
@@ -95,6 +105,19 @@ export default function Cobranza() {
return () => { activo = false; }; return () => { activo = false; };
}, [ano, mes, sectorista]); }, [ano, mes, sectorista]);
// Clasificación (sede/filtro) de los programas de los alumnos, para filtrar
// las tarjetas "Saldo por Retirar" y el buscador por Sede/Programa (REPLACE_META).
const [progClasif, setProgClasif] = useState({});
useEffect(() => {
const nombres = [...new Set(alumnos.map((a)=>String(a.programa)).filter(Boolean))];
if (!nombres.length) { setProgClasif({}); return; }
let activo = true;
api.clasificarFull(nombres)
.then((r)=>{ if(activo) setProgClasif(r.mapa || {}); })
.catch(()=>{ if(activo) setProgClasif({}); });
return () => { activo = false; };
}, [alumnos]);
// Cargar matrículas POR RETIRAR (para el "Saldo por Retirar" de las tarjetas) // Cargar matrículas POR RETIRAR (para el "Saldo por Retirar" de las tarjetas)
const [porRetirar, setPorRetirar] = useState(new Set()); const [porRetirar, setPorRetirar] = useState(new Set());
useEffect(() => { useEffect(() => {
@@ -103,6 +126,28 @@ export default function Cobranza() {
.catch(() => {}); .catch(() => {});
}, [ano, mes]); }, [ano, mes]);
// En agrupación SEDE: cargar la vista "Por Programa" (para poder expandir cada sede
// y ver sus programas con FREC/N° CUOTA/VENCIMIENTO). Se filtra por sede al expandir.
useEffect(() => {
if (agrupacion !== "SEDE") { setFilasPrograma([]); setExpandidas(new Set()); return; }
let activo = true;
api.cobranza(ano, mes, sectorista, "PROGRAMA", sedeFiltro, progFiltro, vencHasta)
.then((res) => {
if (!activo) return;
const fp = (res.filas || []).filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL");
setFilasPrograma(fp);
// clasificar cada programa a su SEDE y FILTRO (TEAC/TERC/...) via REPLACE_META
const nombres = fp.map((f)=>String(f[0]));
if (nombres.length) {
api.clasificarFull(nombres)
.then((r)=>{ if(activo) setSedeMapPrograma(r.mapa || {}); })
.catch(()=>{ if(activo) setSedeMapPrograma({}); });
}
})
.catch(() => { if (activo) setFilasPrograma([]); });
return () => { activo = false; };
}, [agrupacion, ano, mes, sectorista, sedeFiltro, progFiltro, vencHasta]);
// Cargar clasificación de sede (sede.json) para los programas visibles // Cargar clasificación de sede (sede.json) para los programas visibles
useEffect(() => { useEffect(() => {
if (!esPrograma) return; if (!esPrograma) return;
@@ -118,7 +163,7 @@ export default function Cobranza() {
}, [filas, esPrograma]); }, [filas, esPrograma]);
// Filtro sede/frecuencia (solo PROGRAMA) en cliente — usa sede.json // Filtro sede/frecuencia (solo PROGRAMA) en cliente — usa sede.json
const datos = useMemo(() => { const datosCalc = useMemo(() => {
let arr = filas.filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL"); let arr = filas.filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL");
if (esPrograma) { if (esPrograma) {
if (sede !== "TODOS") { if (sede !== "TODOS") {
@@ -128,10 +173,20 @@ export default function Cobranza() {
}); });
} }
if (frec !== "TODOS") arr = arr.filter((f) => String(f[1]).toUpperCase() === frec); if (frec !== "TODOS") arr = arr.filter((f) => String(f[1]).toUpperCase() === frec);
return arr;
} }
// Vista POR SEDE / POR ASESOR: el backend ya devuelve las filas filtradas por
// Sede y Programa (JSON REPLACE_META), así que se muestran tal cual.
return arr; return arr;
}, [filas, esPrograma, sede, frec, sedeMap]); }, [filas, esPrograma, sede, frec, sedeMap]);
// Mientras carga, mostrar la ÚLTIMA vista renderizada (congelada) hasta que llegue
// la respuesta nueva. Así al filtrar solo se opaca lo anterior, sin recalcular a 0.
const datosViewRef = useRef([]);
if (!loading) datosViewRef.current = datosCalc;
const datos = loading ? datosViewRef.current : datosCalc;
// KPIs: 3 tarjetas (recalculadas con datos filtrados) // KPIs: 3 tarjetas (recalculadas con datos filtrados)
const kpis = useMemo(() => { const kpis = useMemo(() => {
let cAnt=0,obAnt=0,cMes=0,obMes=0,cTot=0,obTot=0; let cAnt=0,obAnt=0,cMes=0,obMes=0,cTot=0,obTot=0;
@@ -143,21 +198,81 @@ export default function Cobranza() {
return { cAnt,obAnt,cMes,obMes,cTot,obTot, saldo: cTot-obTot }; return { cAnt,obAnt,cMes,obMes,cTot,obTot, saldo: cTot-obTot };
}, [datos, IDX]); }, [datos, IDX]);
const headers = esPrograma const esAsesor = agrupacion === "ASESOR";
? ["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."] // Las 3 columnas extra (FREC/N°CUOTA/VENC) SOLO se muestran en SEDE cuando hay
: ["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."]; // al menos una sede expandida, o siempre en PROGRAMA. En ASESOR nunca.
const conColsExtra = esPrograma || (!esAsesor && expandidas.size > 0);
const headers = !conColsExtra
? ["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."]
: [(esPrograma?"PROGRAMA":"GRUPO"),"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."];
// Anchos para columnas redimensionables (1ª columna más ancha, resto estándar) // 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 anchosCob = headers.map((h, i) => i === 0 ? (conColsExtra ? 280 : 200) : (h === "FREC." || h.startsWith("%") || h === "OPC." ? 80 : 120));
const cols = useColumnasAjustables(anchosCob); const cols = useColumnasAjustables(anchosCob);
// Índices de cartera según el layout de columnas de la tabla actual.
const LAY = conColsExtra
? { 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] };
function cartClase(ci) { function cartClase(ci) {
if (IDX.ant.includes(ci)) return "cart-ant"; if (LAY.ant.includes(ci)) return "cart-ant";
if (IDX.mes.includes(ci)) return "cart-mes"; if (LAY.mes.includes(ci)) return "cart-mes";
if (IDX.tot.includes(ci)) return "cart-tot"; if (LAY.tot.includes(ci)) return "cart-tot";
return ""; return "";
} }
// Columnas fijas (sticky) de la tabla principal:
// - sin expandir: solo GRUPO (1)
// - expandido: GRUPO, FREC., N° CUOTA, VENCIMIENTO (4)
const FIJAS_MAIN = conColsExtra ? 4 : 1;
const stickyMain = (ci, { esHeader = false, bg = "#fff" } = {}) => {
if (ci >= FIJAS_MAIN) return {};
let left = 0;
for (let k = 0; k < ci; k++) left += (cols.anchos[k] || 0);
const ultima = ci === FIJAS_MAIN - 1;
const lineaColor = esHeader ? "#475569" : "#e2e8f0";
return {
position: "sticky", left, zIndex: esHeader ? 20 : 10, backgroundColor: bg,
borderRight: "none",
boxShadow: `inset -1px 0 0 0 ${lineaColor}` + (ultima ? ", 3px 0 5px -2px rgba(0,0,0,0.18)" : ""),
};
};
// Normaliza una fila de SEDE al layout activo. Si conColsExtra (hay expansión),
// inserta FREC/N°CUOTA/VENC vacías tras el grupo. Si no, la deja tal cual.
const filaLayout = (f) => {
if (esPrograma) return f; // ya viene con las 15 columnas
if (esAsesor || !conColsExtra) return f; // layout simple (12 cols)
// SEDE con expansión: [grupo, "","","", cta_ant,cob_ant,%ant, cta_mes,... saldo]
return [f[0], "", "", "", f[1],f[2],f[3], f[4],f[5],f[6], f[7],f[8],f[9], f[10]];
};
// Expandir/contraer una sede (agrupación SEDE)
const toggleSede = (s) => {
const key = String(s).toUpperCase();
setExpandidas((prev) => {
const n = new Set(prev);
n.has(key) ? n.delete(key) : n.add(key);
return n;
});
};
// sede/filtro de un programa (desde REPLACE_META). sedeMapPrograma[nombre]={sede,filtro}
const infoProg = (nombre) => sedeMapPrograma[String(nombre)] || {sede:"LIMA", filtro:"OTROS"};
// Programas (filas de la vista Por Programa) que pertenecen a una sede,
// aplicando el filtro Programa (TEAC/TERC/...) si está activo.
const programasDeSede = (s) => {
const key = String(s).toUpperCase();
return filasPrograma.filter((fp) => {
const ip = infoProg(fp[0]);
if (String(ip.sede).toUpperCase() !== key) return false;
if (progFiltro !== "TODOS" && String(ip.filtro).toUpperCase() !== progFiltro) return false;
return true;
});
};
// Alumnos visibles según filtros activos (mismo dataset que Ver / Estado de Cuenta) // Alumnos visibles según filtros activos (mismo dataset que Ver / Estado de Cuenta)
const alumnosFiltrados = useMemo(() => { const alumnosFiltrados = useMemo(() => {
let arr = alumnos; let arr = alumnos;
@@ -165,8 +280,30 @@ export default function Cobranza() {
if (sede !== "TODOS") arr = arr.filter((a) => String(a.sede).toUpperCase() === sede.toUpperCase()); 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); if (frec !== "TODOS") arr = arr.filter((a) => String(a.frecuencia).toUpperCase() === frec);
} }
// Filtros Sede / Programa (REPLACE_META) — aplican en Por Sede y Por Asesor
if (sedeFiltro !== "TODOS") {
arr = arr.filter((a) => {
const s = (progClasif[String(a.programa)]?.sede || a.sede || "LIMA");
return String(s).toUpperCase() === sedeFiltro.toUpperCase();
});
}
if (progFiltro !== "TODOS") {
arr = arr.filter((a) => String(progClasif[String(a.programa)]?.filtro || "").toUpperCase() === progFiltro);
}
// Filtro por F. Vencimiento (<=). Cuotas sin fecha se incluyen siempre.
if (vencHasta !== "TODOS") {
const toTs = (s) => {
const [dd,mm,yy] = String(s).split("/").map(Number);
return (dd&&mm&&yy) ? new Date(yy,mm-1,dd).getTime() : null;
};
const corte = toTs(vencHasta);
if (corte != null) arr = arr.filter((a) => {
const t = toTs(a.fch_venc);
return t == null || t <= corte; // sin fecha => siempre
});
}
return arr; return arr;
}, [alumnos, esPrograma, sede, frec]); }, [alumnos, esPrograma, sede, frec, sedeFiltro, progFiltro, progClasif, vencHasta]);
function grupoDe(a) { function grupoDe(a) {
// El programa detallado del alumno, en TODOS los filtros (Sede, Asesor, Programa). // El programa detallado del alumno, en TODOS los filtros (Sede, Asesor, Programa).
@@ -191,6 +328,8 @@ export default function Cobranza() {
const base = alumnosFiltrados; const base = alumnosFiltrados;
if (base.length === 0) { alert("No hay alumnos para exportar con los filtros actuales."); return; } if (base.length === 0) { alert("No hay alumnos para exportar con los filtros actuales."); return; }
// Fecha como TEXTO tal cual (dd/mm/yyyy). No convertir a Date: al escribir
// un Date en Excel se corre un día por zona horaria (UTC-5).
const toDate = (v) => { const toDate = (v) => {
if (!v || v === "-") return ""; if (!v || v === "-") return "";
return String(v).trim(); return String(v).trim();
@@ -231,7 +370,7 @@ export default function Cobranza() {
const st = cellStyle(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}:{}) }; 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 ([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 ws[ref] = { v: v ?? "", t:"s" }; else ws[ref] = { v: v ?? "", t:"s" }; // F. VENC. (col 4) va como texto tal cual
}); });
}); });
@@ -266,11 +405,25 @@ export default function Cobranza() {
<Filters> <Filters>
<Select label="Año" value={ano} options={ANOS} onChange={(v)=>setAno(+v)} /> <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="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="Agrupación" value={agrupacion} options={AGRUPACIONES} onChange={(v)=>{setAgrupacion(v);setSede("TODOS");setFrec("TODOS");setSedeFiltro("TODOS");setProgFiltro("TODOS");}} />
<Select label="Sectorista" value={sectorista} options={sectoristas} onChange={setSectorista} /> <Select label="Sectorista" value={sectorista} options={sectoristas} onChange={setSectorista} width={250} />
{esPrograma && <Select label="Sede" value={sede} options={SEDES} onChange={setSede} />} {esPrograma && <Select label="Sede" value={sede} options={SEDES} onChange={setSede} />}
{esPrograma && <Select label="Frecuencia" value={frec} options={FRECS} onChange={setFrec} />} {esPrograma && <Select label="Frecuencia" value={frec} options={FRECS} onChange={setFrec} />}
{(agrupacion==="SEDE"||agrupacion==="ASESOR") && <Select label="Sede" value={sedeFiltro} options={SEDES} onChange={(v)=>{setSedeFiltro(v);setExpandidas(new Set());}} />}
{(agrupacion==="SEDE"||agrupacion==="ASESOR") && <Select label="Programa" value={progFiltro} options={PROGRAMAS_FILTRO} onChange={(v)=>{setProgFiltro(v);setExpandidas(new Set());}} />}
<div style={{display:"flex",gap:8,alignItems:"flex-end",marginLeft:"auto"}}> <div style={{display:"flex",gap:8,alignItems:"flex-end",marginLeft:"auto"}}>
<div style={{position:"relative"}}>
<button className="btn btn-ghost"
style={{height:38,padding:"0 12px",
...(vencHasta!=="TODOS"?{background:"#eff6ff",color:"#1e40af",fontWeight:700}:{})}}
onClick={()=>setCalAbierto((v)=>!v)}
title={vencHasta!=="TODOS" ? `Hasta ${vencHasta}` : "Filtrar por F. Vencimiento"}>
📅{vencHasta!=="TODOS" ? ` ${vencHasta}` : ""}
</button>
{calAbierto && <MiniCalendario ano={ano} mes={mes} sel={vencHasta}
onSelect={(f)=>{ setVencHasta(f); setCalAbierto(false); }}
onClose={()=>setCalAbierto(false)} />}
</div>
<button className="btn btn-ghost" style={{height:38}} onClick={()=>setBuscadorAbierto(true)}>🔍 Buscar</button> <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}> <button className="btn btn-primary" style={{height:38}} disabled={exportando || !alumnosFiltrados.length} onClick={exportarDetalle}>
{exportando ? "Exportando..." : "⬇️ Exportar"} {exportando ? "Exportando..." : "⬇️ Exportar"}
@@ -278,91 +431,147 @@ export default function Cobranza() {
</div> </div>
</Filters> </Filters>
{loading ? <Loader text="Calculando cobranza..." /> : {loading && !datos.length ? <Loader text="Calculando cobranza..." /> :
error ? <ErrorBox msg={error} /> : error ? <ErrorBox msg={error} /> :
<> <div style={{opacity: loading ? 0.45 : 1, pointerEvents: loading ? "none" : "auto", transition:"opacity .15s"}}>
<Tarjetas kpis={kpis} retiro={retiroKpi} /> <Tarjetas kpis={kpis} retiro={retiroKpi} loading={loading} />
<div className="table-wrap" style={{marginTop:16, overflowX:"auto"}}> <div className="table-wrap" style={{marginTop:16, overflow:"auto", maxHeight:"60vh"}}>
<table className="cob-table" {...cols.tableProps}> <table className="cob-table" {...cols.tableProps}>
<cols.ColGroup /> <cols.ColGroup />
<thead><tr>{headers.map((h, i)=><th key={h} style={{position:"relative"}}>{h}<cols.Resizer index={i} /></th>)}</tr></thead> <thead><tr>{headers.map((h, i)=>{
const st = stickyMain(i, {esHeader:true, bg:"#334155"});
const fija = i < FIJAS_MAIN;
// Header siempre pegado arriba (top:0); columnas fijas además a la izquierda.
const stH = fija
? {...st, top:0, zIndex:30}
: {position:"sticky", top:0, zIndex:22, backgroundColor:"#334155"};
return <th key={h} style={{...stH, position: stH.position || "relative"}}>{h}<cols.Resizer index={i} /></th>;
})}</tr></thead>
<tbody> <tbody>
{datos.map((f, ri) => { {datos.map((fRaw, ri) => {
// En la tabla principal SIEMPRE son agrupaciones (Sede/Programa/Asesor), const f = filaLayout(fRaw); // layout normalizado (15 o 12 cols)
// nunca alumnos individuales → NO aplicar rojo por deuda aquí. const nCols = headers.length - 1; // sin OPC
// Regla "-": si % del grupo está vacío, CTA y COB del grupo → "-" const sedeKey = String(f[0]).toUpperCase();
const antVacio = !String(f[IDX.ant[2]]||"").trim() || ["-","—"].includes(String(f[IDX.ant[2]]).trim()); const abiertaSede = !esPrograma && !esAsesor && expandidas.has(sedeKey);
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()); const renderCeldas = (fila, {indent=false, bg="#fff"}={}) => {
return ( const av = !String(fila[LAY.ant[2]]||"").trim() || ["-","—"].includes(String(fila[LAY.ant[2]]).trim());
<tr key={ri}> const mv = !String(fila[LAY.mes[2]]||"").trim() || ["-","—"].includes(String(fila[LAY.mes[2]]).trim());
{f.slice(0, headers.length-1).map((v, ci) => { const tv = !String(fila[LAY.tot[2]]||"").trim() || ["-","—"].includes(String(fila[LAY.tot[2]]).trim());
const isPct = IDX.ant[2]===ci || IDX.mes[2]===ci || IDX.tot[2]===ci; return fila.slice(0, nCols).map((v, ci) => {
const isPct = LAY.ant[2]===ci || LAY.mes[2]===ci || LAY.tot[2]===ci;
let val = v; let val = v;
if ((ci===IDX.ant[0]||ci===IDX.ant[1]||ci===IDX.ant[2]) && antVacio) val = "—"; if ((ci===LAY.ant[0]||ci===LAY.ant[1]||ci===LAY.ant[2]) && av) val = "—";
if ((ci===IDX.mes[0]||ci===IDX.mes[1]||ci===IDX.mes[2]) && mesVacio) val = "—"; if ((ci===LAY.mes[0]||ci===LAY.mes[1]||ci===LAY.mes[2]) && mv) val = "—";
if ((ci===IDX.tot[0]||ci===IDX.tot[1]||ci===IDX.tot[2]) && totVacio) val = "—"; if ((ci===LAY.tot[0]||ci===LAY.tot[1]||ci===LAY.tot[2]) && tv) val = "—";
const cartCls = cartClase(ci); const cartCls = cartClase(ci);
const clss = (ci===0?"col-name":"") + (cartCls?` ${cartCls}`:""); const clss = (ci===0?"col-name":"") + (cartCls?` ${cartCls}`:"");
const puedeExpandir = (!esPrograma && !esAsesor && ci===0 && !indent);
const abierta = puedeExpandir && expandidas.has(String(fila[0]).toUpperCase());
return ( return (
<td key={ci} className={clss.trim()} <td key={ci} className={clss.trim()}
style={{textAlign: ci===0?"left":"center", style={{textAlign: ci===0?"left":"center",
minWidth: ci===0?(esPrograma?300:200):undefined,
maxWidth: ci===0?500:undefined,
whiteSpace: "nowrap", whiteSpace: "nowrap",
paddingLeft: (ci===0 && indent)?28:undefined,
overflow: ci===0?"hidden":undefined, overflow: ci===0?"hidden":undefined,
textOverflow: ci===0?"ellipsis":undefined}}> textOverflow: ci===0?"ellipsis":undefined,
color: indent?"#475569":undefined,
...stickyMain(ci, {bg})}}>
{puedeExpandir && (
<span onClick={()=>toggleSede(String(fila[0]))}
style={{cursor:"pointer",color:"#1e40af",fontWeight:800,
marginRight:8,userSelect:"none",fontSize:14,
display:"inline-block",width:12,textAlign:"center",
transition:"transform .15s ease"}}>
{abierta ? "−" : "+"}
</span>
)}
{isPct ? (val==="—" ? <span style={{color:"#cbd5e1"}}>—</span> : <MiniBar valStr={val} />) : fmtMoneda(val)} {isPct ? (val==="—" ? <span style={{color:"#cbd5e1"}}>—</span> : <MiniBar valStr={val} />) : fmtMoneda(val)}
</td> </td>
); );
})} });
};
return (
<Fragment key={ri}>
<tr>
{renderCeldas(f)}
<td> <td>
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}} <button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
onClick={() => setModal({ grupo: f[0] })}>👁️ Ver</button> onClick={() => setModal({ grupo: fRaw[0] })}>👁️ Ver</button>
</td> </td>
</tr> </tr>
{abiertaSede && programasDeSede(fRaw[0]).map((fp, pi) => (
<tr key={"p"+pi} className="fila-prog" style={{background:"#f8fafc"}}>
{renderCeldas(fp, {indent:true, bg:"#f8fafc"})}
<td>
<button className="btn btn-ghost" style={{padding:"3px 8px",fontSize:10}}
onClick={()=>setModal({ grupo: fp[0], agrupacion: "PROGRAMA" })}>👁️ Ver</button>
</td>
</tr>
))}
</Fragment>
); );
})} })}
{datos.length > 0 && (() => { {datos.length > 0 && (() => {
// Fila TOTAL GENERAL — suma de los datos visibles (respeta filtros) // Fila TOTAL GENERAL — suma de los datos CRUDOS (IDX), render en layout LAY.
const t = {}; const suma = (idx)=> datos.reduce((s,f)=>s+toMonto(f[idx]),0);
[IDX.ant[0],IDX.ant[1],IDX.mes[0],IDX.mes[1],IDX.tot[0],IDX.tot[1],IDX.saldo] const T = { cAnt:suma(IDX.ant[0]), oAnt:suma(IDX.ant[1]),
.forEach((ci)=>{ t[ci] = datos.reduce((s,f)=>s+toMonto(f[ci]),0); }); cMes:suma(IDX.mes[0]), oMes:suma(IDX.mes[1]),
cTot:suma(IDX.tot[0]), oTot:suma(IDX.tot[1]),
saldo:suma(IDX.saldo) };
const ratio = (cob,cta)=> cta>0 ? `${Math.round(cob/cta*100)}%` : ""; const ratio = (cob,cta)=> cta>0 ? `${Math.round(cob/cta*100)}%` : "";
const money = (n)=> "S/ " + n.toLocaleString("es-PE",{maximumFractionDigits:0});
// valor por columna del layout LAY
const cell = (ci) => { const cell = (ci) => {
if (ci===0) return "TOTAL GENERAL"; if (ci===0) return "TOTAL GENERAL";
if (ci===IDX.ant[2]) return ratio(t[IDX.ant[1]],t[IDX.ant[0]]); if (ci===LAY.ant[0]) return money(T.cAnt);
if (ci===IDX.mes[2]) return ratio(t[IDX.mes[1]],t[IDX.mes[0]]); if (ci===LAY.ant[1]) return money(T.oAnt);
if (ci===IDX.tot[2]) return ratio(t[IDX.tot[1]],t[IDX.tot[0]]); if (ci===LAY.ant[2]) return ratio(T.oAnt,T.cAnt);
if (t[ci]!==undefined) return "S/ " + t[ci].toLocaleString("es-PE",{maximumFractionDigits:0}); if (ci===LAY.mes[0]) return money(T.cMes);
if (ci===LAY.mes[1]) return money(T.oMes);
if (ci===LAY.mes[2]) return ratio(T.oMes,T.cMes);
if (ci===LAY.tot[0]) return money(T.cTot);
if (ci===LAY.tot[1]) return money(T.oTot);
if (ci===LAY.tot[2]) return ratio(T.oTot,T.cTot);
if (ci===LAY.saldo) return money(T.saldo);
return ""; return "";
}; };
return ( return (
<tr className="total-row"> <tr className="total-row">
{Array.from({length: headers.length-1}).map((_,ci)=>{ {Array.from({length: headers.length-1}).map((_,ci)=>{
const isPct = IDX.ant[2]===ci || IDX.mes[2]===ci || IDX.tot[2]===ci; const isPct = LAY.ant[2]===ci || LAY.mes[2]===ci || LAY.tot[2]===ci;
const val = cell(ci); const val = cell(ci);
const fija = ci < FIJAS_MAIN;
let left = 0; for (let k=0;k<ci;k++) left += (cols.anchos[k]||0);
// Fila TOTAL pegada abajo (bottom:0); columnas fijas además a la izquierda.
const st = fija
? { position:"sticky", bottom:0, left, zIndex:15, backgroundColor:"#eff6ff",
boxShadow:`inset -1px 0 0 0 #cbd5e1${ci===FIJAS_MAIN-1?", 3px 0 5px -2px rgba(0,0,0,0.18)":""}, inset 0 1px 0 0 #cbd5e1` }
: { position:"sticky", bottom:0, zIndex:9, backgroundColor:"#eff6ff",
boxShadow:"inset 0 1px 0 0 #cbd5e1" };
return ( return (
<td key={ci} className={ci===0?"col-name":""} <td key={ci} className={ci===0?"col-name":""}
style={{textAlign: ci===0?"left":"center", style={{textAlign: ci===0?"left":"center",whiteSpace:"nowrap",...st}}>
whiteSpace:"nowrap"}}>
{isPct ? <MiniBar valStr={val} /> : val} {isPct ? <MiniBar valStr={val} /> : val}
</td> </td>
); );
})} })}
<td>—</td> <td style={{position:"sticky",bottom:0,zIndex:9,backgroundColor:"#eff6ff",boxShadow:"inset 0 1px 0 0 #cbd5e1"}}>—</td>
</tr> </tr>
); );
})()} })()}
</tbody> </tbody>
</table> </table>
</div> </div>
</>} </div>}
{modal && ( {modal && (
<ModalEstadoCuenta grupo={modal.grupo} ano={ano} mes={mes} <ModalEstadoCuenta grupo={modal.grupo} ano={ano} mes={mes}
sectorista={sectorista} agrupacion={agrupacion} onClose={() => setModal(null)} /> sectorista={sectorista} agrupacion={modal.agrupacion || agrupacion}
progFiltro={progFiltro} sedeFiltro={sedeFiltro} vencHasta={vencHasta}
onClose={() => setModal(null)} />
)} )}
{buscadorAbierto && ( {buscadorAbierto && (
@@ -377,9 +586,79 @@ export default function Cobranza() {
); );
} }
function Tarjetas({ kpis, retiro }) { // Mini-calendario. Muestra el mes/año del filtro, fijo. Al elegir un día filtra
// por F. Vencimiento (<= ese día). Para ver todo, elegir el último día del mes.
function MiniCalendario({ ano, mes, sel, onSelect, onClose }) {
const hoy = new Date();
const mIdx = (mes - 1); // el filtro usa mes 1..12; Date usa 0..11
const ref = useRef(null);
useEffect(() => {
const fuera = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
document.addEventListener("mousedown", fuera);
return () => document.removeEventListener("mousedown", fuera);
}, [onClose]);
const MESN = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"];
const DIAS = ["Lu","Ma","Mi","Ju","Vi","Sá","Do"];
const primerDia = new Date(ano, mIdx, 1).getDay(); // 0=Dom
const offset = (primerDia + 6) % 7; // lunes primero
const diasMes = new Date(ano, mIdx + 1, 0).getDate();
const celdas = [...Array(offset).fill(null), ...Array.from({length:diasMes},(_,i)=>i+1)];
// Día seleccionado (si sel es dd/mm/yyyy de este mes/año)
const selDia = (() => {
if (!sel || sel === "TODOS") return null;
const [dd,mm,yy] = String(sel).split("/").map(Number);
return (mm===mes && yy===ano) ? dd : null;
})();
const fmt = (d) => `${String(d).padStart(2,"0")}/${String(mes).padStart(2,"0")}/${ano}`;
const mismaFecha = (d) => d && selDia===d;
const esHoy = (d) => d && d===hoy.getDate() && mIdx===hoy.getMonth() && ano===hoy.getFullYear();
return (
<div ref={ref} style={{position:"absolute",top:46,right:0,zIndex:50,width:280,
background:"#fff",border:"1px solid #e2e8f0",borderRadius:14,
boxShadow:"0 12px 30px -8px rgba(15,23,42,0.25)",padding:14,userSelect:"none"}}>
{/* Encabezado (fijo, sin navegación) */}
<div style={{textAlign:"center",fontWeight:800,fontSize:14,color:"#0f172a",marginBottom:12}}>
{MESN[mIdx]} {ano}
</div>
{/* Días de la semana */}
<div style={{display:"grid",gridTemplateColumns:"repeat(7,1fr)",gap:2,marginBottom:4}}>
{DIAS.map((d)=><div key={d} style={{textAlign:"center",fontSize:11,fontWeight:700,color:"#94a3b8",padding:"4px 0"}}>{d}</div>)}
</div>
{/* Celdas */}
<div style={{display:"grid",gridTemplateColumns:"repeat(7,1fr)",gap:2}}>
{celdas.map((d,i)=>(
<div key={i}
onClick={()=>d && onSelect(fmt(d))}
style={{textAlign:"center",padding:"7px 0",fontSize:13,borderRadius:8,
cursor:d?"pointer":"default",fontWeight:mismaFecha(d)?800:500,
color:mismaFecha(d)?"#fff":(esHoy(d)?"#1e40af":"#334155"),
background:mismaFecha(d)?"#1e40af":(esHoy(d)?"#eff6ff":"transparent"),
border:esHoy(d)&&!mismaFecha(d)?"1px solid #bfdbfe":"1px solid transparent",
transition:"background .12s"}}
onMouseEnter={(e)=>{ if(d&&!mismaFecha(d)) e.currentTarget.style.background="#f1f5f9"; }}
onMouseLeave={(e)=>{ if(d&&!mismaFecha(d)) e.currentTarget.style.background=esHoy(d)?"#eff6ff":"transparent"; }}>
{d||""}
</div>
))}
</div>
{/* Pie: limpiar filtro */}
{sel && sel!=="TODOS" && (
<div style={{marginTop:12,paddingTop:10,borderTop:"1px solid #f1f5f9",textAlign:"center"}}>
<button onClick={()=>onSelect("TODOS")}
style={{border:"none",background:"#fef2f2",color:"#dc2626",fontSize:12,fontWeight:700,
padding:"6px 14px",borderRadius:8,cursor:"pointer"}}>Quitar filtro de fecha</button>
</div>
)}
</div>
);
}
function Tarjetas({ kpis, retiro, loading }) {
const rt = retiro || { sAnt:0, sMes:0, sTot:0 }; const rt = retiro || { sAnt:0, sMes:0, sTot:0 };
const alDia = kpis.saldo <= 0.01; const alDia = !loading && kpis.saldo >= -0.01 && kpis.saldo <= 0.01;
if (alDia) { if (alDia) {
return ( return (
<div style={{background:"linear-gradient(135deg,#d1fae5,#a7f3d0)",border:"2px solid #10b981", <div style={{background:"linear-gradient(135deg,#d1fae5,#a7f3d0)",border:"2px solid #10b981",
@@ -481,8 +760,8 @@ function TarjetasResumen({ r, retiro }) {
function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) { function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
const [texto, setTexto] = useState(""); const [texto, setTexto] = useState("");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [porRetirar, setPorRetirar] = useState(new Set()); const [porRetirar, setPorRetirar] = useState(new Set()); // matrículas POR RETIRAR
const [infoAlumno, setInfoAlumno] = useState(null); const [infoAlumno, setInfoAlumno] = useState(null); // alumno del pop-up INFO
const cargarPorRetirar = () => { const cargarPorRetirar = () => {
api.alumnosPorRetirar() api.alumnosPorRetirar()
@@ -504,9 +783,10 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
const COLS = ["PROGRAMA","ALUMNO","N° CUOTA","F. VENC.", const COLS = ["PROGRAMA","ALUMNO","N° CUOTA","F. VENC.",
"CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO","INFO"]; "CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO","INFO"];
// Anchos iniciales — columnas redimensionables manualmente.
const anchosBusc = COLS.map((h,i) => { const anchosBusc = COLS.map((h,i) => {
if (i===0) return 250; if (i===0) return 250; // PROGRAMA
if (i===1) return 250; if (i===1) return 250; // ALUMNO
if (h==="N° CUOTA" || h==="F. VENC.") return 100; if (h==="N° CUOTA" || h==="F. VENC.") return 100;
if (h==="INFO") return 70; if (h==="INFO") return 70;
if (h.startsWith("%")) return 80; if (h.startsWith("%")) return 80;
@@ -514,6 +794,7 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
}); });
const cols = useColumnasAjustables(anchosBusc); const cols = useColumnasAjustables(anchosBusc);
// Congelar las 4 primeras columnas (PROGRAMA, ALUMNO, N° CUOTA, F. VENC.).
const FIJAS = 4; const FIJAS = 4;
const stickyCol = (j, { esHeader = false, bg = "#fff" } = {}) => { const stickyCol = (j, { esHeader = false, bg = "#fff" } = {}) => {
if (j >= FIJAS) return {}; if (j >= FIJAS) return {};
@@ -570,7 +851,7 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
<tbody> <tbody>
{resultados.map((a,i)=>{ {resultados.map((a,i)=>{
const deuda = a.saldo > 0.01; const deuda = a.saldo > 0.01;
const retira = porRetirar.has(String(a.matricula)); const retira = porRetirar.has(String(a.matricula)); // POR RETIRAR
const bgFila = (i % 2 === 1) ? "#f8fafc" : "#fff"; const bgFila = (i % 2 === 1) ? "#f8fafc" : "#fff";
const celdas = [ const celdas = [
{v:a.programa, cls:"col-name", al:"left", texto:true}, {v:a.programa, cls:"col-name", al:"left", texto:true},
@@ -630,21 +911,20 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
); );
} }
function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose }) { function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, progFiltro = "TODOS", sedeFiltro = "TODOS", vencHasta = "TODOS", onClose }) {
const [filas, setFilas] = useState([]); const [filas, setFilas] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [porRetirar, setPorRetirar] = useState(new Set()); // matrículas POR RETIRAR const [porRetirar, setPorRetirar] = useState(new Set()); // matrículas POR RETIRAR
const [soloRetirar, setSoloRetirar] = useState(false); // filtro: ver solo POR RETIRAR const [soloRetirar, setSoloRetirar] = useState(false); // filtro: ver solo POR RETIRAR
const [infoAlumno, setInfoAlumno] = useState(null); // alumno del pop-up INFO
useEffect(() => { useEffect(() => {
let activo = true; let activo = true;
setLoading(true); setLoading(true);
api.cobranzaDetalle(grupo, ano, mes, sectorista, agrupacion) api.cobranzaDetalle(grupo, ano, mes, sectorista, agrupacion, progFiltro, sedeFiltro, vencHasta)
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
.catch(() => { if (activo) { setFilas([]); setLoading(false); } }); .catch(() => { if (activo) { setFilas([]); setLoading(false); } });
return () => { activo = false; }; return () => { activo = false; };
}, [grupo, ano, mes, sectorista, agrupacion]); }, [grupo, ano, mes, sectorista, agrupacion, progFiltro, sedeFiltro, vencHasta]);
// Cargar (y recargar) las matrículas marcadas POR RETIRAR para tachar sus filas // Cargar (y recargar) las matrículas marcadas POR RETIRAR para tachar sus filas
const cargarPorRetirar = () => { const cargarPorRetirar = () => {
@@ -656,6 +936,9 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
const COLS = ["PROGRAMA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO","INFO"]; const COLS = ["PROGRAMA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO","INFO"];
// Alumno seleccionado para el pop-up de INFO (estado + observaciones)
const [infoAlumno, setInfoAlumno] = useState(null);
// Columnas redimensionables (igual que la tabla principal): PROGRAMA y ALUMNO más anchas // Columnas redimensionables (igual que la tabla principal): PROGRAMA y ALUMNO más anchas
const anchosDet = COLS.map((h, i) => i === 0 ? 240 : (i === 1 ? 220 : (h==="INFO" ? 70 : h.startsWith("%") ? 80 : 110))); const anchosDet = COLS.map((h, i) => i === 0 ? 240 : (i === 1 ? 220 : (h==="INFO" ? 70 : h.startsWith("%") ? 80 : 110)));
const cols = useColumnasAjustables(anchosDet); const cols = useColumnasAjustables(anchosDet);
@@ -668,10 +951,15 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
for (let k = 0; k < j; k++) left += (cols.anchos[k] || 0); for (let k = 0; k < j; k++) left += (cols.anchos[k] || 0);
const ultima = j === FIJAS - 1; const ultima = j === FIJAS - 1;
const lineaColor = esHeader ? "#475569" : "#e2e8f0"; const lineaColor = esHeader ? "#475569" : "#e2e8f0";
// Usamos box-shadow (no border) para la línea divisoria: el box-shadow SÍ tapa
// por completo lo que pasa por detrás al hacer scroll (el border dejaba ver el color).
const sombraLinea = `inset -1px 0 0 0 ${lineaColor}`;
const sombraCorte = ultima ? ", 3px 0 5px -2px rgba(0,0,0,0.18)" : "";
return { return {
position: "sticky", left, zIndex: esHeader ? 20 : 10, backgroundColor: bg, position: "sticky", left, zIndex: esHeader ? 20 : 10,
backgroundColor: bg,
borderRight: "none", borderRight: "none",
boxShadow: `inset -1px 0 0 0 ${lineaColor}` + (ultima ? ", 3px 0 5px -2px rgba(0,0,0,0.18)" : ""), boxShadow: sombraLinea + sombraCorte,
}; };
}; };
@@ -723,6 +1011,7 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
</div> </div>
{/* Etiquetas de conteo de alumnos */} {/* Etiquetas de conteo de alumnos */}
<div style={{display:"flex",gap:10,marginBottom:14,flexWrap:"wrap",alignItems:"center"}}> <div style={{display:"flex",gap:10,marginBottom:14,flexWrap:"wrap",alignItems:"center"}}>
{/* Grupo 1: conteo general */}
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px", <span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#eff6ff",color:"#1e40af",fontSize:13,fontWeight:700, borderRadius:999,background:"#eff6ff",color:"#1e40af",fontSize:13,fontWeight:700,
border:"1px solid #bfdbfe"}}> border:"1px solid #bfdbfe"}}>
@@ -738,7 +1027,11 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
border:"1px solid #fde68a"}}> border:"1px solid #fde68a"}}>
⏳ Por pagar: {conteo.deben} ⏳ Por pagar: {conteo.deben}
</span> </span>
{/* Divisor */}
<span style={{width:1,height:26,background:"#e2e8f0",margin:"0 4px"}} /> <span style={{width:1,height:26,background:"#e2e8f0",margin:"0 4px"}} />
{/* Grupo 2: POR RETIRAR */}
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px", <span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#fef2f2",color:"#dc2626",fontSize:13,fontWeight:700, borderRadius:999,background:"#fef2f2",color:"#dc2626",fontSize:13,fontWeight:700,
border:"1px solid #fecaca"}}> border:"1px solid #fecaca"}}>
@@ -749,12 +1042,15 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
border:"1px solid #fecaca"}}> border:"1px solid #fecaca"}}>
💸 Saldo: S/ {retiro.saldo.toLocaleString("es-PE",{maximumFractionDigits:0})} 💸 Saldo: S/ {retiro.saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}
</span> </span>
{/* Checkbox alineado a la derecha */}
<label style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px", <label style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:soloRetirar?"#fee2e2":"#f8fafc",marginLeft:"auto", borderRadius:999,background:soloRetirar?"#fee2e2":"#f8fafc",marginLeft:"auto",
color:soloRetirar?"#b91c1c":"#64748b",fontSize:13,fontWeight:700,cursor:"pointer", color:soloRetirar?"#b91c1c":"#64748b",fontSize:13,fontWeight:700,cursor:"pointer",
border:`1px solid ${soloRetirar?"#fca5a5":"#e2e8f0"}`}}> border:`1px solid ${soloRetirar?"#fca5a5":"#e2e8f0"}`}}>
<input type="checkbox" checked={soloRetirar} <input type="checkbox" checked={soloRetirar}
onChange={(e)=>setSoloRetirar(e.target.checked)} style={{cursor:"pointer"}} /> onChange={(e)=>setSoloRetirar(e.target.checked)}
style={{cursor:"pointer"}} />
Ver solo por retirar Ver solo por retirar
</label> </label>
</div> </div>
@@ -774,28 +1070,31 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
.map((f, i) => { .map((f, i) => {
const saldo = toMonto(f[13]); const saldo = toMonto(f[13]);
const deuda = saldo > 0.01; 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 antVacio = !String(f[6]||"").trim() || ["-","—"].includes(String(f[6]).trim());
const mesVacio = !String(f[9]||"").trim() || ["-","—"].includes(String(f[9]).trim()); const mesVacio = !String(f[9]||"").trim() || ["-","—"].includes(String(f[9]).trim());
const totVacio = !String(f[12]||"").trim() || ["-","—"].includes(String(f[12]).trim()); const totVacio = !String(f[12]||"").trim() || ["-","—"].includes(String(f[12]).trim());
const programa = f[14] || "-"; const programa = f[14] || "-"; // programa del alumno (extra del backend)
const retira = porRetirar.has(String(f[0])); const retira = porRetirar.has(String(f[0])); // alumno POR RETIRAR
return ( return (
<tr key={i} className={deuda?"row-deuda":""} <tr key={i} className={deuda?"row-deuda":""}
style={retira ? { textDecoration:"line-through", textDecorationColor:"#dc2626", style={retira ? { textDecoration:"line-through", textDecorationColor:"#dc2626",
textDecorationThickness:"2px" } : undefined}> textDecorationThickness:"2px" } : undefined}>
{f.slice(0, 14).map((v, j) => { {f.slice(0, 14).map((v, j) => {
// Columna 0: mostrar PROGRAMA en vez de la matrícula
let val = (j===0) ? programa : v; let val = (j===0) ? programa : v;
if ((j===4||j===5||j===6) && antVacio) val = "—"; if ((j===4||j===5||j===6) && antVacio) val = "—";
if ((j===7||j===8||j===9) && mesVacio) val = "—"; if ((j===7||j===8||j===9) && mesVacio) val = "—";
if ((j===10||j===11||j===12) && totVacio) val = "—"; if ((j===10||j===11||j===12) && totVacio) val = "—";
const isPct = [6,9,12].includes(j); const isPct = [6,9,12].includes(j);
const isMonto = String(val).includes("S/");
let cart = ""; let cart = "";
if ([4,5,6].includes(j)) cart = "cart-ant"; if ([4,5,6].includes(j)) cart = "cart-ant";
else if ([7,8,9].includes(j)) cart = "cart-mes"; else if ([7,8,9].includes(j)) cart = "cart-mes";
else if ([10,11,12].includes(j)) cart = "cart-tot"; else if ([10,11,12].includes(j)) cart = "cart-tot";
const clss = (((j===0||j===1)?"col-name":"") + (cart?` ${cart}`:"")).trim(); const clss = (((j===0||j===1)?"col-name":"") + (cart?` ${cart}`:"")).trim();
const esTexto = (j===0||j===1); const esTexto = (j===0||j===1);
const bgFila = (i % 2 === 1) ? "#f8fafc" : "#fff"; const bgFila = (i % 2 === 1) ? "#f8fafc" : "#fff"; // par/impar (i base 0)
return ( return (
<td key={j} className={clss} <td key={j} className={clss}
title={esTexto ? String(val) : undefined} title={esTexto ? String(val) : undefined}
@@ -810,6 +1109,7 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
</td> </td>
); );
})} })}
{/* Botón INFO por alumno (estado + observaciones) */}
<td style={{textAlign:"center"}}> <td style={{textAlign:"center"}}>
<button <button
onClick={()=>setInfoAlumno({ matricula:f[0], alumno:f[1], programa:programa })} onClick={()=>setInfoAlumno({ matricula:f[0], alumno:f[1], programa:programa })}
@@ -825,6 +1125,7 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
</tbody> </tbody>
<tfoot> <tfoot>
{(() => { {(() => {
// TOTAL GENERAL del modal — solo alumnos visibles (respeta sede y filtro POR RETIRAR)
const alumnos = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL") const alumnos = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL")
.filter((f)=> !soloRetirar || porRetirar.has(String(f[0]))); .filter((f)=> !soloRetirar || porRetirar.has(String(f[0])));
if (alumnos.length===0) return null; if (alumnos.length===0) return null;
@@ -847,11 +1148,12 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
const val=celda(j); const val=celda(j);
const fija = j < FIJAS; const fija = j < FIJAS;
let left = 0; for (let k=0;k<j;k++) left += (cols.anchos[k]||0); let left = 0; for (let k=0;k<j;k++) left += (cols.anchos[k]||0);
// Sticky abajo (bottom:0) SIEMPRE; y las 4 primeras tambien a la izquierda.
const st = { const st = {
position:"sticky", bottom:0, position:"sticky", bottom:0,
zIndex: fija ? 15 : 8, zIndex: fija ? 15 : 8,
backgroundColor:"#eff6ff", backgroundColor:"#eff6ff",
padding:"11px 8px", padding:"11px 8px", // misma altura que las filas normales
...(fija ? { left, boxShadow:`inset -1px 0 0 0 #cbd5e1${j===FIJAS-1?", 3px 0 5px -2px rgba(0,0,0,0.18)":""}, inset 0 1px 0 0 #cbd5e1` } : { boxShadow:"inset 0 1px 0 0 #cbd5e1" }), ...(fija ? { left, boxShadow:`inset -1px 0 0 0 #cbd5e1${j===FIJAS-1?", 3px 0 5px -2px rgba(0,0,0,0.18)":""}, inset 0 1px 0 0 #cbd5e1` } : { boxShadow:"inset 0 1px 0 0 #cbd5e1" }),
}; };
return ( return (
@@ -878,15 +1180,16 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
} }
// Pop-up de INFO por alumno: cambiar estado (EN CURSO / POR RETIRAR) y observaciones. // Pop-up de INFO por alumno: cambiar estado (EN CURSO / POR RETIRAR) y observaciones.
// Carga y guarda en Supabase (tabla cobranza_estado_alumno). // Carga y guarda en Supabase (tabla cobranza_alumno_estado).
function ModalInfoAlumno({ alumno, onClose, onGuardado }) { function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
const [estado, setEstado] = useState("EN CURSO"); const [estado, setEstado] = useState("EN CURSO"); // EN CURSO | POR RETIRAR
const [nuevaObs, setNuevaObs] = useState(""); const [nuevaObs, setNuevaObs] = useState("");
const [observaciones, setObservaciones] = useState([]); const [observaciones, setObservaciones] = useState([]);
const [cargando, setCargando] = useState(true); const [cargando, setCargando] = useState(true);
const [guardando, setGuardando] = useState(false); const [guardando, setGuardando] = useState(false);
const [guardado, setGuardado] = useState(false); const [guardado, setGuardado] = useState(false);
// Cargar estado/observaciones actuales del alumno al abrir
useEffect(() => { useEffect(() => {
let activo = true; let activo = true;
api.alumnoEstado(alumno.matricula) api.alumnoEstado(alumno.matricula)
@@ -910,7 +1213,7 @@ function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
try { try {
await api.alumnoEstadoGuardar(alumno.matricula, estado, observaciones); await api.alumnoEstadoGuardar(alumno.matricula, estado, observaciones);
setGuardado(true); setGuardado(true);
if (onGuardado) onGuardado(); if (onGuardado) onGuardado(); // refresca el tachado en la tabla
} catch { } catch {
alert("No se pudo guardar. Intenta de nuevo."); alert("No se pudo guardar. Intenta de nuevo.");
} finally { } finally {
@@ -920,6 +1223,7 @@ function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
return ( return (
<Modal title={`ℹ️ Información del alumno`} onClose={onClose} width={560}> <Modal title={`ℹ️ Información del alumno`} onClose={onClose} width={560}>
{/* Datos del alumno */}
<div style={{marginBottom:16}}> <div style={{marginBottom:16}}>
<div style={{fontSize:15,fontWeight:800,color:"#0f172a"}}>{alumno.alumno}</div> <div style={{fontSize:15,fontWeight:800,color:"#0f172a"}}>{alumno.alumno}</div>
<div style={{fontSize:12,color:"#64748b",marginTop:2}}> <div style={{fontSize:12,color:"#64748b",marginTop:2}}>
@@ -927,6 +1231,7 @@ function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
</div> </div>
</div> </div>
{/* Estado */}
<div style={{marginBottom:18}}> <div style={{marginBottom:18}}>
<label style={{fontSize:12,fontWeight:700,color:"#334155",display:"block",marginBottom:6}}> <label style={{fontSize:12,fontWeight:700,color:"#334155",display:"block",marginBottom:6}}>
Estado del alumno Estado del alumno
@@ -940,6 +1245,7 @@ function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
</select> </select>
</div> </div>
{/* Observaciones */}
<div style={{marginBottom:14}}> <div style={{marginBottom:14}}>
<label style={{fontSize:12,fontWeight:700,color:"#334155",display:"block",marginBottom:6}}> <label style={{fontSize:12,fontWeight:700,color:"#334155",display:"block",marginBottom:6}}>
Observaciones Observaciones
@@ -955,6 +1261,7 @@ function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
Agregar Agregar
</button> </button>
</div> </div>
{/* Lista de observaciones agregadas (local) */}
<div style={{marginTop:10,display:"flex",flexDirection:"column",gap:6,maxHeight:180,overflowY:"auto"}}> <div style={{marginTop:10,display:"flex",flexDirection:"column",gap:6,maxHeight:180,overflowY:"auto"}}>
{observaciones.length===0 ? ( {observaciones.length===0 ? (
<div style={{fontSize:12,color:"#94a3b8",padding:"6px 2px"}}>Sin observaciones.</div> <div style={{fontSize:12,color:"#94a3b8",padding:"6px 2px"}}>Sin observaciones.</div>
@@ -976,6 +1283,7 @@ function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
</div> </div>
</div> </div>
{/* Acciones */}
<div style={{display:"flex",justifyContent:"flex-end",alignItems:"center",gap:10,marginTop:8, <div style={{display:"flex",justifyContent:"flex-end",alignItems:"center",gap:10,marginTop:8,
borderTop:"1px solid #f1f5f9",paddingTop:12}}> borderTop:"1px solid #f1f5f9",paddingTop:12}}>
<button onClick={onClose} <button onClick={onClose}
@@ -992,6 +1300,7 @@ function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
</button> </button>
</div> </div>
{/* Mensaje de confirmación (se mantiene visible) */}
{guardado && ( {guardado && (
<div style={{marginTop:12,display:"flex",alignItems:"center",gap:8, <div style={{marginTop:12,display:"flex",alignItems:"center",gap:8,
background:"#ecfdf5",border:"1px solid #a7f3d0",color:"#047857", background:"#ecfdf5",border:"1px solid #a7f3d0",color:"#047857",