Añadir frontend/src/pages/Alertas.jsx
This commit is contained in:
208
frontend/src/pages/Alertas.jsx
Normal file
208
frontend/src/pages/Alertas.jsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
|
||||||
|
export default function Alertas() {
|
||||||
|
const [abierto, setAbierto] = useState(null);
|
||||||
|
const [filtro, setFiltro] = useState("critico"); // nivel mostrado
|
||||||
|
const [datos, setDatos] = useState([]);
|
||||||
|
const [iniciados, setIniciados] = useState([]);
|
||||||
|
const [cargando, setCargando] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const cargar = (intento = 0) => {
|
||||||
|
api.alertas()
|
||||||
|
.then((r) => {
|
||||||
|
const items = (r && r.items) || [];
|
||||||
|
setDatos(items);
|
||||||
|
setIniciados((r && r.iniciados) || []);
|
||||||
|
setError("");
|
||||||
|
setCargando(false);
|
||||||
|
// Si llego vacio y aun no reintentamos mucho, el backend quiza no habia
|
||||||
|
// terminado de cargar la data: reintentar en 3s (hasta 5 veces).
|
||||||
|
if (items.length === 0 && intento < 5) {
|
||||||
|
setTimeout(() => cargar(intento + 1), 3000);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError("No se pudieron cargar las alertas.");
|
||||||
|
setCargando(false);
|
||||||
|
if (intento < 5) setTimeout(() => cargar(intento + 1), 3000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
cargar();
|
||||||
|
const h = () => cargar();
|
||||||
|
window.addEventListener("datos-actualizar", h);
|
||||||
|
return () => window.removeEventListener("datos-actualizar", h);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const criticos = datos.filter((a) => a.nivel === "critico");
|
||||||
|
const adv = datos.filter((a) => a.nivel === "advertencia");
|
||||||
|
const oks = datos.filter((a) => a.nivel === "ok");
|
||||||
|
// "iniciados" es un filtro más: muestra los programas que ya iniciaron.
|
||||||
|
const lista = filtro === "iniciados" ? iniciados : datos.filter((a) => a.nivel === filtro);
|
||||||
|
|
||||||
|
const colorNivel = (n) => n === "critico" ? "#E24B4A" : n === "advertencia" ? "#EF9F27" : "#0f766e";
|
||||||
|
const bgNivel = (n) => n === "critico" ? "#fdecec" : n === "advertencia" ? "#fdf3e3" : "#f0fdfa";
|
||||||
|
const txtNivel = (n) => n === "critico" ? "#a32d2d" : n === "advertencia" ? "#854f0b" : "#0f766e";
|
||||||
|
const etiqNivel = (n) => n === "critico" ? "Crítico" : n === "advertencia" ? "Advertencia" : "En rumbo";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">⚠️ Alertas</h1>
|
||||||
|
<p style={{ color: "#64748b", fontSize: 13, marginTop: -8, marginBottom: 18 }}>
|
||||||
|
Cursos próximos a iniciar (≤ 30 días) con pauta activa. El estado se define por las
|
||||||
|
matrículas frente a lo esperado según los días que faltan. Usa "Ya iniciados" para ver
|
||||||
|
los programas que iniciaron en los últimos 30 días.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{cargando && (
|
||||||
|
<div style={{ padding: 30, textAlign: "center", color: "#94a3b8", fontSize: 13 }}>
|
||||||
|
Cargando alertas…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div style={{ padding: 16, background: "#fdecec", color: "#a32d2d", borderRadius: 8,
|
||||||
|
fontSize: 13, marginBottom: 16 }}>{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!cargando && !error && (<>
|
||||||
|
{/* Contadores: los 3 niveles a la izquierda, "Ya iniciados" a la derecha */}
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "stretch",
|
||||||
|
gap: 12, marginBottom: 20, flexWrap: "wrap" }}>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 12, flex: 1, maxWidth: 620 }}>
|
||||||
|
{[
|
||||||
|
{ key: "critico", l: "Críticos", v: criticos.length, c: "#E24B4A", bg: "#fdecec" },
|
||||||
|
{ key: "advertencia", l: "Advertencias", v: adv.length, c: "#EF9F27", bg: "#fdf3e3" },
|
||||||
|
{ key: "ok", l: "En buen rumbo", v: oks.length, c: "#0f766e", bg: "#f0fdfa" },
|
||||||
|
].map((k) => (
|
||||||
|
<div key={k.l} onClick={() => { setFiltro(k.key); setAbierto(null); }}
|
||||||
|
style={{ background: k.bg, borderRadius: 12, padding: "12px 16px", cursor: "pointer",
|
||||||
|
border: filtro === k.key ? `2px solid ${k.c}` : "2px solid transparent",
|
||||||
|
transition: "border .15s ease" }}>
|
||||||
|
<div style={{ fontSize: 12, color: k.c, fontWeight: 700 }}>{k.l}</div>
|
||||||
|
<div style={{ fontSize: 26, fontWeight: 800, color: k.c }}>{k.v}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Botón "Ya iniciados" a la derecha */}
|
||||||
|
<div onClick={() => { setFiltro("iniciados"); setAbierto(null); }}
|
||||||
|
style={{ background: "#eaf1fb", borderRadius: 12, padding: "12px 20px", cursor: "pointer",
|
||||||
|
display: "flex", flexDirection: "column", justifyContent: "center", minWidth: 150,
|
||||||
|
border: filtro === "iniciados" ? "2px solid #2563eb" : "2px solid transparent",
|
||||||
|
transition: "border .15s ease" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#2563eb", fontWeight: 700 }}>🕓 Ya iniciados</div>
|
||||||
|
<div style={{ fontSize: 26, fontWeight: 800, color: "#1e40af" }}>{iniciados.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Encabezado de la lista cuando es "Ya iniciados" */}
|
||||||
|
{filtro === "iniciados" && (
|
||||||
|
<p style={{ color: "#64748b", fontSize: 13, margin: "0 0 12px" }}>
|
||||||
|
Análisis de los programas que iniciaron entre ayer y hace 30 días.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lista (segun el filtro elegido) */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
{lista.length === 0 && (
|
||||||
|
<div style={{ padding: 30, textAlign: "center", color: "#94a3b8", fontSize: 13 }}>
|
||||||
|
{filtro === "iniciados" ? "No hay programas iniciados en los últimos 30 días." : "No hay cursos en esta categoría."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lista.map((a) => renderTarjeta(a))}
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function renderTarjeta(a) {
|
||||||
|
const open = abierto === a.id;
|
||||||
|
return (
|
||||||
|
<div key={a.id} style={{ background: "#fff", border: "1px solid #eaeef4",
|
||||||
|
borderLeft: `4px solid ${colorNivel(a.nivel)}`, borderRadius: 8, overflow: "hidden" }}>
|
||||||
|
<div onClick={() => setAbierto(open ? null : a.id)}
|
||||||
|
style={{ padding: "14px 16px", cursor: "pointer" }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 6 }}>
|
||||||
|
<span style={{ fontWeight: 700, color: "#0f172a", display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
{a.curso}
|
||||||
|
{a.suspendido && (
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: 0.3,
|
||||||
|
background: "#fde2e2", color: "#b91c1c", border: "1px solid #f5b5b5",
|
||||||
|
padding: "2px 9px", borderRadius: 999 }}>
|
||||||
|
⛔ SUSPENDIDO
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<span style={{ fontSize: 12, background: bgNivel(a.nivel), color: txtNivel(a.nivel),
|
||||||
|
padding: "2px 10px", borderRadius: 999, fontWeight: 700 }}>
|
||||||
|
{etiqNivel(a.nivel)} · {a.ya_inicio ? `inició hace ${a.dias_desde} días` : `inicia en ${a.dias_inicio} días`}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#94a3b8", fontSize: 14 }}>{open ? "▲" : "▼"}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#64748b" }}>{a.resumen}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div style={{ padding: "0 16px 16px", borderTop: "1px solid #f1f5f9" }}>
|
||||||
|
{/* Métricas con color por estado (rojo/amarillo/verde) */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(6,1fr)", gap: 10, margin: "14px 0" }}>
|
||||||
|
{(a.metricas || []).map((m) => {
|
||||||
|
const c = m.est === "rojo" ? "#E24B4A" : m.est === "amarillo" ? "#EF9F27" : "#0f766e";
|
||||||
|
const bg = m.est === "rojo" ? "#fdecec" : m.est === "amarillo" ? "#fdf3e3" : "#f0fdfa";
|
||||||
|
const tc = m.est === "rojo" ? "#a32d2d" : m.est === "amarillo" ? "#854f0b" : "#0f766e";
|
||||||
|
return (
|
||||||
|
<div key={m.l} style={{ background: bg, borderRadius: 8, padding: "9px 12px",
|
||||||
|
borderLeft: `3px solid ${c}` }}>
|
||||||
|
<div style={{ fontSize: 11, color: tc, display: "flex", alignItems: "center", gap: 4 }}>
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: c }} />{m.l}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 800, color: tc }}>{m.v}</div>
|
||||||
|
<div style={{ fontSize: 10, color: "#94a3b8" }}>{m.sub}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* Análisis dividido en Óptimo / En observación / Crítico */}
|
||||||
|
<div style={{ background: bgNivel(a.nivel), borderRadius: 10, padding: "12px 14px" }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 800, color: txtNivel(a.nivel), marginBottom: 8,
|
||||||
|
textTransform: "uppercase", letterSpacing: 0.4 }}>Análisis</div>
|
||||||
|
{(a.grupos || []).filter((g) => g.items.length > 0).map((g) => {
|
||||||
|
const c = g.color === "rojo" ? "#a32d2d" : g.color === "amarillo" ? "#854f0b" : "#0f766e";
|
||||||
|
return (
|
||||||
|
<div key={g.titulo} style={{ marginBottom: 10 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 800, color: c, marginBottom: 4 }}>{g.titulo}</div>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: 0, listStyle: "none",
|
||||||
|
display: "flex", flexDirection: "column", gap: 5 }}>
|
||||||
|
{g.items.map((t, i) => (
|
||||||
|
<li key={i} style={{ fontSize: 13, color: "#334155", lineHeight: 1.55,
|
||||||
|
display: "flex", gap: 8 }}>
|
||||||
|
<span style={{ color: c, fontWeight: 800, flexShrink: 0 }}>›</span>
|
||||||
|
<span>{t}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{a.cruce && (
|
||||||
|
<div style={{ marginTop: 10, paddingTop: 10, borderTop: `1px solid ${txtNivel(a.nivel)}22`,
|
||||||
|
fontSize: 13, color: "#334155", lineHeight: 1.55 }}>
|
||||||
|
<span style={{ fontWeight: 800, color: txtNivel(a.nivel) }}>Cruce por teléfono: </span>{a.cruce}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{a.reco && (
|
||||||
|
<div style={{ marginTop: 10, paddingTop: 10, borderTop: `1px solid ${txtNivel(a.nivel)}22`,
|
||||||
|
fontSize: 13, color: "#334155", lineHeight: 1.55 }}>
|
||||||
|
<span style={{ fontWeight: 800, color: txtNivel(a.nivel) }}>Recomendación: </span>{a.reco}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user