Files
dashboard-completo/frontend/src/pages/Ocupabilidad.jsx

447 lines
21 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (
<div>
<h1 className="page-title">📊 Ocupabilidad</h1>
<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} />
<div className="filter-group">
<label>Reprogramados</label>
<label style={{display:"flex",alignItems:"center",gap:6,height:38,cursor:"pointer",fontSize:13}}>
<input type="checkbox" checked={mostrarRepro} onChange={(e)=>setMostrarRepro(e.target.checked)} />
Mostrar
</label>
</div>
<button className="btn btn-primary" style={{height:38,marginLeft:"auto"}} onClick={exportarExcel} disabled={!datosVisibles.length}>
⬇️ Exportar Excel
</button>
</Filters>
{loading ? <Loader text="Calculando ocupabilidad..." /> :
error ? <ErrorBox msg={error} /> :
<>
<div className="kpis">
{KPI_CARDS.map(([ico,label,val,sub]) => (
<div className="kpi" key={label}>
<div className="ico">{ico}</div>
<div className="label" style={{fontSize:15}}>{label}</div>
<div className="value">{val}</div>
<div className="sub">{sub}</div>
</div>
))}
</div>
<div className="table-wrap" style={{overflow:"auto", maxHeight:"60vh"}}>
<table {...cols.tableProps}>
<cols.ColGroup />
<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>
{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 (
<tr key={i} className={esInicio ? "row-inicio" : ""}>
<td className="col-name" style={{minWidth:300,maxWidth:480,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"}}>{prog}</td>
<td>{d.fch_inicio || ""}</td>
<td style={!esInicio && dias < 0 ? {color:"#dc2626",fontWeight:700} : {}}>{d.dias_para_inicio ?? ""}</td>
<td>{cel(parseInt(d.Inscritos_Totales)||0)}</td>
<td>{cel(parseInt(d.Retirados ?? d.Inscritos_Retirados)||0)}</td>
<td>{cel(parseInt(d.Inscritos_Activos)||0)}</td>
<td>{cel(parseInt(d.Meta_Curso)||0)}</td>
<td>{esInicio ? "-" : <ProgressBar pct={pct} />}</td>
<td>{cel(parseInt(d.Inscritos_Mes)||0)}</td>
<td>{cel(parseInt(d.Inscritos_PC)||0)}</td>
<td>{cel(parseInt(d.Descuento)||0)}</td>
<td>{cel(parseInt(d.Inscritos_Continuidad)||0)}</td>
<td>
{esInicio ? "-" : (
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
onClick={() => setModalCurso({ num_indice: d.num_indice, programa: prog })}>
👁️ Ver
</button>
)}
</td>
</tr>
);
})}
{datosVisibles.length > 0 && (
<tr className="total-row">
<td className="col-name" style={ST}>TOTAL GENERAL</td>
<td style={ST}>—</td><td style={ST}>—</td>
<td style={ST}>{totales.total}</td>
<td style={ST}>{totales.retirados}</td>
<td style={ST}>{totales.activos}</td>
<td style={ST}>{totales.meta}</td>
<td style={ST}><ProgressBar pct={totales.meta>0 ? totales.total/totales.meta*100 : 0} /></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 style={ST}>—</td>
</tr>
)}
</tbody>
</table>
</div>
</>}
{modalCurso && (
<ModalAlumnosCurso
numIndice={modalCurso.num_indice}
programa={modalCurso.programa}
onClose={() => setModalCurso(null)}
/>
)}
</div>
);
}
// 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 }) {
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 (
<Modal title={`👥 Alumnos Inscritos — ${programa}`} onClose={onClose} width={1050}>
{loading ? <Loader text="Cargando alumnos..." /> :
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"}}>
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#eff6ff",color:"#1e40af",fontSize:13,fontWeight:700,
border:"1px solid #bfdbfe"}}>
👥 Total: {alumnos.length} alumno{alumnos.length!==1?"s":""}
</span>
{["ALU","PRE","RET"].map((e)=> conteo[e] ? (
<span key={e} style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:colorEstado(e).bg,color:colorEstado(e).fg,fontSize:13,fontWeight:700,
border:`1px solid ${colorEstado(e).bd}`}}>
{e}: {conteo[e]}
</span>
) : null)}
</div>
<div className="table-wrap" style={{overflowX:"auto"}}>
<table className="cob-table" style={{minWidth:900}}>
<thead><tr>
<th style={{textAlign:"left"}}>ALUMNO</th>
<th>ESTADO</th>
<th style={{textAlign:"left"}}>CURSO ANTERIOR (TEAC/TERC)</th>
<th>ESTADO ANTERIOR</th>
</tr></thead>
<tbody>
{alumnos.map((a, i) => {
const c = colorEstado(a.estado);
const tieneAnt = a.curso_anterior && a.curso_anterior !== "-";
const cAnt = colorEstado(a.estado_anterior);
return (
<tr key={i}>
<td className="col-name" style={{textAlign:"left"}}>{a.alumno}</td>
<td style={{textAlign:"center"}}>
<span style={{display:"inline-block",padding:"2px 10px",borderRadius:999,
background:c.bg,color:c.fg,fontWeight:700,fontSize:12,border:`1px solid ${c.bd}`}}>
{a.estado}
</span>
</td>
<td className="col-name" style={{textAlign:"left",color:tieneAnt?undefined:"#cbd5e1"}}>
{a.curso_anterior || "-"}
</td>
<td style={{textAlign:"center"}}>
{tieneAnt ? (
<span style={{display:"inline-block",padding:"2px 10px",borderRadius:999,
background:cAnt.bg,color:cAnt.fg,fontWeight:700,fontSize:12,border:`1px solid ${cAnt.bd}`}}>
{a.estado_anterior}
</span>
) : <span style={{color:"#cbd5e1"}}>-</span>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>}
</Modal>
);
}