Actualizar frontend/src/pages/Cobranza.jsx
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user