// src/pages/Cobranza.jsx 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"; 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 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 const BG_ANT = "#fef3c7", BG_MES = "#d1fae5", BG_TOT = "#ede9fe"; // Versiones más oscuras para hover de cada cartera const HV_ANT = "#fde68a", HV_MES = "#a7f3d0", HV_TOT = "#ddd6fe"; function toMonto(s){ const n = parseFloat(String(s).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; } function toPct(s){ const n = parseFloat(String(s).replace("%","").trim()); return isNaN(n)?0:n; } // Formato moneda SIN decimales (solo visualización) function fmtMoneda(s){ if (s == null) return ""; if (!String(s).includes("S/")) return s; // no es monto, devolver tal cual const n = toMonto(s); return "S/ " + n.toLocaleString("es-PE", { maximumFractionDigits: 0 }); } function MiniBar({ valStr }) { if (!valStr || !String(valStr).includes("%")) return —; const pct = toPct(valStr); const c = colorSemaforo(pct); const w = Math.min(Math.max(pct,0),100); return (
{pct.toFixed(0)}%
); } export default function Cobranza() { const hoy = new Date(); const [ano, setAno] = useState(hoy.getFullYear()); const [mes, setMes] = useState(hoy.getMonth() + 1); const [agrupacion, setAgrupacion] = useState("SEDE"); const [sectorista, setSectorista] = useState("TODOS"); const [sede, setSede] = useState("TODOS"); const [frec, setFrec] = useState("TODOS"); // 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); const [error, setError] = useState(null); const [modal, setModal] = useState(null); const [sedeMap, setSedeMap] = useState({}); // programa -> sede (sede.json) const [exportando, setExportando] = useState(false); const [alumnos, setAlumnos] = useState([]); // todos los alumnos (1 sola consulta) const [buscadorAbierto, setBuscadorAbierto] = useState(false); const [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 const IDX = esPrograma ? { saldo:13, ant:[4,5,6], mes:[7,8,9], tot:[10,11,12] } : { saldo:10, ant:[1,2,3], mes:[4,5,6], tot:[7,8,9] }; useEffect(() => { let activo = true; setVistaActual(`cobranza:${ano}|${mes}|${sectorista}|${agrupacion}|${sedeFiltro}|${progFiltro}|${vencHasta}`); const cargar = () => { setLoading(true); setError(null); api.cobranza(ano, mes, sectorista, agrupacion, sedeFiltro, progFiltro, vencHasta) .then((res) => { if (!activo) return; setFilas(res.filas || []); setSectoristas(res.sectoristas || ["TODOS"]); setLoading(false); }) .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); }; cargar(); const off = onRefrescar(cargar); return () => { activo = false; off(); }; }, [ano, mes, agrupacion, sectorista, sedeFiltro, progFiltro, vencHasta]); // Cargar TODOS los alumnos una sola vez (para exportar y buscar) — 1 consulta cacheada useEffect(() => { let activo = true; api.cobranzaDetalleTodos(ano, mes, sectorista) .then((res) => { if (activo) setAlumnos(res.alumnos || []); }) .catch(() => { if (activo) setAlumnos([]); }); return () => { activo = false; }; }, [ano, mes, sectorista]); // 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(() => { api.alumnosPorRetirar() .then((r) => setPorRetirar(new Set((r.matriculas || []).map(String)))) .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; const nombres = filas .filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL") .map((f) => String(f[0])); if (nombres.length === 0) return; let activo = true; api.clasificarSede(nombres) .then((res) => { if (activo) setSedeMap(res.mapa || {}); }) .catch(() => { if (activo) setSedeMap({}); }); return () => { activo = false; }; }, [filas, esPrograma]); // Filtro sede/frecuencia (solo PROGRAMA) en cliente — usa sede.json const datosCalc = useMemo(() => { let arr = filas.filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL"); if (esPrograma) { if (sede !== "TODOS") { arr = arr.filter((f) => { const s = (sedeMap[String(f[0])] || "LIMA").toUpperCase(); return s === sede.toUpperCase(); }); } if (frec !== "TODOS") arr = arr.filter((f) => String(f[1]).toUpperCase() === frec); return arr; } // 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; datos.forEach((f) => { cAnt += toMonto(f[IDX.ant[0]]); obAnt += toMonto(f[IDX.ant[1]]); cMes += toMonto(f[IDX.mes[0]]); obMes += toMonto(f[IDX.mes[1]]); cTot += toMonto(f[IDX.tot[0]]); obTot += toMonto(f[IDX.tot[1]]); }); return { cAnt,obAnt,cMes,obMes,cTot,obTot, saldo: cTot-obTot }; }, [datos, IDX]); const 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 ? (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 (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; if (esPrograma) { if (sede !== "TODOS") arr = arr.filter((a) => String(a.sede).toUpperCase() === sede.toUpperCase()); if (frec !== "TODOS") arr = arr.filter((a) => String(a.frecuencia).toUpperCase() === frec); } // 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, sedeFiltro, progFiltro, progClasif, vencHasta]); function grupoDe(a) { // El programa detallado del alumno, en TODOS los filtros (Sede, Asesor, Programa). return a.programa; } // Saldo por retirar (ANT/MES/TOTAL) de los alumnos marcados POR RETIRAR (según filtros) const retiroKpi = useMemo(() => { let sAnt=0, sMes=0; alumnosFiltrados.forEach((a) => { if (!porRetirar.has(String(a.matricula))) return; sAnt += ((Number(a.cta_ant)||0) - (Number(a.cob_ant)||0)); sMes += ((Number(a.cta_cur)||0) - (Number(a.cob_cur)||0)); }); return { sAnt, sMes, sTot: sAnt + sMes }; }, [alumnosFiltrados, porRetirar]); // Exportar el DETALLE DE ALUMNOS (rápido: usa el dataset ya cargado, sin N consultas) async function exportarDetalle() { setExportando(true); try { const base = alumnosFiltrados; if (base.length === 0) { alert("No hay alumnos para exportar con los filtros actuales."); return; } // 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(); }; const pct = (cob, cta) => cta > 0 ? cob/cta : null; const XLSX = await import("xlsx-js-style"); const COLS = ["GRUPO","MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"]; // Colores suaves por cartera (igual que el dashboard): ANT ámbar, MES verde, TOT lila const CART = { ant:"FEF3C7", mes:"D1FAE5", tot:"EDE9FE" }; const cartFill = (c) => { if ([5,6,7].includes(c)) return CART.ant; if ([8,9,10].includes(c)) return CART.mes; if ([11,12,13].includes(c)) return CART.tot; return null; }; const headerStyle = { font:{bold:true,color:{rgb:"FFFFFF"},sz:11}, fill:{fgColor:{rgb:"1E40AF"}}, alignment:{horizontal:"center",vertical:"center"} }; const moneyFmt = '"S/" #,##0'; const pctFmt = '0%'; const dateFmt = "dd/mm/yyyy"; const cellStyle = (c) => { const f = cartFill(c); return f ? { fill:{fgColor:{rgb:f}} } : undefined; }; const ws = {}; COLS.forEach((h,c)=>{ ws[XLSX.utils.encode_cell({r:0,c})] = {v:h,t:"s",s:headerStyle}; }); base.forEach((a, ri) => { const r = ri + 1; const vals = [ grupoDe(a), a.matricula, a.alumno, a.num_cuota, toDate(a.fch_venc), a.cta_ant, a.cob_ant, pct(a.cob_ant,a.cta_ant), a.cta_cur, a.cob_cur, pct(a.cob_cur,a.cta_cur), a.cta_tot, a.cob_tot, pct(a.cob_tot,a.cta_tot), a.saldo, ]; vals.forEach((v, c) => { const ref = XLSX.utils.encode_cell({r,c}); const st = cellStyle(c); if ([5,6,8,9,11,12,14].includes(c)) ws[ref] = { v:Number(v)||0, t:"n", z:moneyFmt, ...(st?{s:st}:{}) }; else if ([7,10,13].includes(c)) ws[ref] = (v===null) ? { v:"-", t:"s", ...(st?{s:st}:{}) } : { v:Number(v), t:"n", z:pctFmt, ...(st?{s:st}:{}) }; else ws[ref] = { v: v ?? "", t:"s" }; // F. VENC. (col 4) va como texto tal cual }); }); // TOTAL GENERAL (suma de montos) const rTot = base.length + 1; const totalStyle = { font:{bold:true}, fill:{fgColor:{rgb:"EFF6FF"}} }; const sum = (k) => base.reduce((s,a)=>s+(Number(a[k])||0),0); const totVals = { 5:sum("cta_ant"),6:sum("cob_ant"),8:sum("cta_cur"),9:sum("cob_cur"),11:sum("cta_tot"),12:sum("cob_tot"),14:sum("saldo") }; COLS.forEach((col,c)=>{ const ref = XLSX.utils.encode_cell({r:rTot,c}); if (c===0) ws[ref] = {v:"TOTAL GENERAL",t:"s",s:totalStyle}; else if (totVals[c]!==undefined) ws[ref] = {v:totVals[c],t:"n",z:moneyFmt,s:totalStyle}; else ws[ref] = {v:"",t:"s",s:totalStyle}; }); ws["!ref"] = XLSX.utils.encode_range({ s:{r:0,c:0}, e:{r:rTot,c:COLS.length-1} }); ws["!cols"] = [{wch:26},{wch:12},{wch:28},{wch:9},{wch:12},{wch:12},{wch:12},{wch:8},{wch:12},{wch:12},{wch:8},{wch:12},{wch:12},{wch:8},{wch:13}]; const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, "Cobranza"); const sufijo = agrupacion + (esPrograma && sede!=="TODOS" ? `_${sede}` : "") + (sectorista!=="TODOS" ? `_${sectorista}` : ""); XLSX.writeFile(wb, `cobranza_${sufijo}_${ano}_${mes}.xlsx`); } finally { setExportando(false); } } return (

📋 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 && !datos.length ? : error ? :
{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((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 ( {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 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===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 = 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 {isPct ? : val} ); })} ); })()}
{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)}
—
} {modal && ( setModal(null)} /> )} {buscadorAbierto && ( setBuscadorAbierto(false)} /> )}
); } // 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 = !loading && kpis.saldo >= -0.01 && kpis.saldo <= 0.01; if (alDia) { return (
✅
AL DÍA
Sin deuda pendiente
); } const cards = [ ["⚠ MES ANTERIOR","#d97706",kpis.cAnt,kpis.obAnt, rt.sAnt], ["📅 MES EN CURSO","#059669",kpis.cMes,kpis.obMes, rt.sMes], ["💰 CARTERA TOTAL","#7c3aed",kpis.cTot,kpis.obTot, rt.sTot], ]; return (
{cards.map(([titulo,color,cta,cob,saldoRetiro]) => { const ratio = cta>0 ? (cob/cta*100) : 0; const c = colorSemaforo(ratio); const saldo = cta - cob; return (
{titulo}
0?"#dc2626":"#10b981"} /> {cta>0 && (
% de Pago {ratio.toFixed(0)}%
)}
); })}
); } function Row({ k, v, bold, color }) { return (
{k} {v}
); } function TarjetasResumen({ r, retiro }) { const rt = retiro || { sAnt:0, sMes:0, sTot:0 }; const cards = [ ["⚠ MES ANTERIOR","#d97706",r.cAnt,r.obAnt, rt.sAnt], ["📅 MES EN CURSO","#059669",r.cMes,r.obMes, rt.sMes], ["💰 CARTERA TOTAL","#7c3aed",r.cTot,r.obTot, rt.sTot], ]; return (
{cards.map(([titulo,color,cta,cob,saldoRetiro]) => { const ratio = cta>0 ? (cob/cta*100) : 0; const c = colorSemaforo(ratio); const saldo = cta - cob; return (
{titulo}
0?"#dc2626":"#10b981"} /> {cta>0 && (
% de Pago {ratio.toFixed(0)}%
)}
); })}
); } function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) { const [texto, setTexto] = useState(""); const [query, setQuery] = useState(""); const [porRetirar, setPorRetirar] = useState(new Set()); // matrículas POR RETIRAR const [infoAlumno, setInfoAlumno] = useState(null); // alumno del pop-up INFO const cargarPorRetirar = () => { api.alumnosPorRetirar() .then((r)=>setPorRetirar(new Set((r.matriculas||[]).map(String)))) .catch(()=>{}); }; useEffect(() => { cargarPorRetirar(); }, []); const resultados = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return []; return alumnos.filter((a) => String(a.alumno).toLowerCase().includes(q) || String(a.programa).toLowerCase().includes(q) || String(a.matricula).toLowerCase().includes(q) ); }, [alumnos, query]); const COLS = ["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; // 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; return 110; }); 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 {}; let left = 0; for (let k = 0; k < j; k++) left += (cols.anchos[k] || 0); const ultima = j === FIJAS - 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)" : ""), }; }; const pctStr = (cob, cta) => cta > 0 ? `${Math.round(cob/cta*100)}%` : ""; return (
setTexto(e.target.value)} onKeyDown={(e)=>{ if(e.key==="Enter") setQuery(texto); }} placeholder="Escribe nombre, programa o matrícula (ej: Aro) y presiona Enter" style={{flex:1,padding:"10px 14px",border:"1px solid #cbd5e1",borderRadius:8,fontSize:14}} /> {query && }
{!query ? (
Escribe un texto y presiona Enter para buscar.
) : resultados.length === 0 ? (
Sin coincidencias para "{query}".
) : ( <>
{resultados.length} alumno(s) encontrado(s)
{COLS.map((c,i)=>{ const fija = i < FIJAS; const stH = fija ? stickyCol(i,{esHeader:true,bg:"#334155"}) : { position:"sticky", top:0, zIndex:12, backgroundColor:"#334155" }; return ; })} {resultados.map((a,i)=>{ const deuda = a.saldo > 0.01; 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}, {v:a.alumno, cls:"col-name", al:"left", texto:true}, {v:a.num_cuota}, {v:a.fch_venc}, {v:fmtMoneda("S/ "+a.cta_ant), cart:"cart-ant"}, {v:fmtMoneda("S/ "+a.cob_ant), cart:"cart-ant"}, {pct:pctStr(a.cob_ant,a.cta_ant), cart:"cart-ant"}, {v:fmtMoneda("S/ "+a.cta_cur), cart:"cart-mes"}, {v:fmtMoneda("S/ "+a.cob_cur), cart:"cart-mes"}, {pct:pctStr(a.cob_cur,a.cta_cur), cart:"cart-mes"}, {v:fmtMoneda("S/ "+a.cta_tot), cart:"cart-tot"}, {v:fmtMoneda("S/ "+a.cob_tot), cart:"cart-tot"}, {pct:pctStr(a.cob_tot,a.cta_tot), cart:"cart-tot"}, {v:fmtMoneda("S/ "+a.saldo), saldo:true}, ]; return ( {celdas.map((c,j)=>( ))} ); })}
{c}
{c.pct!==undefined ? (c.pct ? : "—") : c.v}
)} {infoAlumno && ( setInfoAlumno(null)} onGuardado={cargarPorRetirar} /> )}
); } 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 useEffect(() => { let activo = true; setLoading(true); 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, progFiltro, sedeFiltro, vencHasta]); // Cargar (y recargar) las matrículas marcadas POR RETIRAR para tachar sus filas const cargarPorRetirar = () => { api.alumnosPorRetirar() .then((r) => setPorRetirar(new Set((r.matriculas || []).map(String)))) .catch(() => {}); }; useEffect(() => { cargarPorRetirar(); }, []); 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); // 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 {}; let left = 0; 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, borderRight: "none", boxShadow: sombraLinea + sombraCorte, }; }; // Resumen para las 3 tarjetas (siempre visibles) — suma del detalle de alumnos const resumen = useMemo(() => { let cAnt=0,obAnt=0,cMes=0,obMes=0,cTot=0,obTot=0; filas.forEach((f) => { if (String(f[0]).toUpperCase() === "TOTAL") return; cAnt += toMonto(f[4]); obAnt += toMonto(f[5]); cMes += toMonto(f[7]); obMes += toMonto(f[8]); cTot += toMonto(f[10]); obTot += toMonto(f[11]); }); return { cAnt,obAnt,cMes,obMes,cTot,obTot }; }, [filas]); // Conteo de alumnos: total, ya pagaron (saldo 0) y por pagar (saldo > 0) const conteo = useMemo(() => { let total=0, pagaron=0, deben=0; filas.forEach((f) => { if (String(f[0]).toUpperCase() === "TOTAL") return; total++; if (toMonto(f[13]) > 0.01) deben++; else pagaron++; }); return { total, pagaron, deben }; }, [filas]); // POR RETIRAR: cantidad de alumnos marcados y saldos por cartera (ANT, MES, TOTAL) const retiro = useMemo(() => { let cant=0, saldo=0, sAnt=0, sMes=0; filas.forEach((f) => { if (String(f[0]).toUpperCase() === "TOTAL") return; if (porRetirar.has(String(f[0]))) { cant++; saldo += toMonto(f[13]); sAnt += (toMonto(f[4]) - toMonto(f[5])); // CTA ANT - COB ANT sMes += (toMonto(f[7]) - toMonto(f[8])); // CTA MES - COB MES } }); return { cant, saldo, sAnt, sMes, sTot: sAnt + sMes }; }, [filas, porRetirar]); return ( {loading ? : filas.length === 0 ?
Sin datos.
: <>
{/* Etiquetas de conteo de alumnos */}
{/* Grupo 1: conteo general */} 👥 Total: {conteo.total} ✅ Pagaron: {conteo.pagaron} ⏳ Por pagar: {conteo.deben} {/* Divisor */} {/* Grupo 2: POR RETIRAR */} 🚫 Por retirar: {retiro.cant} 💸 Saldo: S/ {retiro.saldo.toLocaleString("es-PE",{maximumFractionDigits:0})} {/* Checkbox alineado a la derecha */}
{COLS.map((c,i)=>{ const fija = i < FIJAS; const stH = fija ? stickyCol(i,{esHeader:true,bg:"#334155"}) : { position:"sticky", top:0, zIndex:12, backgroundColor:"#334155" }; return ; })} {filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL") .filter((f)=> !soloRetirar || porRetirar.has(String(f[0]))) .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] || "-"; // 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"; // par/impar (i base 0) return ( ); })} {/* Botón INFO por alumno (estado + observaciones) */} ); })} {(() => { // TOTAL GENERAL del modal — solo alumnos visibles (respeta sede y filtro POR RETIRAR) const alumnos = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL") .filter((f)=> !soloRetirar || porRetirar.has(String(f[0]))); if (alumnos.length===0) return null; const sum = (j)=>alumnos.reduce((s,f)=>s+toMonto(f[j]),0); const t = {4:sum(4),5:sum(5),7:sum(7),8:sum(8),10:sum(10),11:sum(11),13:sum(13)}; const r = (cob,cta)=>cta>0?`${Math.round(cob/cta*100)}%`:""; const celda = (j)=>{ if (j===0) return "TOTAL GENERAL"; if (j===1||j===2||j===3) return ""; if (j===6) return r(t[5],t[4]); if (j===9) return r(t[8],t[7]); if (j===12) return r(t[11],t[10]); if (t[j]!==undefined) return "S/ "+t[j].toLocaleString("es-PE",{maximumFractionDigits:0}); return ""; }; return ( {Array.from({length:14}).map((_,j)=>{ const isPct=[6,9,12].includes(j); const val=celda(j); const fija = j < FIJAS; let left = 0; for (let k=0;k {isPct?:val} ); })} ); })()}
{c}
{isPct ? (val==="—"?—:) : fmtMoneda(val)}
} {infoAlumno && ( setInfoAlumno(null)} onGuardado={cargarPorRetirar} /> )}
); } // Pop-up de INFO por alumno: cambiar estado (EN CURSO / POR RETIRAR) y observaciones. // Carga y guarda en Supabase (tabla cobranza_alumno_estado). function ModalInfoAlumno({ alumno, onClose, onGuardado }) { const [estado, setEstado] = useState("EN CURSO"); // EN CURSO | POR RETIRAR const [nuevaObs, setNuevaObs] = useState(""); const [observaciones, setObservaciones] = useState([]); const [cargando, setCargando] = useState(true); const [guardando, setGuardando] = useState(false); const [guardado, setGuardado] = useState(false); // Cargar estado/observaciones actuales del alumno al abrir useEffect(() => { let activo = true; api.alumnoEstado(alumno.matricula) .then((r)=>{ if(activo){ setEstado(r.estado||"EN CURSO"); setObservaciones(r.observaciones||[]); } }) .catch(()=>{}) .finally(()=>{ if(activo) setCargando(false); }); return ()=>{ activo=false; }; }, [alumno.matricula]); const agregarObs = () => { const t = nuevaObs.trim(); if (!t) return; const fecha = new Date().toLocaleString("es-PE", { dateStyle:"short", timeStyle:"short" }); setObservaciones((prev)=>[...prev, { texto:t, fecha }]); setNuevaObs(""); setGuardado(false); }; const guardar = async () => { setGuardando(true); setGuardado(false); try { await api.alumnoEstadoGuardar(alumno.matricula, estado, observaciones); setGuardado(true); if (onGuardado) onGuardado(); // refresca el tachado en la tabla } catch { alert("No se pudo guardar. Intenta de nuevo."); } finally { setGuardando(false); } }; return ( {/* Datos del alumno */}
{alumno.alumno}
Matrícula: {alumno.matricula} · {alumno.programa}
{/* Estado */}
{/* Observaciones */}
setNuevaObs(e.target.value)} onKeyDown={(e)=>{ if(e.key==="Enter") agregarObs(); }} placeholder="Escribe una observación…" style={{flex:1,padding:"8px 10px",fontSize:13,borderRadius:8,border:"1px solid #cbd5e1"}} />
{/* Lista de observaciones agregadas (local) */}
{observaciones.length===0 ? (
Sin observaciones.
) : observaciones.map((o,i)=>(
{o.texto}
{o.fecha}
))}
{/* Acciones */}
{/* Mensaje de confirmación (se mantiene visible) */} {guardado && (
✅ Guardado correctamente.
)}
); }