From 84ca180559a6229574c1ffac0fd78e2508dbbd57 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 10 Sep 2026 12:58:41 -0500 Subject: [PATCH] Actualizar frontend/src/pages/Rentabilidad.jsx --- frontend/src/pages/Rentabilidad.jsx | 621 ++++++++++++++++++++++++++-- 1 file changed, 588 insertions(+), 33 deletions(-) diff --git a/frontend/src/pages/Rentabilidad.jsx b/frontend/src/pages/Rentabilidad.jsx index d02c384..2764294 100644 --- a/frontend/src/pages/Rentabilidad.jsx +++ b/frontend/src/pages/Rentabilidad.jsx @@ -1,7 +1,7 @@ // src/pages/Rentabilidad.jsx -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useRef } from "react"; import { api } from "../lib/api"; -import { Loader, ErrorBox, Filters, Select } from "../components/UI"; +import { Loader, ErrorBox, Filters } from "../components/UI"; import Modal from "../components/Modal"; import { useColumnasAjustables } from "../lib/useColumnasAjustables"; import { setVistaActual, onRefrescar } from "../lib/vistaActual"; @@ -20,7 +20,9 @@ function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"" export default function Rentabilidad() { const hoy = new Date(); const [ano, setAno] = useState(hoy.getFullYear()); - const [mes, setMes] = useState(hoy.getMonth() + 1); + // meses = array de meses seleccionados (multi). 'mes' (primero) se usa en modales/export. + const [meses, setMeses] = useState([hoy.getMonth() + 1]); + const mes = meses.length ? Math.min(...meses) : (hoy.getMonth() + 1); const [sede, setSede] = useState("TODOS"); const [programa, setPrograma] = useState("TODOS"); const [mostrarInscritos, setMostrarInscritos] = useState(true); // TOTAL, RETIRADOS, EN CURSO @@ -35,6 +37,389 @@ export default function Rentabilidad() { const anchosVisibles = ANCHOS_BASE.filter((_, i) => !ocultar(i)); const cols = useColumnasAjustables(anchosVisibles); const [modalCostos, setModalCostos] = useState(null); + const [exportando, setExportando] = useState(false); + const [exportandoAnual, setExportandoAnual] = useState(false); + + // Exportar TODOS los inicios del año (enero → mes actual), agrupados por mes. + async function exportarAnual() { + if (exportandoAnual) return; + setExportandoAnual(true); + try { + const hoy = new Date(); + const mesHasta = (ano === hoy.getFullYear()) ? (hoy.getMonth() + 1) : 12; + const MESES_UP = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; + + // Traer rentabilidad de cada mes (enero..mesHasta), en paralelo + const peticiones = []; + for (let m = 1; m <= mesHasta; m++) peticiones.push(api.rentabilidad(ano, m, "TODOS", "TODOS")); + const resultados = await Promise.all(peticiones); + + const XLSX = await import("xlsx-js-style"); + const HEAD = ["Sede","Programa","Inicio","Fin","Total Inscritos","Retirados","En curso", + "Venta Inicial","Costo Inicial","M.B Inicial %","Venta Actual","Costo Actual","M.B Actual %", + "Costo x Certificado","Costo por docente","Costo por MKT","Costo por Consumibles"]; + const NCOL = HEAD.length; // 17 + const B = { style:"thin", color:{rgb:"BFBFBF"} }; + const border = { top:B, bottom:B, left:B, right:B }; + const money = '_-[$S/-280A] * #,##0_-;-[$S/-280A] * #,##0_-;_-[$S/-280A] * "-"??_-;_-@_-'; + const pctFmt = '0.0%'; + const dateFmt = "dd/mm/yyyy"; + const num = (v) => toNum(v); + + // Colores idénticos al Excel de referencia + const VERDE = "E2EFDA", AZUL = "DAE3F3", DORADO = "FFE699", MORADO = "BF95AA", GRIS = "D9D9D9"; + // Color de encabezado por columna: 0-6 verde, 7-9 azul, 10-16 dorado + const headFill = (c) => c <= 6 ? VERDE : (c <= 9 ? AZUL : DORADO); + + const fechaSerial = (s) => { + const t = String(s || "").trim(); + let d, m, y; + if (t.includes("/")) { [d,m,y] = t.split("/").map(Number); } + else if (t.includes("-")) { const p = t.split("-").map(Number); + if (p[0] > 31) { [y,m,d] = p; } else { [d,m,y] = p; } } + if (!d || !m || !y) return null; + return Math.floor((Date.UTC(y,m-1,d) - Date.UTC(1899,11,30)) / 86400000); + }; + + const ws = {}; + const merges = []; + let R = 0; // fila actual (0-based) + + resultados.forEach((res, idx) => { + const m = idx + 1; + const base = (res.filas || []).filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL"); + const ex = res.extras || {}; + if (!base.length) return; // mes sin inicios -> se omite + + // 1) Título del mes (combinado a lo ancho, morado) + for (let c = 0; c < NCOL; c++) + ws[XLSX.utils.encode_cell({r:R,c})] = { v: c===0 ? `${MESES_UP[m-1]} ${ano}` : "", t:"s", + s:{ font:{bold:true,sz:12,color:{rgb:"000000"}}, fill:{fgColor:{rgb:MORADO}}, + alignment:{horizontal:"center",vertical:"center"}, border } }; + merges.push({ s:{r:R,c:0}, e:{r:R,c:NCOL-1} }); + R++; + + // 2) Encabezados (colores por grupo) + HEAD.forEach((h, c) => { ws[XLSX.utils.encode_cell({r:R,c})] = { v:h, t:"s", + s:{ font:{bold:true,color:{rgb:"000000"},sz:10}, fill:{fgColor:{rgb:headFill(c)}}, + alignment:{horizontal:"center",vertical:"center",wrapText:true}, border } }; }); + R++; + + // Acumuladores del mes + const T = {ins:0,ret:0,cur:0,vi:0,ci:0,va:0,ca:0,cert:0,doc:0,mkt:0,cons:0}; + + // 3) Filas de datos + base.forEach((f) => { + const prog = String(f[0]).trim(); + const e = ex[prog] || {}; + const cel = (c, v, t, z, al) => { ws[XLSX.utils.encode_cell({r:R,c})] = + { v: v ?? "", t, ...(z?{z}:{}), s:{ border, alignment:{horizontal: al || "center"} } }; }; + const serial = fechaSerial(f[1]); + const ti=num(e.total_ins), re=num(e.retirados), cu=num(e.en_curso); + const vi=num(e.venta_ini), ci=num(e.costo_ini), va=num(e.venta_act), ca=num(e.costo_act); + const cert=num(e.costo_cert), doc=num(e.costo_doc), mkt=num(e.costo_mkt), cons=num(e.costo_cons); + const xr = R + 1; // fila real Excel (1-based) + const fcell = (c, formula) => { ws[XLSX.utils.encode_cell({r:R,c})] = + { t:"n", z:pctFmt, f:formula, s:{border,alignment:{horizontal:"center"}} }; }; + cel(0, String(e.sede || ""), "s", null, "left"); + cel(1, prog, "s", null, "left"); + if (serial != null) ws[XLSX.utils.encode_cell({r:R,c:2})] = { v:serial, t:"n", z:dateFmt, s:{border,alignment:{horizontal:"center"}} }; + else cel(2, String(f[1]||""), "s"); + cel(3, "", "s"); // Fin (no disponible) + cel(4, ti, "n"); cel(5, re, "n"); cel(6, cu, "n"); + cel(7, vi, "n", money); cel(8, ci, "n", money); + fcell(9, `1-I${xr}/H${xr}`); // M.B Inicial % = 1 - Costo Ini / Venta Ini + cel(10, va, "n", money); cel(11, ca, "n", money); + fcell(12, `1-L${xr}/K${xr}`); // M.B Actual % = 1 - Costo Act / Venta Act + cel(13, cert, "n", money); cel(14, doc, "n", money); cel(15, mkt, "n", money); cel(16, cons, "n", money); + T.ins+=ti; T.ret+=re; T.cur+=cu; T.vi+=vi; T.ci+=ci; T.va+=va; T.ca+=ca; + T.cert+=cert; T.doc+=doc; T.mkt+=mkt; T.cons+=cons; + R++; + }); + + // 4) Fila TOTAL del mes (gris) + const tCell = (c, v, t, z, al) => { ws[XLSX.utils.encode_cell({r:R,c})] = + { v: v ?? "", t, ...(z?{z}:{}), s:{ font:{bold:true}, fill:{fgColor:{rgb:GRIS}}, + border, alignment:{horizontal: al || "center"} } }; }; + // "TOTAL" combinado sobre col 0-3 + const xT = R + 1; // fila total Excel (1-based) + for (let c=0;c<4;c++) tCell(c, c===0?"TOTAL":"", "s"); + merges.push({ s:{r:R,c:0}, e:{r:R,c:3} }); + tCell(4, T.ins, "n"); tCell(5, T.ret, "n"); tCell(6, T.cur, "n"); + tCell(7, T.vi, "n", money); tCell(8, T.ci, "n", money); + ws[XLSX.utils.encode_cell({r:R,c:9})] = { t:"n", z:pctFmt, f:`1-I${xT}/H${xT}`, + s:{ font:{bold:true}, fill:{fgColor:{rgb:GRIS}}, border, alignment:{horizontal:"center"} } }; + tCell(10, T.va, "n", money); tCell(11, T.ca, "n", money); + ws[XLSX.utils.encode_cell({r:R,c:12})] = { t:"n", z:pctFmt, f:`1-L${xT}/K${xT}`, + s:{ font:{bold:true}, fill:{fgColor:{rgb:GRIS}}, border, alignment:{horizontal:"center"} } }; + tCell(13, T.cert, "n", money); tCell(14, T.doc, "n", money); + tCell(15, T.mkt, "n", money); tCell(16, T.cons, "n", money); + R++; + + // 5) Fila vacía entre meses + R++; + }); + + ws["!merges"] = merges; + ws["!ref"] = XLSX.utils.encode_range({ s:{r:0,c:0}, e:{r:Math.max(R,1), c:NCOL-1} }); + ws["!cols"] = [{wch:10},{wch:22},{wch:12},{wch:12},{wch:13},{wch:10},{wch:10}, + {wch:13},{wch:13},{wch:11},{wch:13},{wch:13},{wch:11},{wch:15},{wch:14},{wch:13},{wch:17}]; + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Hoja1"); + XLSX.writeFile(wb, `margen_bruto_${ano}.xlsx`); + } catch (e) { + alert("No se pudo exportar: " + (e.message || e)); + } finally { + setExportandoAnual(false); + } + } + + // Exportar Excel: cruza RENTABILIDAD (filas + extras) con OCUPABILIDAD (por programa). + async function exportarExcel() { + if (exportando) return; + setExportando(true); + try { + // Traer ocupabilidad y rentabilidad frescas del mismo periodo/filtros + const [ocu, rent] = await Promise.all([ + api.ocupabilidad(ano, mes, sede, programa), + api.rentabilidad(ano, mes, sede, programa), + ]); + const ocuPorProg = {}; + (ocu.datos || []).forEach((d) => { + const k = String(d.programa_frecuencia || "").trim(); + if (k) ocuPorProg[k] = d; + }); + const extras = rent.extras || {}; + const filaTotal = (rent.filas || []).find((f) => String(f[0]).toUpperCase() === "TOTAL GENERAL"); + const base = (rent.filas || []).filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL"); + if (!base.length) { alert("No hay datos para exportar."); return; } + + const XLSX = await import("xlsx-js-style"); + const COLS_EXP = [ + "Nombre Programa","Fecha","Matriculas REFRI","Matriculas CONTINUIDAD", + "Matriculas Nuevas","INSCRITOS TOTALES","Matriculas P.C.","Promedio Cuotas", + "Promedio Desc. E.","Valor Venta","Costo x Programa","MB %", + "Costo EPP + CERTIFICADO","Costo Docentes","Costo Consumibles","Costo MKT", + ]; + const num = (v) => toNum(v); + const money = '"S/" #,##0'; + const pctFmt = '0.00%'; + const dateFmt = "dd/mm/yyyy"; + + // ── Estilos base ── + const B = { style: "thin", color: { rgb: "BFBFBF" } }; + const border = { top:B, bottom:B, left:B, right:B }; + const AZUL = "2F5496", CAFE = "833C00"; + const hCell = (rgb) => ({ font:{bold:true,color:{rgb:"FFFFFF"},sz:11}, + fill:{fgColor:{rgb}}, alignment:{horizontal:"center",vertical:"center",wrapText:true}, border }); + const HEAD_CAFE = new Set([9,10,11,12,13,14,15]); // Valor Venta..Costo MKT + + // Interpola blanco -> color destino según t (0..1). Devuelve hex sin #. + const lerpHex = (from, to, t) => { + const p = (h) => [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)]; + const [r1,g1,b1] = p(from), [r2,g2,b2] = p(to); + const c = (a,b) => Math.round(a + (b-a)*t).toString(16).padStart(2,"0"); + return c(r1,r2)+c(g1,g2)+c(b1,b2); + }; + // Fondo rojo escalado por valor de Desc.E (0=blanco, >=200=rojo fuerte) + const fondoDesc = (v) => { + if (!v || v <= 0) return "FFFFFF"; + const t = Math.min(v, 200) / 200; // 0..1 + return lerpHex("FFFFFF", "E06666", t); // blanco -> rojo + }; + const naranjaClaro = "FDEBD0"; // Consumibles + const azulClaroMat = "DDEBF7"; // Matriculas (refri/contin/nuevas/total) + const verdeMB = "C6EFCE", rojoMB = "F4C7C3"; + + // Convierte "dd-mm-YYYY" o "dd/mm/YYYY" o "YYYY-mm-dd" a serial de Excel (fecha real) + const fechaSerial = (s) => { + const t = String(s || "").trim(); + let d, m, y; + if (t.includes("/")) { [d,m,y] = t.split("/").map(Number); } + else if (t.includes("-")) { + const p = t.split("-").map(Number); + if (p[0] > 31) { [y,m,d] = p; } else { [d,m,y] = p; } + } + if (!d || !m || !y) return null; + const dt = new Date(Date.UTC(y, m-1, d)); + const serial = Math.floor((dt - new Date(Date.UTC(1899,11,30))) / 86400000); + return serial; + }; + + const ws = {}; + COLS_EXP.forEach((h, c) => { + ws[XLSX.utils.encode_cell({r:0,c})] = { v:h, t:"s", s: hCell(HEAD_CAFE.has(c) ? CAFE : AZUL) }; + }); + + const T = {refri:0, contin:0, nuevas:0, ins:0, pc:0, venta:0, costo:0, eppc:0, doc:0, cons:0, mkt:0}; + let sumCuota=0, cntCuota=0, sumDesc=0, cntDesc=0; + + base.forEach((f, ri) => { + const r = ri + 1; + const xr = r + 1; // fila real en Excel (1-based, +header) + const prog = String(f[0] || "").trim(); + const o = ocuPorProg[prog] || {}; + const ex = extras[prog] || {}; + const refri = num(o.Descuento); + const contin = num(o.Inscritos_Continuidad); + const totalIns = num(o.Inscritos_Totales); + const nuevas = Math.max(0, totalIns - refri - contin); + const insTotal = refri + contin + nuevas; + const pc = num(o.Inscritos_PC); + const cuota = num(f[5]); + const descE = num(f[6]); + const vVenta = num(f[7]); + const mb = num(f[9]); + const doc = num(ex.costo_doc); + const cons = num(ex.costo_cons); + const mkt = num(ex.costo_mkt); + const eppc = num(ex.costo_epp_cert); // valor real del dashboard (EPP + Certificado) + const costo = eppc + doc + cons + mkt; + const fInicio = o.fch_inicio || f[1] || ""; + const serial = fechaSerial(fInicio); + + const cel = (c,v,t,z,extra) => { ws[XLSX.utils.encode_cell({r,c})] = + { v, t, ...(z?{z}:{}), s:{ border, alignment:{horizontal:"center"}, ...(extra||{}) } }; }; + // Programa (izquierda) + ws[XLSX.utils.encode_cell({r,c:0})] = { v:prog, t:"s", s:{ border, alignment:{horizontal:"left"} } }; + // Fecha (serial con formato dd/mm/yyyy; si no parsea, texto) + if (serial != null) ws[XLSX.utils.encode_cell({r,c:1})] = { v:serial, t:"n", z:dateFmt, s:{border,alignment:{horizontal:"center"}} }; + else cel(1, String(fInicio), "s"); + // Matriculas: azul claro + cel(2, refri, "n", null, {fill:{fgColor:{rgb:azulClaroMat}}}); + cel(3, contin, "n", null, {fill:{fgColor:{rgb:azulClaroMat}}}); + cel(4, nuevas, "n", null, {fill:{fgColor:{rgb:azulClaroMat}}}); + cel(5, insTotal, "n", null, {fill:{fgColor:{rgb:azulClaroMat}}}); + cel(6, pc, "n"); + cel(7, cuota, "n", money); + // Desc.E: fondo rojo según valor + cel(8, descE, "n", money, {fill:{fgColor:{rgb:fondoDesc(descE)}}}); + cel(9, vVenta, "n", money); + // Costo x Programa: FÓRMULA =M+N+O+P + ws[XLSX.utils.encode_cell({r,c:10})] = { t:"n", z:money, + f:`M${xr}+N${xr}+O${xr}+P${xr}`, s:{border,alignment:{horizontal:"center"}} }; + // MB %: FÓRMULA =1-K/J (verde si >65%, rojo si menos) + ws[XLSX.utils.encode_cell({r,c:11})] = { t:"n", z:pctFmt, f:`1-K${xr}/J${xr}`, + s:{ border, alignment:{horizontal:"center"}, fill:{fgColor:{rgb: mb>65 ? verdeMB : rojoMB}} } }; + // Costo EPP+CERT: valor real del dashboard (COSTO EPP + COSTO CERTIFICADO) + cel(12, eppc, "n", money); + cel(13, doc, "n", money); + // Consumibles: naranja super claro + cel(14, cons, "n", money, {fill:{fgColor:{rgb:naranjaClaro}}}); + cel(15, mkt, "n", money); + + T.refri+=refri; T.contin+=contin; T.nuevas+=nuevas; T.ins+=insTotal; T.pc+=pc; + T.venta+=vVenta; T.costo+=costo; T.eppc+=eppc; T.doc+=doc; T.cons+=cons; T.mkt+=mkt; + if (cuota>0){ sumCuota+=cuota; cntCuota++; } + if (descE>0){ sumDesc+=descE; cntDesc++; } + }); + + // ── Fila TOTAL ── + const rT = base.length + 1; + const totFill = { fill:{fgColor:{rgb:"DDEBF7"}} }; + const totCell = (val,t,z) => ({ v:val??"", t, ...(z?{z}:{}), s:{ font:{bold:true}, + border, alignment:{horizontal:"center"}, ...totFill } }); + const mbTot = T.venta>0 ? (1 - T.costo/T.venta) : 0; + // CUOTA PROM. y DESC.E TOTAL: usar el promedio ponderado del dashboard + // (fila TOTAL GENERAL de rentabilidad), no el promedio simple de las filas. + const cuotaTot = filaTotal ? num(filaTotal[5]) : (cntCuota? sumCuota/cntCuota : 0); + const descTot = filaTotal ? num(filaTotal[6]) : (cntDesc? sumDesc/cntDesc : 0); + const totRow = [ + {v:"TOTAL",t:"s",s:{font:{bold:true},border,alignment:{horizontal:"left"},...totFill}}, + totCell("","s"), + totCell(T.refri,"n"), totCell(T.contin,"n"), totCell(T.nuevas,"n"), + totCell(T.ins,"n"), totCell(T.pc,"n"), + totCell(cuotaTot,"n",money), totCell(descTot,"n",money), + totCell(T.venta,"n",money), totCell(T.costo,"n",money), + totCell(mbTot,"n",pctFmt), + totCell(T.eppc,"n",money), totCell(T.doc,"n",money), + totCell(T.cons,"n",money), totCell(T.mkt,"n",money), + ]; + totRow.forEach((c,i) => { ws[XLSX.utils.encode_cell({r:rT,c:i})] = c; }); + // MB% del TOTAL: fórmula =1-K/J de la propia fila TOTAL + const xTot = rT + 1; + ws[XLSX.utils.encode_cell({r:rT,c:11})] = { t:"n", z:pctFmt, f:`1-K${xTot}/J${xTot}`, + s:{ font:{bold:true}, border, alignment:{horizontal:"center"}, fill:{fgColor:{rgb:"DDEBF7"}} } }; + + ws["!ref"] = XLSX.utils.encode_range({ s:{r:0,c:0}, e:{r:rT, c:COLS_EXP.length-1} }); + ws["!cols"] = [{wch:32},{wch:11},{wch:13},{wch:15},{wch:12},{wch:12},{wch:12}, + {wch:12},{wch:14},{wch:13},{wch:14},{wch:9},{wch:16},{wch:14},{wch:15},{wch:12}]; + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Rentabilidad"); + + // ── Una hoja por cada inicio (programa) con el detalle de sus alumnos ── + const HDET = ["VENDEDOR","ALUMNO","FECHA MATRICULA","FECHA 1° CUOTA CANCELACION", + "TIPO DE ALUMNO","PROM. CUOTA","DESCUENTO ESPECIAL","INVERSION"]; + const usados = new Set(); + const nombreHoja = (prog) => { + // Excel: máx 31 chars, sin : \ / ? * [ ]. Evitar duplicados. + let base = String(prog).replace(/[:\\/?*\[\]]/g, " ").trim().slice(0, 28) || "Inicio"; + let n = base, i = 2; + while (usados.has(n.toLowerCase())) { n = base.slice(0, 25) + " " + i; i++; } + usados.add(n.toLowerCase()); + return n; + }; + // Traer los detalles en paralelo (una llamada por programa) + const detalles = await Promise.all( + base.map((f) => api.rentabilidadDetalle(String(f[0]).trim(), ano, mes) + .then((r) => r.filas || []).catch(() => [])) + ); + + base.forEach((f, i) => { + const prog = String(f[0]).trim(); + const todas = detalles[i] || []; + const rowTot = todas.find((row) => String(row[0]).toUpperCase() === "TOTAL GENERAL"); + const filasDet = todas.filter((row) => String(row[0]).toUpperCase() !== "TOTAL GENERAL"); + const wsd = {}; + HDET.forEach((h, c) => { wsd[XLSX.utils.encode_cell({r:0,c})] = { v:h, t:"s", s: hCell(AZUL) }; }); + filasDet.forEach((row, ri) => { + const r = ri + 1; + const fz = ri % 2 === 1 ? { fill:{fgColor:{rgb:"F2F6FC"}} } : undefined; + const put = (c, v, tipo, z, al) => { wsd[XLSX.utils.encode_cell({r,c})] = + { v: v ?? "", t: tipo, ...(z?{z}:{}), s:{ border, alignment:{horizontal: al||"center"}, ...(fz||{}) } }; }; + put(0, String(row[0]||""), "s", null, "left"); // vendedor + put(1, String(row[1]||""), "s", null, "left"); // alumno + put(2, String(row[2]||""), "s"); // f. matricula (texto dd/mm/yyyy) + put(3, String(row[3]||""), "s"); // f. cancelacion + put(4, String(row[4]||""), "s"); // tipo de alumno + const cuotaV = num(row[5]); + const descV = num(row[6]); + const invV = num(row[7]); + // PROM. CUOTA: vacío si no tiene valor (regla del dashboard: " ") + if (String(row[5]||"").trim() === "") put(5, "", "s"); + else put(5, cuotaV, "n", money); + if (String(row[6]||"").trim() === "") put(6, "", "s"); + else put(6, descV, "n", money); + put(7, invV, "n", money); + }); + // ── Fila TOTAL GENERAL (valores del dashboard: promedio ponderado + suma inversión) ── + const rTd = filasDet.length + 1; + const tf = { fill:{fgColor:{rgb:"DDEBF7"}} }; + const tCell = (c, v, tipo, z) => { wsd[XLSX.utils.encode_cell({r:rTd,c})] = + { v: v ?? "", t: tipo, ...(z?{z}:{}), s:{ font:{bold:true}, border, alignment:{horizontal: c<=1?"left":"center"}, ...tf } }; }; + tCell(0, "TOTAL GENERAL", "s"); + tCell(1, "", "s"); tCell(2, "", "s"); tCell(3, "", "s"); tCell(4, "", "s"); + if (rowTot) { + const cT = num(rowTot[5]), dT = num(rowTot[6]), iT = num(rowTot[7]); + if (String(rowTot[5]||"").trim() === "") tCell(5, "", "s"); else tCell(5, cT, "n", money); + if (String(rowTot[6]||"").trim() === "") tCell(6, "", "s"); else tCell(6, dT, "n", money); + tCell(7, iT, "n", money); + } else { + tCell(5, "", "s"); tCell(6, "", "s"); tCell(7, "", "s"); + } + wsd["!ref"] = XLSX.utils.encode_range({ s:{r:0,c:0}, e:{r:rTd, c:HDET.length-1} }); + wsd["!cols"] = [{wch:26},{wch:30},{wch:16},{wch:24},{wch:16},{wch:12},{wch:16},{wch:14}]; + XLSX.utils.book_append_sheet(wb, wsd, nombreHoja(prog)); + }); + + const suf = `${MESES[mes-1]}_${ano}` + (sede!=="TODOS"?`_${sede}`:"") + (programa!=="TODOS"?`_${programa}`:""); + XLSX.writeFile(wb, `rentabilidad_${suf}.xlsx`); + } catch (e) { + alert("No se pudo exportar: " + (e.message || e)); + } finally { + setExportando(false); + } + } function cargar() { setLoading(true); setError(null); @@ -43,33 +428,62 @@ export default function Rentabilidad() { .catch((e) => { setError(e.message); setLoading(false); }); } + // Clave estable de meses (ordenados) para las dependencias del effect. + const mesesKey = useMemo(() => [...meses].sort((a, b) => a - b).join(","), [meses]); + useEffect(() => { let activo = true; - setVistaActual(`rentabilidad:${ano}|${mes}|${sede}|${programa}`); + const lista = mesesKey ? mesesKey.split(",").map(Number) : []; + setVistaActual(`rentabilidad:${ano}|${mesesKey}|${sede}|${programa}`); const cargarSeguro = () => { setLoading(true); setError(null); - api.rentabilidad(ano, mes, sede, programa) - .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) + if (!lista.length) { setFilas([]); setLoading(false); return; } + // Una llamada por cada mes; se juntan las filas de datos (sin la fila TOTAL de cada uno). + Promise.all(lista.map((m) => api.rentabilidad(ano, m, sede, programa))) + .then((resArr) => { + if (!activo) return; + const juntos = []; + resArr.forEach((res) => (res.filas || []).forEach((f) => { + if (String(f[0]).toUpperCase() !== "TOTAL GENERAL") juntos.push(f); + })); + setFilas(juntos); + setLoading(false); + }) .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); }; cargarSeguro(); const off = onRefrescar(cargarSeguro); return () => { activo = false; off(); }; - }, [ano, mes, sede, programa]); + }, [ano, mesesKey, sede, programa]); const datos = useMemo(() => filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"), [filas]); - const total = useMemo(() => filas.find((f)=>String(f[0]).toUpperCase()==="TOTAL GENERAL"), [filas]); + // TOTAL GENERAL recalculado en frontend (varios meses juntos). Suma columnas + // sumables, recalcula MB% (=1-costo/venta), y promedia cuota/desc de las filas con valor. + const total = useMemo(() => { + if (!datos.length) return null; + const sum = (ci) => datos.reduce((s,f)=>s+toNum(f[ci]),0); + const promCol = (ci) => { const v = datos.map((f)=>toNum(f[ci])).filter((x)=>x>0); + return v.length ? v.reduce((a,b)=>a+b,0)/v.length : 0; }; + const vIni=sum(7), cIni=sum(8), vAct=sum(10), cAct=sum(11); + const mbIni = vIni>0 ? (1 - cIni/vIni)*100 : 0; + const mbAct = vAct>0 ? (1 - cAct/vAct)*100 : 0; + const S = (n)=>`S/ ${Math.round(n).toLocaleString("es-PE")}`; + const t = []; + t[0]="TOTAL GENERAL"; t[1]=""; + t[2]=String(Math.round(sum(2))); t[3]=String(Math.round(sum(3))); t[4]=String(Math.round(sum(4))); + t[5]=S(promCol(5)); t[6]=S(promCol(6)); + t[7]=S(vIni); t[8]=S(cIni); t[9]=`${mbIni.toFixed(1)} %`; + t[10]=S(vAct); t[11]=S(cAct); t[12]=`${mbAct.toFixed(1)} %`; + return t; + }, [datos]); const kpis = useMemo(() => { if (!total) return null; return { - programas: datos.length, - enCurso: total[4], - ventaAct: total[10], - costoAct: total[11], - mbAct: total[12], + ventaIni: total[7], costoIni: total[8], mbIni: total[9], + ventaAct: total[10], costoAct: total[11], mbAct: total[12], }; - }, [datos, total]); + }, [total]); function bgCol(ci){ if([7,8,9].includes(ci))return BG_INI; if([10,11,12].includes(ci))return BG_ACT; return ""; } @@ -78,10 +492,10 @@ export default function Rentabilidad() {

