Files
dashboard-completo/frontend/src/pages/Cobranza.jsx

1314 lines
64 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <span style={{color:"#cbd5e1"}}>—</span>;
const pct = toPct(valStr);
const c = colorSemaforo(pct);
const w = Math.min(Math.max(pct,0),100);
return (
<div style={{display:"flex",alignItems:"center",gap:5}}>
<span style={{fontSize:10,fontWeight:700,color:c.fg,minWidth:30,textAlign:"right"}}>{pct.toFixed(0)}%</span>
<div style={{flex:1,background:"#e2e8f0",borderRadius:4,height:5,overflow:"hidden",minWidth:30}}>
<div style={{width:`${w}%`,background:c.fill,height:"100%"}} />
</div>
</div>
);
}
export default function Cobranza() {
const hoy = new Date();
const [ano, setAno] = useState(hoy.getFullYear());
const [mes, setMes] = useState(hoy.getMonth() + 1);
const [agrupacion, setAgrupacion] = useState("SEDE");
const [sectorista, setSectorista] = useState("TODOS");
const [sede, setSede] = useState("TODOS");
const [frec, setFrec] = useState("TODOS");
// 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 (
<div>
<h1 className="page-title">📋 Cobranza</h1>
<Filters>
<Select label="Año" value={ano} options={ANOS} onChange={(v)=>setAno(+v)} />
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} />
<Select label="Agrupación" value={agrupacion} options={AGRUPACIONES} onChange={(v)=>{setAgrupacion(v);setSede("TODOS");setFrec("TODOS");setSedeFiltro("TODOS");setProgFiltro("TODOS");}} />
<Select label="Sectorista" value={sectorista} options={sectoristas} onChange={setSectorista} width={250} />
{esPrograma && <Select label="Sede" value={sede} options={SEDES} onChange={setSede} />}
{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={{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-primary" style={{height:38}} disabled={exportando || !alumnosFiltrados.length} onClick={exportarDetalle}>
{exportando ? "Exportando..." : "⬇️ Exportar"}
</button>
</div>
</Filters>
{loading && !datos.length ? <Loader text="Calculando cobranza..." /> :
error ? <ErrorBox msg={error} /> :
<div style={{opacity: loading ? 0.45 : 1, pointerEvents: loading ? "none" : "auto", transition:"opacity .15s"}}>
<Tarjetas kpis={kpis} retiro={retiroKpi} loading={loading} />
<div className="table-wrap" style={{marginTop:16, overflow:"auto", maxHeight:"60vh"}}>
<table className="cob-table" {...cols.tableProps}>
<cols.ColGroup />
<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>
{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 (
<td key={ci} className={clss.trim()}
style={{textAlign: ci===0?"left":"center",
whiteSpace: "nowrap",
paddingLeft: (ci===0 && indent)?28:undefined,
overflow: ci===0?"hidden":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)}
</td>
);
});
};
return (
<Fragment key={ri}>
<tr>
{renderCeldas(f)}
<td>
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
onClick={() => setModal({ grupo: fRaw[0] })}>👁️ Ver</button>
</td>
</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 && (() => {
// 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 (
<tr className="total-row">
{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<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 (
<td key={ci} className={ci===0?"col-name":""}
style={{textAlign: ci===0?"left":"center",whiteSpace:"nowrap",...st}}>
{isPct ? <MiniBar valStr={val} /> : val}
</td>
);
})}
<td style={{position:"sticky",bottom:0,zIndex:9,backgroundColor:"#eff6ff",boxShadow:"inset 0 1px 0 0 #cbd5e1"}}>—</td>
</tr>
);
})()}
</tbody>
</table>
</div>
</div>}
{modal && (
<ModalEstadoCuenta grupo={modal.grupo} ano={ano} mes={mes}
sectorista={sectorista} agrupacion={modal.agrupacion || agrupacion}
progFiltro={progFiltro} sedeFiltro={sedeFiltro} vencHasta={vencHasta}
onClose={() => setModal(null)} />
)}
{buscadorAbierto && (
<ModalBuscador
alumnos={alumnosFiltrados}
agrupacion={agrupacion}
sectorista={sectorista}
onClose={() => setBuscadorAbierto(false)}
/>
)}
</div>
);
}
// 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 alDia = !loading && kpis.saldo >= -0.01 && kpis.saldo <= 0.01;
if (alDia) {
return (
<div style={{background:"linear-gradient(135deg,#d1fae5,#a7f3d0)",border:"2px solid #10b981",
borderRadius:14,padding:18,textAlign:"center"}}>
<div style={{fontSize:32}}>✅</div>
<div style={{fontSize:18,fontWeight:800,color:"#065f46",letterSpacing:1}}>AL DÍA</div>
<div style={{fontSize:12,color:"#047857"}}>Sin deuda pendiente</div>
</div>
);
}
const cards = [
["⚠ MES ANTERIOR","#d97706",kpis.cAnt,kpis.obAnt, rt.sAnt],
["📅 MES EN CURSO","#059669",kpis.cMes,kpis.obMes, rt.sMes],
["💰 CARTERA TOTAL","#7c3aed",kpis.cTot,kpis.obTot, rt.sTot],
];
return (
<div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:14}}>
{cards.map(([titulo,color,cta,cob,saldoRetiro]) => {
const ratio = cta>0 ? (cob/cta*100) : 0;
const c = colorSemaforo(ratio);
const saldo = cta - cob;
return (
<div key={titulo} style={{background:"#fff",border:"1px solid #e2e8f0",borderRadius:12,overflow:"hidden"}}>
<div style={{background:color,color:"#fff",padding:"10px 14px",fontWeight:700,fontSize:13,textAlign:"center"}}>{titulo}</div>
<div style={{padding:14}}>
<Row k="Cta x Cobrar" v={`S/ ${cta.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
<Row k="Cobrado" v={`S/ ${cob.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
<Row k="Saldo" v={`S/ ${saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}`} bold color={saldo>0?"#dc2626":"#10b981"} />
<Row k="Saldo por Retirar" v={`S/ ${(saldoRetiro||0).toLocaleString("es-PE",{maximumFractionDigits:0})}`} color="#dc2626" />
{cta>0 && (
<div style={{marginTop:10}}>
<div style={{display:"flex",justifyContent:"space-between",marginBottom:4}}>
<span style={{fontSize:10,fontWeight:600,color:"#64748b",textTransform:"uppercase"}}>% de Pago</span>
<span style={{fontSize:14,fontWeight:700,color:c.fg}}>{ratio.toFixed(0)}%</span>
</div>
<div style={{background:"#e2e8f0",borderRadius:6,height:8,overflow:"hidden"}}>
<div style={{width:`${Math.min(ratio,100)}%`,background:c.fill,height:"100%"}} />
</div>
</div>
)}
</div>
</div>
);
})}
</div>
);
}
function Row({ k, v, bold, color }) {
return (
<div style={{display:"flex",justifyContent:"space-between",fontSize:11,color:"#64748b",marginBottom:4,
...(bold?{paddingTop:6,borderTop:"1px solid #f1f5f9"}:{})}}>
<span>{k}</span>
<span style={{fontWeight:bold?700:600,color:color||"#0f172a"}}>{v}</span>
</div>
);
}
function TarjetasResumen({ r, 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 (
<div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:14}}>
{cards.map(([titulo,color,cta,cob,saldoRetiro]) => {
const ratio = cta>0 ? (cob/cta*100) : 0;
const c = colorSemaforo(ratio);
const saldo = cta - cob;
return (
<div key={titulo} style={{background:"#fff",border:"1px solid #e2e8f0",borderRadius:12,overflow:"hidden"}}>
<div style={{background:color,color:"#fff",padding:"9px 12px",fontWeight:700,fontSize:12,textAlign:"center"}}>{titulo}</div>
<div style={{padding:12}}>
<Row k="Cta x Cobrar" v={`S/ ${cta.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
<Row k="Cobrado" v={`S/ ${cob.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
<Row k="Saldo" v={`S/ ${saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}`} bold color={saldo>0?"#dc2626":"#10b981"} />
<Row k="Saldo por Retirar" v={`S/ ${(saldoRetiro||0).toLocaleString("es-PE",{maximumFractionDigits:0})}`} color="#dc2626" />
{cta>0 && (
<div style={{marginTop:8}}>
<div style={{display:"flex",justifyContent:"space-between",marginBottom:4}}>
<span style={{fontSize:10,fontWeight:600,color:"#64748b",textTransform:"uppercase"}}>% de Pago</span>
<span style={{fontSize:13,fontWeight:700,color:c.fg}}>{ratio.toFixed(0)}%</span>
</div>
<div style={{background:"#e2e8f0",borderRadius:6,height:7,overflow:"hidden"}}>
<div style={{width:`${Math.min(ratio,100)}%`,background:c.fill,height:"100%"}} />
</div>
</div>
)}
</div>
</div>
);
})}
</div>
);
}
function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
const [texto, setTexto] = useState("");
const [query, setQuery] = useState("");
const [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 (
<Modal title="🔍 Buscar Alumno" onClose={onClose} width={1360}>
<div style={{display:"flex",gap:8,marginBottom:16}}>
<input
autoFocus
value={texto}
onChange={(e)=>setTexto(e.target.value)}
onKeyDown={(e)=>{ if(e.key==="Enter") setQuery(texto); }}
placeholder="Escribe nombre, programa o matrícula (ej: Aro) y presiona Enter"
style={{flex:1,padding:"10px 14px",border:"1px solid #cbd5e1",borderRadius:8,fontSize:14}}
/>
<button className="btn btn-primary" onClick={()=>setQuery(texto)}>🔍 Buscar</button>
{query && <button className="btn btn-ghost" onClick={()=>{setTexto("");setQuery("");}}>Limpiar</button>}
</div>
{!query ? (
<div style={{color:"#94a3b8",padding:30,textAlign:"center",fontSize:14}}>
Escribe un texto y presiona Enter para buscar.
</div>
) : resultados.length === 0 ? (
<div style={{color:"#94a3b8",padding:30,textAlign:"center",fontSize:14}}>
Sin coincidencias para "{query}".
</div>
) : (
<>
<div style={{fontSize:13,color:"#1e40af",fontWeight:600,marginBottom:8}}>
{resultados.length} alumno(s) encontrado(s)
</div>
<div className="table-wrap" style={{overflow:"auto", maxHeight:"60vh"}}>
<table className="cob-table" {...cols.tableProps}>
<cols.ColGroup />
<thead><tr>{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 <th key={c} style={{...stH, position:stH.position||"relative"}}>{c}<cols.Resizer index={i} /></th>;
})}</tr></thead>
<tbody>
{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 (
<tr key={i} className={deuda?"row-deuda":""}
style={retira ? { textDecoration:"line-through", textDecorationColor:"#dc2626",
textDecorationThickness:"2px" } : undefined}>
{celdas.map((c,j)=>(
<td key={j} className={((c.cls||"")+" "+(c.cart||"")).trim()}
title={c.texto ? String(c.v) : undefined}
style={{textAlign:c.al||"center",
fontWeight:c.saldo?700:undefined,
color:c.saldo&&deuda?"#dc2626":undefined,
whiteSpace:"nowrap",
overflow:c.texto?"hidden":undefined,
textOverflow:c.texto?"ellipsis":undefined,
...stickyCol(j,{bg:bgFila})}}>
{c.pct!==undefined ? (c.pct ? <MiniBar valStr={c.pct} /> : "—") : c.v}
</td>
))}
<td style={{textAlign:"center"}}>
<button
onClick={()=>setInfoAlumno({ matricula:a.matricula, alumno:a.alumno, programa:a.programa })}
style={{padding:"3px 10px",fontSize:11,fontWeight:700,cursor:"pointer",
border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#334155"}}
title="Ver/editar estado y observaciones">
INFO
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
{infoAlumno && (
<ModalInfoAlumno alumno={infoAlumno} onClose={()=>setInfoAlumno(null)}
onGuardado={cargarPorRetirar} />
)}
</Modal>
);
}
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 (
<Modal title={`📊 Estado de Cuenta — ${grupo}`} onClose={onClose} width={1500}>
{loading ? <Loader text="Cargando estado de cuenta..." /> :
filas.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin datos.</div> :
<>
<div style={{marginBottom:16}}>
<TarjetasResumen r={resumen} retiro={retiro} />
</div>
{/* Etiquetas de conteo de alumnos */}
<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",
borderRadius:999,background:"#eff6ff",color:"#1e40af",fontSize:13,fontWeight:700,
border:"1px solid #bfdbfe"}}>
👥 Total: {conteo.total}
</span>
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#ecfdf5",color:"#047857",fontSize:13,fontWeight:700,
border:"1px solid #a7f3d0"}}>
✅ Pagaron: {conteo.pagaron}
</span>
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#fffbeb",color:"#b45309",fontSize:13,fontWeight:700,
border:"1px solid #fde68a"}}>
⏳ Por pagar: {conteo.deben}
</span>
{/* Divisor */}
<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",
borderRadius:999,background:"#fef2f2",color:"#dc2626",fontSize:13,fontWeight:700,
border:"1px solid #fecaca"}}>
🚫 Por retirar: {retiro.cant}
</span>
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#fef2f2",color:"#dc2626",fontSize:13,fontWeight:700,
border:"1px solid #fecaca"}}>
💸 Saldo: S/ {retiro.saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}
</span>
{/* Checkbox alineado a la derecha */}
<label style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:soloRetirar?"#fee2e2":"#f8fafc",marginLeft:"auto",
color:soloRetirar?"#b91c1c":"#64748b",fontSize:13,fontWeight:700,cursor:"pointer",
border:`1px solid ${soloRetirar?"#fca5a5":"#e2e8f0"}`}}>
<input type="checkbox" checked={soloRetirar}
onChange={(e)=>setSoloRetirar(e.target.checked)}
style={{cursor:"pointer"}} />
Ver solo por retirar
</label>
</div>
<div className="table-wrap" style={{overflow:"auto", maxHeight:"60vh"}}>
<table className="cob-table" {...cols.tableProps}>
<cols.ColGroup />
<thead><tr>{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 <th key={c} style={{...stH, position:stH.position||"relative"}}>{c}<cols.Resizer index={i} /></th>;
})}</tr></thead>
<tbody>
{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 (
<tr key={i} className={deuda?"row-deuda":""}
style={retira ? { textDecoration:"line-through", textDecorationColor:"#dc2626",
textDecorationThickness:"2px" } : undefined}>
{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 (
<td key={j} className={clss}
title={esTexto ? String(val) : undefined}
style={{textAlign:esTexto?"left":"center",
whiteSpace:"nowrap",
overflow:esTexto?"hidden":undefined,
textOverflow:esTexto?"ellipsis":undefined,
color:j===13&&deuda?"#dc2626":undefined,
fontWeight:j===13?700:undefined,
...stickyCol(j,{bg:bgFila})}}>
{isPct ? (val==="—"?<span style={{color:"#cbd5e1"}}>—</span>:<MiniBar valStr={val} />) : fmtMoneda(val)}
</td>
);
})}
{/* Botón INFO por alumno (estado + observaciones) */}
<td style={{textAlign:"center"}}>
<button
onClick={()=>setInfoAlumno({ matricula:f[0], alumno:f[1], programa:programa })}
style={{padding:"3px 10px",fontSize:11,fontWeight:700,cursor:"pointer",
border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#334155"}}
title="Ver/editar estado y observaciones">
INFO
</button>
</td>
</tr>
);
})}
</tbody>
<tfoot>
{(() => {
// 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 (
<tr className="total-row">
{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<j;k++) left += (cols.anchos[k]||0);
// Sticky abajo (bottom:0) SIEMPRE; y las 4 primeras tambien a la izquierda.
const st = {
position:"sticky", bottom:0,
zIndex: fija ? 15 : 8,
backgroundColor:"#eff6ff",
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" }),
};
return (
<td key={j} className={j===0?"col-name":""}
style={{textAlign:j===0?"left":"center",whiteSpace:"nowrap",...st}}>
{isPct?<MiniBar valStr={val} />:val}
</td>
);
})}
<td style={{position:"sticky",bottom:0,zIndex:8,backgroundColor:"#eff6ff",boxShadow:"inset 0 1px 0 0 #cbd5e1"}}></td>
</tr>
);
})()}
</tfoot>
</table>
</div>
</>}
{infoAlumno && (
<ModalInfoAlumno alumno={infoAlumno} onClose={()=>setInfoAlumno(null)}
onGuardado={cargarPorRetirar} />
)}
</Modal>
);
}
// 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 (
<Modal title={`ℹ️ Información del alumno`} onClose={onClose} width={560}>
{/* Datos del alumno */}
<div style={{marginBottom:16}}>
<div style={{fontSize:15,fontWeight:800,color:"#0f172a"}}>{alumno.alumno}</div>
<div style={{fontSize:12,color:"#64748b",marginTop:2}}>
Matrícula: {alumno.matricula} · {alumno.programa}
</div>
</div>
{/* Estado */}
<div style={{marginBottom:18}}>
<label style={{fontSize:12,fontWeight:700,color:"#334155",display:"block",marginBottom:6}}>
Estado del alumno
</label>
<select value={estado} onChange={(e)=>{setEstado(e.target.value); setGuardado(false);}}
style={{width:"100%",padding:"8px 10px",fontSize:14,borderRadius:8,
border:"1px solid #cbd5e1",background:"#fff",
color: estado==="POR RETIRAR" ? "#b91c1c" : "#047857", fontWeight:700}}>
<option value="EN CURSO">EN CURSO</option>
<option value="POR RETIRAR">POR RETIRAR</option>
</select>
</div>
{/* Observaciones */}
<div style={{marginBottom:14}}>
<label style={{fontSize:12,fontWeight:700,color:"#334155",display:"block",marginBottom:6}}>
Observaciones
</label>
<div style={{display:"flex",gap:8}}>
<input value={nuevaObs} onChange={(e)=>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"}} />
<button onClick={agregarObs}
style={{padding:"8px 14px",fontSize:13,fontWeight:700,cursor:"pointer",
border:"none",borderRadius:8,background:"#1e40af",color:"#fff"}}>
Agregar
</button>
</div>
{/* Lista de observaciones agregadas (local) */}
<div style={{marginTop:10,display:"flex",flexDirection:"column",gap:6,maxHeight:180,overflowY:"auto"}}>
{observaciones.length===0 ? (
<div style={{fontSize:12,color:"#94a3b8",padding:"6px 2px"}}>Sin observaciones.</div>
) : observaciones.map((o,i)=>(
<div key={i} style={{background:"#f8fafc",border:"1px solid #eef2f7",borderRadius:8,
padding:"8px 10px",display:"flex",alignItems:"flex-start",gap:8}}>
<div style={{flex:1}}>
<div style={{fontSize:13,color:"#334155"}}>{o.texto}</div>
<div style={{fontSize:10,color:"#94a3b8",marginTop:2}}>{o.fecha}</div>
</div>
<button onClick={()=>{ setObservaciones(prev=>prev.filter((_,k)=>k!==i)); setGuardado(false); }}
title="Eliminar observación (se aplica al guardar)"
style={{border:"none",background:"transparent",color:"#dc2626",cursor:"pointer",
fontSize:16,fontWeight:800,lineHeight:1,padding:"0 2px"}}>
×
</button>
</div>
))}
</div>
</div>
{/* Acciones */}
<div style={{display:"flex",justifyContent:"flex-end",alignItems:"center",gap:10,marginTop:8,
borderTop:"1px solid #f1f5f9",paddingTop:12}}>
<button onClick={onClose}
style={{padding:"8px 16px",fontSize:13,fontWeight:700,cursor:"pointer",
border:"1px solid #cbd5e1",borderRadius:8,background:"#fff",color:"#334155"}}>
Cerrar
</button>
<button onClick={guardar} disabled={guardando||cargando}
style={{padding:"8px 18px",fontSize:13,fontWeight:700,
cursor:(guardando||cargando)?"not-allowed":"pointer",
border:"none",borderRadius:8,background:"#1e40af",color:"#fff",
opacity:(guardando||cargando)?.7:1}}>
{guardando ? "Guardando…" : "Guardar"}
</button>
</div>
{/* Mensaje de confirmación (se mantiene visible) */}
{guardado && (
<div style={{marginTop:12,display:"flex",alignItems:"center",gap:8,
background:"#ecfdf5",border:"1px solid #a7f3d0",color:"#047857",
borderRadius:8,padding:"10px 14px",fontSize:13,fontWeight:700}}>
<span style={{fontSize:16}}>✅</span> Guardado correctamente.
</div>
)}
</Modal>
);
}