Estructura inicial del frontend

This commit is contained in:
Panchito
2026-06-26 13:07:13 -05:00
commit d6c65c44b1
52 changed files with 10924 additions and 0 deletions

660
src/pages/Cobranza.jsx Normal file
View File

@@ -0,0 +1,660 @@
// 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>
);
}