+ );
+}
+
+/**
+ * TaskProgressCard — contenedor flotante que renderiza todas las tareas activas/completadas.
+ * Se monta globalmente en App.jsx y persiste durante toda la navegación.
+ */
+export default function TaskProgressCard() {
+ const { visibleTasks } = useTaskProgress();
+
+ if (visibleTasks.length === 0) return null;
+
+ return (
+ <>
+ {/* Conectores SSE — uno por tarea en progreso, sin UI propia */}
+ {visibleTasks.map((task) => (
+
+ ))}
+
+ {/* Cards flotantes en esquina inferior derecha */}
+
+ {visibleTasks.map((task) => (
+
+ ))}
+
+ >
+ );
+}
diff --git a/src/context/TaskProgressContext.jsx b/src/context/TaskProgressContext.jsx
new file mode 100644
index 0000000..3cc5089
--- /dev/null
+++ b/src/context/TaskProgressContext.jsx
@@ -0,0 +1,161 @@
+import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
+import { fetchNotificacionByTaskId } from '../api/notificaciones';
+
+const STORAGE_KEY = 'efc_audit_tasks';
+
+/**
+ * TaskProgressContext
+ *
+ * Gestiona las tareas de auditoría en background. Persiste en localStorage para
+ * sobrevivir recargas de página. Reconecta SSE automáticamente para tareas que
+ * quedaron en "processing" tras un refresh.
+ *
+ * Estructura de cada tarea:
+ * {
+ * task_id: string,
+ * label: string, // 'EDocuments', 'COVEs', etc.
+ * status: 'submitted' | 'processing' | 'completed' | 'failed',
+ * message: string,
+ * progress: number, // 0-100
+ * resultado: object | null,
+ * organizacion_id: string,
+ * started_at: ISO string,
+ * dismissed: boolean,
+ * }
+ */
+
+const TaskProgressContext = createContext(null);
+
+export function useTaskProgress() {
+ const ctx = useContext(TaskProgressContext);
+ if (!ctx) throw new Error('useTaskProgress debe usarse dentro de TaskProgressProvider');
+ return ctx;
+}
+
+function loadFromStorage() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ return raw ? JSON.parse(raw) : [];
+ } catch {
+ return [];
+ }
+}
+
+function saveToStorage(tasks) {
+ try {
+ // Solo persistir las últimas 20 tareas no descartadas para no crecer indefinidamente
+ const toSave = tasks.slice(-20);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
+ } catch {
+ // Storage lleno — ignorar
+ }
+}
+
+export function TaskProgressProvider({ children }) {
+ const [tasks, setTasksRaw] = useState(() => loadFromStorage());
+ // task_id que Auditor.jsx debe abrir automáticamente al montar
+ const [pendingOpenTaskId, setPendingOpenTaskId] = useState(null);
+
+ const setTasks = useCallback((updater) => {
+ setTasksRaw((prev) => {
+ const next = typeof updater === 'function' ? updater(prev) : updater;
+ saveToStorage(next);
+ return next;
+ });
+ }, []);
+
+ // Añade una tarea nueva (cuando el usuario dispara una auditoría)
+ const addTask = useCallback((taskData) => {
+ setTasks((prev) => {
+ // Evitar duplicados
+ if (prev.find((t) => t.task_id === taskData.task_id)) return prev;
+ return [
+ ...prev,
+ {
+ status: 'submitted',
+ message: 'Tarea enviada',
+ progress: 0,
+ resultado: null,
+ dismissed: false,
+ started_at: new Date().toISOString(),
+ ...taskData,
+ },
+ ];
+ });
+ }, [setTasks]);
+
+ // Actualiza campos de una tarea existente por task_id
+ const updateTask = useCallback((task_id, patch) => {
+ setTasks((prev) =>
+ prev.map((t) => (t.task_id === task_id ? { ...t, ...patch } : t))
+ );
+ }, [setTasks]);
+
+ // Descarta una tarea de la vista (no la borra de storage)
+ const dismissTask = useCallback((task_id) => {
+ setTasks((prev) =>
+ prev.map((t) => (t.task_id === task_id ? { ...t, dismissed: true } : t))
+ );
+ }, [setTasks]);
+
+ // Llamado desde TaskProgressCard cuando el usuario hace click en "Ver resultado"
+ const openTaskInAuditor = useCallback((task_id) => {
+ setPendingOpenTaskId(task_id);
+ }, []);
+
+ // Llamado desde Auditor.jsx después de consumir el pendingOpenTaskId
+ const clearPendingOpen = useCallback(() => {
+ setPendingOpenTaskId(null);
+ }, []);
+
+ const getTask = useCallback(
+ (task_id) => tasks.find((t) => t.task_id === task_id) ?? null,
+ [tasks]
+ );
+
+ // Al montar: intenta recuperar tareas que completaron mientras el cliente no estaba conectado
+ // (Redis TTL expirado, página cerrada, etc.). Consulta la Notificacion en DB por task_id.
+ useEffect(() => {
+ const staleTasks = loadFromStorage().filter(
+ (t) => !t.dismissed && t.status !== 'completed' && t.status !== 'failed'
+ );
+ if (staleTasks.length === 0) return;
+
+ staleTasks.forEach(async (task) => {
+ try {
+ const notif = await fetchNotificacionByTaskId(task.task_id);
+ if (notif?.datos?.resultado) {
+ updateTask(task.task_id, {
+ status: 'completed',
+ resultado: notif.datos.resultado,
+ message: notif.mensaje,
+ progress: 100,
+ });
+ }
+ } catch {
+ // Tarea aún en proceso o usuario no autenticado aún — ignorar
+ }
+ });
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Tareas visibles (no descartadas)
+ const visibleTasks = tasks.filter((t) => !t.dismissed);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/hooks/useServerSentEvents.js b/src/hooks/useServerSentEvents.js
index e69de29..ad826e6 100644
--- a/src/hooks/useServerSentEvents.js
+++ b/src/hooks/useServerSentEvents.js
@@ -0,0 +1,96 @@
+import { useEffect, useRef, useState, useCallback } from 'react';
+
+const MICROSERVICE_V2_URL = import.meta.env.VITE_EFC_MICROSERVICE_URL?.replace('/api/v1', '/api/v2') ?? '';
+
+/**
+ * Hook para conectarse a un endpoint SSE del microservicio y recibir eventos
+ * de progreso de una tarea de auditoría.
+ *
+ * @param {string|null} taskId - ID de la tarea Celery. null = no conectar.
+ * @param {object} options
+ * @param {function} options.onUpdate - cb({ task_id, status, message, progress, resultado })
+ * @param {function} options.onCompleted - cb(resultado)
+ * @param {function} options.onFailed - cb(message)
+ */
+export function useServerSentEvents(taskId, { onUpdate, onCompleted, onFailed } = {}) {
+ const [connected, setConnected] = useState(false);
+ const [lastEvent, setLastEvent] = useState(null);
+ const esRef = useRef(null);
+ const taskIdRef = useRef(taskId);
+ taskIdRef.current = taskId;
+
+ const disconnect = useCallback(() => {
+ if (esRef.current) {
+ esRef.current.close();
+ esRef.current = null;
+ setConnected(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!taskId) {
+ disconnect();
+ return;
+ }
+
+ // Evita reconexión si ya estamos conectados al mismo taskId
+ if (esRef.current) {
+ disconnect();
+ }
+
+ const url = `${MICROSERVICE_V2_URL}/stream/tasks/${taskId}`;
+ const es = new EventSource(url);
+ esRef.current = es;
+
+ es.onopen = () => setConnected(true);
+
+ es.addEventListener('task_update', (e) => {
+ try {
+ const data = JSON.parse(e.data);
+ setLastEvent(data);
+ onUpdate?.(data);
+
+ if (data.status === 'completed') {
+ onCompleted?.(data.resultado ?? data);
+ disconnect();
+ } else if (data.status === 'failed') {
+ onFailed?.(data.message ?? 'La tarea falló');
+ disconnect();
+ }
+ } catch {
+ // JSON mal formado — ignorar
+ }
+ });
+
+ es.addEventListener('heartbeat', () => {
+ // Mantiene la conexión viva, no requiere acción
+ });
+
+ es.addEventListener('error', (e) => {
+ // Solo fallar ante un evento SSE explícito del servidor (tiene e.data).
+ // Los errores nativos de EventSource (red caída, timeout de Nginx) NO tienen e.data
+ // y EventSource se reconecta automáticamente — no deben marcar la tarea como fallida.
+ if (e instanceof MessageEvent && e.data) {
+ try {
+ onFailed?.(JSON.parse(e.data)?.message ?? 'Error en el stream');
+ } catch {
+ onFailed?.('Error en el stream');
+ }
+ disconnect();
+ }
+ });
+
+ es.onerror = () => {
+ // Error de conexión nativo — EventSource reintenta automáticamente.
+ setConnected(false);
+ };
+
+ return () => {
+ es.close();
+ esRef.current = null;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [taskId]);
+
+ return { connected, lastEvent, disconnect };
+}
diff --git a/src/pages/Auditor.jsx b/src/pages/Auditor.jsx
index de30586..1982721 100644
--- a/src/pages/Auditor.jsx
+++ b/src/pages/Auditor.jsx
@@ -1,8 +1,10 @@
import React, { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
-import { getWithAuth, postWithAuth, fetchWithAuth } from '../fetchWithAuth';
+import { getWithAuth, postWithAuth, fetchWithAuth, patchWithAuth } from '../fetchWithAuth';
import { useNotification } from '../context/NotificationContext';
+import { useTaskProgress } from '../context/TaskProgressContext';
+import { useServerSentEvents } from '../hooks/useServerSentEvents';
import hljs from 'highlight.js/lib/core';
import xml from 'highlight.js/lib/languages/xml';
import 'highlight.js/styles/github.css';
@@ -488,14 +490,31 @@ function GlobalAuditModal({ modal, onClose, onConsultar, consultando, onIniciarP
{/* Cuerpo scrolleable */}
{!resultado ? (
- /* Fase: tarea lanzada, esperando resultado */
+ /* Fase: tarea en progreso — SSE actualiza en tiempo real */
<>
-