// src/pages/Ocupabilidad.jsx import { useState, useEffect, useMemo, useRef } from "react"; import { api } from "../lib/api"; import { Loader, ErrorBox, ProgressBar, Filters } from "../components/UI"; import { useColumnasAjustables } from "../lib/useColumnasAjustables"; import Modal from "../components/Modal"; 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 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()); // 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); // 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; const lista = mesesKey ? mesesKey.split(",").map(Number) : []; setVistaActual(`ocupabilidad:${ano}|${mesesKey}|${sede}|${programa}`); const cargar = () => { setLoading(true); setError(null); 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, mesesKey, sede, programa]); // Toggle "Mostrar Reprogramados": si está apagado, ocultar filas de inicio const datosVisibles = useMemo(() => { if (mostrarRepro) return datos; return datos.filter((d) => String(d.dias_para_inicio).trim().toUpperCase() !== "REPROGRAMADO"); }, [datos, mostrarRepro]); const kpis = useMemo(() => { if (!datosVisibles.length) return null; const esRepro = (d) => String(d.dias_para_inicio).trim().toUpperCase() === "REPROGRAMADO"; // Las filas REPROGRAMADO (inicios) no entran en las métricas (igual que escritorio) const validos = datosVisibles.filter((d) => !esRepro(d)); const insc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Activos) || 0), 0); const totalInsc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Totales) || 0), 0); const retirados = validos.reduce((s, d) => s + (parseInt(d.Retirados ?? d.Inscritos_Retirados) || 0), 0); const meta = validos.reduce((s, d) => s + (parseInt(d.Meta_Curso) || 0), 0); return { cursos: validos.length, // solo programados (sin reprogramados) reprogramados: datosVisibles.filter(esRepro).length, // inicios rojos (REPROGRAMADO) 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), }; }, [datosVisibles]); const totales = useMemo(() => { const t = { mes:0, total:0, pc:0, retirados:0, activos:0, refri:0, cont:0, meta:0 }; datosVisibles.forEach((d) => { t.mes += parseInt(d.Inscritos_Mes)||0; t.total += parseInt(d.Inscritos_Totales)||0; t.pc += parseInt(d.Inscritos_PC)||0; t.retirados += parseInt(d.Retirados ?? d.Inscritos_Retirados)||0; t.activos += parseInt(d.Inscritos_Activos)||0; t.refri += parseInt(d.Descuento)||0; t.cont += parseInt(d.Inscritos_Continuidad)||0; t.meta += parseInt(d.Meta_Curso)||0; }); return t; }, [datosVisibles]); const KPI_CARDS = kpis ? [ ["📚","Cursos Activos", kpis.cursos, "programas este mes"], ["🔁","Cursos Reprogramados", kpis.reprogramados, "inicios reprogramados"], ["👥","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)}%`, `${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 const XLSX = await import("xlsx"); const filas = datosVisibles.map((d) => ({ "PROGRAMA": d.programa_frecuencia || d.dsc_programa || "", "FECHA INICIO": d.fch_inicio || "", "DÍAS PARA INICIO": d.dias_para_inicio ?? "", "TOTAL INSCRITOS": parseInt(d.Inscritos_Totales)||0, "RETIRADOS": parseInt(d.Retirados ?? d.Inscritos_Retirados)||0, "INSCRITOS EN CURSO": parseInt(d.Inscritos_Activos)||0, "META INSCRITOS": parseInt(d.Meta_Curso)||0, "AVANCE INSCRITOS": d.Avance_Inscritos || "", "INSCRITOS MES": parseInt(d.Inscritos_Mes)||0, "INSCRITOS P.C": parseInt(d.Inscritos_PC)||0, "INSCRITOS REFRIPERU": parseInt(d.Descuento)||0, "INSCRITOS CONTINUIDAD": parseInt(d.Inscritos_Continuidad)||0, })); 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}_${mesesKey.replace(/,/g, "-")}.xlsx`); } return (

📊 Ocupabilidad

setAno(+v)} minWidth={110} />
{loading ? : error ? : <>
{KPI_CARDS.map(([ico,label,val,sub]) => (
{ico}
{label}
{val}
{sub}
))}
{COLS.map((c, i) => )} {datosVisibles.map((d, i) => { const prog = d.programa_frecuencia || d.dsc_programa || ""; const dias = parseInt(d.dias_para_inicio); const pct = toNum(d.Avance_Inscritos); // Inicio reprogramado (existe en Supabase pero no en SQL este mes) → fila roja const esInicio = String(d.dias_para_inicio).trim().toUpperCase() === "REPROGRAMADO"; const cel = (v) => esInicio ? "-" : v; return ( ); })} {datosVisibles.length > 0 && ( )}
{c}
{prog} {d.fch_inicio || ""} {d.dias_para_inicio ?? ""} {cel(parseInt(d.Inscritos_Totales)||0)} {cel(parseInt(d.Retirados ?? d.Inscritos_Retirados)||0)} {cel(parseInt(d.Inscritos_Activos)||0)} {cel(parseInt(d.Meta_Curso)||0)} {esInicio ? "-" : } {cel(parseInt(d.Inscritos_Mes)||0)} {cel(parseInt(d.Inscritos_PC)||0)} {cel(parseInt(d.Descuento)||0)} {cel(parseInt(d.Inscritos_Continuidad)||0)} {esInicio ? "-" : ( )}
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} —
} {modalCurso && ( setModalCurso(null)} /> )}
); } // 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); useEffect(() => { let activo = true; setLoading(true); api.ocupabilidadAlumnos(numIndice) .then((res) => { if (activo) { setAlumnos(res.alumnos || []); setLoading(false); } }) .catch(() => { if (activo) { setAlumnos([]); setLoading(false); } }); 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" }; if (e === "RET") return { bg:"#fef2f2", fg:"#b91c1c", bd:"#fecaca" }; 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; }); return c; }, [alumnos]); return ( {loading ? : alumnos.length === 0 ?
Sin alumnos.
: <> {/* Etiquetas de conteo */}
👥 Total: {alumnos.length} alumno{alumnos.length!==1?"s":""} {["ALU","PRE","RET"].map((e)=> conteo[e] ? ( {e}: {conteo[e]} ) : null)}
{alumnos.map((a, i) => { const c = colorEstado(a.estado); const tieneAnt = a.curso_anterior && a.curso_anterior !== "-"; const cAnt = colorEstado(a.estado_anterior); return ( ); })}
ALUMNO ESTADO CURSO ANTERIOR (TEAC/TERC) ESTADO ANTERIOR
{a.alumno} {a.estado} {a.curso_anterior || "-"} {tieneAnt ? ( {a.estado_anterior} ) : -}
}
); }