Actualizar frontend/src/pages/Rentabilidad.jsx
This commit is contained in:
@@ -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() {
|
||||
<h1 className="page-title">📈 Rentabilidad</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="Sede" value={sede} options={SEDES} onChange={setSede} />
|
||||
<Select label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} />
|
||||
<SelectBonito label="Año" value={ano} options={ANOS} onChange={(v)=>setAno(+v)} minWidth={110} />
|
||||
<MesesMulti label="Mes" meses={meses} onChange={setMeses} />
|
||||
<SelectBonito label="Sede" value={sede} options={SEDES} onChange={setSede} minWidth={140} />
|
||||
<SelectBonito label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} minWidth={150} />
|
||||
<div className="filter-group" style={{marginLeft:"auto"}}>
|
||||
<label>Inscritos</label>
|
||||
<label style={{display:"flex",alignItems:"center",gap:6,height:38,cursor:"pointer",fontSize:13}}>
|
||||
@@ -89,30 +503,33 @@ export default function Rentabilidad() {
|
||||
Total / Retirados / En curso
|
||||
</label>
|
||||
</div>
|
||||
<button className="btn btn-primary" style={{height:38,alignSelf:"flex-end"}}
|
||||
disabled={exportando || !datos.length} onClick={exportarExcel}>
|
||||
{exportando ? "Exportando..." : "⬇️ Exportar"}
|
||||
</button>
|
||||
<button className="btn btn-ghost" style={{height:38,alignSelf:"flex-end"}}
|
||||
disabled={exportandoAnual} onClick={exportarAnual}
|
||||
title="Exportar todos los inicios del año (enero → hoy)">
|
||||
{exportandoAnual ? "Exportando..." : "📅 Exportar Año"}
|
||||
</button>
|
||||
</Filters>
|
||||
|
||||
{loading ? <Loader text="Calculando rentabilidad..." /> :
|
||||
error ? <ErrorBox msg={error} /> :
|
||||
<>
|
||||
{kpis && (
|
||||
<div className="kpis" style={{gridTemplateColumns:"repeat(5,1fr)"}}>
|
||||
{[["📚","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])=>(
|
||||
<div className="kpi" key={label}>
|
||||
<div className="ico">{ico}</div><div className="label">{label}</div>
|
||||
<div className="value" style={{fontSize:18}}>{val}</div><div className="sub">{sub}</div>
|
||||
</div>
|
||||
))}
|
||||
<div style={{display:"grid",gridTemplateColumns:"repeat(2,1fr)",gap:14}}>
|
||||
<TarjetaRenta titulo="📊 INICIAL" color="#1e3a5f"
|
||||
venta={kpis.ventaIni} costo={kpis.costoIni} mb={kpis.mbIni} />
|
||||
<TarjetaRenta titulo="📈 ACTUAL" color="#1a5f4a"
|
||||
venta={kpis.ventaAct} costo={kpis.costoAct} mb={kpis.mbAct} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="table-wrap" style={{marginTop:16, overflowX:"auto"}}>
|
||||
<div className="table-wrap" style={{marginTop:16, overflow:"auto", maxHeight:"60vh"}}>
|
||||
<table {...cols.tableProps}>
|
||||
<cols.ColGroup />
|
||||
<thead><tr>{COLS.filter((_,i)=>!ocultar(i)).map((c, vi)=><th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={vi} /></th>)}</tr></thead>
|
||||
<thead><tr>{COLS.filter((_,i)=>!ocultar(i)).map((c, vi)=><th key={c} style={{position:"sticky",top:0,zIndex:2}}>{c}<cols.Resizer index={vi} /></th>)}</tr></thead>
|
||||
<tbody>
|
||||
{datos.map((f, ri) => (
|
||||
<tr key={ri}>
|
||||
@@ -140,9 +557,11 @@ export default function Rentabilidad() {
|
||||
{total.slice(0,13).map((v,ci)=>(
|
||||
ocultar(ci) ? null :
|
||||
<td key={ci} className={ci===0?"col-name":""}
|
||||
style={{textAlign:ci===0?"left":"center"}}>{v}</td>
|
||||
style={{textAlign:ci===0?"left":"center",
|
||||
position:"sticky",bottom:0,zIndex:9,
|
||||
backgroundColor:"#eff6ff",boxShadow:"inset 0 1px 0 0 #cbd5e1"}}>{v}</td>
|
||||
))}
|
||||
<td>—</td>
|
||||
<td style={{position:"sticky",bottom:0,zIndex:9,backgroundColor:"#eff6ff",boxShadow:"inset 0 1px 0 0 #cbd5e1"}}>—</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
@@ -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 (
|
||||
<div className="filter-group" ref={ref} style={{ position: "relative", minWidth }}>
|
||||
<label>{label}</label>
|
||||
<button type="button" onClick={() => setAbierto((v) => !v)}
|
||||
style={{ height: 38, width: "100%", textAlign: "left", padding: "0 12px",
|
||||
border: "1px solid #cbd5e1", borderRadius: 8, background: "#fff",
|
||||
cursor: "pointer", fontSize: 13, display: "flex", alignItems: "center",
|
||||
justifyContent: "space-between", gap: 8 }}>
|
||||
<span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{actual ? actual.label : "—"}
|
||||
</span>
|
||||
<span style={{ color: "#94a3b8", fontSize: 11, transition: "transform .15s",
|
||||
transform: abierto ? "rotate(180deg)" : "none" }}>▼</span>
|
||||
</button>
|
||||
{abierto && (
|
||||
<div style={{ position: "absolute", top: "100%", left: 0, right: 0, zIndex: 50, marginTop: 4,
|
||||
background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.12)", padding: 6,
|
||||
maxHeight: 300, overflowY: "auto" }}>
|
||||
{opts.map((o) => {
|
||||
const on = String(o.value) === String(value);
|
||||
return (
|
||||
<div key={o.value} onClick={() => { 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}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="filter-group" ref={ref} style={{ position: "relative", minWidth: 200 }}>
|
||||
<label>{label}</label>
|
||||
<button type="button" onClick={() => setAbierto((v) => !v)}
|
||||
style={{ height: 38, width: "100%", textAlign: "left", padding: "0 12px",
|
||||
border: "1px solid #cbd5e1", borderRadius: 8, background: "#fff",
|
||||
cursor: "pointer", fontSize: 13, display: "flex", alignItems: "center",
|
||||
justifyContent: "space-between", gap: 8 }}>
|
||||
<span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{texto}</span>
|
||||
<span style={{ color: "#94a3b8", fontSize: 11 }}>▼</span>
|
||||
</button>
|
||||
{abierto && (
|
||||
<div style={{ position: "absolute", top: "100%", left: 0, right: 0, zIndex: 50, marginTop: 4,
|
||||
background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.12)", padding: 6, minWidth: 180,
|
||||
maxHeight: 300, overflowY: "auto" }}>
|
||||
<div style={{ display: "flex", gap: 6, padding: "4px 6px 8px", borderBottom: "1px solid #f1f5f9", marginBottom: 4 }}>
|
||||
<button type="button" onClick={() => onChange(MESES.map((_, i) => i + 1))}
|
||||
style={{ flex: 1, fontSize: 11, padding: "4px 6px", border: "1px solid #e2e8f0",
|
||||
borderRadius: 6, background: "#f8fafc", cursor: "pointer" }}>Todos</button>
|
||||
<button type="button" onClick={() => onChange([])}
|
||||
style={{ flex: 1, fontSize: 11, padding: "4px 6px", border: "1px solid #e2e8f0",
|
||||
borderRadius: 6, background: "#f8fafc", cursor: "pointer" }}>Ninguno</button>
|
||||
</div>
|
||||
{MESES.map((m, i) => {
|
||||
const val = i + 1;
|
||||
const on = meses.includes(val);
|
||||
return (
|
||||
<label key={m} style={{ display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "6px 8px", borderRadius: 6, cursor: "pointer", fontSize: 13,
|
||||
background: on ? "#eff6ff" : "transparent" }}>
|
||||
<input type="checkbox" checked={on} onChange={() => toggle(val)} />
|
||||
{m}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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) => (
|
||||
<div style={{display:"flex",justifyContent:"space-between",fontSize:13,color:"#64748b",marginBottom:6,
|
||||
...(bold?{paddingTop:8,borderTop:"1px solid #f1f5f9"}:{})}}>
|
||||
<span>{k}</span>
|
||||
<span style={{fontWeight:bold?700:600,color:"#0f172a"}}>{v}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{background:"#fff",border:"1px solid #e2e8f0",borderRadius:12,overflow:"hidden"}}>
|
||||
<div style={{background:color,color:"#fff",padding:"10px 14px",fontWeight:700,fontSize:14,textAlign:"center"}}>{titulo}</div>
|
||||
<div style={{padding:16}}>
|
||||
{fila("Venta", venta)}
|
||||
{fila("Costo", costo)}
|
||||
{fila("Margen Bruto", mb, true)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Columna({ titulo, color, campos, valores, setVal, total }) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user