Files
dashboard-completo/frontend/src/pages/Rentabilidad.jsx

959 lines
47 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>
);
}