Files
frontend/src/pages/Cobranza.jsx
2026-06-26 13:07:13 -05:00

661 lines
31 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.
// 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";
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;
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); } });
return () => { activo = false; };
}, [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) {
if (agrupacion === "SEDE") return a.sede;
if (agrupacion === "ASESOR") return ""; // el sectorista ya filtra; no hay sub-grupo por alumno
return a.programa; // 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 "";
const s = String(v).trim().replace(/\//g,"-");
const p = s.split("-");
if (p.length === 3 && p[2].length === 4) return new Date(+p[2], +p[1]-1, +p[0]);
return String(v);
};
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 if (c===4 && v instanceof Date) ws[ref] = { v, t:"d", z:dateFmt };
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 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 = ["GRUPO","MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.",
"CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"];
const pctStr = (cob, cta) => cta > 0 ? `${Math.round(cob/cta*100)}%` : "";
return (
<Modal title="🔍 Buscar Alumno" onClose={onClose} width={1200}>
<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">
<table className="cob-table" style={{minWidth:1100}}>
<thead><tr>{COLS.map((c)=><th key={c}>{c}</th>)}</tr></thead>
<tbody>
{resultados.map((a,i)=>{
const deuda = a.saldo > 0.01;
const grp = agrupacion==="SEDE" ? a.sede : (agrupacion==="ASESOR" ? sectorista : a.programa);
const celdas = [
{v:grp, cls:"col-name", al:"left"},
{v:a.matricula},
{v:a.alumno, cls:"col-name", al:"left"},
{v:a.num_cuota},
{v:a.fch_venc},
{v:fmtMoneda("S/ "+a.cta_ant), al:"right", cart:"cart-ant"},
{v:fmtMoneda("S/ "+a.cob_ant), al:"right", cart:"cart-ant"},
{pct:pctStr(a.cob_ant,a.cta_ant), cart:"cart-ant"},
{v:fmtMoneda("S/ "+a.cta_cur), al:"right", cart:"cart-mes"},
{v:fmtMoneda("S/ "+a.cob_cur), al:"right", cart:"cart-mes"},
{pct:pctStr(a.cob_cur,a.cta_cur), cart:"cart-mes"},
{v:fmtMoneda("S/ "+a.cta_tot), al:"right", cart:"cart-tot"},
{v:fmtMoneda("S/ "+a.cob_tot), al:"right", cart:"cart-tot"},
{pct:pctStr(a.cob_tot,a.cta_tot), cart:"cart-tot"},
{v:fmtMoneda("S/ "+a.saldo), al:"right", saldo:true},
];
return (
<tr key={i} className={deuda?"row-deuda":""}>
{celdas.map((c,j)=>(
<td key={j} className={((c.cls||"")+" "+(c.cart||"")).trim()}
style={{textAlign:c.al||"center",
fontWeight:c.saldo?700:undefined,
color:c.saldo&&deuda?"#dc2626":undefined}}>
{c.pct!==undefined ? (c.pct ? <MiniBar valStr={c.pct} /> : "—") : c.v}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
</Modal>
);
}
function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose }) {
const [filas, setFilas] = useState([]);
const [loading, setLoading] = useState(true);
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]);
const COLS = ["MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"];
// 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]);
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>
<div className="table-wrap">
<table className="cob-table" style={{minWidth:1000}}>
<thead><tr>{COLS.map((c)=><th key={c}>{c}</th>)}</tr></thead>
<tbody>
{filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL").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());
return (
<tr key={i} className={deuda?"row-deuda":""}>
{f.map((v, j) => {
let val = 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===1?"col-name":"") + (cart?` ${cart}`:"")).trim();
return (
<td key={j} className={clss}
style={{textAlign:j===1?"left":(isMonto?"right":"center"),
whiteSpace:"nowrap",
color:j===13&&deuda?"#dc2626":undefined,
fontWeight:j===13?700:undefined}}>
{isPct ? (val==="—"?<span style={{color:"#cbd5e1"}}></span>:<MiniBar valStr={val} />) : fmtMoneda(val)}
</td>
);
})}
</tr>
);
})}
{(() => {
// TOTAL GENERAL del modal — solo alumnos visibles (respeta la sede filtrada)
const alumnos = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL");
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);
return (
<td key={j} className={j===0?"col-name":""}
style={{textAlign:j===0?"left":(String(val).includes("S/")?"right":"center"),whiteSpace:"nowrap"}}>
{isPct?<MiniBar valStr={val} />:val}
</td>
);
})}
</tr>
);
})()}
</tbody>
</table>
</div>
</>}
</Modal>
);
}