diff --git a/src/App.jsx b/src/App.jsx index ebfcd7f..aea5eb7 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,6 +5,8 @@ import Vucem from './pages/Vucem'; import Auditor from './pages/Auditor'; import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'; import { UserProvider } from './context/UserContext'; +import { TaskProgressProvider } from './context/TaskProgressContext'; +import TaskProgressCard from './components/TaskProgressCard'; import Navbar from './components/Navbar'; import Layout from './components/Layout'; import Login from './pages/Login'; @@ -166,7 +168,10 @@ function App() { return ( - + + + + ); diff --git a/src/api/notificaciones.ts b/src/api/notificaciones.ts index 7744687..d28fc8d 100644 --- a/src/api/notificaciones.ts +++ b/src/api/notificaciones.ts @@ -15,11 +15,18 @@ export interface TipoNotificacion { descripcion: string; } +export interface NotificacionDatos { + task_id: string; + label: string; + resultado: Record; +} + export interface Notificacion { id: number; tipo: TipoNotificacion; dirigido: string; mensaje: string; + datos: NotificacionDatos | null; fecha_envio: string; created_at: string; visto: boolean; @@ -47,4 +54,12 @@ export async function fetchAllNotifications({page = 1, page_size=10}): Promise { + const url = `${API_URL}/notificaciones/notificaciones/by-task/${taskId}/`; + const res = await fetchWithAuth(url); + if (res.status === 404) return null; + if (!res.ok) throw new Error('Error al obtener notificación por task_id'); + return await res.json(); } \ No newline at end of file diff --git a/src/components/TaskProgressCard.jsx b/src/components/TaskProgressCard.jsx new file mode 100644 index 0000000..132b40d --- /dev/null +++ b/src/components/TaskProgressCard.jsx @@ -0,0 +1,158 @@ +import React, { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTaskProgress } from '../context/TaskProgressContext'; +import { useServerSentEvents } from '../hooks/useServerSentEvents'; + +const STATUS_LABEL = { + submitted: 'Enviando...', + processing: 'Procesando', + completed: 'Completado', + failed: 'Error', +}; + +const STATUS_COLOR = { + submitted: 'bg-slate-100 border-slate-300 text-slate-700', + processing: 'bg-blue-50 border-blue-300 text-blue-800', + completed: 'bg-green-50 border-green-300 text-green-800', + failed: 'bg-red-50 border-red-300 text-red-800', +}; + +const BADGE_COLOR = { + submitted: 'bg-slate-200 text-slate-700', + processing: 'bg-blue-100 text-blue-700', + completed: 'bg-green-100 text-green-700', + failed: 'bg-red-100 text-red-700', +}; + +/** Conecta SSE para UNA tarea y actualiza el contexto con los eventos recibidos. */ +function TaskSSEConnector({ task }) { + const { updateTask } = useTaskProgress(); + + useServerSentEvents( + task.status === 'completed' || task.status === 'failed' ? null : task.task_id, + { + onUpdate(data) { + updateTask(task.task_id, { + status: data.status, + message: data.message, + progress: data.progress ?? task.progress, + resultado: data.resultado ?? task.resultado, + }); + }, + onCompleted(resultado) { + updateTask(task.task_id, { status: 'completed', progress: 100, resultado }); + }, + onFailed(message) { + updateTask(task.task_id, { status: 'failed', message }); + }, + } + ); + + return null; +} + +/** Card individual de una tarea. */ +function SingleTaskCard({ task }) { + const { dismissTask, openTaskInAuditor } = useTaskProgress(); + const navigate = useNavigate(); + const isActive = task.status === 'submitted' || task.status === 'processing'; + + const handleVerResultado = () => { + openTaskInAuditor(task.task_id); + navigate('/auditor'); + }; + + return ( +
+ {/* Encabezado */} +
+
+ {task.label} + + {STATUS_LABEL[task.status] ?? task.status} + +
+ +
+ + {/* Mensaje */} + {task.message && ( +

{task.message}

+ )} + + {/* Barra de progreso */} + {isActive && ( +
+
+
+ )} + + {/* Aviso de navegación libre cuando está en progreso */} + {isActive && ( +

+ Puedes seguir navegando — te avisamos cuando termine. +

+ )} + + {/* Botón "Ver resultado" cuando completó */} + {task.status === 'completed' && ( + + )} + + {/* Mensaje de error */} + {task.status === 'failed' && ( +

+ La tarea falló. Revisa el Panel de Auditoría. +

+ )} +
+ ); +} + +/** + * 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 */ <> -
- - +
+ + +
-

