diff --git a/frontend/src/pages/Alertas.jsx b/frontend/src/pages/Alertas.jsx
new file mode 100644
index 0000000..9d90137
--- /dev/null
+++ b/frontend/src/pages/Alertas.jsx
@@ -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 (
+
+
⚠️ Alertas
+
+ 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.
+
+
+ {cargando && (
+
+ Cargando alertas…
+
+ )}
+ {error && (
+
{error}
+ )}
+
+ {!cargando && !error && (<>
+ {/* Contadores: los 3 niveles a la izquierda, "Ya iniciados" a la derecha */}
+
+
+ {[
+ { 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) => (
+
{ 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" }}>
+
{k.l}
+
{k.v}
+
+ ))}
+
+ {/* Botón "Ya iniciados" a la derecha */}
+
{ 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" }}>
+
🕓 Ya iniciados
+
{iniciados.length}
+
+
+
+ {/* Encabezado de la lista cuando es "Ya iniciados" */}
+ {filtro === "iniciados" && (
+
+ Análisis de los programas que iniciaron entre ayer y hace 30 días.
+
+ )}
+
+ {/* Lista (segun el filtro elegido) */}
+
+ {lista.length === 0 && (
+
+ {filtro === "iniciados" ? "No hay programas iniciados en los últimos 30 días." : "No hay cursos en esta categoría."}
+
+ )}
+ {lista.map((a) => renderTarjeta(a))}
+
+ >)}
+
+ );
+
+ function renderTarjeta(a) {
+ const open = abierto === a.id;
+ return (
+
+
setAbierto(open ? null : a.id)}
+ style={{ padding: "14px 16px", cursor: "pointer" }}>
+
+
+ {a.curso}
+ {a.suspendido && (
+
+ ⛔ SUSPENDIDO
+
+ )}
+
+
+
+ {etiqNivel(a.nivel)} · {a.ya_inicio ? `inició hace ${a.dias_desde} días` : `inicia en ${a.dias_inicio} días`}
+
+ {open ? "▲" : "▼"}
+
+
+
{a.resumen}
+
+
+ {open && (
+
+ {/* Métricas con color por estado (rojo/amarillo/verde) */}
+
+ {(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 (
+
+
+ {m.l}
+
+
{m.v}
+
{m.sub}
+
+ );
+ })}
+
+ {/* Análisis dividido en Óptimo / En observación / Crítico */}
+
+
Análisis
+ {(a.grupos || []).filter((g) => g.items.length > 0).map((g) => {
+ const c = g.color === "rojo" ? "#a32d2d" : g.color === "amarillo" ? "#854f0b" : "#0f766e";
+ return (
+
+
{g.titulo}
+
+ {g.items.map((t, i) => (
+ -
+ ›
+ {t}
+
+ ))}
+
+
+ );
+ })}
+ {a.cruce && (
+
+ Cruce por teléfono: {a.cruce}
+
+ )}
+ {a.reco && (
+
+ Recomendación: {a.reco}
+
+ )}
+
+
+ )}
+
+ );
+ }
+}