diff --git a/frontend/src/pages/Cobranza.jsx b/frontend/src/pages/Cobranza.jsx index 05cedc2..62ee409 100644 --- a/frontend/src/pages/Cobranza.jsx +++ b/frontend/src/pages/Cobranza.jsx @@ -1,5 +1,5 @@ // src/pages/Cobranza.jsx -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useRef, Fragment } from "react"; import { api } from "../lib/api"; import { Loader, ErrorBox, Filters, Select, colorSemaforo } from "../components/UI"; 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 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 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) // ANTERIOR = ámbar suave | MES = verde menta | TOTAL = lila/violeta suave @@ -51,6 +52,9 @@ export default function Cobranza() { const [sectorista, setSectorista] = useState("TODOS"); const [sede, setSede] = 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 [sectoristas, setSectoristas] = useState(["TODOS"]); const [loading, setLoading] = useState(true); @@ -60,6 +64,12 @@ export default function Cobranza() { const [exportando, setExportando] = useState(false); const [alumnos, setAlumnos] = useState([]); // todos los alumnos (1 sola consulta) 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"; // índices según agrupación @@ -69,10 +79,10 @@ export default function Cobranza() { useEffect(() => { let activo = true; - setVistaActual(`cobranza:${ano}|${mes}|${sectorista}|${agrupacion}`); + setVistaActual(`cobranza:${ano}|${mes}|${sectorista}|${agrupacion}|${sedeFiltro}|${progFiltro}|${vencHasta}`); const cargar = () => { setLoading(true); setError(null); - api.cobranza(ano, mes, sectorista, agrupacion) + api.cobranza(ano, mes, sectorista, agrupacion, sedeFiltro, progFiltro, vencHasta) .then((res) => { if (!activo) return; setFilas(res.filas || []); @@ -84,7 +94,7 @@ export default function Cobranza() { cargar(); const off = onRefrescar(cargar); 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 useEffect(() => { @@ -95,6 +105,19 @@ export default function Cobranza() { return () => { activo = false; }; }, [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) const [porRetirar, setPorRetirar] = useState(new Set()); useEffect(() => { @@ -103,6 +126,28 @@ export default function Cobranza() { .catch(() => {}); }, [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 useEffect(() => { if (!esPrograma) return; @@ -118,7 +163,7 @@ export default function Cobranza() { }, [filas, esPrograma]); // 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"); if (esPrograma) { if (sede !== "TODOS") { @@ -128,10 +173,20 @@ export default function Cobranza() { }); } 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; }, [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) const kpis = useMemo(() => { 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 }; }, [datos, IDX]); - const headers = esPrograma - ? ["PROGRAMA","FREC.","N° CUOTA","VENCIMIENTO","CTA X COB. MES ANT.","COB. MES ANT.","% ANT","CTA X COB EN CURSO","COB. EN CURSO","% MES","CTA X COB TOTAL","COB. TOTAL","% TOTAL","SALDO","OPC."] - : ["GRUPO","CTA X COB. MES ANT.","COB. MES ANT.","% ANT","CTA X COB EN CURSO","COB. EN CURSO","% MES","CTA X COB TOTAL","COB. TOTAL","% TOTAL","SALDO","OPC."]; + const esAsesor = agrupacion === "ASESOR"; + // Las 3 columnas extra (FREC/N°CUOTA/VENC) SOLO se muestran en SEDE cuando hay + // 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) - 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); + // Í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) { - if (IDX.ant.includes(ci)) return "cart-ant"; - if (IDX.mes.includes(ci)) return "cart-mes"; - if (IDX.tot.includes(ci)) return "cart-tot"; + if (LAY.ant.includes(ci)) return "cart-ant"; + if (LAY.mes.includes(ci)) return "cart-mes"; + if (LAY.tot.includes(ci)) return "cart-tot"; 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) const alumnosFiltrados = useMemo(() => { 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 (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; - }, [alumnos, esPrograma, sede, frec]); + }, [alumnos, esPrograma, sede, frec, sedeFiltro, progFiltro, progClasif, vencHasta]); function grupoDe(a) { // El programa detallado del alumno, en TODOS los filtros (Sede, Asesor, Programa). @@ -191,6 +328,8 @@ export default function Cobranza() { const base = alumnosFiltrados; 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) => { if (!v || v === "-") return ""; return String(v).trim(); @@ -231,7 +370,7 @@ export default function Cobranza() { const st = cellStyle(c); if ([5,6,8,9,11,12,14].includes(c)) ws[ref] = { v:Number(v)||0, t:"n", z:moneyFmt, ...(st?{s:st}:{}) }; else if ([7,10,13].includes(c)) ws[ref] = (v===null) ? { v:"-", t:"s", ...(st?{s:st}:{}) } : { v:Number(v), t:"n", z:pctFmt, ...(st?{s:st}:{}) }; - else 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() { ({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} /> - + {esPrograma && } + {(agrupacion==="SEDE"||agrupacion==="ASESOR") && {setProgFiltro(v);setExpandidas(new Set());}} />}
+
+ + {calAbierto && { setVencHasta(f); setCalAbierto(false); }} + onClose={()=>setCalAbierto(false)} />} +
- {loading ? : + {loading && !datos.length ? : error ? : - <> - +
+ -
+
- {headers.map((h, i)=>)} + {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 ; + })} - {datos.map((f, ri) => { - // En la tabla principal SIEMPRE son agrupaciones (Sede/Programa/Asesor), - // nunca alumnos individuales → NO aplicar rojo por deuda aquí. - // Regla "-": si % del grupo está vacío, CTA y COB del grupo → "-" - const antVacio = !String(f[IDX.ant[2]]||"").trim() || ["-","—"].includes(String(f[IDX.ant[2]]).trim()); - const mesVacio = !String(f[IDX.mes[2]]||"").trim() || ["-","—"].includes(String(f[IDX.mes[2]]).trim()); - const totVacio = !String(f[IDX.tot[2]]||"").trim() || ["-","—"].includes(String(f[IDX.tot[2]]).trim()); + {datos.map((fRaw, ri) => { + const f = filaLayout(fRaw); // layout normalizado (15 o 12 cols) + const nCols = headers.length - 1; // sin OPC + const sedeKey = String(f[0]).toUpperCase(); + const abiertaSede = !esPrograma && !esAsesor && expandidas.has(sedeKey); + + const renderCeldas = (fila, {indent=false, bg="#fff"}={}) => { + const av = !String(fila[LAY.ant[2]]||"").trim() || ["-","—"].includes(String(fila[LAY.ant[2]]).trim()); + const mv = !String(fila[LAY.mes[2]]||"").trim() || ["-","—"].includes(String(fila[LAY.mes[2]]).trim()); + const tv = !String(fila[LAY.tot[2]]||"").trim() || ["-","—"].includes(String(fila[LAY.tot[2]]).trim()); + 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; + if ((ci===LAY.ant[0]||ci===LAY.ant[1]||ci===LAY.ant[2]) && av) val = "—"; + if ((ci===LAY.mes[0]||ci===LAY.mes[1]||ci===LAY.mes[2]) && mv) val = "—"; + if ((ci===LAY.tot[0]||ci===LAY.tot[1]||ci===LAY.tot[2]) && tv) val = "—"; + const cartCls = cartClase(ci); + 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 ( - - {f.slice(0, headers.length-1).map((v, ci) => { - const isPct = IDX.ant[2]===ci || IDX.mes[2]===ci || IDX.tot[2]===ci; - let val = v; - if ((ci===IDX.ant[0]||ci===IDX.ant[1]||ci===IDX.ant[2]) && antVacio) val = "—"; - if ((ci===IDX.mes[0]||ci===IDX.mes[1]||ci===IDX.mes[2]) && mesVacio) val = "—"; - if ((ci===IDX.tot[0]||ci===IDX.tot[1]||ci===IDX.tot[2]) && totVacio) val = "—"; - const cartCls = cartClase(ci); - const clss = (ci===0?"col-name":"") + (cartCls?` ${cartCls}`:""); - return ( - - ); - })} + + + {renderCeldas(f)} + {abiertaSede && programasDeSede(fRaw[0]).map((fp, pi) => ( + + {renderCeldas(fp, {indent:true, bg:"#f8fafc"})} + + + ))} + ); })} {datos.length > 0 && (() => { - // Fila TOTAL GENERAL — suma de los datos visibles (respeta filtros) - const t = {}; - [IDX.ant[0],IDX.ant[1],IDX.mes[0],IDX.mes[1],IDX.tot[0],IDX.tot[1],IDX.saldo] - .forEach((ci)=>{ t[ci] = datos.reduce((s,f)=>s+toMonto(f[ci]),0); }); + // Fila TOTAL GENERAL — suma de los datos CRUDOS (IDX), render en layout LAY. + const suma = (idx)=> datos.reduce((s,f)=>s+toMonto(f[idx]),0); + const T = { cAnt:suma(IDX.ant[0]), oAnt:suma(IDX.ant[1]), + 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 money = (n)=> "S/ " + n.toLocaleString("es-PE",{maximumFractionDigits:0}); + // valor por columna del layout LAY const cell = (ci) => { if (ci===0) return "TOTAL GENERAL"; - if (ci===IDX.ant[2]) return ratio(t[IDX.ant[1]],t[IDX.ant[0]]); - if (ci===IDX.mes[2]) return ratio(t[IDX.mes[1]],t[IDX.mes[0]]); - if (ci===IDX.tot[2]) return ratio(t[IDX.tot[1]],t[IDX.tot[0]]); - if (t[ci]!==undefined) return "S/ " + t[ci].toLocaleString("es-PE",{maximumFractionDigits:0}); + if (ci===LAY.ant[0]) return money(T.cAnt); + if (ci===LAY.ant[1]) return money(T.oAnt); + if (ci===LAY.ant[2]) return ratio(T.oAnt,T.cAnt); + 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 ( {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 fija = ci < FIJAS_MAIN; + let left = 0; for (let k=0;k + style={{textAlign: ci===0?"left":"center",whiteSpace:"nowrap",...st}}> {isPct ? : val} ); })} - + ); })()}
{h}
{h}
+ {puedeExpandir && ( + 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 ? "−" : "+"} + + )} + {isPct ? (val==="—" ? — : ) : fmtMoneda(val)} +
- {isPct ? (val==="—" ? — : ) : fmtMoneda(val)} -
+ onClick={() => setModal({ grupo: fRaw[0] })}>👁️ Ver
+ +
——
- } +
} {modal && ( setModal(null)} /> + sectorista={sectorista} agrupacion={modal.agrupacion || agrupacion} + progFiltro={progFiltro} sedeFiltro={sedeFiltro} vencHasta={vencHasta} + onClose={() => setModal(null)} /> )} {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 ( +
+ {/* Encabezado (fijo, sin navegación) */} +
+ {MESN[mIdx]} {ano} +
+ {/* Días de la semana */} +
+ {DIAS.map((d)=>
{d}
)} +
+ {/* Celdas */} +
+ {celdas.map((d,i)=>( +
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||""} +
+ ))} +
+ {/* Pie: limpiar filtro */} + {sel && sel!=="TODOS" && ( +
+ +
+ )} +
+ ); +} + +function Tarjetas({ kpis, retiro, loading }) { 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) { return (
{ api.alumnosPorRetirar() @@ -504,9 +783,10 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, 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"]; + // Anchos iniciales — columnas redimensionables manualmente. const anchosBusc = COLS.map((h,i) => { - if (i===0) return 250; - if (i===1) return 250; + if (i===0) return 250; // PROGRAMA + if (i===1) return 250; // ALUMNO if (h==="N° CUOTA" || h==="F. VENC.") return 100; if (h==="INFO") return 70; if (h.startsWith("%")) return 80; @@ -514,6 +794,7 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) { }); const cols = useColumnasAjustables(anchosBusc); + // Congelar las 4 primeras columnas (PROGRAMA, ALUMNO, N° CUOTA, F. VENC.). const FIJAS = 4; const stickyCol = (j, { esHeader = false, bg = "#fff" } = {}) => { if (j >= FIJAS) return {}; @@ -570,7 +851,7 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) { {resultados.map((a,i)=>{ 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 celdas = [ {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 [loading, setLoading] = useState(true); const [porRetirar, setPorRetirar] = useState(new Set()); // matrículas POR RETIRAR const [soloRetirar, setSoloRetirar] = useState(false); // filtro: ver solo POR RETIRAR - const [infoAlumno, setInfoAlumno] = useState(null); // alumno del pop-up INFO useEffect(() => { let activo = 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); } }) .catch(() => { if (activo) { setFilas([]); setLoading(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 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"]; + // 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 const anchosDet = COLS.map((h, i) => i === 0 ? 240 : (i === 1 ? 220 : (h==="INFO" ? 70 : h.startsWith("%") ? 80 : 110))); 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); const ultima = j === FIJAS - 1; 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 { - position: "sticky", left, zIndex: esHeader ? 20 : 10, backgroundColor: bg, + 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)" : ""), + boxShadow: sombraLinea + sombraCorte, }; }; @@ -723,6 +1011,7 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
{/* Etiquetas de conteo de alumnos */}
+ {/* Grupo 1: conteo general */} @@ -738,7 +1027,11 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose }) border:"1px solid #fde68a"}}> ⏳ Por pagar: {conteo.deben} + + {/* Divisor */} + + {/* Grupo 2: POR RETIRAR */} @@ -749,12 +1042,15 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose }) border:"1px solid #fecaca"}}> 💸 Saldo: S/ {retiro.saldo.toLocaleString("es-PE",{maximumFractionDigits:0})} + + {/* Checkbox alineado a la derecha */}
@@ -774,28 +1070,31 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose }) .map((f, i) => { const saldo = toMonto(f[13]); const deuda = saldo > 0.01; + // Regla "-": si %ANT vacío → CTA/COB ANT a "—" (igual MES y TOT) const antVacio = !String(f[6]||"").trim() || ["-","—"].includes(String(f[6]).trim()); const mesVacio = !String(f[9]||"").trim() || ["-","—"].includes(String(f[9]).trim()); const totVacio = !String(f[12]||"").trim() || ["-","—"].includes(String(f[12]).trim()); - const programa = f[14] || "-"; - const retira = porRetirar.has(String(f[0])); + const programa = f[14] || "-"; // programa del alumno (extra del backend) + const retira = porRetirar.has(String(f[0])); // alumno POR RETIRAR return ( {f.slice(0, 14).map((v, j) => { + // Columna 0: mostrar PROGRAMA en vez de la matrícula let val = (j===0) ? programa : v; if ((j===4||j===5||j===6) && antVacio) val = "—"; if ((j===7||j===8||j===9) && mesVacio) val = "—"; if ((j===10||j===11||j===12) && totVacio) val = "—"; const isPct = [6,9,12].includes(j); + const isMonto = String(val).includes("S/"); let cart = ""; if ([4,5,6].includes(j)) cart = "cart-ant"; else if ([7,8,9].includes(j)) cart = "cart-mes"; else if ([10,11,12].includes(j)) cart = "cart-tot"; const clss = (((j===0||j===1)?"col-name":"") + (cart?` ${cart}`:"")).trim(); 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 ( ); })} + {/* Botón INFO por alumno (estado + observaciones) */}
+ {/* Lista de observaciones agregadas (local) */}
{observaciones.length===0 ? (
Sin observaciones.
@@ -976,6 +1283,7 @@ function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
+ {/* Acciones */}
+ {/* Mensaje de confirmación (se mantiene visible) */} {guardado && (