Actualizar frontend/src/pages/Ocupabilidad.jsx

This commit is contained in:
2026-09-10 12:22:46 -05:00
parent 37c54f108a
commit 754c4fbd77

View File

@@ -1,7 +1,7 @@
// src/pages/Ocupabilidad.jsx // src/pages/Ocupabilidad.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, ProgressBar, Filters, Select } from "../components/UI"; import { Loader, ErrorBox, ProgressBar, Filters } from "../components/UI";
import { useColumnasAjustables } from "../lib/useColumnasAjustables"; import { useColumnasAjustables } from "../lib/useColumnasAjustables";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
import { setVistaActual, onRefrescar } from "../lib/vistaActual"; import { setVistaActual, onRefrescar } from "../lib/vistaActual";
@@ -12,34 +12,48 @@ const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"];
const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"]; const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"];
const COLS = ["PROGRAMA","FECHA INICIO","DÍAS PARA INICIO","TOTAL INSCRITOS","RETIRADOS","INSCRITOS EN CURSO","META INSCRITOS","AVANCE INSCRITOS","INSCRITOS MES","INSCRITOS P.C","INSCRITOS REFRIPERU","INSCRITOS CONTINUIDAD","OPC."]; const COLS = ["PROGRAMA","FECHA INICIO","DÍAS PARA INICIO","TOTAL INSCRITOS","RETIRADOS","INSCRITOS EN CURSO","META INSCRITOS","AVANCE INSCRITOS","INSCRITOS MES","INSCRITOS P.C","INSCRITOS REFRIPERU","INSCRITOS CONTINUIDAD","OPC."];
function toNum(v) { const n = parseFloat(String(v).replace("%","").trim()); return isNaN(n) ? 0 : n; } function toNum(v) { const n = parseFloat(String(v).replace("%","").trim()); return isNaN(n) ? 0 : n; }
export default function Ocupabilidad() { export default function Ocupabilidad() {
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 (1-12). Permite elegir varios.
const [meses, setMeses] = useState([hoy.getMonth() + 1]);
const [sede, setSede] = useState("TODOS"); const [sede, setSede] = useState("TODOS");
const [programa, setPrograma] = useState("TODOS"); const [programa, setPrograma] = useState("TODOS");
const [mostrarRepro, setMostrarRepro] = useState(true); const [mostrarRepro, setMostrarRepro] = useState(true);
const [datos, setDatos] = useState([]); const [datos, setDatos] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [modalCurso, setModalCurso] = useState(null); const [modalCurso, setModalCurso] = useState(null); // curso seleccionado para "Ver alumnos"
const cols = useColumnasAjustables([280, 110, 120, 110, 100, 120, 110, 120, 100, 100, 130, 140, 80]); const cols = useColumnasAjustables([280, 110, 120, 110, 100, 120, 110, 120, 100, 100, 130, 140, 80]);
// 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(`ocupabilidad:${ano}|${mes}|${sede}|${programa}`); const lista = mesesKey ? mesesKey.split(",").map(Number) : [];
setVistaActual(`ocupabilidad:${ano}|${mesesKey}|${sede}|${programa}`);
const cargar = () => { const cargar = () => {
setLoading(true); setError(null); setLoading(true); setError(null);
api.ocupabilidad(ano, mes, sede, programa) if (!lista.length) { setDatos([]); setLoading(false); return; }
.then((res) => { if (activo) { setDatos(res.datos || []); setLoading(false); } }) // Una llamada por cada mes seleccionado; se juntan todas las filas.
Promise.all(lista.map((m) => api.ocupabilidad(ano, m, sede, programa)))
.then((resArr) => {
if (!activo) return;
const juntos = [];
resArr.forEach((res) => (res.datos || []).forEach((d) => juntos.push(d)));
setDatos(juntos);
setLoading(false);
})
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); .catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
}; };
cargar(); cargar();
const off = onRefrescar(cargar); const off = onRefrescar(cargar);
return () => { activo = false; off(); }; return () => { activo = false; off(); };
}, [ano, mes, sede, programa]); }, [ano, mesesKey, sede, programa]);
// Toggle "Mostrar Reprogramados": si está apagado, ocultar filas de inicio // Toggle "Mostrar Reprogramados": si está apagado, ocultar filas de inicio
const datosVisibles = useMemo(() => { const datosVisibles = useMemo(() => {
@@ -62,6 +76,7 @@ export default function Ocupabilidad() {
inscritos: totalInsc, // columna TOTAL INSCRITOS inscritos: totalInsc, // columna TOTAL INSCRITOS
meta, meta,
avance: meta > 0 ? (totalInsc / meta * 100) : 0, avance: meta > 0 ? (totalInsc / meta * 100) : 0,
retirados,
desercion: totalInsc > 0 ? (retirados / totalInsc * 100) : 0, desercion: totalInsc > 0 ? (retirados / totalInsc * 100) : 0,
sobreMeta: validos.filter((d) => (parseInt(d.Inscritos_Totales)||0) >= (parseInt(d.Meta_Curso)||1)).length, sobreMeta: validos.filter((d) => (parseInt(d.Inscritos_Totales)||0) >= (parseInt(d.Meta_Curso)||1)).length,
pc: validos.reduce((s, d) => s + (parseInt(d.Inscritos_PC) || 0), 0), pc: validos.reduce((s, d) => s + (parseInt(d.Inscritos_PC) || 0), 0),
@@ -89,9 +104,13 @@ export default function Ocupabilidad() {
["👥","Total Inscritos", kpis.inscritos, "alumnos en curso"], ["👥","Total Inscritos", kpis.inscritos, "alumnos en curso"],
["🎯","Meta Total", kpis.meta, "inscritos objetivo"], ["🎯","Meta Total", kpis.meta, "inscritos objetivo"],
["📈","Avance Inscritos", `${kpis.avance.toFixed(1)}%`, "total inscritos / meta"], ["📈","Avance Inscritos", `${kpis.avance.toFixed(1)}%`, "total inscritos / meta"],
["📉","Deserción", `${kpis.desercion.toFixed(1)}%`, "retirados / total inscritos"], ["📉","Deserción", `${kpis.desercion.toFixed(1)}%`, `${kpis.retirados} retirados / ${kpis.inscritos} inscritos`],
] : []; ] : [];
// Estilo para inmovilizar la fila TOTAL GENERAL abajo (sticky), igual que Cobranza.
const ST = { position: "sticky", bottom: 0, zIndex: 9, backgroundColor: "#eff6ff",
boxShadow: "inset 0 1px 0 0 #cbd5e1" };
async function exportarExcel() { async function exportarExcel() {
if (!datosVisibles.length) return; if (!datosVisibles.length) return;
// Cargar SheetJS desde CDN solo cuando se necesita // Cargar SheetJS desde CDN solo cuando se necesita
@@ -113,7 +132,7 @@ export default function Ocupabilidad() {
const ws = XLSX.utils.json_to_sheet(filas); const ws = XLSX.utils.json_to_sheet(filas);
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Ocupabilidad"); XLSX.utils.book_append_sheet(wb, ws, "Ocupabilidad");
XLSX.writeFile(wb, `cursos_${ano}_${mes}.xlsx`); XLSX.writeFile(wb, `cursos_${ano}_${mesesKey.replace(/,/g, "-")}.xlsx`);
} }
return ( return (
@@ -121,10 +140,10 @@ export default function Ocupabilidad() {
<h1 className="page-title">📊 Ocupabilidad</h1> <h1 className="page-title">📊 Ocupabilidad</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"> <div className="filter-group">
<label>Reprogramados</label> <label>Reprogramados</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}}>
@@ -151,10 +170,10 @@ export default function Ocupabilidad() {
))} ))}
</div> </div>
<div className="table-wrap" style={{overflowX:"auto"}}> <div className="table-wrap" style={{overflow:"auto", maxHeight:"60vh"}}>
<table {...cols.tableProps}> <table {...cols.tableProps}>
<cols.ColGroup /> <cols.ColGroup />
<thead><tr>{COLS.map((c, i) => <th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={i} /></th>)}</tr></thead> <thead><tr>{COLS.map((c, i) => <th key={c} style={{position:"sticky",top:0,zIndex:2}}>{c}<cols.Resizer index={i} /></th>)}</tr></thead>
<tbody> <tbody>
{datosVisibles.map((d, i) => { {datosVisibles.map((d, i) => {
const prog = d.programa_frecuencia || d.dsc_programa || ""; const prog = d.programa_frecuencia || d.dsc_programa || "";
@@ -190,15 +209,15 @@ export default function Ocupabilidad() {
})} })}
{datosVisibles.length > 0 && ( {datosVisibles.length > 0 && (
<tr className="total-row"> <tr className="total-row">
<td className="col-name">TOTAL GENERAL</td> <td className="col-name" style={ST}>TOTAL GENERAL</td>
<td>—</td><td>—</td> <td style={ST}>—</td><td style={ST}>—</td>
<td>{totales.total}</td> <td style={ST}>{totales.total}</td>
<td>{totales.retirados}</td> <td style={ST}>{totales.retirados}</td>
<td>{totales.activos}</td> <td style={ST}>{totales.activos}</td>
<td>{totales.meta}</td> <td style={ST}>{totales.meta}</td>
<td><ProgressBar pct={totales.meta>0 ? totales.total/totales.meta*100 : 0} /></td> <td style={ST}><ProgressBar pct={totales.meta>0 ? totales.total/totales.meta*100 : 0} /></td>
<td>{totales.mes}</td><td>{totales.pc}</td><td>{totales.refri}</td><td>{totales.cont}</td> <td style={ST}>{totales.mes}</td><td style={ST}>{totales.pc}</td><td style={ST}>{totales.refri}</td><td style={ST}>{totales.cont}</td>
<td>—</td> <td style={ST}>—</td>
</tr> </tr>
)} )}
</tbody> </tbody>
@@ -217,6 +236,123 @@ export default function Ocupabilidad() {
); );
} }
// Select de una opcion, desplegable custom (mismo estilo que el filtro de Mes).
// Opciones: array de strings o de {value, label}.
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 ModalAlumnosCurso({ numIndice, programa, onClose }) { function ModalAlumnosCurso({ numIndice, programa, onClose }) {
const [alumnos, setAlumnos] = useState([]); const [alumnos, setAlumnos] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -230,6 +366,7 @@ function ModalAlumnosCurso({ numIndice, programa, onClose }) {
return () => { activo = false; }; return () => { activo = false; };
}, [numIndice]); }, [numIndice]);
// Colores por estado
const colorEstado = (e) => { const colorEstado = (e) => {
if (e === "ALU") return { bg:"#ecfdf5", fg:"#047857", bd:"#a7f3d0" }; if (e === "ALU") return { bg:"#ecfdf5", fg:"#047857", bd:"#a7f3d0" };
if (e === "PRE") return { bg:"#eff6ff", fg:"#1e40af", bd:"#bfdbfe" }; if (e === "PRE") return { bg:"#eff6ff", fg:"#1e40af", bd:"#bfdbfe" };
@@ -237,6 +374,7 @@ function ModalAlumnosCurso({ numIndice, programa, onClose }) {
return { bg:"#f1f5f9", fg:"#475569", bd:"#e2e8f0" }; return { bg:"#f1f5f9", fg:"#475569", bd:"#e2e8f0" };
}; };
// Conteo por estado
const conteo = useMemo(() => { const conteo = useMemo(() => {
const c = {}; const c = {};
alumnos.forEach((a) => { c[a.estado] = (c[a.estado] || 0) + 1; }); alumnos.forEach((a) => { c[a.estado] = (c[a.estado] || 0) + 1; });
@@ -248,6 +386,7 @@ function ModalAlumnosCurso({ numIndice, programa, onClose }) {
{loading ? <Loader text="Cargando alumnos..." /> : {loading ? <Loader text="Cargando alumnos..." /> :
alumnos.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin alumnos.</div> : alumnos.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin alumnos.</div> :
<> <>
{/* Etiquetas de conteo */}
<div style={{display:"flex",gap:10,marginBottom:14,flexWrap:"wrap"}}> <div style={{display:"flex",gap:10,marginBottom:14,flexWrap:"wrap"}}>
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px", <span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#eff6ff",color:"#1e40af",fontSize:13,fontWeight:700, borderRadius:999,background:"#eff6ff",color:"#1e40af",fontSize:13,fontWeight:700,