959 lines
47 KiB
JavaScript
959 lines
47 KiB
JavaScript
// src/pages/Rentabilidad.jsx
|
||
import { useState, useEffect, useMemo, useRef } from "react";
|
||
import { api } from "../lib/api";
|
||
import { Loader, ErrorBox, Filters } from "../components/UI";
|
||
import Modal from "../components/Modal";
|
||
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
|
||
|
||
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||
const ANOS = [2024, 2025, 2026];
|
||
const SEDES = ["LIMA","AREQUIPA","PIURA","TRUJILLO"];
|
||
const PROGRAMAS = ["SEMINARIOS","OTROS","TEAC","TERC"];
|
||
|
||
const COLS = ["PROGRAMA","FECHA INICIO","TOTAL","RETIRADOS","EN CURSO","CUOTA PROM.","PROM. DESC. E.","V. VENTA INICIAL","V. COSTO INICIAL","M.B. INICIAL %","V. VENTA ACTUAL","V. COSTO ACTUAL","M.B. ACTUAL %","OPC."];
|
||
// índices: inicial = 7,8,9 | actual = 10,11,12
|
||
const BG_INI = "#eef4ff", BG_ACT = "#edfff6";
|
||
|
||
function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").replace("%","").trim()); return isNaN(n)?0:n; }
|
||
|
||
// ── Ordenamiento (detecta monto S/, %, fecha dd-mm-yyyy, número o texto) ──
|
||
function _valorOrden(v) {
|
||
const s = String(v ?? "").trim();
|
||
if (s === "" || s === "—" || s === "-") return { t: "vacio", n: 0, s: "" };
|
||
const mf = s.match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{4})$/);
|
||
if (mf) { const [, d, m, y] = mf; return { t: "fecha", n: new Date(+y, +m - 1, +d).getTime(), s }; }
|
||
const limpio = s.replace(/S\/|%|,/g, "").replace(/\s/g, "").trim();
|
||
if (limpio !== "" && !isNaN(Number(limpio))) return { t: "num", n: Number(limpio), s };
|
||
return { t: "texto", n: 0, s: s.toLowerCase() };
|
||
}
|
||
function _cmp(a, b, dir) {
|
||
const mult = dir === "asc" ? 1 : -1;
|
||
const va = _valorOrden(a), vb = _valorOrden(b);
|
||
if (va.t === "vacio" && vb.t === "vacio") return 0;
|
||
if (va.t === "vacio") return 1;
|
||
if (vb.t === "vacio") return -1;
|
||
if (va.t === "texto" || vb.t === "texto") return mult * va.s.localeCompare(vb.s, "es");
|
||
return mult * (va.n - vb.n);
|
||
}
|
||
function _flecha(activo, dir) {
|
||
if (!activo) return <span style={{ opacity: 0.3, marginLeft: 4 }}>⇅</span>;
|
||
return <span style={{ marginLeft: 4 }}>{dir === "asc" ? "▲" : "▼"}</span>;
|
||
}
|
||
|
||
export default function Rentabilidad() {
|
||
const hoy = new Date();
|
||
const [ano, setAno] = useState(hoy.getFullYear());
|
||
// 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 [sedes, setSedes] = useState([]); // multi; vacío = todas (filtro en front)
|
||
const [programas, setProgramas] = useState([]); // multi; vacío = todos (filtro en front)
|
||
const [mostrarInscritos, setMostrarInscritos] = useState(true); // TOTAL, RETIRADOS, EN CURSO
|
||
const [filas, setFilas] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
const [modalVer, setModalVer] = useState(null);
|
||
const [ordCol, setOrdCol] = useState(null); // índice REAL de columna
|
||
const [ordDir, setOrdDir] = useState(null);
|
||
const clickOrden = (i) => {
|
||
if (i === 13) return; // columna OPC. no se ordena
|
||
if (ordCol !== i) { setOrdCol(i); setOrdDir("asc"); }
|
||
else if (ordDir === "asc") setOrdDir("desc");
|
||
else { setOrdCol(null); setOrdDir(null); }
|
||
};
|
||
// Índices de columnas TOTAL(2), RETIRADOS(3), EN CURSO(4) — se ocultan si el check está apagado
|
||
const IDX_INSCRITOS = [2, 3, 4];
|
||
const ocultar = (i) => !mostrarInscritos && IDX_INSCRITOS.includes(i);
|
||
const ANCHOS_BASE = [260, 110, 80, 90, 90, 110, 120, 120, 120, 110, 120, 120, 110, 80];
|
||
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, sedeArg, progArg),
|
||
api.rentabilidad(ano, mes, sedeArg, progArg),
|
||
]);
|
||
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}` + (sedesKey?`_${sedesKey}`:"") + (programasKey?`_${programasKey}`:"");
|
||
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);
|
||
api.rentabilidad(ano, mes, sedeArg, progArg)
|
||
.then((res) => { setFilas(res.filas || []); setLoading(false); })
|
||
.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]);
|
||
const sedesKey = useMemo(() => [...sedes].sort().join(","), [sedes]);
|
||
const programasKey = useMemo(() => [...programas].sort().join(","), [programas]);
|
||
const sedeArg = sedesKey || "TODOS";
|
||
const progArg = programasKey || "TODOS";
|
||
|
||
useEffect(() => {
|
||
let activo = true;
|
||
const lista = mesesKey ? mesesKey.split(",").map(Number) : [];
|
||
setVistaActual(`rentabilidad:${ano}|${mesesKey}|${sedeArg}|${progArg}`);
|
||
const cargarSeguro = () => {
|
||
setLoading(true); setError(null);
|
||
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, sedeArg, progArg)))
|
||
.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, mesesKey, sedeArg, progArg]);
|
||
|
||
const datos = useMemo(() => {
|
||
let base = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL");
|
||
if (ordCol != null && ordDir) base = [...base].sort((a,b)=>_cmp(a[ordCol], b[ordCol], ordDir));
|
||
return base;
|
||
}, [filas, ordCol, ordDir]);
|
||
// 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 {
|
||
ventaIni: total[7], costoIni: total[8], mbIni: total[9],
|
||
ventaAct: total[10], costoAct: total[11], mbAct: total[12],
|
||
};
|
||
}, [total]);
|
||
|
||
function bgCol(ci){ if([7,8,9].includes(ci))return BG_INI; if([10,11,12].includes(ci))return BG_ACT; return ""; }
|
||
|
||
return (
|
||
<div>
|
||
<h1 className="page-title">📈 Rentabilidad</h1>
|
||
|
||
<Filters>
|
||
<SelectBonito label="Año" value={ano} options={ANOS} onChange={(v)=>setAno(+v)} minWidth={110} />
|
||
<MesesMulti label="Mes" meses={meses} onChange={setMeses} />
|
||
<ListaMulti label="Sede" opciones={SEDES} seleccion={sedes} onChange={setSedes} plural="sedes" minWidth={140} />
|
||
<ListaMulti label="Programa" opciones={PROGRAMAS} seleccion={programas} onChange={setProgramas} plural="programas" 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}}>
|
||
<input type="checkbox" checked={mostrarInscritos} onChange={(e)=>setMostrarInscritos(e.target.checked)} />
|
||
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 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, overflow:"auto", maxHeight:"60vh"}}>
|
||
<table {...cols.tableProps}>
|
||
<cols.ColGroup />
|
||
<thead><tr>{COLS.map((c,i)=>({c,i})).filter((o)=>!ocultar(o.i)).map((o, vi)=>{
|
||
const ordenable = o.i !== 13;
|
||
return (
|
||
<th key={o.c} onClick={()=>clickOrden(o.i)}
|
||
style={{position:"sticky",top:0,zIndex:2, cursor: ordenable?"pointer":"default", userSelect:"none"}}>
|
||
{o.c}{ordenable && _flecha(ordCol===o.i, ordDir)}<cols.Resizer index={vi} />
|
||
</th>
|
||
);
|
||
})}</tr></thead>
|
||
<tbody>
|
||
{datos.map((f, ri) => (
|
||
<tr key={ri}>
|
||
{f.slice(0,13).map((v,ci)=>{
|
||
if (ocultar(ci)) return null;
|
||
const isPct = ci===9||ci===12;
|
||
return (
|
||
<td key={ci} className={ci===0?"col-name":""}
|
||
style={{background:bgCol(ci)||undefined,
|
||
textAlign: ci===0?"left":"center",
|
||
fontWeight:isPct?700:undefined,
|
||
minWidth:ci===0?260:undefined}}>{v}</td>
|
||
);
|
||
})}
|
||
<td style={{whiteSpace:"nowrap"}}>
|
||
<button className="btn btn-ghost" style={{padding:"3px 8px",fontSize:11,marginRight:4}}
|
||
onClick={()=>setModalVer({programa:f[0],
|
||
ventaIni:f[7], costoIni:f[8], mbIni:f[9],
|
||
ventaAct:f[10], costoAct:f[11], mbAct:f[12]})}>👁️</button>
|
||
<button className="btn btn-ghost" style={{padding:"3px 8px",fontSize:11}}
|
||
onClick={()=>setModalCostos({programa:f[0]})}>✏️</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{total && (
|
||
<tr className="total-row">
|
||
{total.slice(0,13).map((v,ci)=>(
|
||
ocultar(ci) ? null :
|
||
<td key={ci} className={ci===0?"col-name":""}
|
||
style={{textAlign:ci===0?"left":"center",
|
||
position:"sticky",bottom:0,zIndex:9,
|
||
backgroundColor:"#eff6ff",boxShadow:"inset 0 1px 0 0 #cbd5e1"}}>{v}</td>
|
||
))}
|
||
<td style={{position:"sticky",bottom:0,zIndex:9,backgroundColor:"#eff6ff",boxShadow:"inset 0 1px 0 0 #cbd5e1"}}>—</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</>}
|
||
|
||
{modalVer && <ModalVerDetalle programa={modalVer.programa} ano={ano} mes={mes} resumen={modalVer} onClose={()=>setModalVer(null)} />}
|
||
{modalCostos && <ModalCostos programa={modalCostos.programa} ano={ano} mes={mes} onClose={()=>setModalCostos(null)} onGuardado={cargar} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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" }}>
|
||
{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, resumen, onClose }) {
|
||
const [filas, setFilas] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [ordCol, setOrdCol] = useState(null);
|
||
const [ordDir, setOrdDir] = useState(null);
|
||
const clickOrden = (i) => {
|
||
if (ordCol !== i) { setOrdCol(i); setOrdDir("asc"); }
|
||
else if (ordDir === "asc") setOrdDir("desc");
|
||
else { setOrdCol(null); setOrdDir(null); }
|
||
};
|
||
useEffect(() => {
|
||
let activo = true; setLoading(true);
|
||
api.rentabilidadDetalle(programa, ano, mes)
|
||
.then((res)=>{ if(activo){setFilas(res.filas||[]);setLoading(false);} })
|
||
.catch(()=>{ if(activo){setFilas([]);setLoading(false);} });
|
||
return ()=>{activo=false;};
|
||
}, [programa, ano, mes]);
|
||
|
||
const COLS_DET = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","VALOR CUOTA","PROM. DESC. E.","VALOR VENTA","ESTADO"];
|
||
|
||
// Ordena dejando la fila TOTAL GENERAL siempre al final.
|
||
const filasOrd = useMemo(() => {
|
||
if (ordCol == null || !ordDir) return filas;
|
||
const tot = filas.filter((f)=>String(f[0]).toUpperCase()==="TOTAL GENERAL");
|
||
const rest = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL");
|
||
rest.sort((a,b)=>_cmp(a[ordCol], b[ordCol], ordDir));
|
||
return [...rest, ...tot];
|
||
}, [filas, ordCol, ordDir]);
|
||
|
||
return (
|
||
<Modal title={`👁️ Detalle — ${programa}`} onClose={onClose} width={1100}>
|
||
{resumen && (
|
||
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:16,marginBottom:16}}>
|
||
<TarjetaRenta titulo="📊 INICIAL" color="#1e3a5f"
|
||
venta={resumen.ventaIni} costo={resumen.costoIni} mb={resumen.mbIni} />
|
||
<TarjetaRenta titulo="📈 ACTUAL" color="#1a5f4a"
|
||
venta={resumen.ventaAct} costo={resumen.costoAct} mb={resumen.mbAct} />
|
||
</div>
|
||
)}
|
||
{loading ? <Loader text="Cargando detalle..." /> :
|
||
filas.length===0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin alumnos.</div> :
|
||
<div className="table-wrap" style={{overflowX:"auto", overflowY:"auto", maxHeight:"65vh"}}>
|
||
<table style={{minWidth:1100, width:"max-content"}}>
|
||
<thead><tr>{COLS_DET.map((c,i)=>(
|
||
<th key={c} onClick={()=>clickOrden(i)}
|
||
style={{whiteSpace:"nowrap", position:"sticky", top:0, zIndex:2, cursor:"pointer", userSelect:"none"}}>
|
||
{c}{_flecha(ordCol===i, ordDir)}
|
||
</th>
|
||
))}</tr></thead>
|
||
<tbody>
|
||
{filasOrd.map((f,i)=>{
|
||
const esTot = String(f[0]).toUpperCase()==="TOTAL GENERAL";
|
||
const stTot = esTot ? {position:"sticky",bottom:0,zIndex:9,
|
||
backgroundColor:"#dbeafe",fontWeight:700,color:"#1e3a5f",
|
||
boxShadow:"inset 0 1px 0 0 #93c5fd"} : {};
|
||
return (
|
||
<tr key={i}>
|
||
{f.map((v,j)=>(
|
||
<td key={j} className={j===1?"col-name":""}
|
||
style={{textAlign:j===1?"left":"center", whiteSpace:"nowrap", ...stTot}}>{v}</td>
|
||
))}
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>}
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function ModalCostos({ programa, ano, mes, onClose, onGuardado }) {
|
||
const [costos, setCostos] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [guardando, setGuardando] = useState(false);
|
||
const [msg, setMsg] = useState(null);
|
||
// [keyGuardar, etiqueta, prefijoBackend]
|
||
const CAMPOS = [["epp","COSTO EPP","epp"],["certificado","COSTO CERTIFICADO","cert"],["docente","COSTO DOCENTE","doc"],["marketing","COSTO MARKETING","mkt"],["consumibles","COSTO CONSUMIBLES","cons"]];
|
||
|
||
const [ini, setIni] = useState({});
|
||
const [act, setAct] = useState({});
|
||
|
||
useEffect(() => {
|
||
let activo = true; setLoading(true);
|
||
api.rentabilidadCostos(programa, ano, mes)
|
||
.then((res)=>{
|
||
if(!activo) return;
|
||
const c = res.costos || {};
|
||
setCostos(c);
|
||
const gi={}, ga={};
|
||
CAMPOS.forEach(([k,,bp])=>{ gi[k]=+(c[`${bp}_inicial`]||0); ga[k]=+(c[`${bp}_actual`]||0); });
|
||
setIni(gi); setAct(ga); setLoading(false);
|
||
})
|
||
.catch(()=>{ if(activo){setCostos(null);setLoading(false);} });
|
||
return ()=>{activo=false;};
|
||
}, [programa, ano, mes]);
|
||
|
||
const totIni = Object.values(ini).reduce((a,b)=>a+(+b||0),0);
|
||
const totAct = Object.values(act).reduce((a,b)=>a+(+b||0),0);
|
||
|
||
async function guardar() {
|
||
setGuardando(true); setMsg(null);
|
||
try {
|
||
await api.guardarCostos(costos.num_indice, ini, act);
|
||
if (onGuardado) onGuardado(); // recarga la tabla principal
|
||
onClose(); // cierra el modal
|
||
} catch (e) {
|
||
setMsg({ ok:false, txt:"❌ Error al guardar. Verifica Supabase." });
|
||
setGuardando(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Modal title={`✏️ Editar Costos — ${programa}`} onClose={onClose} width={760}>
|
||
{loading ? <Loader text="Cargando costos..." /> :
|
||
!costos ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin datos de costos.</div> :
|
||
<>
|
||
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:16}}>
|
||
<Columna titulo="INICIAL" color="#1e3a5f" campos={CAMPOS} valores={ini} setVal={setIni} total={totIni} />
|
||
<Columna titulo="ACTUAL" color="#1a5f4a" campos={CAMPOS} valores={act} setVal={setAct} total={totAct} />
|
||
</div>
|
||
{msg && <div style={{marginTop:14,padding:10,borderRadius:8,fontSize:13,
|
||
background:msg.ok?"#d1fae5":"#fee2e2",color:msg.ok?"#065f46":"#991b1b"}}>{msg.txt}</div>}
|
||
<div style={{display:"flex",gap:10,marginTop:16}}>
|
||
<button className="btn btn-primary" disabled={guardando} onClick={guardar}>
|
||
{guardando ? "Guardando..." : "💾 Guardar"}
|
||
</button>
|
||
<button className="btn btn-ghost" onClick={onClose}>Cerrar</button>
|
||
</div>
|
||
</>}
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
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>
|
||
<div style={{background:color,color:"#fff",borderRadius:8,padding:"8px 12px",
|
||
marginBottom:10,fontWeight:600,fontSize:12,textAlign:"center"}}>{titulo}</div>
|
||
{campos.map(([k,label])=>(
|
||
<div key={k} style={{marginBottom:8}}>
|
||
<label style={{fontSize:11,fontWeight:600,color:"#64748b",display:"block",marginBottom:3}}>{label}</label>
|
||
<input type="number" value={valores[k]??0} min="0" step="100"
|
||
onChange={(e)=>setVal({...valores,[k]:+e.target.value})}
|
||
style={{width:"100%",padding:"7px 10px",border:"1px solid #cbd5e1",borderRadius:8,fontSize:13}} />
|
||
</div>
|
||
))}
|
||
<div style={{background:"#f1f5f9",borderRadius:8,padding:10,textAlign:"center",
|
||
fontWeight:700,color,marginTop:8}}>
|
||
TOTAL: S/ {total.toLocaleString("es-PE",{maximumFractionDigits:0})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Filtro multi-selección con casillas (Sede, Programa). Vacío = todos.
|
||
function ListaMulti({ label, opciones, seleccion, onChange, plural = "opciones", minWidth = 150 }) {
|
||
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 = (v) => {
|
||
const set = new Set(seleccion);
|
||
set.has(v) ? set.delete(v) : set.add(v);
|
||
onChange([...set]);
|
||
};
|
||
const texto = seleccion.length === 0 ? "— Todos —"
|
||
: seleccion.length === 1 ? seleccion[0]
|
||
: `${seleccion.length} ${plural}`;
|
||
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" }}>{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, maxHeight: 300, overflowY: "auto" }}>
|
||
{opciones.map((o) => {
|
||
const on = seleccion.includes(o);
|
||
return (
|
||
<label key={o} 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(o)} />
|
||
{o}
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|