📈 Rentabilidad

- ({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} /> - + setAno(+v)} minWidth={110} /> + + +
+ +
{loading ? : error ? : <> {kpis && ( -
- {[["📚","Programas",kpis.programas,"este mes"], - ["🎓","En Curso",kpis.enCurso,"alumnos activos"], - ["💵","Venta Actual",kpis.ventaAct,"ingreso total"], - ["💸","Costo Actual",kpis.costoAct,"gasto total"], - ["📊","M.B. Actual",kpis.mbAct,"margen bruto"]].map(([ico,label,val,sub])=>( -
-
{ico}
{label}
-
{val}
{sub}
-
- ))} +
+ +
)} -
+
- {COLS.filter((_,i)=>!ocultar(i)).map((c, vi)=>)} + {COLS.filter((_,i)=>!ocultar(i)).map((c, vi)=>)} {datos.map((f, ri) => ( @@ -140,9 +557,11 @@ export default function Rentabilidad() { {total.slice(0,13).map((v,ci)=>( ocultar(ci) ? null : + style={{textAlign:ci===0?"left":"center", + position:"sticky",bottom:0,zIndex:9, + backgroundColor:"#eff6ff",boxShadow:"inset 0 1px 0 0 #cbd5e1"}}>{v} ))} - + )} @@ -156,6 +575,122 @@ export default function Rentabilidad() { ); } +// Select de una opcion, desplegable custom (mismo estilo que el filtro de Mes). +function SelectBonito({ label, value, options, onChange, minWidth = 150 }) { + const [abierto, setAbierto] = useState(false); + const ref = useRef(null); + const opts = options.map((o) => (typeof o === "object" ? o : { value: o, label: String(o) })); + const actual = opts.find((o) => String(o.value) === String(value)); + + useEffect(() => { + const fuera = (e) => { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); }; + document.addEventListener("mousedown", fuera); + return () => document.removeEventListener("mousedown", fuera); + }, []); + + return ( +
+ + + {abierto && ( +
+ {opts.map((o) => { + const on = String(o.value) === String(value); + return ( +
{ onChange(o.value); setAbierto(false); }} + style={{ padding: "8px 10px", borderRadius: 6, cursor: "pointer", fontSize: 13, + fontWeight: on ? 700 : 500, + color: on ? "#1e3a5f" : "#334155", + background: on ? "#eff6ff" : "transparent" }} + onMouseEnter={(e) => { if (!on) e.currentTarget.style.background = "#f8fafc"; }} + onMouseLeave={(e) => { if (!on) e.currentTarget.style.background = "transparent"; }}> + {o.label} +
+ ); + })} +
+ )} +
+ ); +} + +// Filtro de MES con casillas (multi-seleccion). meses = array de 1..12. +function MesesMulti({ label, meses, onChange }) { + const [abierto, setAbierto] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const fuera = (e) => { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); }; + document.addEventListener("mousedown", fuera); + return () => document.removeEventListener("mousedown", fuera); + }, []); + + const toggle = (m) => { + const set = new Set(meses); + if (set.has(m)) set.delete(m); else set.add(m); + onChange([...set].sort((a, b) => a - b)); + }; + + const texto = meses.length === 0 ? "— Elegir —" + : meses.length === 1 ? MESES[meses[0] - 1] + : meses.length === 12 ? "TODOS" + : `${meses.length} meses`; + + return ( +
+ + + {abierto && ( +
+
+ + +
+ {MESES.map((m, i) => { + const val = i + 1; + const on = meses.includes(val); + return ( + + ); + })} +
+ )} +
+ ); +} + function ModalVerDetalle({ programa, ano, mes, onClose }) { const [filas, setFilas] = useState([]); const [loading, setLoading] = useState(true); @@ -254,6 +789,26 @@ function ModalCostos({ programa, ano, mes, onClose, onGuardado }) { ); } +function TarjetaRenta({ titulo, color, venta, costo, mb }) { + const fila = (k, v, bold) => ( +
+ {k} + {v} +
+ ); + return ( +
+
{titulo}
+
+ {fila("Venta", venta)} + {fila("Costo", costo)} + {fila("Margen Bruto", mb, true)} +
+
+ ); +} + function Columna({ titulo, color, campos, valores, setVal, total }) { return (
{c}
{c}
{v}——