// 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 ⇅; return {dir === "asc" ? "▲" : "▼"}; } 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 (

📈 Rentabilidad

setAno(+v)} minWidth={110} />
{loading ? : error ? : <> {kpis && (
)}
{COLS.map((c,i)=>({c,i})).filter((o)=>!ocultar(o.i)).map((o, vi)=>{ const ordenable = o.i !== 13; return ( ); })} {datos.map((f, ri) => ( {f.slice(0,13).map((v,ci)=>{ if (ocultar(ci)) return null; const isPct = ci===9||ci===12; return ( ); })} ))} {total && ( {total.slice(0,13).map((v,ci)=>( ocultar(ci) ? null : ))} )}
clickOrden(o.i)} style={{position:"sticky",top:0,zIndex:2, cursor: ordenable?"pointer":"default", userSelect:"none"}}> {o.c}{ordenable && _flecha(ordCol===o.i, ordDir)}
{v}
{v}—
} {modalVer && setModalVer(null)} />} {modalCostos && setModalCostos(null)} onGuardado={cargar} />}
); } // Select de una opcion, desplegable custom (mismo estilo que el filtro de Mes). function SelectBonito({ label, value, options, onChange, minWidth = 150 }) { const [abierto, setAbierto] = useState(false); const ref = useRef(null); const opts = options.map((o) => (typeof o === "object" ? o : { value: o, label: String(o) })); const actual = opts.find((o) => String(o.value) === String(value)); useEffect(() => { const fuera = (e) => { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); }; document.addEventListener("mousedown", fuera); return () => document.removeEventListener("mousedown", fuera); }, []); return (
{abierto && (
{opts.map((o) => { const on = String(o.value) === String(value); return (
{ onChange(o.value); setAbierto(false); }} style={{ padding: "8px 10px", borderRadius: 6, cursor: "pointer", fontSize: 13, fontWeight: on ? 700 : 500, color: on ? "#1e3a5f" : "#334155", background: on ? "#eff6ff" : "transparent" }} onMouseEnter={(e) => { if (!on) e.currentTarget.style.background = "#f8fafc"; }} onMouseLeave={(e) => { if (!on) e.currentTarget.style.background = "transparent"; }}> {o.label}
); })}
)}
); } // Filtro de MES con casillas (multi-seleccion). meses = array de 1..12. function MesesMulti({ label, meses, onChange }) { const [abierto, setAbierto] = useState(false); const ref = useRef(null); useEffect(() => { const fuera = (e) => { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); }; document.addEventListener("mousedown", fuera); return () => document.removeEventListener("mousedown", fuera); }, []); const toggle = (m) => { const set = new Set(meses); if (set.has(m)) set.delete(m); else set.add(m); onChange([...set].sort((a, b) => a - b)); }; const texto = meses.length === 0 ? "— Elegir —" : meses.length === 1 ? MESES[meses[0] - 1] : meses.length === 12 ? "TODOS" : `${meses.length} meses`; return (
{abierto && (
{MESES.map((m, i) => { const val = i + 1; const on = meses.includes(val); return ( ); })}
)}
); } function ModalVerDetalle({ programa, ano, mes, 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 ( {resumen && (
)} {loading ? : filas.length===0 ?
Sin alumnos.
:
{COLS_DET.map((c,i)=>( ))} {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 ( {f.map((v,j)=>( ))} ); })}
clickOrden(i)} style={{whiteSpace:"nowrap", position:"sticky", top:0, zIndex:2, cursor:"pointer", userSelect:"none"}}> {c}{_flecha(ordCol===i, ordDir)}
{v}
}
); } 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 ( {loading ? : !costos ?
Sin datos de costos.
: <>
{msg &&
{msg.txt}
}
}
); } function TarjetaRenta({ titulo, color, venta, costo, mb }) { const fila = (k, v, bold) => (
{k} {v}
); return (
{titulo}
{fila("Venta", venta)} {fila("Costo", costo)} {fila("Margen Bruto", mb, true)}
); } function Columna({ titulo, color, campos, valores, setVal, total }) { return (
{titulo}
{campos.map(([k,label])=>(
setVal({...valores,[k]:+e.target.value})} style={{width:"100%",padding:"7px 10px",border:"1px solid #cbd5e1",borderRadius:8,fontSize:13}} />
))}
TOTAL: S/ {total.toLocaleString("es-PE",{maximumFractionDigits:0})}
); } // 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 (
{abierto && (
{opciones.map((o) => { const on = seleccion.includes(o); return ( ); })}
)}
); }