diff --git a/frontend/src/pages/Cobranza.jsx b/frontend/src/pages/Cobranza.jsx index 09ca90d..0494098 100644 --- a/frontend/src/pages/Cobranza.jsx +++ b/frontend/src/pages/Cobranza.jsx @@ -484,63 +484,121 @@ export default function Cobranza() { }; 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"]; - - // 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 = [ + 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})); + // estilo cabecera + 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 CART = { ant:"FFFEF3C7", mes:"FFD1FAE5", tot:"FFEDE9FE" }; + 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; }; + base.forEach((a)=>{ + const row = ws.addRow([ 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" }; // F. VENC. (col 4) va como texto tal cual + Number(a.cta_ant)||0, Number(a.cob_ant)||0, pct(a.cob_ant,a.cta_ant), + Number(a.cta_cur)||0, Number(a.cob_cur)||0, pct(a.cob_cur,a.cta_cur), + Number(a.cta_tot)||0, Number(a.cob_tot)||0, pct(a.cob_tot,a.cta_tot), + 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'; }); - // 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}; + // ── 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 }, + ]; + const toM = (v)=>{ const n=parseFloat(String(v??"").replace(/S\/|,|\s/g,"")); return isNaN(n)?0:n; }; + const diaV = (s)=>{ const p=String(s||"").split("/").map(Number); return (p.length===3&&p[0])?p[0]:null; }; + const G = GRP.map(()=>({cta:0,cob:0,ret:0,dif:0,n:0})); + (filasG||[]).forEach((f)=>{ + if (!Array.isArray(f) || String(f[0]).toUpperCase().includes("TOTAL")) return; + 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"}}; }); - 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"); + // Descargar 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 { setExportando(false); } @@ -776,8 +834,8 @@ function GraficoVencimientos({ filas, ano, mes }) { const SERIES = [ { k: "cta", label: "CTA x COB en curso", color: "#64748b" }, // gris azulado { k: "cob", label: "Cobrado en curso", color: "#5b8a72" }, // verde apagado - { k: "dif", label: "Retiros", color: "#9a93bf" }, // 3ª barra - { k: "ret", label: "Diferencia", color: "#b98a8a" }, // 4ª barra + { k: "dif", label: "Retiros", color: "#e08989" }, // 3ª barra · Retiros · rojo suave + { 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"]; @@ -882,7 +940,7 @@ function GraficoVencimientos({ filas, ano, mes }) { }; 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 gW = plotW / GRUPOS.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 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 n = Math.round(v); if (Math.abs(n) >= 1000000) return (n / 1000000).toFixed(n % 1000000 ? 1 : 0) + "M"; @@ -929,7 +993,7 @@ function GraficoVencimientos({ filas, ano, mes }) { ) : (