diff --git a/frontend/src/pages/Ocupabilidad.jsx b/frontend/src/pages/Ocupabilidad.jsx index 1fbe444..ca242bd 100644 --- a/frontend/src/pages/Ocupabilidad.jsx +++ b/frontend/src/pages/Ocupabilidad.jsx @@ -1,7 +1,7 @@ // src/pages/Ocupabilidad.jsx -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useRef } from "react"; 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 Modal from "../components/Modal"; 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 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; } export default function Ocupabilidad() { const hoy = new Date(); 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 [programa, setPrograma] = useState("TODOS"); const [mostrarRepro, setMostrarRepro] = useState(true); const [datos, setDatos] = useState([]); const [loading, setLoading] = useState(true); 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]); + // Clave estable de meses (ordenados) para las dependencias del effect. + const mesesKey = useMemo(() => [...meses].sort((a, b) => a - b).join(","), [meses]); + useEffect(() => { let activo = true; - setVistaActual(`ocupabilidad:${ano}|${mes}|${sede}|${programa}`); + const lista = mesesKey ? mesesKey.split(",").map(Number) : []; + setVistaActual(`ocupabilidad:${ano}|${mesesKey}|${sede}|${programa}`); const cargar = () => { setLoading(true); setError(null); - api.ocupabilidad(ano, mes, sede, programa) - .then((res) => { if (activo) { setDatos(res.datos || []); setLoading(false); } }) + if (!lista.length) { setDatos([]); setLoading(false); return; } + // 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); } }); }; cargar(); const off = onRefrescar(cargar); return () => { activo = false; off(); }; - }, [ano, mes, sede, programa]); + }, [ano, mesesKey, sede, programa]); // Toggle "Mostrar Reprogramados": si está apagado, ocultar filas de inicio const datosVisibles = useMemo(() => { @@ -62,6 +76,7 @@ export default function Ocupabilidad() { inscritos: totalInsc, // columna TOTAL INSCRITOS meta, avance: meta > 0 ? (totalInsc / meta * 100) : 0, + retirados, desercion: totalInsc > 0 ? (retirados / totalInsc * 100) : 0, 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), @@ -89,9 +104,13 @@ export default function Ocupabilidad() { ["👥","Total Inscritos", kpis.inscritos, "alumnos en curso"], ["🎯","Meta Total", kpis.meta, "inscritos objetivo"], ["📈","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() { if (!datosVisibles.length) return; // 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 wb = XLSX.utils.book_new(); 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 ( @@ -121,10 +140,10 @@ export default function Ocupabilidad() {

📊 Ocupabilidad

- ({value:i+1,label:m}))} onChange={(v) => setMes(+v)} /> - + setAno(+v)} minWidth={110} /> + + +
-
+
- {COLS.map((c, i) => )} + {COLS.map((c, i) => )} {datosVisibles.map((d, i) => { const prog = d.programa_frecuencia || d.dsc_programa || ""; @@ -190,15 +209,15 @@ export default function Ocupabilidad() { })} {datosVisibles.length > 0 && ( - - - - - - - - - + + + + + + + + + )} @@ -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 ( +
+ + + {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 ModalAlumnosCurso({ numIndice, programa, onClose }) { const [alumnos, setAlumnos] = useState([]); const [loading, setLoading] = useState(true); @@ -230,6 +366,7 @@ function ModalAlumnosCurso({ numIndice, programa, onClose }) { return () => { activo = false; }; }, [numIndice]); + // Colores por estado const colorEstado = (e) => { if (e === "ALU") return { bg:"#ecfdf5", fg:"#047857", bd:"#a7f3d0" }; 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" }; }; + // Conteo por estado const conteo = useMemo(() => { const c = {}; alumnos.forEach((a) => { c[a.estado] = (c[a.estado] || 0) + 1; }); @@ -248,6 +386,7 @@ function ModalAlumnosCurso({ numIndice, programa, onClose }) { {loading ? : alumnos.length === 0 ?
Sin alumnos.
: <> + {/* Etiquetas de conteo */}
{c}
{c}
TOTAL GENERAL——{totales.total}{totales.retirados}{totales.activos}{totales.meta}0 ? totales.total/totales.meta*100 : 0} />{totales.mes}{totales.pc}{totales.refri}{totales.cont}—TOTAL GENERAL——{totales.total}{totales.retirados}{totales.activos}{totales.meta}0 ? totales.total/totales.meta*100 : 0} />{totales.mes}{totales.pc}{totales.refri}{totales.cont}—