La tarea fue enviada al worker. Consulta el resultado cuando haya finalizado.

+ {modal._message && ( +

{modal._message}

+ )} + {modal._progress != null && ( +
+
+
+ )} + {!modal._message && ( +

La tarea está procesando. El resultado aparecerá automáticamente al terminar.

+ )} +

+ Puedes navegar libremente — te notificaremos cuando termine. +

Task ID: {task_id} @@ -521,25 +540,85 @@ function GlobalAuditModal({ modal, onClose, onConsultar, consultando, onIniciarP

{resultado.mensaje}

- {/* Grid de cifras */} -
-
- {resultado.result?.total_pedimentos ?? '—'} - Total + {/* Grid de cifras — auto-corrección */} + {resultado.result?.total_revisados != null ? ( +
+
+ {resultado.result.total_revisados} + Revisados +
+
+ {resultado.result.corregidos} + Corregidos +
+
+ {resultado.result.ignorados} + Sin cambios +
-
- {resultado.result?.completados ?? '—'} - Completos + ) : ( + /* Grid de cifras — auditoría */ +
+
+ {resultado.result?.total_pedimentos ?? '—'} + Total +
+
+ {resultado.result?.completados ?? '—'} + Completos +
+
+ {resultado.result?.con_pendientes ?? '—'} + Pendientes +
+
+ {resultado.result?.con_errores ?? '—'} + Errores +
-
- {resultado.result?.con_pendientes ?? '—'} - Pendientes -
-
- {resultado.result?.con_errores ?? '—'} - Errores -
-
+ )} + + {/* Detalle de auto-corrección */} + {resultado.result?.detalles && resultado.result.detalles.length > 0 && ( +
+ + Detalle de correcciones ({resultado.result.detalles.length}) + + + + +
+ + + + + + + + + + {resultado.result.detalles.map((item, i) => ( + + + + + + ))} + +
PedimentoAcciónCampos
{item.pedimento} + + {item.accion} + + +
+ {(item.campos_pedimento ?? []).map((c, j) => ( + {c} + ))} +
+
+
+
+ )} {/* Tabla de pedimentos con pendientes */} {resultado.result?.detalle_pendientes && resultado.result.detalle_pendientes.length > 0 && ( @@ -629,32 +708,7 @@ function GlobalAuditModal({ modal, onClose, onConsultar, consultando, onIniciarP {/* Footer */}
- {/* Botón "Consultar estado" cuando aún no hay resultado */} - {!resultado && ( - - )} + {/* Fase en progreso: sin botón de consulta — SSE actualiza automáticamente */} {/* Botón "Iniciar Proceso" cuando hay resultado con pendientes/errores */} {hayPendientes && ( @@ -696,8 +750,212 @@ function GlobalAuditModal({ modal, onClose, onConsultar, consultando, onIniciarP ); } +function AutoCorregirResultModal({ modal, onClose, onConsultar, consultando, onEjecutar, ejecutando }) { + const { task_id, resultado } = modal; + const hayCorregibles = resultado && resultado.corregibles > 0; + const esVacio = resultado && resultado.corregibles === 0; + + const headerBg = !resultado + ? 'bg-blue-50 border-b border-blue-100' + : hayCorregibles + ? 'bg-amber-50 border-b border-amber-100' + : 'bg-green-50 border-b border-green-100'; + + const badge = !resultado ? ( + + Analizando + + ) : hayCorregibles ? ( + + {resultado.corregibles} corregibles + + ) : ( + + Sin cambios pendientes + + ); + + return ( +
+
+
+
+
+

Análisis — Auto-corrección

+

Pedimentos Incompletos

+
+ {badge} +
+
+ +
+ {!resultado ? ( + <> +
+ + + + +
+ {modal._message + ?

{modal._message}

+ :

Analizando pedimentos con consultar_vucem=False...

+ } +

Puedes navegar libremente — te notificaremos cuando termine.

+
+ Task ID: + {task_id} +
+ + ) : ( + <> +
+
+ {resultado.total_revisados ?? '—'} + Revisados +
+
+ {resultado.corregibles ?? '—'} + Corregibles +
+
+ {resultado.sin_xml_o_ilegible ?? '—'} + Sin XML +
+
+ +
+
+ {resultado.xml_no_es_pedimento_completo ?? '—'} + XML sin PC +
+
+ {resultado.numero_pedimento_no_coincide ?? '—'} + Núm. no coincide +
+
+ {resultado.con_error_vucem ?? '—'} + Error VUCEM +
+
+ + {resultado.pedimentos?.length > 0 && ( +
+ + Pedimentos corregibles ({resultado.pedimentos.length}) + + + + +
+ + + + + + + + + + {resultado.pedimentos.map((item, i) => ( + + + + + + ))} + +
PedimentoCampos a corregirDoc. nuevo
{item.pedimento_app} +
+ {item.campos_a_corregir?.length > 0 ? ( + item.campos_a_corregir.map((c, j) => ( + {c.campo} + )) + ) : ( + solo reclasificación + )} +
+
+ {item.documento_nuevo_nombre + ? item.documento_nuevo_nombre.split('/').pop() + : } +
+
+
+ )} + + {esVacio && ( +

No hay pedimentos corregibles en este momento.

+ )} + + )} +
+ +
+ {resultado && hayCorregibles && ( + + )} +
+ {!resultado && ( + + )} + +
+
+
+
+ ); +} + function Auditor() { const { showMessage } = useNotification(); + const { addTask, pendingOpenTaskId, clearPendingOpen, getTask } = useTaskProgress(); const [loading, setLoading] = useState(false); const [loadingTable, setLoadingTable] = useState(false); const isFirstLoad = useRef(true); @@ -722,6 +980,15 @@ function Auditor() { const [auditandoEDocuments, setAuditandoEDocuments] = useState(false); const [auditandoAcuses, setAuditandoAcuses] = useState(false); const [showXmlModal, setShowXmlModal] = useState(false); + const [corregirModal, setCorregirModal] = useState(null); // pedimento object + const [corregirClosing, setCorregirClosing] = useState(false); + const [corregirForm, setCorregirForm] = useState({}); + const [savingCorregir, setSavingCorregir] = useState(false); + const [analizandoIncompletos, setAnalizandoIncompletos] = useState(false); + const [autoCorregirModal, setAutoCorregirModal] = useState(null); + const [consultandoAutoCorregir, setConsultandoAutoCorregir] = useState(false); + const [ejecutandoAutoCorregir, setEjecutandoAutoCorregir] = useState(false); + const [analizandoIndividual, setAnalizandoIndividual] = useState(null); // pedimento_id en curso, o null const [xmlData, setXmlData] = useState(null); // Estados para modal de preview @@ -755,6 +1022,70 @@ function Auditor() { const [consultandoTask, setConsultandoTask] = useState(false); const [iniciandoProcesoGlobal, setIniciandoProcesoGlobal] = useState(false); + // SSE: cuando el usuario está en esta página con el modal abierto, actualiza en tiempo real + useServerSentEvents(globalAuditModal?.task_id ?? null, { + onUpdate(data) { + if (data.progress != null) { + setGlobalAuditModal(prev => prev ? { ...prev, _progress: data.progress, _message: data.message } : prev); + } + }, + onCompleted(rawResultado) { + const normalizado = { + result: rawResultado, + mensaje: rawResultado?.auditoria + ? `Auditoría de ${rawResultado.auditoria} completada` + : 'Auditoría completada', + }; + setGlobalAuditModal(prev => prev ? { ...prev, resultado: normalizado } : prev); + }, + onFailed(message) { + showMessage(message || 'La tarea de auditoría falló', 'error'); + }, + }); + + useServerSentEvents(autoCorregirModal?.task_id ?? null, { + onUpdate(data) { + if (data.progress != null) { + setAutoCorregirModal(prev => prev ? { ...prev, _progress: data.progress, _message: data.message } : prev); + } + }, + onCompleted(rawResultado) { + setAutoCorregirModal(prev => prev ? { ...prev, resultado: rawResultado } : prev); + }, + onFailed(message) { + showMessage(message || 'La tarea de análisis falló', 'error'); + }, + }); + + // Auto-open modal cuando el usuario regresa desde una card flotante + useEffect(() => { + if (!pendingOpenTaskId) return; + const task = getTask(pendingOpenTaskId); + clearPendingOpen(); + if (!task) return; + + const base = { + label: task.label, + organizacion_id: task.organizacion_id, + task_id: task.task_id, + }; + + if (task.resultado) { + setGlobalAuditModal({ + ...base, + resultado: { + result: task.resultado, + mensaje: task.resultado?.auditoria + ? `Auditoría de ${task.resultado.auditoria} completada` + : 'Auditoría completada', + }, + }); + } else { + setGlobalAuditModal({ ...base, resultado: null }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pendingOpenTaskId]); + const handleIniciarProcesoGlobal = async () => { if (!globalAuditModal?.procesamiento || !globalAuditModal?.organizacion_id || iniciandoProcesoGlobal) return; try { @@ -846,6 +1177,7 @@ function Auditor() { throw new Error(await extractApiError(response)); } const data = await response.json(); + addTask({ task_id: data.task_id, label: 'Acuses', organizacion_id: organizacionId }); setGlobalAuditModal({ label: 'Acuses', procesamiento: 'acuses', organizacion_id: organizacionId, task_id: data.task_id, resultado: null }); } catch (error) { showMessage(error.message || 'Error al iniciar la auditoría de acuses', 'error'); @@ -870,6 +1202,7 @@ function Auditor() { throw new Error(await extractApiError(response)); } const data = await response.json(); + addTask({ task_id: data.task_id, label: 'EDocuments', organizacion_id: organizacionId }); setGlobalAuditModal({ label: 'EDocuments', procesamiento: 'edocs', organizacion_id: organizacionId, task_id: data.task_id, resultado: null }); } catch (error) { showMessage(error.message || 'Error al iniciar la auditoría de EDocuments', 'error'); @@ -894,6 +1227,7 @@ function Auditor() { throw new Error(await extractApiError(response)); } const data = await response.json(); + addTask({ task_id: data.task_id, label: 'Acuses de COVE', organizacion_id: organizacionId }); setGlobalAuditModal({ label: 'Acuses de COVE', procesamiento: 'acuse_coves', organizacion_id: organizacionId, task_id: data.task_id, resultado: null }); } catch (error) { showMessage(error.message || 'Error al iniciar la auditoría de acuses cove', 'error'); @@ -967,6 +1301,7 @@ function Auditor() { } const data = await response.json(); + addTask({ task_id: data.task_id, label: 'Remesas', organizacion_id: organizacionId }); setGlobalAuditModal({ label: 'Remesas', procesamiento: 'remesas', organizacion_id: organizacionId, task_id: data.task_id, resultado: null }); } catch (error) { @@ -1019,6 +1354,7 @@ function Auditor() { } const data = await response.json(); + addTask({ task_id: data.task_id, label: 'Partidas', organizacion_id: organizacionId }); setGlobalAuditModal({ label: 'Partidas', procesamiento: 'partidas', organizacion_id: organizacionId, task_id: data.task_id, resultado: null }); } catch (error) { @@ -1048,6 +1384,7 @@ function Auditor() { } const data = await response.json(); + addTask({ task_id: data.task_id, label: 'Pedimentos', organizacion_id: organizacionId }); setGlobalAuditModal({ label: 'Pedimentos', organizacion_id: organizacionId, task_id: data.task_id, resultado: null }); } catch (error) { @@ -1164,7 +1501,93 @@ const handleAuditarEDocumentPedimento = async (pedimentoId) => { { setPeticionPedimentoVU(null); } - }; + }; + + const handleAnalizarIncompletos = async () => { + if (analizandoIncompletos) return; + try { + setAnalizandoIncompletos(true); + const organizacionId = pedimentos[0]?.organizacion; + if (!organizacionId) throw new Error('No hay organización disponible'); + const response = await postWithAuth(`${API_URL}/customs/auditor/auditar-pedamentos-incompletos/`, { + organizacion_id: organizacionId, + }); + if (!response.ok) throw new Error(await extractApiError(response)); + const data = await response.json(); + addTask({ task_id: data.task_id, label: 'Análisis Incompletos', organizacion_id: organizacionId }); + setAutoCorregirModal({ task_id: data.task_id, organizacion_id: organizacionId, resultado: null }); + } catch (error) { + showMessage(error.message || 'Error al iniciar el análisis', 'error'); + } finally { + setAnalizandoIncompletos(false); + } + }; + + const handleConsultarAutoCorregir = async () => { + if (!autoCorregirModal?.task_id || consultandoAutoCorregir) return; + try { + setConsultandoAutoCorregir(true); + const response = await getWithAuth(`${API_URL}/tasks/status/${autoCorregirModal.task_id}/`); + if (!response.ok) throw new Error(await extractApiError(response)); + const data = await response.json(); + if (data.ready) { + setAutoCorregirModal(prev => prev ? { ...prev, resultado: data.resultado ?? data.result ?? data } : prev); + } else { + showMessage('La tarea aún no ha finalizado, intenta en unos segundos', 'info'); + } + } catch (error) { + showMessage(error.message || 'Error al consultar tarea', 'error'); + } finally { + setConsultandoAutoCorregir(false); + } + }; + + const handleEjecutarAutoCorregir = async () => { + if (ejecutandoAutoCorregir || !autoCorregirModal) return; + try { + setEjecutandoAutoCorregir(true); + let response; + if (autoCorregirModal.pedimento_id) { + response = await postWithAuth(`${API_URL}/customs/auditor/auto-corregir-pedamento/`, { + pedimento_id: autoCorregirModal.pedimento_id, + }); + } else { + response = await postWithAuth(`${API_URL}/customs/auditor/auto-corregir-pedamentos/`, { + organizacion_id: autoCorregirModal.organizacion_id, + }); + } + if (!response.ok) throw new Error(await extractApiError(response)); + const data = await response.json(); + setAutoCorregirModal(null); + const label = autoCorregirModal.pedimento_id + ? `Auto-corrección: ${data.pedimento}` + : 'Auto-corrección de Pedimentos'; + addTask({ task_id: data.task_id, label, organizacion_id: autoCorregirModal.organizacion_id ?? data.pedimento_id }); + setGlobalAuditModal({ label, organizacion_id: autoCorregirModal.organizacion_id, task_id: data.task_id, resultado: null }); + } catch (error) { + showMessage(error.message || 'Error al ejecutar la auto-corrección', 'error'); + } finally { + setEjecutandoAutoCorregir(false); + } + }; + + const handleAnalizarPedimentoIndividual = async (pedimentoId) => { + if (analizandoIndividual) return; + try { + setAnalizandoIndividual(pedimentoId); + const response = await postWithAuth(`${API_URL}/customs/auditor/auditar-pedamento-incompleto/`, { + pedimento_id: pedimentoId, + }); + if (!response.ok) throw new Error(await extractApiError(response)); + const data = await response.json(); + addTask({ task_id: data.task_id, label: `Análisis: ${data.pedimento}`, organizacion_id: data.pedimento_id }); + setAutoCorregirModal({ task_id: data.task_id, organizacion_id: null, pedimento_id: data.pedimento_id, resultado: null }); + } catch (error) { + showMessage(error.message || 'Error al analizar el pedimento', 'error'); + } finally { + setAnalizandoIndividual(null); + } + }; // const handleViewXmlPedimentoVURequest= async (pedimentoId) => { @@ -1288,6 +1711,48 @@ const handleAuditarEDocumentPedimento = async (pedimentoId) => { }; + const closeCorregirDrawer = () => { + setCorregirClosing(true); + setTimeout(() => { + setCorregirModal(null); + setCorregirClosing(false); + }, 280); + }; + + const handleCorregirOpen = (pedimento) => { + setCorregirForm({ + numero_operacion: pedimento.numero_operacion || '', + aduana: pedimento.aduana || '', + patente: pedimento.patente || '', + pedimento: pedimento.pedimento || '', + fecha_pago: pedimento.fecha_pago || '', + regimen: pedimento.regimen || '', + clave_pedimento: pedimento.clave_pedimento || '', + }); + setCorregirModal(pedimento); + }; + + const handleCorregirSubmit = async (e) => { + e.preventDefault(); + if (!corregirModal) return; + setSavingCorregir(true); + try { + const response = await patchWithAuth(`${API_URL}/customs/pedimentos/${corregirModal.id}/`, corregirForm); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.detail || err.message || `Error ${response.status}`); + } + const updated = await response.json(); + setPedimentos(prev => prev.map(p => p.id === updated.id ? { ...p, ...updated } : p)); + closeCorregirDrawer(); + showMessage('Pedimento corregido exitosamente', 'success'); + } catch (error) { + showMessage(`Error al corregir: ${error.message}`, 'error'); + } finally { + setSavingCorregir(false); + } + }; + const getVistaNombre = (vista) => { const nombres = { 'pc': 'Pedimento Completo', @@ -2091,6 +2556,35 @@ function formatXml(xml) { )} + + +
@@ -2163,6 +2657,9 @@ function formatXml(xml) { EDoc EDocument + + Corregir + @@ -2383,6 +2880,26 @@ function formatXml(xml) {
+ {/* Auditar incompleto individual */} + + + ))} @@ -2549,6 +3066,18 @@ function formatXml(xml) { /> )} + {/* Modal auto-corrección de pedamentos incompletos */} + {autoCorregirModal && ( + setAutoCorregirModal(null)} + onConsultar={handleConsultarAutoCorregir} + consultando={consultandoAutoCorregir} + onEjecutar={handleEjecutarAutoCorregir} + ejecutando={ejecutandoAutoCorregir} + /> + )} + {/* Modal de Vista Previa XML */} {/* {showXmlModal && xmlData && (
@@ -3066,6 +3595,156 @@ function formatXml(xml) {
)} + {/* Drawer — Corregir Pedimento */} + {corregirModal && ( + <> + {/* Overlay */} +
+ {/* Panel lateral derecho */} +
+ {/* Header */} +
+
+
+ + + +
+
+

Corregir Pedimento

+

{corregirModal.pedimento_app}

+
+
+ +
+ + {/* Form — cuerpo scrollable + footer fijo */} +
+
+
+ + setCorregirForm(f => ({ ...f, numero_operacion: e.target.value }))} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" + /> +
+
+
+ + setCorregirForm(f => ({ ...f, aduana: e.target.value }))} + maxLength={3} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" + /> +
+
+ + setCorregirForm(f => ({ ...f, patente: e.target.value }))} + maxLength={4} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" + /> +
+
+
+ + setCorregirForm(f => ({ ...f, pedimento: e.target.value }))} + maxLength={7} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" + /> +
+
+ + setCorregirForm(f => ({ ...f, fecha_pago: e.target.value }))} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" + /> +
+
+
+ + setCorregirForm(f => ({ ...f, regimen: e.target.value }))} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" + /> +
+
+ + setCorregirForm(f => ({ ...f, clave_pedimento: e.target.value }))} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" + /> +
+
+
+ + {/* Footer fijo */} +
+ + +
+
+
+ + )} + {/* Animaciones CSS */}