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

977 lines
46 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 } 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:"PROGRAMA",label:"Por Programa"},{value:"ASESOR",label:"Por Asesor"}];
const SEDES = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"];
const FRECS = ["TODOS","DOM","SAB","NOC","MAN","VIR","TAR"];
// 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");
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 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}`);
const cargar = () => {
setLoading(true); setError(null);
api.cobranza(ano, mes, sectorista, agrupacion)
.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]);
// 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]);
// 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 datos = 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;
}, [filas, esPrograma, sede, frec, sedeMap]);
// 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 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."];
// 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 cols = useColumnasAjustables(anchosCob);
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";
return "";
}
// 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);
}
return arr;
}, [alumnos, esPrograma, sede, frec]);
function grupoDe(a) {
// El programa detallado del alumno, en TODOS los filtros (Sede, Asesor, Programa).
return a.programa;
}
// 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; }
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" };
});
});
// 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");}} />
<Select label="Sectorista" value={sectorista} options={sectoristas} onChange={setSectorista} />
{esPrograma && <Select label="Sede" value={sede} options={SEDES} onChange={setSede} />}
{esPrograma && <Select label="Frecuencia" value={frec} options={FRECS} onChange={setFrec} />}
<div style={{display:"flex",gap:8,alignItems:"flex-end",marginLeft:"auto"}}>
<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 ? <Loader text="Calculando cobranza..." /> :
error ? <ErrorBox msg={error} /> :
<>
<Tarjetas kpis={kpis} />
<div className="table-wrap" style={{marginTop:16, overflowX:"auto"}}>
<table className="cob-table" {...cols.tableProps}>
<cols.ColGroup />
<thead><tr>{headers.map((h, i)=><th key={h} style={{position:"relative"}}>{h}<cols.Resizer index={i} /></th>)}</tr></thead>
<tbody>
{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());
return (
<tr key={ri}>
{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 (
<td key={ci} className={clss.trim()}
style={{textAlign: ci===0?"left":"center",
minWidth: ci===0?(esPrograma?300:200):undefined,
maxWidth: ci===0?500:undefined,
whiteSpace: "nowrap",
overflow: ci===0?"hidden":undefined,
textOverflow: ci===0?"ellipsis":undefined}}>
{isPct ? (val==="—" ? <span style={{color:"#cbd5e1"}}></span> : <MiniBar valStr={val} />) : fmtMoneda(val)}
</td>
);
})}
<td>
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
onClick={() => setModal({ grupo: f[0] })}>👁 Ver</button>
</td>
</tr>
);
})}
{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); });
const ratio = (cob,cta)=> cta>0 ? `${Math.round(cob/cta*100)}%` : "";
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});
return "";
};
return (
<tr className="total-row">
{Array.from({length: headers.length-1}).map((_,ci)=>{
const isPct = IDX.ant[2]===ci || IDX.mes[2]===ci || IDX.tot[2]===ci;
const val = cell(ci);
return (
<td key={ci} className={ci===0?"col-name":""}
style={{textAlign: ci===0?"left":"center",
whiteSpace:"nowrap"}}>
{isPct ? <MiniBar valStr={val} /> : val}
</td>
);
})}
<td></td>
</tr>
);
})()}
</tbody>
</table>
</div>
</>}
{modal && (
<ModalEstadoCuenta grupo={modal.grupo} ano={ano} mes={mes}
sectorista={sectorista} agrupacion={agrupacion} onClose={() => setModal(null)} />
)}
{buscadorAbierto && (
<ModalBuscador
alumnos={alumnosFiltrados}
agrupacion={agrupacion}
sectorista={sectorista}
onClose={() => setBuscadorAbierto(false)}
/>
)}
</div>
);
}
function Tarjetas({ kpis }) {
const alDia = 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],
["📅 MES EN CURSO","#059669",kpis.cMes,kpis.obMes],
["💰 CARTERA TOTAL","#7c3aed",kpis.cTot,kpis.obTot],
];
return (
<div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:14}}>
{cards.map(([titulo,color,cta,cob]) => {
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"} />
{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 }) {
const cards = [
["⚠ MES ANTERIOR","#d97706",r.cAnt,r.obAnt],
["📅 MES EN CURSO","#059669",r.cMes,r.obMes],
["💰 CARTERA TOTAL","#7c3aed",r.cTot,r.obTot],
];
return (
<div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:14}}>
{cards.map(([titulo,color,cta,cob]) => {
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"} />
{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());
const [infoAlumno, setInfoAlumno] = useState(null);
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"];
const anchosBusc = COLS.map((h,i) => {
if (i===0) return 250;
if (i===1) return 250;
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);
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));
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, 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)
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
.catch(() => { if (activo) { setFilas([]); setLoading(false); } });
return () => { activo = false; };
}, [grupo, ano, mes, sectorista, agrupacion]);
// 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"];
// 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";
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)" : ""),
};
};
// 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 suma de su saldo
const retiro = useMemo(() => {
let cant=0, saldo=0;
filas.forEach((f) => {
if (String(f[0]).toUpperCase() === "TOTAL") return;
if (porRetirar.has(String(f[0]))) { cant++; saldo += toMonto(f[13]); }
});
return { cant, saldo };
}, [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} />
</div>
{/* Etiquetas de conteo de alumnos */}
<div style={{display:"flex",gap:10,marginBottom:14,flexWrap:"wrap",alignItems:"center"}}>
<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>
<span style={{width:1,height:26,background:"#e2e8f0",margin:"0 4px"}} />
<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>
<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;
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]));
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) => {
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);
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";
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>
);
})}
<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>
{(() => {
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);
const st = {
position:"sticky", bottom:0,
zIndex: fija ? 15 : 8,
backgroundColor:"#eff6ff",
padding:"11px 8px",
...(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_estado_alumno).
function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
const [estado, setEstado] = useState("EN CURSO");
const [nuevaObs, setNuevaObs] = useState("");
const [observaciones, setObservaciones] = useState([]);
const [cargando, setCargando] = useState(true);
const [guardando, setGuardando] = useState(false);
const [guardado, setGuardado] = useState(false);
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();
} catch {
alert("No se pudo guardar. Intenta de nuevo.");
} finally {
setGuardando(false);
}
};
return (
<Modal title={` Información del alumno`} onClose={onClose} width={560}>
<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>
<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>
<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>
<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>
<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>
{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>
);
}