Actualizar frontend/src/pages/Rentabilidad.jsx

This commit is contained in:
2026-09-23 10:42:47 -05:00
parent 0fb44d046c
commit 5c585f29c5

View File

@@ -8,8 +8,8 @@ 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 = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"];
const PROGRAMAS = ["TODOS","SEMINARIOS","OTROS","TEAC","TERC"];
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
@@ -47,8 +47,8 @@ export default function Rentabilidad() {
// meses = array de meses seleccionados (multi). 'mes' (primero) se usa en modales/export.
const [meses, setMeses] = useState([hoy.getMonth() + 1]);
const mes = meses.length ? Math.min(...meses) : (hoy.getMonth() + 1);
const [sede, setSede] = useState("TODOS");
const [programa, setPrograma] = useState("TODOS");
const [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);
@@ -213,8 +213,8 @@ export default function Rentabilidad() {
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),
api.ocupabilidad(ano, mes, sedeArg, progArg),
api.rentabilidad(ano, mes, sedeArg, progArg),
]);
const ocuPorProg = {};
(ocu.datos || []).forEach((d) => {
@@ -444,7 +444,7 @@ export default function Rentabilidad() {
XLSX.utils.book_append_sheet(wb, wsd, nombreHoja(prog));
});
const suf = `${MESES[mes-1]}_${ano}` + (sede!=="TODOS"?`_${sede}`:"") + (programa!=="TODOS"?`_${programa}`:"");
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));
@@ -455,23 +455,27 @@ export default function Rentabilidad() {
function cargar() {
setLoading(true); setError(null);
api.rentabilidad(ano, mes, sede, programa)
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}|${sede}|${programa}`);
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, sede, programa)))
Promise.all(lista.map((m) => api.rentabilidad(ano, m, sedeArg, progArg)))
.then((resArr) => {
if (!activo) return;
const juntos = [];
@@ -486,7 +490,7 @@ export default function Rentabilidad() {
cargarSeguro();
const off = onRefrescar(cargarSeguro);
return () => { activo = false; off(); };
}, [ano, mesesKey, sede, programa]);
}, [ano, mesesKey, sedeArg, progArg]);
const datos = useMemo(() => {
let base = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL");
@@ -530,8 +534,8 @@ export default function Rentabilidad() {
<Filters>
<SelectBonito label="Año" value={ano} options={ANOS} onChange={(v)=>setAno(+v)} minWidth={110} />
<MesesMulti label="Mes" meses={meses} onChange={setMeses} />
<SelectBonito label="Sede" value={sede} options={SEDES} onChange={setSede} minWidth={140} />
<SelectBonito label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} minWidth={150} />
<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}}>
@@ -711,14 +715,6 @@ function MesesMulti({ label, meses, onChange }) {
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);
@@ -911,3 +907,52 @@ function Columna({ titulo, color, campos, valores, setVal, total }) {
</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>
);
}