Actualizar frontend/src/pages/Cobranza.jsx

This commit is contained in:
2026-10-06 14:44:27 -05:00
parent da70bc461a
commit 3fd0f0e614

View File

@@ -484,63 +484,121 @@ export default function Cobranza() {
}; };
const pct = (cob, cta) => cta > 0 ? cob/cta : null; const pct = (cob, cta) => cta > 0 ? cob/cta : null;
const XLSX = await import("xlsx-js-style"); const ExcelJS = (await import("exceljs")).default || (await import("exceljs"));
const wb = new ExcelJS.Workbook();
// ── HOJA 1: "Cobranza" (alumnos de la tabla principal) ──
const ws = wb.addWorksheet("Cobranza");
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 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"];
ws.columns = [{width:26},{width:12},{width:28},{width:9},{width:12},{width:12},{width:12},{width:8},{width:12},{width:12},{width:8},{width:12},{width:12},{width:8},{width:13}].map((c,i)=>({header:COLS[i],width:c.width}));
// Colores suaves por cartera (igual que el dashboard): ANT ámbar, MES verde, TOT lila // estilo cabecera
const CART = { ant:"FEF3C7", mes:"D1FAE5", tot:"EDE9FE" }; ws.getRow(1).eachCell((cell)=>{ cell.font={bold:true,color:{argb:"FFFFFFFF"}}; cell.fill={type:"pattern",pattern:"solid",fgColor:{argb:"FF1E40AF"}}; cell.alignment={horizontal:"center",vertical:"middle"}; });
const cartFill = (c) => { const CART = { ant:"FFFEF3C7", mes:"FFD1FAE5", tot:"FFEDE9FE" };
if ([5,6,7].includes(c)) return CART.ant; const cartFill = (c0) => { if ([5,6,7].includes(c0)) return CART.ant; if ([8,9,10].includes(c0)) return CART.mes; if ([11,12,13].includes(c0)) return CART.tot; return null; };
if ([8,9,10].includes(c)) return CART.mes; base.forEach((a)=>{
if ([11,12,13].includes(c)) return CART.tot; const row = ws.addRow([
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), 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), Number(a.cta_ant)||0, Number(a.cob_ant)||0, pct(a.cob_ant,a.cta_ant),
a.cta_cur, a.cob_cur, pct(a.cob_cur,a.cta_cur), Number(a.cta_cur)||0, Number(a.cob_cur)||0, pct(a.cob_cur,a.cta_cur),
a.cta_tot, a.cob_tot, pct(a.cob_tot,a.cta_tot), Number(a.cta_tot)||0, Number(a.cob_tot)||0, pct(a.cob_tot,a.cta_tot),
a.saldo, Number(a.saldo)||0,
]);
row.eachCell((cell,cn)=>{
const c0 = cn-1;
const f = cartFill(c0); if (f) cell.fill={type:"pattern",pattern:"solid",fgColor:{argb:f}};
if ([5,6,8,9,11,12,14].includes(c0)) cell.numFmt='"S/" #,##0';
else if ([7,10,13].includes(c0)) { if (cell.value===null) cell.value="-"; else cell.numFmt='0%'; }
});
});
// TOTAL GENERAL
const sum = (k)=>base.reduce((s,a)=>s+(Number(a[k])||0),0);
const totRow = ws.addRow(["TOTAL GENERAL","","","","",sum("cta_ant"),sum("cob_ant"),"",sum("cta_cur"),sum("cob_cur"),"",sum("cta_tot"),sum("cob_tot"),"",sum("saldo")]);
totRow.eachCell((cell,cn)=>{ cell.font={bold:true}; cell.fill={type:"pattern",pattern:"solid",fgColor:{argb:"FFEFF6FF"}}; if ([6,7,9,10,12,13,15].includes(cn)) cell.numFmt='"S/" #,##0'; });
// ── Capturar el gráfico (SVG) como PNG base64 ──
let imgG = null;
try {
const svgEl = document.getElementById("grafico-vencimiento-svg");
if (svgEl) {
const clone = svgEl.cloneNode(true);
clone.setAttribute("xmlns","http://www.w3.org/2000/svg");
const vb = (svgEl.getAttribute("viewBox")||"0 0 860 300").split(" ").map(Number);
const w = vb[2]||860, h = vb[3]||300;
const bg = document.createElementNS("http://www.w3.org/2000/svg","rect");
bg.setAttribute("x",0); bg.setAttribute("y",0); bg.setAttribute("width",w); bg.setAttribute("height",h); bg.setAttribute("fill","#ffffff");
clone.insertBefore(bg, clone.firstChild);
const xml = new XMLSerializer().serializeToString(clone);
const svg64 = "data:image/svg+xml;base64,"+btoa(unescape(encodeURIComponent(xml)));
const esc = 2;
const png = await new Promise((resolve)=>{
const im = new Image();
im.onload = ()=>{ const cv=document.createElement("canvas"); cv.width=w*esc; cv.height=h*esc; const cx=cv.getContext("2d"); cx.fillStyle="#fff"; cx.fillRect(0,0,cv.width,cv.height); cx.drawImage(im,0,0,cv.width,cv.height); resolve(cv.toDataURL("image/png")); };
im.onerror = ()=>resolve(null); im.src = svg64;
});
if (png) imgG = { png, w, h };
}
} catch(e){ console.error("capturar grafico:", e); }
// ── HOJA 2: "Grafico" — imagen del gráfico + tabla de los 5 grupos ──
const ws2 = wb.addWorksheet("Grafico");
const filasG = esPrograma ? datosCalc : filasPrograma;
const IXg = { venc:3, ctaAnt:4, cobAnt:5, ctaCur:7, cobCur:8, saldo:13, saldoRet:14 };
const finM = new Date(Number(ano), Number(mes), 0).getDate();
const GRP = [
{ label:"Vencido (mes anterior)", vencido:true },
{ label:"1 – 10", lo:1, hi:10 },
{ label:"11 – 20", lo:11, hi:20 },
{ label:"21 – 25", lo:21, hi:25 },
{ label:`26 – ${finM}`, lo:26, hi:finM },
]; ];
vals.forEach((v, c) => { const toM = (v)=>{ const n=parseFloat(String(v??"").replace(/S\/|,|\s/g,"")); return isNaN(n)?0:n; };
const ref = XLSX.utils.encode_cell({r,c}); const diaV = (s)=>{ const p=String(s||"").split("/").map(Number); return (p.length===3&&p[0])?p[0]:null; };
const st = cellStyle(c); const G = GRP.map(()=>({cta:0,cob:0,ret:0,dif:0,n:0}));
if ([5,6,8,9,11,12,14].includes(c)) ws[ref] = { v:Number(v)||0, t:"n", z:moneyFmt, ...(st?{s:st}:{}) }; (filasG||[]).forEach((f)=>{
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}:{}) }; if (!Array.isArray(f) || String(f[0]).toUpperCase().includes("TOTAL")) return;
else ws[ref] = { v: v ?? "", t:"s" }; // F. VENC. (col 4) va como texto tal cual const ctaAnt=toM(f[IXg.ctaAnt]), cobAnt=toM(f[IXg.cobAnt]);
const ctaCur=toM(f[IXg.ctaCur]), cobCur=toM(f[IXg.cobCur]);
const saldo=toM(f[IXg.saldo]), saldoRet=toM(f[IXg.saldoRet]);
const retT=saldo-saldoRet, difT=saldoRet;
if (ctaAnt!==0||cobAnt!==0){ G[0].cta+=ctaAnt; G[0].cob+=cobAnt; G[0].n+=1; }
let gi=-1;
if (ctaCur!==0||cobCur!==0){ const d=diaV(f[IXg.venc]); gi=d!=null?GRP.findIndex((g)=>!g.vencido&&d>=g.lo&&d<=g.hi):-1; if(gi<0)gi=GRP.length-1; G[gi].cta+=ctaCur; G[gi].cob+=cobCur; G[gi].n+=1; }
const totCta=(ctaAnt+ctaCur)||1, propC=ctaCur/totCta;
const retC=retT*propC, difC=difT*propC, retA=retT-retC, difA=difT-difC;
if (ctaCur!==0||cobCur!==0){ G[gi].ret+=retC; G[gi].dif+=difC; G[0].ret+=retA; G[0].dif+=difA; }
else { G[0].ret+=retT; G[0].dif+=difT; }
}); });
// Insertar imagen (si se capturó), dejando filas arriba para ella
let filaTabla = 1;
if (imgG) {
const imgId = wb.addImage({ base64: imgG.png, extension: "png" });
// ancho ~ 820px → ~14 columnas; alto ~ 300px → ~15 filas
ws2.addImage(imgId, { tl:{col:0,row:0}, ext:{ width: imgG.w, height: imgG.h } });
filaTabla = Math.ceil(imgG.h / 20) + 2; // tabla debajo de la imagen
}
ws2.columns = [{width:24},{width:9},{width:20},{width:18},{width:14},{width:14}];
const H2 = ["RANGO","CUOTAS","CTA x COB EN CURSO","COBRADO EN CURSO","RETIROS","DIFERENCIA"];
const hr = ws2.getRow(filaTabla);
H2.forEach((h,i)=>{ const cell=hr.getCell(i+1); cell.value=h; cell.font={bold:true,color:{argb:"FFFFFFFF"}}; cell.fill={type:"pattern",pattern:"solid",fgColor:{argb:"FF1E3A5F"}}; cell.alignment={horizontal:"center"}; });
G.forEach((d,ri)=>{
const r = ws2.getRow(filaTabla+1+ri);
r.getCell(1).value=GRP[ri].label; r.getCell(1).font={bold:true};
r.getCell(2).value=d.n;
[["3",d.cta],["4",d.cob],["5",d.ret],["6",d.dif]].forEach(([c,v])=>{ const cl=r.getCell(Number(c)); cl.value=Math.round(v); cl.numFmt='"S/" #,##0'; });
}); });
const rt = ws2.getRow(filaTabla+1+G.length);
rt.getCell(1).value="TOTAL"; rt.getCell(2).value=G.reduce((a,d)=>a+d.n,0);
[["3","cta"],["4","cob"],["5","ret"],["6","dif"]].forEach(([c,k])=>{ const cl=rt.getCell(Number(c)); cl.value=Math.round(G.reduce((a,d)=>a+d[k],0)); cl.numFmt='"S/" #,##0'; });
rt.eachCell((cell)=>{ cell.font={bold:true}; cell.fill={type:"pattern",pattern:"solid",fgColor:{argb:"FFDBEAFE"}}; });
// TOTAL GENERAL (suma de montos) // Descargar
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}` : ""); const sufijo = agrupacion + (esPrograma && sede!=="TODOS" ? `_${sede}` : "") + (sectorista!=="TODOS" ? `_${sectorista}` : "");
XLSX.writeFile(wb, `cobranza_${sufijo}_${ano}_${mes}.xlsx`); const buf = await wb.xlsx.writeBuffer();
const blob = new Blob([buf], { type:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url; link.download = `cobranza_${sufijo}_${ano}_${mes}.xlsx`;
link.click(); URL.revokeObjectURL(url);
} finally { } finally {
setExportando(false); setExportando(false);
} }
@@ -776,8 +834,8 @@ function GraficoVencimientos({ filas, ano, mes }) {
const SERIES = [ const SERIES = [
{ k: "cta", label: "CTA x COB en curso", color: "#64748b" }, // gris azulado { k: "cta", label: "CTA x COB en curso", color: "#64748b" }, // gris azulado
{ k: "cob", label: "Cobrado en curso", color: "#5b8a72" }, // verde apagado { k: "cob", label: "Cobrado en curso", color: "#5b8a72" }, // verde apagado
{ k: "dif", label: "Retiros", color: "#9a93bf" }, // 3ª barra { k: "dif", label: "Retiros", color: "#e08989" }, // 3ª barra · Retiros · rojo suave
{ k: "ret", label: "Diferencia", color: "#b98a8a" }, // 4ª barra { k: "ret", label: "Diferencia", color: "#a594c9" }, // 4ª barra · Diferencia · morado suave
]; ];
const MESES_N = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; const MESES_N = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
@@ -882,7 +940,7 @@ function GraficoVencimientos({ filas, ano, mes }) {
}; };
const techo = nice(maxVal); const techo = nice(maxVal);
const W = 860, H = 300, padL = 56, padR = 20, padT = 14, padB = 46; const W = 860, H = 320, padL = 56, padR = 20, padT = 36, padB = 46;
const plotW = W - padL - padR, plotH = H - padT - padB; const plotW = W - padL - padR, plotH = H - padT - padB;
const gW = plotW / GRUPOS.length; const gW = plotW / GRUPOS.length;
const bW = (gW * 0.56) / SERIES.length; const bW = (gW * 0.56) / SERIES.length;
@@ -890,6 +948,12 @@ function GraficoVencimientos({ filas, ano, mes }) {
const yScale = (v) => (Math.abs(v) / techo) * plotH; const yScale = (v) => (Math.abs(v) / techo) * plotH;
const fmtMoneda = (v) => "S/ " + Math.round(v).toLocaleString("es-PE"); const fmtMoneda = (v) => "S/ " + Math.round(v).toLocaleString("es-PE");
// Etiqueta corta para encima de la barra: 29k si ≥1000 (redondeado), si no el número.
const fmtEtiq = (v) => {
const n = Math.round(v);
if (n >= 1000) return Math.round(n / 1000) + "k";
return String(n);
};
const fmtEje = (v) => { const fmtEje = (v) => {
const n = Math.round(v); const n = Math.round(v);
if (Math.abs(n) >= 1000000) return (n / 1000000).toFixed(n % 1000000 ? 1 : 0) + "M"; if (Math.abs(n) >= 1000000) return (n / 1000000).toFixed(n % 1000000 ? 1 : 0) + "M";
@@ -929,7 +993,7 @@ function GraficoVencimientos({ filas, ano, mes }) {
</div> </div>
) : ( ) : (
<div style={{ overflowX: "auto" }}> <div style={{ overflowX: "auto" }}>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", minWidth: 640, height: "auto", display: "block" }}> <svg id="grafico-vencimiento-svg" viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", minWidth: 640, height: "auto", display: "block" }}>
{/* Grid horizontal suave */} {/* Grid horizontal suave */}
{[0, 0.25, 0.5, 0.75, 1].map((t, i) => { {[0, 0.25, 0.5, 0.75, 1].map((t, i) => {
const y = y0 - t * plotH; const y = y0 - t * plotH;
@@ -969,16 +1033,50 @@ function GraficoVencimientos({ filas, ano, mes }) {
const x = gx + si * bW; const x = gx + si * bW;
const y = y0 - h; const y = y0 - h;
const dim = hover != null && hover !== gi; const dim = hover != null && hover !== gi;
const cx = x + (Math.max(3, bW - 4)) / 2;
return ( return (
<rect key={s.k} x={x} y={y} width={Math.max(3, bW - 4)} height={h} <g key={s.k} style={{ opacity: dim ? 0.3 : 1, transition: "opacity .15s" }}>
fill={s.color} rx="2.5" <rect x={x} y={y} width={Math.max(3, bW - 4)} height={h} fill={s.color} rx="2.5" />
style={{ opacity: dim ? 0.3 : 0.9, transition: "opacity .15s" }} /> {/* Etiqueta de dato encima de la barra (formato K) */}
{v > 0 && (
<text x={cx} y={y - 4} textAnchor="middle" fontSize="9.5"
fontWeight="700" fill="#64748b">{fmtEtiq(v)}</text>
)}
</g>
); );
})} })}
<text x={padL + gi * gW + gW / 2} y={y0 + 20} textAnchor="middle" <text x={padL + gi * gW + gW / 2} y={y0 + 20} textAnchor="middle"
fontSize="12" fontWeight="600" fill="#475569">{grp.label}</text> fontSize="12" fontWeight="600" fill="#475569">{grp.label}</text>
<text x={padL + gi * gW + gW / 2} y={y0 + 35} textAnchor="middle" <text x={padL + gi * gW + gW / 2} y={y0 + 35} textAnchor="middle"
fontSize="9.5" fill="#b4bcc8">{data[gi].n} cuota{data[gi].n === 1 ? "" : "s"}</text> fontSize="9.5" fill="#b4bcc8">{data[gi].n} cuota{data[gi].n === 1 ? "" : "s"}</text>
{/* Porcentajes arriba del grupo (Cobrado/CTA, Retiros/CTA, Diferencia/CTA) */}
{(() => {
const cta = data[gi].cta;
if (cta <= 0) return null;
const pcts = [
{ v: data[gi].cob, color: "#5b8a72" }, // Cobrado/CTA
{ v: data[gi].dif, color: "#e08989" }, // Retiros/CTA (key dif = retiros)
{ v: data[gi].ret, color: "#a594c9" }, // Diferencia/CTA (key ret = diferencia)
];
const cxg = padL + gi * gW + gW / 2;
return (
<g>
{pcts.map((p, pi) => {
const pct = Math.round((p.v / cta) * 100);
const sep = 30; // separación horizontal entre los 3
const px = cxg + (pi - 1) * sep;
return (
<g key={pi}>
<circle cx={px} cy={padT - 20} r="3" fill={p.color} />
<text x={px} y={padT - 8} textAnchor="middle" fontSize="10.5"
fontWeight="700" fill={p.color}>{pct}%</text>
</g>
);
})}
</g>
);
})()}
</g> </g>
); );
})} })}
@@ -988,36 +1086,41 @@ function GraficoVencimientos({ filas, ano, mes }) {
</div> </div>
)} )}
{/* Tooltip minimalista (claro, sobrio) */} {/* Tarjetas de detalle — una por grupo, alineadas con sus barras de arriba.
{hover != null && hayDatos && (() => { El margen izq/der imita el padL/padR del gráfico para que cada tarjeta
const d = data[hover]; quede debajo de su grupo de barras. */}
const leftPct = ((padL + hover * gW + gW / 2) / W) * 100; {hayDatos && (
return ( <div style={{ marginTop: 14, display: "grid",
<div style={{ position: "absolute", left: `${leftPct}%`, top: 64, gridTemplateColumns: `repeat(${GRUPOS.length}, 1fr)`, gap: 10,
transform: "translateX(-50%)", pointerEvents: "none", zIndex: 5, marginLeft: `${(padL / W) * 100}%`, marginRight: `${(padR / W) * 100}%` }}>
background: "#fff", color: "#1e293b", borderRadius: 10, {GRUPOS.map((grp, gi) => (
padding: "11px 13px", minWidth: 196, <div key={gi} style={{ background: "#fff", border: "1px solid #eef1f5",
boxShadow: "0 6px 24px rgba(15,23,42,.12)", border: "1px solid #eef1f5" }}> borderRadius: 10, padding: "11px 13px", minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: "#1e293b" }}> <div style={{ fontSize: 11.5, fontWeight: 700, color: "#1e293b", whiteSpace: "nowrap",
{GRUPOS[hover].vencido ? "Vencido (mes anterior)" : `Vencen del ${GRUPOS[hover].label}`} overflow: "hidden", textOverflow: "ellipsis" }}>
{grp.vencido ? "Vencido" : grp.label}
</div> </div>
<div style={{ fontSize: 10.5, color: "#a0aec0", marginBottom: 8 }}> <div style={{ fontSize: 10, color: "#a0aec0", marginBottom: 7 }}>
{d.n} cuota{d.n === 1 ? "" : "s"} {data[gi].n} cuota{data[gi].n === 1 ? "" : "s"}
</div> </div>
{SERIES.map((s) => ( {SERIES.map((s) => (
<div key={s.k} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14, padding: "2.5px 0" }}> <div key={s.k} style={{ display: "flex", alignItems: "center", justifyContent: "space-between",
<span style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11.5, color: "#64748b" }}> gap: 6, padding: "2px 0" }}>
<span style={{ width: 8, height: 8, borderRadius: 2, background: s.color }} /> <span style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 10.5, color: "#64748b",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
<span style={{ width: 7, height: 7, borderRadius: 2, background: s.color, flexShrink: 0 }} />
{s.label} {s.label}
</span> </span>
<span style={{ fontSize: 11.5, fontWeight: 600, color: "#1e293b", fontVariantNumeric: "tabular-nums" }}> <span style={{ fontSize: 10.5, fontWeight: 600, color: "#1e293b",
{fmtMoneda(d[s.k])} fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap" }}>
{fmtMoneda(data[gi][s.k])}
</span> </span>
</div> </div>
))} ))}
</div> </div>
); ))}
})()} </div>
)}
</div> </div>
); );
} }