Actualizar frontend/src/pages/Rentabilidad.jsx
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
// src/pages/Rentabilidad.jsx
|
// src/pages/Rentabilidad.jsx
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo, useRef } from "react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
import { Loader, ErrorBox, Filters } from "../components/UI";
|
||||||
import Modal from "../components/Modal";
|
import Modal from "../components/Modal";
|
||||||
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||||||
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
|
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
|
||||||
@@ -20,7 +20,9 @@ function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,""
|
|||||||
export default function Rentabilidad() {
|
export default function Rentabilidad() {
|
||||||
const hoy = new Date();
|
const hoy = new Date();
|
||||||
const [ano, setAno] = useState(hoy.getFullYear());
|
const [ano, setAno] = useState(hoy.getFullYear());
|
||||||
const [mes, setMes] = useState(hoy.getMonth() + 1);
|
// meses = array de meses seleccionados (multi). 'mes' (primero) se usa en modales/export.
|
||||||
|
const [meses, setMeses] = useState([hoy.getMonth() + 1]);
|
||||||
|
const mes = meses.length ? Math.min(...meses) : (hoy.getMonth() + 1);
|
||||||
const [sede, setSede] = useState("TODOS");
|
const [sede, setSede] = useState("TODOS");
|
||||||
const [programa, setPrograma] = useState("TODOS");
|
const [programa, setPrograma] = useState("TODOS");
|
||||||
const [mostrarInscritos, setMostrarInscritos] = useState(true); // TOTAL, RETIRADOS, EN CURSO
|
const [mostrarInscritos, setMostrarInscritos] = useState(true); // TOTAL, RETIRADOS, EN CURSO
|
||||||
@@ -35,6 +37,389 @@ export default function Rentabilidad() {
|
|||||||
const anchosVisibles = ANCHOS_BASE.filter((_, i) => !ocultar(i));
|
const anchosVisibles = ANCHOS_BASE.filter((_, i) => !ocultar(i));
|
||||||
const cols = useColumnasAjustables(anchosVisibles);
|
const cols = useColumnasAjustables(anchosVisibles);
|
||||||
const [modalCostos, setModalCostos] = useState(null);
|
const [modalCostos, setModalCostos] = useState(null);
|
||||||
|
const [exportando, setExportando] = useState(false);
|
||||||
|
const [exportandoAnual, setExportandoAnual] = useState(false);
|
||||||
|
|
||||||
|
// Exportar TODOS los inicios del año (enero → mes actual), agrupados por mes.
|
||||||
|
async function exportarAnual() {
|
||||||
|
if (exportandoAnual) return;
|
||||||
|
setExportandoAnual(true);
|
||||||
|
try {
|
||||||
|
const hoy = new Date();
|
||||||
|
const mesHasta = (ano === hoy.getFullYear()) ? (hoy.getMonth() + 1) : 12;
|
||||||
|
const MESES_UP = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||||||
|
|
||||||
|
// Traer rentabilidad de cada mes (enero..mesHasta), en paralelo
|
||||||
|
const peticiones = [];
|
||||||
|
for (let m = 1; m <= mesHasta; m++) peticiones.push(api.rentabilidad(ano, m, "TODOS", "TODOS"));
|
||||||
|
const resultados = await Promise.all(peticiones);
|
||||||
|
|
||||||
|
const XLSX = await import("xlsx-js-style");
|
||||||
|
const HEAD = ["Sede","Programa","Inicio","Fin","Total Inscritos","Retirados","En curso",
|
||||||
|
"Venta Inicial","Costo Inicial","M.B Inicial %","Venta Actual","Costo Actual","M.B Actual %",
|
||||||
|
"Costo x Certificado","Costo por docente","Costo por MKT","Costo por Consumibles"];
|
||||||
|
const NCOL = HEAD.length; // 17
|
||||||
|
const B = { style:"thin", color:{rgb:"BFBFBF"} };
|
||||||
|
const border = { top:B, bottom:B, left:B, right:B };
|
||||||
|
const money = '_-[$S/-280A] * #,##0_-;-[$S/-280A] * #,##0_-;_-[$S/-280A] * "-"??_-;_-@_-';
|
||||||
|
const pctFmt = '0.0%';
|
||||||
|
const dateFmt = "dd/mm/yyyy";
|
||||||
|
const num = (v) => toNum(v);
|
||||||
|
|
||||||
|
// Colores idénticos al Excel de referencia
|
||||||
|
const VERDE = "E2EFDA", AZUL = "DAE3F3", DORADO = "FFE699", MORADO = "BF95AA", GRIS = "D9D9D9";
|
||||||
|
// Color de encabezado por columna: 0-6 verde, 7-9 azul, 10-16 dorado
|
||||||
|
const headFill = (c) => c <= 6 ? VERDE : (c <= 9 ? AZUL : DORADO);
|
||||||
|
|
||||||
|
const fechaSerial = (s) => {
|
||||||
|
const t = String(s || "").trim();
|
||||||
|
let d, m, y;
|
||||||
|
if (t.includes("/")) { [d,m,y] = t.split("/").map(Number); }
|
||||||
|
else if (t.includes("-")) { const p = t.split("-").map(Number);
|
||||||
|
if (p[0] > 31) { [y,m,d] = p; } else { [d,m,y] = p; } }
|
||||||
|
if (!d || !m || !y) return null;
|
||||||
|
return Math.floor((Date.UTC(y,m-1,d) - Date.UTC(1899,11,30)) / 86400000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ws = {};
|
||||||
|
const merges = [];
|
||||||
|
let R = 0; // fila actual (0-based)
|
||||||
|
|
||||||
|
resultados.forEach((res, idx) => {
|
||||||
|
const m = idx + 1;
|
||||||
|
const base = (res.filas || []).filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL");
|
||||||
|
const ex = res.extras || {};
|
||||||
|
if (!base.length) return; // mes sin inicios -> se omite
|
||||||
|
|
||||||
|
// 1) Título del mes (combinado a lo ancho, morado)
|
||||||
|
for (let c = 0; c < NCOL; c++)
|
||||||
|
ws[XLSX.utils.encode_cell({r:R,c})] = { v: c===0 ? `${MESES_UP[m-1]} ${ano}` : "", t:"s",
|
||||||
|
s:{ font:{bold:true,sz:12,color:{rgb:"000000"}}, fill:{fgColor:{rgb:MORADO}},
|
||||||
|
alignment:{horizontal:"center",vertical:"center"}, border } };
|
||||||
|
merges.push({ s:{r:R,c:0}, e:{r:R,c:NCOL-1} });
|
||||||
|
R++;
|
||||||
|
|
||||||
|
// 2) Encabezados (colores por grupo)
|
||||||
|
HEAD.forEach((h, c) => { ws[XLSX.utils.encode_cell({r:R,c})] = { v:h, t:"s",
|
||||||
|
s:{ font:{bold:true,color:{rgb:"000000"},sz:10}, fill:{fgColor:{rgb:headFill(c)}},
|
||||||
|
alignment:{horizontal:"center",vertical:"center",wrapText:true}, border } }; });
|
||||||
|
R++;
|
||||||
|
|
||||||
|
// Acumuladores del mes
|
||||||
|
const T = {ins:0,ret:0,cur:0,vi:0,ci:0,va:0,ca:0,cert:0,doc:0,mkt:0,cons:0};
|
||||||
|
|
||||||
|
// 3) Filas de datos
|
||||||
|
base.forEach((f) => {
|
||||||
|
const prog = String(f[0]).trim();
|
||||||
|
const e = ex[prog] || {};
|
||||||
|
const cel = (c, v, t, z, al) => { ws[XLSX.utils.encode_cell({r:R,c})] =
|
||||||
|
{ v: v ?? "", t, ...(z?{z}:{}), s:{ border, alignment:{horizontal: al || "center"} } }; };
|
||||||
|
const serial = fechaSerial(f[1]);
|
||||||
|
const ti=num(e.total_ins), re=num(e.retirados), cu=num(e.en_curso);
|
||||||
|
const vi=num(e.venta_ini), ci=num(e.costo_ini), va=num(e.venta_act), ca=num(e.costo_act);
|
||||||
|
const cert=num(e.costo_cert), doc=num(e.costo_doc), mkt=num(e.costo_mkt), cons=num(e.costo_cons);
|
||||||
|
const xr = R + 1; // fila real Excel (1-based)
|
||||||
|
const fcell = (c, formula) => { ws[XLSX.utils.encode_cell({r:R,c})] =
|
||||||
|
{ t:"n", z:pctFmt, f:formula, s:{border,alignment:{horizontal:"center"}} }; };
|
||||||
|
cel(0, String(e.sede || ""), "s", null, "left");
|
||||||
|
cel(1, prog, "s", null, "left");
|
||||||
|
if (serial != null) ws[XLSX.utils.encode_cell({r:R,c:2})] = { v:serial, t:"n", z:dateFmt, s:{border,alignment:{horizontal:"center"}} };
|
||||||
|
else cel(2, String(f[1]||""), "s");
|
||||||
|
cel(3, "", "s"); // Fin (no disponible)
|
||||||
|
cel(4, ti, "n"); cel(5, re, "n"); cel(6, cu, "n");
|
||||||
|
cel(7, vi, "n", money); cel(8, ci, "n", money);
|
||||||
|
fcell(9, `1-I${xr}/H${xr}`); // M.B Inicial % = 1 - Costo Ini / Venta Ini
|
||||||
|
cel(10, va, "n", money); cel(11, ca, "n", money);
|
||||||
|
fcell(12, `1-L${xr}/K${xr}`); // M.B Actual % = 1 - Costo Act / Venta Act
|
||||||
|
cel(13, cert, "n", money); cel(14, doc, "n", money); cel(15, mkt, "n", money); cel(16, cons, "n", money);
|
||||||
|
T.ins+=ti; T.ret+=re; T.cur+=cu; T.vi+=vi; T.ci+=ci; T.va+=va; T.ca+=ca;
|
||||||
|
T.cert+=cert; T.doc+=doc; T.mkt+=mkt; T.cons+=cons;
|
||||||
|
R++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4) Fila TOTAL del mes (gris)
|
||||||
|
const tCell = (c, v, t, z, al) => { ws[XLSX.utils.encode_cell({r:R,c})] =
|
||||||
|
{ v: v ?? "", t, ...(z?{z}:{}), s:{ font:{bold:true}, fill:{fgColor:{rgb:GRIS}},
|
||||||
|
border, alignment:{horizontal: al || "center"} } }; };
|
||||||
|
// "TOTAL" combinado sobre col 0-3
|
||||||
|
const xT = R + 1; // fila total Excel (1-based)
|
||||||
|
for (let c=0;c<4;c++) tCell(c, c===0?"TOTAL":"", "s");
|
||||||
|
merges.push({ s:{r:R,c:0}, e:{r:R,c:3} });
|
||||||
|
tCell(4, T.ins, "n"); tCell(5, T.ret, "n"); tCell(6, T.cur, "n");
|
||||||
|
tCell(7, T.vi, "n", money); tCell(8, T.ci, "n", money);
|
||||||
|
ws[XLSX.utils.encode_cell({r:R,c:9})] = { t:"n", z:pctFmt, f:`1-I${xT}/H${xT}`,
|
||||||
|
s:{ font:{bold:true}, fill:{fgColor:{rgb:GRIS}}, border, alignment:{horizontal:"center"} } };
|
||||||
|
tCell(10, T.va, "n", money); tCell(11, T.ca, "n", money);
|
||||||
|
ws[XLSX.utils.encode_cell({r:R,c:12})] = { t:"n", z:pctFmt, f:`1-L${xT}/K${xT}`,
|
||||||
|
s:{ font:{bold:true}, fill:{fgColor:{rgb:GRIS}}, border, alignment:{horizontal:"center"} } };
|
||||||
|
tCell(13, T.cert, "n", money); tCell(14, T.doc, "n", money);
|
||||||
|
tCell(15, T.mkt, "n", money); tCell(16, T.cons, "n", money);
|
||||||
|
R++;
|
||||||
|
|
||||||
|
// 5) Fila vacía entre meses
|
||||||
|
R++;
|
||||||
|
});
|
||||||
|
|
||||||
|
ws["!merges"] = merges;
|
||||||
|
ws["!ref"] = XLSX.utils.encode_range({ s:{r:0,c:0}, e:{r:Math.max(R,1), c:NCOL-1} });
|
||||||
|
ws["!cols"] = [{wch:10},{wch:22},{wch:12},{wch:12},{wch:13},{wch:10},{wch:10},
|
||||||
|
{wch:13},{wch:13},{wch:11},{wch:13},{wch:13},{wch:11},{wch:15},{wch:14},{wch:13},{wch:17}];
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, "Hoja1");
|
||||||
|
XLSX.writeFile(wb, `margen_bruto_${ano}.xlsx`);
|
||||||
|
} catch (e) {
|
||||||
|
alert("No se pudo exportar: " + (e.message || e));
|
||||||
|
} finally {
|
||||||
|
setExportandoAnual(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exportar Excel: cruza RENTABILIDAD (filas + extras) con OCUPABILIDAD (por programa).
|
||||||
|
async function exportarExcel() {
|
||||||
|
if (exportando) return;
|
||||||
|
setExportando(true);
|
||||||
|
try {
|
||||||
|
// Traer ocupabilidad y rentabilidad frescas del mismo periodo/filtros
|
||||||
|
const [ocu, rent] = await Promise.all([
|
||||||
|
api.ocupabilidad(ano, mes, sede, programa),
|
||||||
|
api.rentabilidad(ano, mes, sede, programa),
|
||||||
|
]);
|
||||||
|
const ocuPorProg = {};
|
||||||
|
(ocu.datos || []).forEach((d) => {
|
||||||
|
const k = String(d.programa_frecuencia || "").trim();
|
||||||
|
if (k) ocuPorProg[k] = d;
|
||||||
|
});
|
||||||
|
const extras = rent.extras || {};
|
||||||
|
const filaTotal = (rent.filas || []).find((f) => String(f[0]).toUpperCase() === "TOTAL GENERAL");
|
||||||
|
const base = (rent.filas || []).filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL");
|
||||||
|
if (!base.length) { alert("No hay datos para exportar."); return; }
|
||||||
|
|
||||||
|
const XLSX = await import("xlsx-js-style");
|
||||||
|
const COLS_EXP = [
|
||||||
|
"Nombre Programa","Fecha","Matriculas REFRI","Matriculas CONTINUIDAD",
|
||||||
|
"Matriculas Nuevas","INSCRITOS TOTALES","Matriculas P.C.","Promedio Cuotas",
|
||||||
|
"Promedio Desc. E.","Valor Venta","Costo x Programa","MB %",
|
||||||
|
"Costo EPP + CERTIFICADO","Costo Docentes","Costo Consumibles","Costo MKT",
|
||||||
|
];
|
||||||
|
const num = (v) => toNum(v);
|
||||||
|
const money = '"S/" #,##0';
|
||||||
|
const pctFmt = '0.00%';
|
||||||
|
const dateFmt = "dd/mm/yyyy";
|
||||||
|
|
||||||
|
// ── Estilos base ──
|
||||||
|
const B = { style: "thin", color: { rgb: "BFBFBF" } };
|
||||||
|
const border = { top:B, bottom:B, left:B, right:B };
|
||||||
|
const AZUL = "2F5496", CAFE = "833C00";
|
||||||
|
const hCell = (rgb) => ({ font:{bold:true,color:{rgb:"FFFFFF"},sz:11},
|
||||||
|
fill:{fgColor:{rgb}}, alignment:{horizontal:"center",vertical:"center",wrapText:true}, border });
|
||||||
|
const HEAD_CAFE = new Set([9,10,11,12,13,14,15]); // Valor Venta..Costo MKT
|
||||||
|
|
||||||
|
// Interpola blanco -> color destino según t (0..1). Devuelve hex sin #.
|
||||||
|
const lerpHex = (from, to, t) => {
|
||||||
|
const p = (h) => [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)];
|
||||||
|
const [r1,g1,b1] = p(from), [r2,g2,b2] = p(to);
|
||||||
|
const c = (a,b) => Math.round(a + (b-a)*t).toString(16).padStart(2,"0");
|
||||||
|
return c(r1,r2)+c(g1,g2)+c(b1,b2);
|
||||||
|
};
|
||||||
|
// Fondo rojo escalado por valor de Desc.E (0=blanco, >=200=rojo fuerte)
|
||||||
|
const fondoDesc = (v) => {
|
||||||
|
if (!v || v <= 0) return "FFFFFF";
|
||||||
|
const t = Math.min(v, 200) / 200; // 0..1
|
||||||
|
return lerpHex("FFFFFF", "E06666", t); // blanco -> rojo
|
||||||
|
};
|
||||||
|
const naranjaClaro = "FDEBD0"; // Consumibles
|
||||||
|
const azulClaroMat = "DDEBF7"; // Matriculas (refri/contin/nuevas/total)
|
||||||
|
const verdeMB = "C6EFCE", rojoMB = "F4C7C3";
|
||||||
|
|
||||||
|
// Convierte "dd-mm-YYYY" o "dd/mm/YYYY" o "YYYY-mm-dd" a serial de Excel (fecha real)
|
||||||
|
const fechaSerial = (s) => {
|
||||||
|
const t = String(s || "").trim();
|
||||||
|
let d, m, y;
|
||||||
|
if (t.includes("/")) { [d,m,y] = t.split("/").map(Number); }
|
||||||
|
else if (t.includes("-")) {
|
||||||
|
const p = t.split("-").map(Number);
|
||||||
|
if (p[0] > 31) { [y,m,d] = p; } else { [d,m,y] = p; }
|
||||||
|
}
|
||||||
|
if (!d || !m || !y) return null;
|
||||||
|
const dt = new Date(Date.UTC(y, m-1, d));
|
||||||
|
const serial = Math.floor((dt - new Date(Date.UTC(1899,11,30))) / 86400000);
|
||||||
|
return serial;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ws = {};
|
||||||
|
COLS_EXP.forEach((h, c) => {
|
||||||
|
ws[XLSX.utils.encode_cell({r:0,c})] = { v:h, t:"s", s: hCell(HEAD_CAFE.has(c) ? CAFE : AZUL) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const T = {refri:0, contin:0, nuevas:0, ins:0, pc:0, venta:0, costo:0, eppc:0, doc:0, cons:0, mkt:0};
|
||||||
|
let sumCuota=0, cntCuota=0, sumDesc=0, cntDesc=0;
|
||||||
|
|
||||||
|
base.forEach((f, ri) => {
|
||||||
|
const r = ri + 1;
|
||||||
|
const xr = r + 1; // fila real en Excel (1-based, +header)
|
||||||
|
const prog = String(f[0] || "").trim();
|
||||||
|
const o = ocuPorProg[prog] || {};
|
||||||
|
const ex = extras[prog] || {};
|
||||||
|
const refri = num(o.Descuento);
|
||||||
|
const contin = num(o.Inscritos_Continuidad);
|
||||||
|
const totalIns = num(o.Inscritos_Totales);
|
||||||
|
const nuevas = Math.max(0, totalIns - refri - contin);
|
||||||
|
const insTotal = refri + contin + nuevas;
|
||||||
|
const pc = num(o.Inscritos_PC);
|
||||||
|
const cuota = num(f[5]);
|
||||||
|
const descE = num(f[6]);
|
||||||
|
const vVenta = num(f[7]);
|
||||||
|
const mb = num(f[9]);
|
||||||
|
const doc = num(ex.costo_doc);
|
||||||
|
const cons = num(ex.costo_cons);
|
||||||
|
const mkt = num(ex.costo_mkt);
|
||||||
|
const eppc = num(ex.costo_epp_cert); // valor real del dashboard (EPP + Certificado)
|
||||||
|
const costo = eppc + doc + cons + mkt;
|
||||||
|
const fInicio = o.fch_inicio || f[1] || "";
|
||||||
|
const serial = fechaSerial(fInicio);
|
||||||
|
|
||||||
|
const cel = (c,v,t,z,extra) => { ws[XLSX.utils.encode_cell({r,c})] =
|
||||||
|
{ v, t, ...(z?{z}:{}), s:{ border, alignment:{horizontal:"center"}, ...(extra||{}) } }; };
|
||||||
|
// Programa (izquierda)
|
||||||
|
ws[XLSX.utils.encode_cell({r,c:0})] = { v:prog, t:"s", s:{ border, alignment:{horizontal:"left"} } };
|
||||||
|
// Fecha (serial con formato dd/mm/yyyy; si no parsea, texto)
|
||||||
|
if (serial != null) ws[XLSX.utils.encode_cell({r,c:1})] = { v:serial, t:"n", z:dateFmt, s:{border,alignment:{horizontal:"center"}} };
|
||||||
|
else cel(1, String(fInicio), "s");
|
||||||
|
// Matriculas: azul claro
|
||||||
|
cel(2, refri, "n", null, {fill:{fgColor:{rgb:azulClaroMat}}});
|
||||||
|
cel(3, contin, "n", null, {fill:{fgColor:{rgb:azulClaroMat}}});
|
||||||
|
cel(4, nuevas, "n", null, {fill:{fgColor:{rgb:azulClaroMat}}});
|
||||||
|
cel(5, insTotal, "n", null, {fill:{fgColor:{rgb:azulClaroMat}}});
|
||||||
|
cel(6, pc, "n");
|
||||||
|
cel(7, cuota, "n", money);
|
||||||
|
// Desc.E: fondo rojo según valor
|
||||||
|
cel(8, descE, "n", money, {fill:{fgColor:{rgb:fondoDesc(descE)}}});
|
||||||
|
cel(9, vVenta, "n", money);
|
||||||
|
// Costo x Programa: FÓRMULA =M+N+O+P
|
||||||
|
ws[XLSX.utils.encode_cell({r,c:10})] = { t:"n", z:money,
|
||||||
|
f:`M${xr}+N${xr}+O${xr}+P${xr}`, s:{border,alignment:{horizontal:"center"}} };
|
||||||
|
// MB %: FÓRMULA =1-K/J (verde si >65%, rojo si menos)
|
||||||
|
ws[XLSX.utils.encode_cell({r,c:11})] = { t:"n", z:pctFmt, f:`1-K${xr}/J${xr}`,
|
||||||
|
s:{ border, alignment:{horizontal:"center"}, fill:{fgColor:{rgb: mb>65 ? verdeMB : rojoMB}} } };
|
||||||
|
// Costo EPP+CERT: valor real del dashboard (COSTO EPP + COSTO CERTIFICADO)
|
||||||
|
cel(12, eppc, "n", money);
|
||||||
|
cel(13, doc, "n", money);
|
||||||
|
// Consumibles: naranja super claro
|
||||||
|
cel(14, cons, "n", money, {fill:{fgColor:{rgb:naranjaClaro}}});
|
||||||
|
cel(15, mkt, "n", money);
|
||||||
|
|
||||||
|
T.refri+=refri; T.contin+=contin; T.nuevas+=nuevas; T.ins+=insTotal; T.pc+=pc;
|
||||||
|
T.venta+=vVenta; T.costo+=costo; T.eppc+=eppc; T.doc+=doc; T.cons+=cons; T.mkt+=mkt;
|
||||||
|
if (cuota>0){ sumCuota+=cuota; cntCuota++; }
|
||||||
|
if (descE>0){ sumDesc+=descE; cntDesc++; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Fila TOTAL ──
|
||||||
|
const rT = base.length + 1;
|
||||||
|
const totFill = { fill:{fgColor:{rgb:"DDEBF7"}} };
|
||||||
|
const totCell = (val,t,z) => ({ v:val??"", t, ...(z?{z}:{}), s:{ font:{bold:true},
|
||||||
|
border, alignment:{horizontal:"center"}, ...totFill } });
|
||||||
|
const mbTot = T.venta>0 ? (1 - T.costo/T.venta) : 0;
|
||||||
|
// CUOTA PROM. y DESC.E TOTAL: usar el promedio ponderado del dashboard
|
||||||
|
// (fila TOTAL GENERAL de rentabilidad), no el promedio simple de las filas.
|
||||||
|
const cuotaTot = filaTotal ? num(filaTotal[5]) : (cntCuota? sumCuota/cntCuota : 0);
|
||||||
|
const descTot = filaTotal ? num(filaTotal[6]) : (cntDesc? sumDesc/cntDesc : 0);
|
||||||
|
const totRow = [
|
||||||
|
{v:"TOTAL",t:"s",s:{font:{bold:true},border,alignment:{horizontal:"left"},...totFill}},
|
||||||
|
totCell("","s"),
|
||||||
|
totCell(T.refri,"n"), totCell(T.contin,"n"), totCell(T.nuevas,"n"),
|
||||||
|
totCell(T.ins,"n"), totCell(T.pc,"n"),
|
||||||
|
totCell(cuotaTot,"n",money), totCell(descTot,"n",money),
|
||||||
|
totCell(T.venta,"n",money), totCell(T.costo,"n",money),
|
||||||
|
totCell(mbTot,"n",pctFmt),
|
||||||
|
totCell(T.eppc,"n",money), totCell(T.doc,"n",money),
|
||||||
|
totCell(T.cons,"n",money), totCell(T.mkt,"n",money),
|
||||||
|
];
|
||||||
|
totRow.forEach((c,i) => { ws[XLSX.utils.encode_cell({r:rT,c:i})] = c; });
|
||||||
|
// MB% del TOTAL: fórmula =1-K/J de la propia fila TOTAL
|
||||||
|
const xTot = rT + 1;
|
||||||
|
ws[XLSX.utils.encode_cell({r:rT,c:11})] = { t:"n", z:pctFmt, f:`1-K${xTot}/J${xTot}`,
|
||||||
|
s:{ font:{bold:true}, border, alignment:{horizontal:"center"}, fill:{fgColor:{rgb:"DDEBF7"}} } };
|
||||||
|
|
||||||
|
ws["!ref"] = XLSX.utils.encode_range({ s:{r:0,c:0}, e:{r:rT, c:COLS_EXP.length-1} });
|
||||||
|
ws["!cols"] = [{wch:32},{wch:11},{wch:13},{wch:15},{wch:12},{wch:12},{wch:12},
|
||||||
|
{wch:12},{wch:14},{wch:13},{wch:14},{wch:9},{wch:16},{wch:14},{wch:15},{wch:12}];
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, "Rentabilidad");
|
||||||
|
|
||||||
|
// ── Una hoja por cada inicio (programa) con el detalle de sus alumnos ──
|
||||||
|
const HDET = ["VENDEDOR","ALUMNO","FECHA MATRICULA","FECHA 1° CUOTA CANCELACION",
|
||||||
|
"TIPO DE ALUMNO","PROM. CUOTA","DESCUENTO ESPECIAL","INVERSION"];
|
||||||
|
const usados = new Set();
|
||||||
|
const nombreHoja = (prog) => {
|
||||||
|
// Excel: máx 31 chars, sin : \ / ? * [ ]. Evitar duplicados.
|
||||||
|
let base = String(prog).replace(/[:\\/?*\[\]]/g, " ").trim().slice(0, 28) || "Inicio";
|
||||||
|
let n = base, i = 2;
|
||||||
|
while (usados.has(n.toLowerCase())) { n = base.slice(0, 25) + " " + i; i++; }
|
||||||
|
usados.add(n.toLowerCase());
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
// Traer los detalles en paralelo (una llamada por programa)
|
||||||
|
const detalles = await Promise.all(
|
||||||
|
base.map((f) => api.rentabilidadDetalle(String(f[0]).trim(), ano, mes)
|
||||||
|
.then((r) => r.filas || []).catch(() => []))
|
||||||
|
);
|
||||||
|
|
||||||
|
base.forEach((f, i) => {
|
||||||
|
const prog = String(f[0]).trim();
|
||||||
|
const todas = detalles[i] || [];
|
||||||
|
const rowTot = todas.find((row) => String(row[0]).toUpperCase() === "TOTAL GENERAL");
|
||||||
|
const filasDet = todas.filter((row) => String(row[0]).toUpperCase() !== "TOTAL GENERAL");
|
||||||
|
const wsd = {};
|
||||||
|
HDET.forEach((h, c) => { wsd[XLSX.utils.encode_cell({r:0,c})] = { v:h, t:"s", s: hCell(AZUL) }; });
|
||||||
|
filasDet.forEach((row, ri) => {
|
||||||
|
const r = ri + 1;
|
||||||
|
const fz = ri % 2 === 1 ? { fill:{fgColor:{rgb:"F2F6FC"}} } : undefined;
|
||||||
|
const put = (c, v, tipo, z, al) => { wsd[XLSX.utils.encode_cell({r,c})] =
|
||||||
|
{ v: v ?? "", t: tipo, ...(z?{z}:{}), s:{ border, alignment:{horizontal: al||"center"}, ...(fz||{}) } }; };
|
||||||
|
put(0, String(row[0]||""), "s", null, "left"); // vendedor
|
||||||
|
put(1, String(row[1]||""), "s", null, "left"); // alumno
|
||||||
|
put(2, String(row[2]||""), "s"); // f. matricula (texto dd/mm/yyyy)
|
||||||
|
put(3, String(row[3]||""), "s"); // f. cancelacion
|
||||||
|
put(4, String(row[4]||""), "s"); // tipo de alumno
|
||||||
|
const cuotaV = num(row[5]);
|
||||||
|
const descV = num(row[6]);
|
||||||
|
const invV = num(row[7]);
|
||||||
|
// PROM. CUOTA: vacío si no tiene valor (regla del dashboard: " ")
|
||||||
|
if (String(row[5]||"").trim() === "") put(5, "", "s");
|
||||||
|
else put(5, cuotaV, "n", money);
|
||||||
|
if (String(row[6]||"").trim() === "") put(6, "", "s");
|
||||||
|
else put(6, descV, "n", money);
|
||||||
|
put(7, invV, "n", money);
|
||||||
|
});
|
||||||
|
// ── Fila TOTAL GENERAL (valores del dashboard: promedio ponderado + suma inversión) ──
|
||||||
|
const rTd = filasDet.length + 1;
|
||||||
|
const tf = { fill:{fgColor:{rgb:"DDEBF7"}} };
|
||||||
|
const tCell = (c, v, tipo, z) => { wsd[XLSX.utils.encode_cell({r:rTd,c})] =
|
||||||
|
{ v: v ?? "", t: tipo, ...(z?{z}:{}), s:{ font:{bold:true}, border, alignment:{horizontal: c<=1?"left":"center"}, ...tf } }; };
|
||||||
|
tCell(0, "TOTAL GENERAL", "s");
|
||||||
|
tCell(1, "", "s"); tCell(2, "", "s"); tCell(3, "", "s"); tCell(4, "", "s");
|
||||||
|
if (rowTot) {
|
||||||
|
const cT = num(rowTot[5]), dT = num(rowTot[6]), iT = num(rowTot[7]);
|
||||||
|
if (String(rowTot[5]||"").trim() === "") tCell(5, "", "s"); else tCell(5, cT, "n", money);
|
||||||
|
if (String(rowTot[6]||"").trim() === "") tCell(6, "", "s"); else tCell(6, dT, "n", money);
|
||||||
|
tCell(7, iT, "n", money);
|
||||||
|
} else {
|
||||||
|
tCell(5, "", "s"); tCell(6, "", "s"); tCell(7, "", "s");
|
||||||
|
}
|
||||||
|
wsd["!ref"] = XLSX.utils.encode_range({ s:{r:0,c:0}, e:{r:rTd, c:HDET.length-1} });
|
||||||
|
wsd["!cols"] = [{wch:26},{wch:30},{wch:16},{wch:24},{wch:16},{wch:12},{wch:16},{wch:14}];
|
||||||
|
XLSX.utils.book_append_sheet(wb, wsd, nombreHoja(prog));
|
||||||
|
});
|
||||||
|
|
||||||
|
const suf = `${MESES[mes-1]}_${ano}` + (sede!=="TODOS"?`_${sede}`:"") + (programa!=="TODOS"?`_${programa}`:"");
|
||||||
|
XLSX.writeFile(wb, `rentabilidad_${suf}.xlsx`);
|
||||||
|
} catch (e) {
|
||||||
|
alert("No se pudo exportar: " + (e.message || e));
|
||||||
|
} finally {
|
||||||
|
setExportando(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function cargar() {
|
function cargar() {
|
||||||
setLoading(true); setError(null);
|
setLoading(true); setError(null);
|
||||||
@@ -43,33 +428,62 @@ export default function Rentabilidad() {
|
|||||||
.catch((e) => { setError(e.message); 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]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let activo = true;
|
let activo = true;
|
||||||
setVistaActual(`rentabilidad:${ano}|${mes}|${sede}|${programa}`);
|
const lista = mesesKey ? mesesKey.split(",").map(Number) : [];
|
||||||
|
setVistaActual(`rentabilidad:${ano}|${mesesKey}|${sede}|${programa}`);
|
||||||
const cargarSeguro = () => {
|
const cargarSeguro = () => {
|
||||||
setLoading(true); setError(null);
|
setLoading(true); setError(null);
|
||||||
api.rentabilidad(ano, mes, sede, programa)
|
if (!lista.length) { setFilas([]); setLoading(false); return; }
|
||||||
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
// Una llamada por cada mes; se juntan las filas de datos (sin la fila TOTAL de cada uno).
|
||||||
|
Promise.all(lista.map((m) => api.rentabilidad(ano, m, sede, programa)))
|
||||||
|
.then((resArr) => {
|
||||||
|
if (!activo) return;
|
||||||
|
const juntos = [];
|
||||||
|
resArr.forEach((res) => (res.filas || []).forEach((f) => {
|
||||||
|
if (String(f[0]).toUpperCase() !== "TOTAL GENERAL") juntos.push(f);
|
||||||
|
}));
|
||||||
|
setFilas(juntos);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||||
};
|
};
|
||||||
cargarSeguro();
|
cargarSeguro();
|
||||||
const off = onRefrescar(cargarSeguro);
|
const off = onRefrescar(cargarSeguro);
|
||||||
return () => { activo = false; off(); };
|
return () => { activo = false; off(); };
|
||||||
}, [ano, mes, sede, programa]);
|
}, [ano, mesesKey, sede, programa]);
|
||||||
|
|
||||||
const datos = useMemo(() => filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"), [filas]);
|
const datos = useMemo(() => filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"), [filas]);
|
||||||
const total = useMemo(() => filas.find((f)=>String(f[0]).toUpperCase()==="TOTAL GENERAL"), [filas]);
|
// TOTAL GENERAL recalculado en frontend (varios meses juntos). Suma columnas
|
||||||
|
// sumables, recalcula MB% (=1-costo/venta), y promedia cuota/desc de las filas con valor.
|
||||||
|
const total = useMemo(() => {
|
||||||
|
if (!datos.length) return null;
|
||||||
|
const sum = (ci) => datos.reduce((s,f)=>s+toNum(f[ci]),0);
|
||||||
|
const promCol = (ci) => { const v = datos.map((f)=>toNum(f[ci])).filter((x)=>x>0);
|
||||||
|
return v.length ? v.reduce((a,b)=>a+b,0)/v.length : 0; };
|
||||||
|
const vIni=sum(7), cIni=sum(8), vAct=sum(10), cAct=sum(11);
|
||||||
|
const mbIni = vIni>0 ? (1 - cIni/vIni)*100 : 0;
|
||||||
|
const mbAct = vAct>0 ? (1 - cAct/vAct)*100 : 0;
|
||||||
|
const S = (n)=>`S/ ${Math.round(n).toLocaleString("es-PE")}`;
|
||||||
|
const t = [];
|
||||||
|
t[0]="TOTAL GENERAL"; t[1]="";
|
||||||
|
t[2]=String(Math.round(sum(2))); t[3]=String(Math.round(sum(3))); t[4]=String(Math.round(sum(4)));
|
||||||
|
t[5]=S(promCol(5)); t[6]=S(promCol(6));
|
||||||
|
t[7]=S(vIni); t[8]=S(cIni); t[9]=`${mbIni.toFixed(1)} %`;
|
||||||
|
t[10]=S(vAct); t[11]=S(cAct); t[12]=`${mbAct.toFixed(1)} %`;
|
||||||
|
return t;
|
||||||
|
}, [datos]);
|
||||||
|
|
||||||
const kpis = useMemo(() => {
|
const kpis = useMemo(() => {
|
||||||
if (!total) return null;
|
if (!total) return null;
|
||||||
return {
|
return {
|
||||||
programas: datos.length,
|
ventaIni: total[7], costoIni: total[8], mbIni: total[9],
|
||||||
enCurso: total[4],
|
ventaAct: total[10], costoAct: total[11], mbAct: total[12],
|
||||||
ventaAct: total[10],
|
|
||||||
costoAct: total[11],
|
|
||||||
mbAct: total[12],
|
|
||||||
};
|
};
|
||||||
}, [datos, total]);
|
}, [total]);
|
||||||
|
|
||||||
function bgCol(ci){ if([7,8,9].includes(ci))return BG_INI; if([10,11,12].includes(ci))return BG_ACT; return ""; }
|
function bgCol(ci){ if([7,8,9].includes(ci))return BG_INI; if([10,11,12].includes(ci))return BG_ACT; return ""; }
|
||||||
|
|
||||||
@@ -78,10 +492,10 @@ export default function Rentabilidad() {
|
|||||||
<h1 className="page-title">📈 Rentabilidad</h1>
|
<h1 className="page-title">📈 Rentabilidad</h1>
|
||||||
|
|
||||||
<Filters>
|
<Filters>
|
||||||
<Select label="Año" value={ano} options={ANOS} onChange={(v)=>setAno(+v)} />
|
<SelectBonito label="Año" value={ano} options={ANOS} onChange={(v)=>setAno(+v)} minWidth={110} />
|
||||||
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} />
|
<MesesMulti label="Mes" meses={meses} onChange={setMeses} />
|
||||||
<Select label="Sede" value={sede} options={SEDES} onChange={setSede} />
|
<SelectBonito label="Sede" value={sede} options={SEDES} onChange={setSede} minWidth={140} />
|
||||||
<Select label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} />
|
<SelectBonito label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} minWidth={150} />
|
||||||
<div className="filter-group" style={{marginLeft:"auto"}}>
|
<div className="filter-group" style={{marginLeft:"auto"}}>
|
||||||
<label>Inscritos</label>
|
<label>Inscritos</label>
|
||||||
<label style={{display:"flex",alignItems:"center",gap:6,height:38,cursor:"pointer",fontSize:13}}>
|
<label style={{display:"flex",alignItems:"center",gap:6,height:38,cursor:"pointer",fontSize:13}}>
|
||||||
@@ -89,30 +503,33 @@ export default function Rentabilidad() {
|
|||||||
Total / Retirados / En curso
|
Total / Retirados / En curso
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</Filters>
|
||||||
|
|
||||||
{loading ? <Loader text="Calculando rentabilidad..." /> :
|
{loading ? <Loader text="Calculando rentabilidad..." /> :
|
||||||
error ? <ErrorBox msg={error} /> :
|
error ? <ErrorBox msg={error} /> :
|
||||||
<>
|
<>
|
||||||
{kpis && (
|
{kpis && (
|
||||||
<div className="kpis" style={{gridTemplateColumns:"repeat(5,1fr)"}}>
|
<div style={{display:"grid",gridTemplateColumns:"repeat(2,1fr)",gap:14}}>
|
||||||
{[["📚","Programas",kpis.programas,"este mes"],
|
<TarjetaRenta titulo="📊 INICIAL" color="#1e3a5f"
|
||||||
["🎓","En Curso",kpis.enCurso,"alumnos activos"],
|
venta={kpis.ventaIni} costo={kpis.costoIni} mb={kpis.mbIni} />
|
||||||
["💵","Venta Actual",kpis.ventaAct,"ingreso total"],
|
<TarjetaRenta titulo="📈 ACTUAL" color="#1a5f4a"
|
||||||
["💸","Costo Actual",kpis.costoAct,"gasto total"],
|
venta={kpis.ventaAct} costo={kpis.costoAct} mb={kpis.mbAct} />
|
||||||
["📊","M.B. Actual",kpis.mbAct,"margen bruto"]].map(([ico,label,val,sub])=>(
|
|
||||||
<div className="kpi" key={label}>
|
|
||||||
<div className="ico">{ico}</div><div className="label">{label}</div>
|
|
||||||
<div className="value" style={{fontSize:18}}>{val}</div><div className="sub">{sub}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="table-wrap" style={{marginTop:16, overflowX:"auto"}}>
|
<div className="table-wrap" style={{marginTop:16, overflow:"auto", maxHeight:"60vh"}}>
|
||||||
<table {...cols.tableProps}>
|
<table {...cols.tableProps}>
|
||||||
<cols.ColGroup />
|
<cols.ColGroup />
|
||||||
<thead><tr>{COLS.filter((_,i)=>!ocultar(i)).map((c, vi)=><th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={vi} /></th>)}</tr></thead>
|
<thead><tr>{COLS.filter((_,i)=>!ocultar(i)).map((c, vi)=><th key={c} style={{position:"sticky",top:0,zIndex:2}}>{c}<cols.Resizer index={vi} /></th>)}</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{datos.map((f, ri) => (
|
{datos.map((f, ri) => (
|
||||||
<tr key={ri}>
|
<tr key={ri}>
|
||||||
@@ -140,9 +557,11 @@ export default function Rentabilidad() {
|
|||||||
{total.slice(0,13).map((v,ci)=>(
|
{total.slice(0,13).map((v,ci)=>(
|
||||||
ocultar(ci) ? null :
|
ocultar(ci) ? null :
|
||||||
<td key={ci} className={ci===0?"col-name":""}
|
<td key={ci} className={ci===0?"col-name":""}
|
||||||
style={{textAlign:ci===0?"left":"center"}}>{v}</td>
|
style={{textAlign:ci===0?"left":"center",
|
||||||
|
position:"sticky",bottom:0,zIndex:9,
|
||||||
|
backgroundColor:"#eff6ff",boxShadow:"inset 0 1px 0 0 #cbd5e1"}}>{v}</td>
|
||||||
))}
|
))}
|
||||||
<td>—</td>
|
<td style={{position:"sticky",bottom:0,zIndex:9,backgroundColor:"#eff6ff",boxShadow:"inset 0 1px 0 0 #cbd5e1"}}>—</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -156,6 +575,122 @@ export default function Rentabilidad() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Select de una opcion, desplegable custom (mismo estilo que el filtro de Mes).
|
||||||
|
function SelectBonito({ label, value, options, onChange, minWidth = 150 }) {
|
||||||
|
const [abierto, setAbierto] = useState(false);
|
||||||
|
const ref = useRef(null);
|
||||||
|
const opts = options.map((o) => (typeof o === "object" ? o : { value: o, label: String(o) }));
|
||||||
|
const actual = opts.find((o) => String(o.value) === String(value));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fuera = (e) => { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); };
|
||||||
|
document.addEventListener("mousedown", fuera);
|
||||||
|
return () => document.removeEventListener("mousedown", fuera);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="filter-group" ref={ref} style={{ position: "relative", minWidth }}>
|
||||||
|
<label>{label}</label>
|
||||||
|
<button type="button" onClick={() => setAbierto((v) => !v)}
|
||||||
|
style={{ height: 38, width: "100%", textAlign: "left", padding: "0 12px",
|
||||||
|
border: "1px solid #cbd5e1", borderRadius: 8, background: "#fff",
|
||||||
|
cursor: "pointer", fontSize: 13, display: "flex", alignItems: "center",
|
||||||
|
justifyContent: "space-between", gap: 8 }}>
|
||||||
|
<span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
{actual ? actual.label : "—"}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#94a3b8", fontSize: 11, transition: "transform .15s",
|
||||||
|
transform: abierto ? "rotate(180deg)" : "none" }}>▼</span>
|
||||||
|
</button>
|
||||||
|
{abierto && (
|
||||||
|
<div style={{ position: "absolute", top: "100%", left: 0, right: 0, zIndex: 50, marginTop: 4,
|
||||||
|
background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8,
|
||||||
|
boxShadow: "0 8px 24px rgba(0,0,0,0.12)", padding: 6,
|
||||||
|
maxHeight: 300, overflowY: "auto" }}>
|
||||||
|
{opts.map((o) => {
|
||||||
|
const on = String(o.value) === String(value);
|
||||||
|
return (
|
||||||
|
<div key={o.value} onClick={() => { onChange(o.value); setAbierto(false); }}
|
||||||
|
style={{ padding: "8px 10px", borderRadius: 6, cursor: "pointer", fontSize: 13,
|
||||||
|
fontWeight: on ? 700 : 500,
|
||||||
|
color: on ? "#1e3a5f" : "#334155",
|
||||||
|
background: on ? "#eff6ff" : "transparent" }}
|
||||||
|
onMouseEnter={(e) => { if (!on) e.currentTarget.style.background = "#f8fafc"; }}
|
||||||
|
onMouseLeave={(e) => { if (!on) e.currentTarget.style.background = "transparent"; }}>
|
||||||
|
{o.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtro de MES con casillas (multi-seleccion). meses = array de 1..12.
|
||||||
|
function MesesMulti({ label, meses, onChange }) {
|
||||||
|
const [abierto, setAbierto] = useState(false);
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fuera = (e) => { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); };
|
||||||
|
document.addEventListener("mousedown", fuera);
|
||||||
|
return () => document.removeEventListener("mousedown", fuera);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = (m) => {
|
||||||
|
const set = new Set(meses);
|
||||||
|
if (set.has(m)) set.delete(m); else set.add(m);
|
||||||
|
onChange([...set].sort((a, b) => a - b));
|
||||||
|
};
|
||||||
|
|
||||||
|
const texto = meses.length === 0 ? "— Elegir —"
|
||||||
|
: meses.length === 1 ? MESES[meses[0] - 1]
|
||||||
|
: meses.length === 12 ? "TODOS"
|
||||||
|
: `${meses.length} meses`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="filter-group" ref={ref} style={{ position: "relative", minWidth: 200 }}>
|
||||||
|
<label>{label}</label>
|
||||||
|
<button type="button" onClick={() => setAbierto((v) => !v)}
|
||||||
|
style={{ height: 38, width: "100%", textAlign: "left", padding: "0 12px",
|
||||||
|
border: "1px solid #cbd5e1", borderRadius: 8, background: "#fff",
|
||||||
|
cursor: "pointer", fontSize: 13, display: "flex", alignItems: "center",
|
||||||
|
justifyContent: "space-between", gap: 8 }}>
|
||||||
|
<span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{texto}</span>
|
||||||
|
<span style={{ color: "#94a3b8", fontSize: 11 }}>▼</span>
|
||||||
|
</button>
|
||||||
|
{abierto && (
|
||||||
|
<div style={{ position: "absolute", top: "100%", left: 0, right: 0, zIndex: 50, marginTop: 4,
|
||||||
|
background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8,
|
||||||
|
boxShadow: "0 8px 24px rgba(0,0,0,0.12)", padding: 6, minWidth: 180,
|
||||||
|
maxHeight: 300, overflowY: "auto" }}>
|
||||||
|
<div style={{ display: "flex", gap: 6, padding: "4px 6px 8px", borderBottom: "1px solid #f1f5f9", marginBottom: 4 }}>
|
||||||
|
<button type="button" onClick={() => onChange(MESES.map((_, i) => i + 1))}
|
||||||
|
style={{ flex: 1, fontSize: 11, padding: "4px 6px", border: "1px solid #e2e8f0",
|
||||||
|
borderRadius: 6, background: "#f8fafc", cursor: "pointer" }}>Todos</button>
|
||||||
|
<button type="button" onClick={() => onChange([])}
|
||||||
|
style={{ flex: 1, fontSize: 11, padding: "4px 6px", border: "1px solid #e2e8f0",
|
||||||
|
borderRadius: 6, background: "#f8fafc", cursor: "pointer" }}>Ninguno</button>
|
||||||
|
</div>
|
||||||
|
{MESES.map((m, i) => {
|
||||||
|
const val = i + 1;
|
||||||
|
const on = meses.includes(val);
|
||||||
|
return (
|
||||||
|
<label key={m} style={{ display: "flex", alignItems: "center", gap: 8,
|
||||||
|
padding: "6px 8px", borderRadius: 6, cursor: "pointer", fontSize: 13,
|
||||||
|
background: on ? "#eff6ff" : "transparent" }}>
|
||||||
|
<input type="checkbox" checked={on} onChange={() => toggle(val)} />
|
||||||
|
{m}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ModalVerDetalle({ programa, ano, mes, onClose }) {
|
function ModalVerDetalle({ programa, ano, mes, onClose }) {
|
||||||
const [filas, setFilas] = useState([]);
|
const [filas, setFilas] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -254,6 +789,26 @@ function ModalCostos({ programa, ano, mes, onClose, onGuardado }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TarjetaRenta({ titulo, color, venta, costo, mb }) {
|
||||||
|
const fila = (k, v, bold) => (
|
||||||
|
<div style={{display:"flex",justifyContent:"space-between",fontSize:13,color:"#64748b",marginBottom:6,
|
||||||
|
...(bold?{paddingTop:8,borderTop:"1px solid #f1f5f9"}:{})}}>
|
||||||
|
<span>{k}</span>
|
||||||
|
<span style={{fontWeight:bold?700:600,color:"#0f172a"}}>{v}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div style={{background:"#fff",border:"1px solid #e2e8f0",borderRadius:12,overflow:"hidden"}}>
|
||||||
|
<div style={{background:color,color:"#fff",padding:"10px 14px",fontWeight:700,fontSize:14,textAlign:"center"}}>{titulo}</div>
|
||||||
|
<div style={{padding:16}}>
|
||||||
|
{fila("Venta", venta)}
|
||||||
|
{fila("Costo", costo)}
|
||||||
|
{fila("Margen Bruto", mb, true)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Columna({ titulo, color, campos, valores, setVal, total }) {
|
function Columna({ titulo, color, campos, valores, setVal, total }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user