From 1c6b07be5891079cac0fa957e836282bce7aa2f4 Mon Sep 17 00:00:00 2001 From: marcos Date: Mon, 8 Jun 2026 07:19:29 -0600 Subject: [PATCH] feature/implementacion de hub en EFC --- .env.example | 1 + src/App.jsx | 4 +- src/api/auth.js | 82 +- src/components/LogoutButton.jsx | 15 +- src/components/Navbar.jsx | 24 +- src/components/RequireAuth.jsx | 18 +- src/components/Sidebar.jsx | 117 ++- src/components/SidebarNew.jsx | 13 +- src/components/TaskProgressCard.jsx | 276 +++-- src/context/TaskProgressContext.jsx | 15 +- src/fetchWithAuth.js | 203 ++-- src/pages/Auditor.jsx | 1511 ++++++++++++++++++--------- src/pages/Documents.jsx | 3 +- src/pages/Expedientes.jsx | 17 +- src/pages/LandingAnimated.jsx | 19 +- src/pages/Login.jsx | 326 +++--- src/pages/PedimentoDetail.jsx | 150 ++- src/pages/Procesos.jsx | 2 +- src/pages/Reports.jsx | 295 +++++- src/pages/SSOCallback.jsx | 114 ++ src/pages/Settings.jsx | 3 +- src/pages/UserForm.jsx | 6 +- src/pages/Users.jsx | 3 +- 23 files changed, 2178 insertions(+), 1039 deletions(-) create mode 100644 src/pages/SSOCallback.jsx diff --git a/.env.example b/.env.example index 0b5fb77..ae85907 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,4 @@ VITE_DEBUG_MODE=false VITE_EFC_API_URL=https://api.efc-aduanasoft.com/api/v1 VITE_EFC_MICROSERVICE_URL=https://api.efc-aduanasoft.com/microservice/api/v1 +VITE_HUB_URL=http://localhost:3001 diff --git a/src/App.jsx b/src/App.jsx index aea5eb7..ec5c60c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -28,11 +28,12 @@ import TableroAlmacenamiento from './pages/TableroAlmacenamiento'; import Notificaciones from './pages/Notificaciones'; import ForgotPassword from './pages/ForgotPassword'; import PasswordResetConfirm from './pages/PasswordResetConfirm'; +import SSOCallback from './pages/SSOCallback'; // Componente para manejar el layout condicional function AppContent() { const location = useLocation(); - const isAuthPage = location.pathname === '/login' || location.pathname === '/' || location.pathname === '/forgot-password' || location.pathname.startsWith('/user/password-reset-confirm/'); + const isAuthPage = location.pathname === '/login' || location.pathname === '/' || location.pathname === '/forgot-password' || location.pathname.startsWith('/user/password-reset-confirm/') || location.pathname === '/auth/sso'; if (isAuthPage) { return ( @@ -42,6 +43,7 @@ function AppContent() { } /> } /> } /> + } /> ); diff --git a/src/api/auth.js b/src/api/auth.js index 7d46763..0ad03dc 100644 --- a/src/api/auth.js +++ b/src/api/auth.js @@ -1,24 +1,74 @@ const API_URL = import.meta.env.VITE_EFC_API_URL; +const HUB_URL = import.meta.env.VITE_HUB_URL || 'https://workspace.aduanasoft.com'; -export async function login(username, password) { - const response = await fetch(`${API_URL}/token/`, { +/** + * Login directo email/password via Hub. + * Devuelve { access_token, user_id, email, ... } + * o { needs_tenant: true, tenants: [...] } si es multi-tenant. + */ +export async function login(username, password, tenant_slug) { + const body = { username, password }; + if (tenant_slug) body.tenant_slug = tenant_slug; + + const response = await fetch(`${API_URL}/auth/login/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), + credentials: 'include', // recibir cookie HTTP-only + body: JSON.stringify(body), }); - if (!response.ok) { - throw new Error('Credenciales inválidas'); - } - return response.json(); // { access, refresh } + if (response.status === 401) throw new Error('Credenciales inválidas'); + if (response.status === 403) throw new Error('Usuario inactivo o sin permisos'); + if (!response.ok) throw new Error('Error al iniciar sesión'); + + return response.json(); } -// export async function refreshToken(refresh) { -// const res = await fetch(`${API_URL}/token/refresh/`, { -// method: 'POST', -// headers: { 'Content-Type': 'application/json' }, -// body: JSON.stringify({ refresh }), -// }); -// if (!res.ok) throw new Error('SESSION_EXPIRED'); -// return res.json(); // { access: '...' } -// } \ No newline at end of file +/** Canjea relay token del Hub por sesión local (SSO entre productos / Microsoft). */ +export async function ssoExchange(relay_token) { + const response = await fetch(`${API_URL}/auth/sso/exchange/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ relay_token }), + }); + + if (response.status === 401) throw new Error('Relay token inválido o expirado'); + if (!response.ok) throw new Error('Error al completar inicio de sesión SSO'); + + return response.json(); +} + +/** Usuario autenticado actual. */ +export async function getMe(token) { + const headers = { 'Content-Type': 'application/json' }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const response = await fetch(`${API_URL}/auth/me/`, { + headers, + credentials: 'include', + }); + + if (!response.ok) throw new Error('No autenticado'); + return response.json(); +} + +/** Cierra sesión: limpia estado local de inmediato, backend en background. */ +export function logout() { + // 1. Limpiar localStorage inmediatamente (síncrono, sin esperar al servidor) + ['access', 'refresh', 'user_id', 'username', 'user_email', + 'user_first_name', 'user_last_name', 'user_groups', + 'user_is_importador', 'user_permissions'].forEach(k => localStorage.removeItem(k)); + + // 2. Notificar a los componentes que la sesión terminó + window.dispatchEvent(new CustomEvent('authStateChanged')); + + // 3. Llamar al backend en segundo plano para limpiar cookies (fire and forget) + fetch(`${API_URL}/auth/logout/`, { method: 'POST', credentials: 'include' }).catch(() => {}); +} + +/** URL para login con Microsoft (redirige al Hub). */ +export function getMicrosoftLoginUrl() { + const returnTo = encodeURIComponent(`${window.location.origin}/auth/sso`); + return `${HUB_URL}/login?return_to=${returnTo}&idp=microsoft`; +} diff --git a/src/components/LogoutButton.jsx b/src/components/LogoutButton.jsx index 65c1380..affd490 100644 --- a/src/components/LogoutButton.jsx +++ b/src/components/LogoutButton.jsx @@ -1,17 +1,12 @@ import React from 'react'; -import { useNavigate } from 'react-router-dom'; +import { logout } from '../api/auth'; + +const HUB_URL = import.meta.env.VITE_HUB_URL || 'http://localhost:3001'; export default function LogoutButton() { - const navigate = useNavigate(); - const handleLogout = () => { - localStorage.removeItem('access'); - localStorage.removeItem('refresh'); - - // Disparar evento para actualizar el navbar - window.dispatchEvent(new CustomEvent('authStateChanged')); - - navigate('/login'); + logout(); // limpia estado + llama backend en bg + window.location.href = `${HUB_URL}/login`; }; return ( diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index e890e9c..e91dd9d 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -1,7 +1,13 @@ import React, { useState, useEffect } from 'react'; -import { Link, useLocation } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; import { colors } from '../theme'; +const hubLoginUrl = () => { + const HUB_URL = import.meta.env.VITE_HUB_URL || 'http://localhost:3001'; + const returnTo = encodeURIComponent(`${window.location.origin}/auth/sso`); + return `${HUB_URL}/login?return_to=${returnTo}`; +}; + export default function Navbar() { const [isLoggedIn, setIsLoggedIn] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -18,11 +24,11 @@ export default function Navbar() { return () => window.removeEventListener('authStateChanged', checkAuthStatus); }, []); - const logout = () => { - localStorage.removeItem('access'); - localStorage.removeItem('refresh'); - window.dispatchEvent(new CustomEvent('authStateChanged')); - window.location.href = '/'; + const logout = async () => { + const { logout: doLogout } = await import('../api/auth'); + doLogout(); // limpia estado + llama backend en bg + const hubUrl = import.meta.env.VITE_HUB_URL || 'http://localhost:3001'; + window.location.href = `${hubUrl}/login`; }; const navLinks = [ @@ -97,15 +103,15 @@ export default function Navbar() { ) : ( - Ingresar - + )} diff --git a/src/components/RequireAuth.jsx b/src/components/RequireAuth.jsx index d1a5abd..3369f85 100644 --- a/src/components/RequireAuth.jsx +++ b/src/components/RequireAuth.jsx @@ -1,18 +1,18 @@ import React from 'react'; -import { Navigate } from 'react-router-dom'; -// Esta función verifica si el usuario está autenticado (por ejemplo, si hay un token en localStorage) +const HUB_URL = import.meta.env.VITE_HUB_URL || 'https://workspace.aduanasoft.com'; + function isAuthenticated() { - const token = localStorage.getItem('access'); - return !!token; + return !!localStorage.getItem('access'); } export default function RequireAuth({ children }) { - const authenticated = isAuthenticated(); - - if (!authenticated) { - return ; + if (!isAuthenticated()) { + // Sesión expirada o no iniciada → redirigir al Hub con return_to + const returnTo = encodeURIComponent(`${window.location.origin}/auth/sso`); + window.location.href = `${HUB_URL}/login?return_to=${returnTo}`; + return null; } - + return children; } diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 7994cb8..4c2a4e3 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -29,19 +29,11 @@ export default function Sidebar({ isMobileOpen, onMobileClose }) { const navigate = useNavigate(); const { user: currentUser, loading } = useUser(); - const handleLogout = () => { - localStorage.removeItem('access'); - localStorage.removeItem('refresh'); - localStorage.removeItem('user_id'); - localStorage.removeItem('user_is_importador'); - localStorage.removeItem('user_groups'); - localStorage.removeItem('user_permissions'); - localStorage.removeItem('username'); - localStorage.removeItem('user_email'); - localStorage.removeItem('user_first_name'); - localStorage.removeItem('user_last_name'); - window.dispatchEvent(new CustomEvent('authStateChanged')); - navigate('/login'); + const handleLogout = async () => { + const { logout } = await import('../api/auth'); + logout(); // limpia estado + llama backend en bg + const hubUrl = import.meta.env.VITE_HUB_URL || 'http://localhost:3001'; + window.location.href = `${hubUrl}/login`; }; // Cerrar menú móvil cuando se navega o cuando la pantalla es grande @@ -114,7 +106,7 @@ export default function Sidebar({ isMobileOpen, onMobileClose }) { title: 'Servicios', items: [ { - name: 'Procesos', + name: 'Historial de Procesos', path: '/procesos', icon: ( @@ -140,47 +132,62 @@ export default function Sidebar({ isMobileOpen, onMobileClose }) { { title: 'Documentación', items: [ - // Mostrar Reportes siempre - { - name: 'Reportes', - path: '/reports', - icon: ( - - - - ) - }, - { - name: 'Expedientes', - path: '/expedientes', - icon: ( - - - - - ) - }, - { - name: 'Documentos', - path: '/documents', - icon: ( - - - - ) - }, - { - name: 'Datastage', - path: '/datastage', - icon: ( - - - - - - ), - onClick: () => navigate('/datastage') - } + ...( + hasPermission('reportes.view') + ? [{ + name: 'Reportes', + path: '/reports', + icon: ( + + + + ) + }] + : [] + ), + ...( + hasPermission('pedimentos.view') + ? [{ + name: 'Expedientes', + path: '/expedientes', + icon: ( + + + + + ) + }] + : [] + ), + ...( + hasPermission('documentos.view') + ? [{ + name: 'Documentos', + path: '/documents', + icon: ( + + + + ) + }] + : [] + ), + ...( + hasPermission('datastage.view') + ? [{ + name: 'Datastage', + path: '/datastage', + icon: ( + + + + + + ), + onClick: () => navigate('/datastage') + }] + : [] + ), ] }, // Nueva sección Tableros - Solo mostrar si DEBUG_MODE es true y tiene cards.view diff --git a/src/components/SidebarNew.jsx b/src/components/SidebarNew.jsx index 6a830a7..75ecb72 100644 --- a/src/components/SidebarNew.jsx +++ b/src/components/SidebarNew.jsx @@ -6,14 +6,11 @@ export default function Sidebar() { const location = useLocation(); const navigate = useNavigate(); - const handleLogout = () => { - localStorage.removeItem('access'); - localStorage.removeItem('refresh'); - - // Disparar evento para actualizar el navbar - window.dispatchEvent(new CustomEvent('authStateChanged')); - - navigate('/login'); + const handleLogout = async () => { + const { logout } = await import('../api/auth'); + logout(); // limpia estado + llama backend en bg + const hubUrl = import.meta.env.VITE_HUB_URL || 'http://localhost:3001'; + window.location.href = `${hubUrl}/login`; }; const menuItems = [ diff --git a/src/components/TaskProgressCard.jsx b/src/components/TaskProgressCard.jsx index 132b40d..593d173 100644 --- a/src/components/TaskProgressCard.jsx +++ b/src/components/TaskProgressCard.jsx @@ -1,7 +1,10 @@ -import React, { useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import React, { useState, useEffect, useRef } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; import { useTaskProgress } from '../context/TaskProgressContext'; import { useServerSentEvents } from '../hooks/useServerSentEvents'; +import { fetchWithAuth } from '../fetchWithAuth'; + +const API_URL = import.meta.env.VITE_EFC_API_URL; const STATUS_LABEL = { submitted: 'Enviando...', @@ -10,13 +13,6 @@ const STATUS_LABEL = { 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', @@ -24,18 +20,19 @@ const BADGE_COLOR = { failed: 'bg-red-100 text-red-700', }; -/** Conecta SSE para UNA tarea y actualiza el contexto con los eventos recibidos. */ +const AUTH_PATHS = new Set(['/', '/login', '/forgot-password', '/auth/sso']); + +/** Conecta SSE para UNA tarea y actualiza el contexto. Se mantiene vivo independientemente del estado del panel. */ 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, + status: data.status, + message: data.message, + progress: data.progress ?? task.progress, resultado: data.resultado ?? task.resultado, }); }, @@ -47,111 +44,258 @@ function TaskSSEConnector({ task }) { }, } ); - return null; } -/** Card individual de una tarea. */ +/** Card compacta de una tarea dentro del panel expandido. */ function SingleTaskCard({ task }) { const { dismissTask, openTaskInAuditor } = useTaskProgress(); const navigate = useNavigate(); - const isActive = task.status === 'submitted' || task.status === 'processing'; + const [downloading, setDownloading] = useState(false); + const isActive = task.status === 'submitted' || task.status === 'processing'; + const isReport = task.taskType === 'report'; const handleVerResultado = () => { openTaskInAuditor(task.task_id); navigate('/auditor'); }; + const handleDescargarReporte = async () => { + const reportId = task.resultado?.report_id ?? task.report_id; + if (!reportId) { navigate('/reports'); return; } + setDownloading(true); + try { + const res = await fetchWithAuth(`${API_URL}/reports/report-document-download/${reportId}/`); + if (!res.ok) { navigate('/reports'); return; } + const blob = await res.blob(); + const disposition = res.headers.get('Content-Disposition'); + let filename = ''; + if (disposition) { + const match = disposition.match(/filename="?([^";\s]+)"?/); + if (match) filename = match[1]; + } + if (!filename) filename = `reporte_${reportId}.xlsx`; + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + dismissTask(task.task_id); + } catch { + navigate('/reports'); + } finally { + setDownloading(false); + } + }; + return ( -
- {/* Encabezado */} -
-
- {task.label} - +
+
+
+ {task.label} + {STATUS_LABEL[task.status] ?? task.status}
- {/* Mensaje */} {task.message && ( -

{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' && ( + {task.status === 'completed' && isReport && ( )} - {/* Mensaje de error */} - {task.status === 'failed' && ( -

- La tarea falló. Revisa el Panel de Auditoría. + {task.status === 'completed' && !isReport && ( + + )} + + {task.status === 'failed' && isReport && ( +

+ Falló — revisa el historial en{' '} + .

)} + + {task.status === 'failed' && !isReport && ( +

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. + * TaskProgressCard — pill minimizado en bottom-left. + * Las conexiones SSE viven siempre; el panel es solo visual y no bloquea la UI. + * Se auto-expande cuando una tarea pasa a completada o fallida. */ export default function TaskProgressCard() { const { visibleTasks } = useTaskProgress(); + const location = useLocation(); + const [expanded, setExpanded] = useState(false); + const prevStatusesRef = useRef({}); + const containerRef = useRef(null); - if (visibleTasks.length === 0) return null; + // Cierra el panel al hacer click fuera del componente + useEffect(() => { + if (!expanded) return; + function handleClickOutside(e) { + if (containerRef.current && !containerRef.current.contains(e.target)) { + setExpanded(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [expanded]); + + // Auto-expandir cuando una tarea activa pasa a estado terminal + useEffect(() => { + let shouldExpand = false; + visibleTasks.forEach(t => { + const prev = prevStatusesRef.current[t.task_id]; + if ( + (t.status === 'completed' || t.status === 'failed') && + prev !== undefined && prev !== 'completed' && prev !== 'failed' + ) { + shouldExpand = true; + } + prevStatusesRef.current[t.task_id] = t.status; + }); + if (shouldExpand) setExpanded(true); + }, [visibleTasks]); + + // Solo renderizar dentro de la app autenticada + const isAuthPage = AUTH_PATHS.has(location.pathname) + || location.pathname.startsWith('/user/password-reset-confirm/'); + + if (isAuthPage || visibleTasks.length === 0) return null; + + const activeTasks = visibleTasks.filter(t => t.status === 'submitted' || t.status === 'processing'); + const completedTasks = visibleTasks.filter(t => t.status === 'completed'); + const failedTasks = visibleTasks.filter(t => t.status === 'failed'); + + // Estilo y contenido del pill según estado dominante + let pillLabel, pillClass, pillIconType; + if (activeTasks.length > 0) { + pillLabel = `${activeTasks.length} tarea${activeTasks.length > 1 ? 's' : ''} en proceso`; + pillClass = 'bg-blue-600 text-white border-blue-700 hover:bg-blue-700'; + pillIconType = 'spinner'; + } else if (failedTasks.length > 0 && completedTasks.length === 0) { + pillLabel = `${failedTasks.length} tarea${failedTasks.length > 1 ? 's' : ''} fallida${failedTasks.length > 1 ? 's' : ''}`; + pillClass = 'bg-red-50 text-red-700 border-red-300 hover:bg-red-100'; + pillIconType = 'x'; + } else { + pillLabel = `${completedTasks.length} tarea${completedTasks.length > 1 ? 's' : ''} completada${completedTasks.length > 1 ? 's' : ''}`; + pillClass = 'bg-green-50 text-green-700 border-green-300 hover:bg-green-100'; + pillIconType = 'check'; + } return ( <> - {/* Conectores SSE — uno por tarea en progreso, sin UI propia */} - {visibleTasks.map((task) => ( - - ))} + {/* Conectores SSE — siempre activos, independientes del estado del panel */} + {visibleTasks + .filter(t => t.status === 'processing' || t.status === 'submitted') + .map(task => )} + + {/* + Dock flotante en top-right. El pill queda arriba y el panel se expande hacia abajo. + items-end alinea todo a la derecha. + */} +
+ + {/* Pill — botón de toggle siempre visible, arriba */} + + + {/* Panel expandido — aparece debajo del pill */} + {expanded && ( +
+
+ + Tareas en segundo plano + + +
+
+ {visibleTasks.map(task => )} +
+
+ )} - {/* Cards flotantes en esquina inferior derecha */} -
- {visibleTasks.map((task) => ( - - ))}
); diff --git a/src/context/TaskProgressContext.jsx b/src/context/TaskProgressContext.jsx index 3cc5089..ea7d26d 100644 --- a/src/context/TaskProgressContext.jsx +++ b/src/context/TaskProgressContext.jsx @@ -35,7 +35,20 @@ export function useTaskProgress() { function loadFromStorage() { try { const raw = localStorage.getItem(STORAGE_KEY); - return raw ? JSON.parse(raw) : []; + if (!raw) return []; + const tasks = JSON.parse(raw); + const cutoff = Date.now() - 24 * 60 * 60 * 1000; // 24 horas + // Auto-descartar tareas "processing/submitted" con más de 24h (nunca terminaron) + return tasks.map(t => { + if ( + (t.status === 'processing' || t.status === 'submitted') && + t.started_at && + new Date(t.started_at).getTime() < cutoff + ) { + return { ...t, dismissed: true, status: 'failed', message: 'Tarea expirada' }; + } + return t; + }); } catch { return []; } diff --git a/src/fetchWithAuth.js b/src/fetchWithAuth.js index 773a993..cffa60e 100644 --- a/src/fetchWithAuth.js +++ b/src/fetchWithAuth.js @@ -1,4 +1,5 @@ -const API_URL = import.meta.env.VITE_EFC_API_URL; +const API_URL = import.meta.env.VITE_EFC_API_URL; // e.g. http://localhost:8000/api/v1 +const BASE_URL = API_URL.replace(/\/api\/v1\/?$/, ''); // e.g. http://localhost:8000 // Variable para controlar si ya hay una renovación de token en proceso let isRefreshing = false; @@ -17,72 +18,69 @@ const processQueue = (error, token = null) => { failedQueue = []; }; -// Función para renovar el token usando el refresh token +/** + * Intenta renovar el token. Estrategia: + * 1. Nuevo endpoint cookie-based (SSO / hub-frontend): no requiere body, + * lee el refresh_token desde la cookie HTTP-only. + * 2. Fallback legacy (apps antiguas): lee refresh de localStorage y llama + * al endpoint de SimpleJWT. + */ const refreshToken = async () => { - try { - const refresh = localStorage.getItem('refresh'); - if (!refresh) { - throw new Error('No refresh token available'); - } - - // Intentar primero con el endpoint completo - let refreshEndpoint = `${API_URL}/token/refresh/`; - - let response = await fetch(refreshEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - refresh: refresh - }), - }); - - // Si falla con 404, intentar con el endpoint alternativo - if (response.status === 404) { - refreshEndpoint = `${API_URL}/token/refresh/`; - response = await fetch(refreshEndpoint, { + // ── Intento 1: SimpleJWT refresh (login directo EFC) ────────────────── + // Usa el endpoint estándar de SimpleJWT con el token de localStorage. + const refresh = localStorage.getItem('refresh'); + if (refresh) { + try { + // Endpoint SimpleJWT estándar (login EFC directo) — campo "refresh" + const res = await fetch(`${BASE_URL}/api/v1/token/refresh/`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - refresh: refresh - }), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh }), }); + if (res.ok) { + const data = await res.json(); + const newAccess = data.access; + if (newAccess) { + localStorage.setItem('access', newAccess); + if (data.refresh) localStorage.setItem('refresh', data.refresh); + return newAccess; + } + } + } catch (_) { + // continúa al fallback } - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to refresh token: ${response.status} - ${errorText}`); - } - - const data = await response.json(); - - // Guardar el nuevo access token - localStorage.setItem('access', data.access); - - // Si viene un nuevo refresh token, guardarlo también - if (data.refresh) { - localStorage.setItem('refresh', data.refresh); - } - - return data.access; - } catch (error) { - // Si falla la renovación, limpiar tokens y redirigir al login - localStorage.removeItem('access'); - localStorage.removeItem('refresh'); - localStorage.removeItem('user_id'); - localStorage.removeItem('user_is_importador'); - localStorage.removeItem('user_permissions'); - - // Redirigir al login después de un pequeño delay - setTimeout(() => { - window.location.href = '/login'; - }, 1000); - - throw new Error('SESSION_EXPIRED'); + try { + // Fallback: endpoint SSO local (tokens HS256 de sesiones Hub) + const res = await fetch(`${API_URL}/auth/session/refresh/`, { + method: 'POST', + credentials: 'include', + }); + if (res.ok) { + const data = await res.json(); + const newAccess = data.access_token || data.access; + if (newAccess) { + localStorage.setItem('access', newAccess); + return newAccess; + } + } + } catch (_) {} } + + // ── Fallo total: limpiar sesión y redirigir al Hub ───────────────────── + localStorage.removeItem('access'); + localStorage.removeItem('refresh'); + localStorage.removeItem('user_id'); + localStorage.removeItem('user_is_importador'); + localStorage.removeItem('user_permissions'); + + const hubUrl = import.meta.env.VITE_HUB_URL || 'http://localhost:3001'; + const returnTo = encodeURIComponent(`${window.location.origin}/auth/sso`); + setTimeout(() => { + window.location.href = `${hubUrl}/login?return_to=${returnTo}`; + }, 1000); + + throw new Error('SESSION_EXPIRED'); }; // Función principal para hacer peticiones con manejo automático de tokens @@ -108,85 +106,66 @@ export const fetchWithAuth = async (url, options = {}) => { } }; - try { - // Hacer la petición inicial - let response = await fetch(url, finalOptions); + // Helper: ejecutar fetch con reintento ante error de red (Fix E) + const safeFetch = async (u, opts, retries = 1) => { + for (let i = 0; i <= retries; i++) { + try { + return await fetch(u, opts); + } catch (networkErr) { + if (i === retries) throw networkErr; + await new Promise(r => setTimeout(r, 300 * (i + 1))); // backoff 300ms + } + } + }; - // Si la respuesta es 401 (Unauthorized), manejar renovación de token + try { + let response = await safeFetch(url, finalOptions); + + // 401 → intentar renovar token if (response.status === 401) { if (!isRefreshing) { isRefreshing = true; - try { - // Renovar el token const newToken = await refreshToken(); - - // Procesar la cola de peticiones pendientes processQueue(null, newToken); - - // Actualizar el header de autorización y reintentar la petición original finalOptions.headers['Authorization'] = `Bearer ${newToken}`; - response = await fetch(url, finalOptions); - + response = await safeFetch(url, finalOptions); } catch (refreshError) { - // Si falla la renovación, procesar la cola con error processQueue(refreshError, null); throw refreshError; } finally { isRefreshing = false; } } else { - // Ya hay un refresh en proceso, agregar esta petición a la cola + // Otro refresh en curso — encolar y esperar return new Promise((resolve, reject) => { failedQueue.push({ - resolve: (token) => { - finalOptions.headers['Authorization'] = `Bearer ${token}`; - fetch(url, finalOptions) - .then(resolve) - .catch(reject); + resolve: (tok) => { + finalOptions.headers['Authorization'] = `Bearer ${tok}`; + safeFetch(url, finalOptions).then(resolve).catch(reject); }, - reject + reject, }); }); } } - - // Si todavía hay un 401 después del intento de renovación, verificar si realmente es un problema de auth + + // 401 persistente después del retry → la sesión expiró definitivamente if (response.status === 401) { - // Verificar si tenemos un token válido en localStorage - const currentToken = localStorage.getItem('access'); - - // Intentar obtener más información del error - let errorDetails = ''; - try { - const errorText = await response.text(); - errorDetails = errorText; - } catch (e) { - // Error al leer respuesta - } - - // Si no hay token o el error indica problema de autenticación, limpiar sesión - if (!currentToken || errorDetails.includes('token') || errorDetails.includes('auth')) { - localStorage.removeItem('access'); - localStorage.removeItem('refresh'); - localStorage.removeItem('user_id'); - localStorage.removeItem('user_is_importador'); - - setTimeout(() => { - window.location.href = '/login'; - }, 1000); - - throw new Error('SESSION_EXPIRED'); - } else { - // Si hay token pero sigue fallando, podría ser un problema del servidor - // No limpiar la sesión, dejar que el error se propague - } + localStorage.removeItem('access'); + localStorage.removeItem('refresh'); + localStorage.removeItem('user_id'); + localStorage.removeItem('user_is_importador'); + localStorage.removeItem('user_permissions'); + const hubUrl = import.meta.env.VITE_HUB_URL || 'http://localhost:3001'; + const returnTo = encodeURIComponent(`${window.location.origin}/auth/sso`); + setTimeout(() => { window.location.href = `${hubUrl}/login?return_to=${returnTo}`; }, 800); + throw new Error('SESSION_EXPIRED'); } return response; } catch (error) { - // Si hay un error de red o cualquier otro error, propagarlo throw error; } }; diff --git a/src/pages/Auditor.jsx b/src/pages/Auditor.jsx index 1982721..6a28a43 100644 --- a/src/pages/Auditor.jsx +++ b/src/pages/Auditor.jsx @@ -22,6 +22,10 @@ const TIPO_LABELS = { cove: 'COVE', ac_cove: 'Acuse de COVE', edoc: 'EDocument', + int_pt: 'Integridad de Partidas', + int_edoc: 'Integridad de EDocuments', + int_cove: 'Integridad de COVEs (PC XML)', + int_rm: 'Integridad de Remesas', }; // Mapeo tipo auditoría → url_path del endpoint de procesamiento individual @@ -34,7 +38,7 @@ const PROCESAMIENTO_URL_MAP = { ac: 'procesar-acuses', }; -function AuditResultModal({ modal, iniciandoProcesamiento, onClose, onIniciarProcesamiento }) { +function AuditResultPanel({ modal, closing, iniciandoProcesamiento, onClose, onIniciarProcesamiento, corrigiendoIntegridad, onCorregirIntegridad, detalles, cargandoDetalles, onCargarDetalles, onPreviewDoc }) { const { tipo, pedimento_app, data } = modal; const esCompletado = tipo === 'pc' @@ -42,7 +46,9 @@ function AuditResultModal({ modal, iniciandoProcesamiento, onClose, onIniciarPro : (data?.estado === 'completado' || data?.auditoria_completa == true); const sinNadaQueHacer = (tipo === 'rm' && data?.tiene_remesas === false); - const mostrarBotonProcesar = !esCompletado && !sinNadaQueHacer; + const esAuditoriaIntegridad = ['int_pt', 'int_edoc', 'int_cove', 'int_rm'].includes(tipo); + const mostrarBotonProcesar = !esCompletado && !sinNadaQueHacer && !esAuditoriaIntegridad; + const mostrarBotonCorregir = esAuditoriaIntegridad && !esCompletado && data?.estado !== 'sin_xml' && data?.estado !== 'sin_datos_xml' && data?.estado !== 'sin_datos'; const tituloTipo = TIPO_LABELS[tipo] || tipo; const hayErroresPC = tipo === 'pc' && data?.hay_errores; @@ -88,8 +94,12 @@ function AuditResultModal({ modal, iniciandoProcesamiento, onClose, onIniciarPro return ( -
-
+ <> +
+
{/* Header */}
@@ -376,6 +386,41 @@ function AuditResultModal({ modal, iniciandoProcesamiento, onClose, onIniciarPro
)} + {/* Integridad COVEs (PC XML) — faltantes */} + {tipo === 'int_cove' && data?.estado === 'incompleto' && data?.faltantes?.length > 0 && ( +
+

COVEs faltantes en DB ({data.faltantes.length})

+
+ {data.faltantes.map((cove, j) => ( + {cove} + ))} +
+ {data.coves_xml != null && ( +

PC XML: {data.coves_xml} COVEs — DB: {data.coves_db}

+ )} +
+ )} + + {/* Integridad Remesa — faltantes */} + {tipo === 'int_rm' && data?.estado === 'incompleto' && data?.faltantes?.length > 0 && ( +
+

COVEs de remesa faltantes en DB ({data.faltantes.length})

+
+ {data.faltantes.map((cove, j) => ( + {cove} + ))} +
+ {data.total_en_remesa != null && ( +

Remesa XML: {data.total_en_remesa} COVEs — DB: {data.coves_db}

+ )} +
+ )} + + {/* Sin remesas */} + {tipo === 'int_rm' && data?.estado === 'sin_remesas' && ( +

{data.mensaje}

+ )} + {/* COVEs para remesa */} {tipo === 'rm' && data?.coves && data.coves.length > 0 && (
@@ -391,10 +436,104 @@ function AuditResultModal({ modal, iniciandoProcesamiento, onClose, onIniciarPro )} )} + + {/* Documentos VUCEM — petición/respuesta */} + {tipo !== 'pc' && !esAuditoriaIntegridad && ( +
+
+
+ + + + +

Documentos VUCEM

+
+ {!detalles && ( + + )} +
+ + {detalles ? ( +
+ {detalles.documentos_peticiones?.length > 0 && ( +
+ + + + + Peticiones ({detalles.total_documentos_peticiones ?? detalles.documentos_peticiones.length}) + +
+ {detalles.documentos_peticiones.map((doc, i) => ( +
+
+

{doc.archivo_original || `Petición ${i + 1}`}

+
+ {doc.extension && {doc.extension}} + {doc.creado_en && {new Date(doc.creado_en).toLocaleDateString('es-MX')}} +
+
+ +
+ ))} +
+
+ )} + {detalles.documentos_respuestas?.length > 0 && ( +
+ + + + + Respuestas ({detalles.total_documentos_respuestas ?? detalles.documentos_respuestas.length}) + +
+ {detalles.documentos_respuestas.map((doc, i) => ( +
+
+

{doc.archivo_original || `Respuesta ${i + 1}`}

+
+ {doc.extension && {doc.extension}} + {doc.creado_en && {new Date(doc.creado_en).toLocaleDateString('es-MX')}} +
+
+ +
+ ))} +
+
+ )} + {!detalles.documentos_peticiones?.length && !detalles.documentos_respuestas?.length && ( +

Sin documentos VUCEM registrados

+ )} +
+ ) : ( +

+ Haz clic en Cargar para ver petición y respuesta de VUCEM +

+ )} +
+ )}
{/* Footer */} -
+
{mostrarBotonProcesar && ( + )}
-
+ ); } -// Modal para auditorías globales (tareas en background) -function GlobalAuditModal({ modal, onClose, onConsultar, consultando, onIniciarProceso, iniciandoProceso }) { +// Panel para auditorías globales (tareas en background) +function GlobalAuditPanel({ modal, closing, onClose, onConsultar, consultando, onIniciarProceso, iniciandoProceso, onCorregirIntegridad, corrigiendoIntegridad }) { const { label, task_id, resultado } = modal; - const hayPendientes = resultado && (resultado.result?.con_pendientes > 0 || resultado.result?.con_errores > 0); + const esIntegridad = !!modal.tipo_integridad; + const hayPendientes = resultado && ( + resultado.result?.con_pendientes > 0 || + resultado.result?.con_errores > 0 || + resultado.result?.con_faltantes > 0 + ); const esCompletado = resultado && !hayPendientes; const headerBg = !resultado @@ -466,16 +638,13 @@ function GlobalAuditModal({ modal, onClose, onConsultar, consultando, onIniciarP ); - const handleIniciarCorreccion = () => { - setIniciandoCorreccion(true); - setTimeout(() => { - setIniciandoCorreccion(false); - }, 1500); - }; - return ( -
-
+ <> +
+
{/* Header */}
@@ -556,8 +725,28 @@ function GlobalAuditModal({ modal, onClose, onConsultar, consultando, onIniciarP Sin cambios
+ ) : esIntegridad ? ( + /* Grid de cifras — auditoría de integridad */ +
+
+ {resultado.result?.total_pedimentos ?? '—'} + Total +
+
+ {resultado.result?.completados ?? '—'} + Completos +
+
+ {resultado.result?.con_faltantes ?? resultado.result?.con_pendientes ?? '—'} + Incompletos +
+
+ {resultado.result?.sin_xml ?? '—'} + Sin XML +
+
) : ( - /* Grid de cifras — auditoría */ + /* Grid de cifras — auditoría normal */
{resultado.result?.total_pedimentos ?? '—'} @@ -620,8 +809,64 @@ function GlobalAuditModal({ modal, onClose, onConsultar, consultando, onIniciarP )} - {/* Tabla de pedimentos con pendientes */} - {resultado.result?.detalle_pendientes && resultado.result.detalle_pendientes.length > 0 && ( + {/* Tabla de integridad — detalle_faltantes (partidas/edocuments/coves) */} + {esIntegridad && resultado.result?.detalle_faltantes && resultado.result.detalle_faltantes.length > 0 && ( +
+ + Pedimentos con faltantes ({resultado.result.detalle_faltantes.length}) + + + + +
+ + + + + + + + + {resultado.result.detalle_faltantes.map((item, i) => ( + + + + + ))} + +
PedimentoDetalle
{item.pedimento} + {/* Partidas: esperadas / en_db / faltantes */} + {item.faltantes != null && item.esperadas != null && ( + Esperadas: {item.esperadas} · En DB: {item.en_db} · Faltan: {item.faltantes} + )} + {/* EDocuments: faltantes_en_db lista */} + {Array.isArray(item.faltantes_en_db) && item.faltantes_en_db.length > 0 && ( +
+ {item.faltantes_en_db.map((num, j) => ( + {num} + ))} +
+ )} + {/* COVEs: lista de problemas */} + {Array.isArray(item.problemas) && item.problemas.length > 0 && ( +
+ {item.problemas.map((p, j) => ( +
+ {p.tipo === 'coves_faltantes_del_pc' && `COVEs faltantes: ${p.faltantes?.join(', ') ?? '—'}`} + {p.tipo === 'remesa_sin_xml' && 'Sin XML de remesa'} + {p.tipo === 'remesa_sin_coves_en_db' && `Remesa sin COVEs en DB: ${p.coves_esperados_de_remesa?.join(', ') ?? '—'}`} + {p.tipo === 'error_leyendo_remesa' && `Error remesa: ${p.error}`} +
+ ))} +
+ )} +
+
+
+ )} + + {/* Tabla de pedimentos con pendientes (auditorías normales) */} + {!esIntegridad && resultado.result?.detalle_pendientes && resultado.result.detalle_pendientes.length > 0 && (
Pedimentos con pendientes ({resultado.result.detalle_pendientes.length}) @@ -708,10 +953,33 @@ function GlobalAuditModal({ modal, onClose, onConsultar, consultando, onIniciarP {/* Footer */}
- {/* Fase en progreso: sin botón de consulta — SSE actualiza automáticamente */} + {hayPendientes && esIntegridad && ( + + )} - {/* Botón "Iniciar Proceso" cuando hay resultado con pendientes/errores */} - {hayPendientes && ( + {hayPendientes && !esIntegridad && (
-
+ ); } -function AutoCorregirResultModal({ modal, onClose, onConsultar, consultando, onEjecutar, ejecutando }) { +function AutoCorregirResultPanel({ modal, closing, onClose, onConsultar, consultando, onEjecutar, ejecutando }) { const { task_id, resultado } = modal; const hayCorregibles = resultado && resultado.corregibles > 0; const esVacio = resultado && resultado.corregibles === 0; @@ -776,8 +1044,12 @@ function AutoCorregirResultModal({ modal, onClose, onConsultar, consultando, onE ); return ( -
-
+ <> +
+
@@ -949,7 +1221,7 @@ function AutoCorregirResultModal({ modal, onClose, onConsultar, consultando, onE
-
+ ); } @@ -959,6 +1231,7 @@ function Auditor() { const [loading, setLoading] = useState(false); const [loadingTable, setLoadingTable] = useState(false); const isFirstLoad = useRef(true); + const rowDropdownRef = useRef(null); const [error, setError] = useState(''); const [pedimentos, setPedimentos] = useState([]); const [showInstructions, setShowInstructions] = useState(false); @@ -991,6 +1264,20 @@ function Auditor() { const [analizandoIndividual, setAnalizandoIndividual] = useState(null); // pedimento_id en curso, o null const [xmlData, setXmlData] = useState(null); + // Estados para auditorías de integridad (globales y por pedimento) + const [auditandoIntegridadPartidas, setAuditandoIntegridadPartidas] = useState(false); + const [auditandoIntegridadEdocuments, setAuditandoIntegridadEdocuments] = useState(false); + const [auditandoIntegridadCoves, setAuditandoIntegridadCoves] = useState(false); + const [auditandoIntegridadRemesa, setAuditandoIntegridadRemesa] = useState(false); + const [procesandoIntegridadPartida, setProcesandoIntegridadPartida] = useState(null); + const [procesandoIntegridadEdocument, setProcesandoIntegridadEdocument] = useState(null); + const [procesandoIntegridadCoves, setProcesandoIntegridadCoves] = useState(null); + const [procesandoIntegridadRemesa, setProcesandoIntegridadRemesa] = useState(null); + + // Estados para correcciones de integridad + const [corrigiendoIntegridadGlobal, setCorrigiendoIntegridadGlobal] = useState(false); + const [corrigiendoIntegridadPedimento, setCorrigiendoIntegridadPedimento] = useState(null); + // Estados para modal de preview const [previewOpen, setPreviewOpen] = useState(false); const [previewUrl, setPreviewUrl] = useState(''); @@ -1010,6 +1297,16 @@ function Auditor() { const [detalleModalXml, setDetalleModalXml] = useState(null); + // Estados para paneles deslizantes y dropdowns + const [auditResultClosing, setAuditResultClosing] = useState(false); + const [globalAuditClosing, setGlobalAuditClosing] = useState(false); + const [autoCorregirClosing, setAutoCorregirClosing] = useState(false); + const [openGlobalDropdown, setOpenGlobalDropdown] = useState(null); + const [openRowDropdown, setOpenRowDropdown] = useState(null); + const [rowDropdownPos, setRowDropdownPos] = useState(null); + const [auditPanelDetalles, setAuditPanelDetalles] = useState(null); + const [cargandoDetallesPeticion, setCargandoDetallesPeticion] = useState(false); + // Agrega este estado cerca de los otros estados const [activeVista, setActiveVista] = useState(null); // 'pc', 'rm', 'pt', 'ac', 'cove', 'ac_cove', 'edoc' @@ -1068,6 +1365,8 @@ function Auditor() { label: task.label, organizacion_id: task.organizacion_id, task_id: task.task_id, + procesamiento: task.procesamiento ?? null, + tipo_integridad: task.tipo_integridad ?? null, }; if (task.resultado) { @@ -1177,7 +1476,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 }); + addTask({ task_id: data.task_id, label: 'Acuses', organizacion_id: organizacionId, procesamiento: 'acuses' }); 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'); @@ -1202,7 +1501,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 }); + addTask({ task_id: data.task_id, label: 'EDocuments', organizacion_id: organizacionId, procesamiento: 'edocs' }); 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'); @@ -1227,7 +1526,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 }); + addTask({ task_id: data.task_id, label: 'Acuses de COVE', organizacion_id: organizacionId, procesamiento: 'acuse_coves' }); 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'); @@ -1301,7 +1600,7 @@ function Auditor() { } const data = await response.json(); - addTask({ task_id: data.task_id, label: 'Remesas', organizacion_id: organizacionId }); + addTask({ task_id: data.task_id, label: 'Remesas', organizacion_id: organizacionId, procesamiento: 'remesas' }); setGlobalAuditModal({ label: 'Remesas', procesamiento: 'remesas', organizacion_id: organizacionId, task_id: data.task_id, resultado: null }); } catch (error) { @@ -1354,7 +1653,7 @@ function Auditor() { } const data = await response.json(); - addTask({ task_id: data.task_id, label: 'Partidas', organizacion_id: organizacionId }); + addTask({ task_id: data.task_id, label: 'Partidas', organizacion_id: organizacionId, procesamiento: 'partidas' }); setGlobalAuditModal({ label: 'Partidas', procesamiento: 'partidas', organizacion_id: organizacionId, task_id: data.task_id, resultado: null }); } catch (error) { @@ -1571,6 +1870,295 @@ const handleAuditarEDocumentPedimento = async (pedimentoId) => { } }; + const handleAuditarIntegridadPartidas = async () => { + if (auditandoIntegridadPartidas) return; + try { + setAuditandoIntegridadPartidas(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-integridad-partidas/`, { organizacion_id: organizacionId }); + if (!response.ok) throw new Error(await extractApiError(response)); + const data = await response.json(); + addTask({ task_id: data.task_id, label: 'Integridad de Partidas', organizacion_id: organizacionId, tipo_integridad: 'partidas' }); + setGlobalAuditModal({ label: 'Integridad de Partidas', tipo_integridad: 'partidas', organizacion_id: organizacionId, task_id: data.task_id, resultado: null }); + } catch (error) { + showMessage(error.message || 'Error al auditar integridad de partidas', 'error'); + } finally { + setAuditandoIntegridadPartidas(false); + } + }; + + const handleAuditarIntegridadEdocuments = async () => { + if (auditandoIntegridadEdocuments) return; + try { + setAuditandoIntegridadEdocuments(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-integridad-edocuments/`, { organizacion_id: organizacionId }); + if (!response.ok) throw new Error(await extractApiError(response)); + const data = await response.json(); + addTask({ task_id: data.task_id, label: 'Integridad de EDocuments', organizacion_id: organizacionId, tipo_integridad: 'edocuments' }); + setGlobalAuditModal({ label: 'Integridad de EDocuments', tipo_integridad: 'edocuments', organizacion_id: organizacionId, task_id: data.task_id, resultado: null }); + } catch (error) { + showMessage(error.message || 'Error al auditar integridad de edocuments', 'error'); + } finally { + setAuditandoIntegridadEdocuments(false); + } + }; + + const handleAuditarIntegridadCoves = async () => { + if (auditandoIntegridadCoves) return; + try { + setAuditandoIntegridadCoves(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-integridad-coves/`, { organizacion_id: organizacionId }); + if (!response.ok) throw new Error(await extractApiError(response)); + const data = await response.json(); + addTask({ task_id: data.task_id, label: 'Integridad COVEs (PC XML)', organizacion_id: organizacionId, tipo_integridad: 'coves' }); + setGlobalAuditModal({ label: 'Integridad COVEs (PC XML)', tipo_integridad: 'coves', organizacion_id: organizacionId, task_id: data.task_id, resultado: null }); + } catch (error) { + showMessage(error.message || 'Error al auditar integridad de COVEs', 'error'); + } finally { + setAuditandoIntegridadCoves(false); + } + }; + + const handleAuditarIntegridadRemesa = async () => { + if (auditandoIntegridadRemesa) return; + try { + setAuditandoIntegridadRemesa(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-integridad-remesa/`, { organizacion_id: organizacionId }); + if (!response.ok) throw new Error(await extractApiError(response)); + const data = await response.json(); + addTask({ task_id: data.task_id, label: 'Integridad Remesas', organizacion_id: organizacionId, tipo_integridad: 'remesa' }); + setGlobalAuditModal({ label: 'Integridad Remesas', tipo_integridad: 'remesa', organizacion_id: organizacionId, task_id: data.task_id, resultado: null }); + } catch (error) { + showMessage(error.message || 'Error al auditar integridad de remesas', 'error'); + } finally { + setAuditandoIntegridadRemesa(false); + } + }; + + const handleAuditarIntegridadPartidasPedimento = async (pedimentoId) => { + if (procesandoIntegridadPartida) return; + const pedimento = pedimentos.find(p => p.id === pedimentoId); + try { + setProcesandoIntegridadPartida(pedimentoId); + const response = await postWithAuth(`${API_URL}/customs/auditor/auditar-integridad-partidas/pedimento/`, { pedimento_id: pedimentoId }); + if (!response.ok) throw new Error(await extractApiError(response)); + const raw = await response.json(); + const completado = raw.estado === 'completado'; + const data = { + estado: raw.estado, + auditoria_completa: completado, + mensaje: completado + ? `Partidas completas: ${raw.en_db} de ${raw.esperadas}` + : raw.estado === 'sin_datos_xml' + ? raw.mensaje + : `Faltan ${raw.faltantes} partida(s): DB tiene ${raw.en_db} de ${raw.esperadas}`, + resumen: (!completado && raw.estado !== 'sin_datos_xml') ? { + 'Esperadas (XML)': raw.esperadas, + 'En DB': raw.en_db, + 'Faltantes': raw.faltantes, + } : null, + }; + setAuditResultModal({ tipo: 'int_pt', pedimento_id: pedimentoId, pedimento_app: pedimento?.pedimento_app, data }); + } catch (error) { + showMessage(error.message || 'Error al auditar integridad de partidas', 'error'); + } finally { + setProcesandoIntegridadPartida(null); + } + }; + + const handleAuditarIntegridadEdocumentsPedimento = async (pedimentoId) => { + if (procesandoIntegridadEdocument) return; + const pedimento = pedimentos.find(p => p.id === pedimentoId); + try { + setProcesandoIntegridadEdocument(pedimentoId); + const response = await postWithAuth(`${API_URL}/customs/auditor/auditar-integridad-edocuments/pedimento/`, { pedimento_id: pedimentoId }); + if (!response.ok) throw new Error(await extractApiError(response)); + const raw = await response.json(); + const completado = raw.estado === 'completado'; + const data = { + estado: raw.estado, + auditoria_completa: completado, + mensaje: completado + ? `EDocuments completos: ${raw.en_db} de ${raw.esperados_xml}` + : raw.estado === 'sin_xml' + ? 'No hay pedimento completo descargado para comparar' + : `Faltan ${raw.faltantes_en_db?.length ?? 0} edocument(s) en DB`, + resumen: (!completado && raw.estado !== 'sin_xml') ? { + 'Esperados (XML)': raw.esperados_xml, + 'En DB': raw.en_db, + 'Faltantes': raw.faltantes_en_db?.length ?? 0, + } : null, + pendientes: raw.faltantes_en_db ?? [], + }; + setAuditResultModal({ tipo: 'int_edoc', pedimento_id: pedimentoId, pedimento_app: pedimento?.pedimento_app, data }); + } catch (error) { + showMessage(error.message || 'Error al auditar integridad de edocuments', 'error'); + } finally { + setProcesandoIntegridadEdocument(null); + } + }; + + const handleAuditarIntegridadCovesPedimento = async (pedimentoId) => { + if (procesandoIntegridadCoves) return; + const pedimento = pedimentos.find(p => p.id === pedimentoId); + try { + setProcesandoIntegridadCoves(pedimentoId); + const response = await postWithAuth(`${API_URL}/customs/auditor/auditar-integridad-coves/pedimento/`, { pedimento_id: pedimentoId }); + if (!response.ok) throw new Error(await extractApiError(response)); + const raw = await response.json(); + const completado = raw.estado === 'completado'; + const data = { + estado: raw.estado, + auditoria_completa: completado, + mensaje: completado + ? `COVEs íntegros: ${raw.coves_db} de ${raw.coves_xml} en XML` + : raw.estado === 'sin_xml' + ? 'No hay pedimento completo descargado para comparar' + : `Faltan ${raw.faltantes?.length ?? 0} COVE(s) en DB`, + resumen: completado + ? { 'COVEs en XML': raw.coves_xml, 'COVEs en DB': raw.coves_db } + : (!completado && raw.estado !== 'sin_xml') ? { 'COVEs en XML': raw.coves_xml, 'COVEs en DB': raw.coves_db } : null, + faltantes: raw.faltantes ?? [], + coves_xml: raw.coves_xml, + coves_db: raw.coves_db, + }; + setAuditResultModal({ tipo: 'int_cove', pedimento_id: pedimentoId, pedimento_app: pedimento?.pedimento_app, data }); + } catch (error) { + showMessage(error.message || 'Error al auditar integridad de COVEs', 'error'); + } finally { + setProcesandoIntegridadCoves(null); + } + }; + + const handleAuditarIntegridadRemesaPedimento = async (pedimentoId) => { + if (procesandoIntegridadRemesa) return; + const pedimento = pedimentos.find(p => p.id === pedimentoId); + try { + setProcesandoIntegridadRemesa(pedimentoId); + const response = await postWithAuth(`${API_URL}/customs/auditor/auditar-integridad-remesa/pedimento/`, { pedimento_id: pedimentoId }); + if (!response.ok) throw new Error(await extractApiError(response)); + const raw = await response.json(); + const completado = raw.estado === 'completado'; + const sinRemesas = raw.estado === 'sin_remesas'; + const data = { + estado: raw.estado, + auditoria_completa: completado || sinRemesas, + mensaje: completado + ? `Remesa íntegra: ${raw.coves_db} COVEs de remesa en DB` + : sinRemesas + ? raw.mensaje + : raw.estado === 'sin_xml' + ? raw.mensaje ?? 'No hay XML de remesa descargado' + : `Faltan ${raw.faltantes?.length ?? 0} COVE(s) de remesa en DB`, + resumen: completado + ? { 'COVEs en remesa': raw.total_en_remesa, 'COVEs en DB': raw.coves_db } + : (!completado && !sinRemesas && raw.estado !== 'sin_xml') ? { 'COVEs en remesa': raw.total_en_remesa, 'COVEs en DB': raw.coves_db } : null, + faltantes: raw.faltantes ?? [], + total_en_remesa: raw.total_en_remesa, + coves_db: raw.coves_db, + }; + setAuditResultModal({ tipo: 'int_rm', pedimento_id: pedimentoId, pedimento_app: pedimento?.pedimento_app, data }); + } catch (error) { + showMessage(error.message || 'Error al auditar integridad de remesa', 'error'); + } finally { + setProcesandoIntegridadRemesa(null); + } + }; + + // Handler para corregir integridad de un pedimento específico + const handleCorregirIntegridadPedimento = async (tipo) => { + if (!auditResultModal || corrigiendoIntegridadPedimento) return; + const { pedimento_id } = auditResultModal; + + const urlMap = { + int_pt: 'corregir-integridad-partidas', + int_edoc: 'corregir-integridad-edocuments', + int_cove: 'corregir-integridad-coves', + int_rm: 'corregir-integridad-remesa', + }; + const path = urlMap[tipo]; + if (!path) return; + + try { + setCorrigiendoIntegridadPedimento(pedimento_id); + const response = await postWithAuth(`${API_URL}/customs/auditor/${path}/pedimento/`, { pedimento_id }); + if (!response.ok) throw new Error(await extractApiError(response)); + const raw = await response.json(); + + // Mostrar resultado de la corrección en el modal + const creados = raw.creadas ?? raw.edocuments_creados?.length ?? raw.coves_creados?.length ?? 0; + const mensaje = raw.estado === 'sin_xml' + ? 'No se encontró el XML del pedimento completo' + : raw.estado === 'sin_datos' + ? raw.razon ?? 'Sin datos suficientes' + : creados > 0 + ? `Se crearon ${creados} registro(s) y se inició el procesamiento VUCEM` + : 'Registros ya existentes — procesamiento VUCEM iniciado'; + + setAuditResultModal(prev => prev ? { + ...prev, + data: { + ...prev.data, + auditoria_completa: raw.estado !== 'sin_xml' && raw.estado !== 'sin_datos', + estado: raw.estado === 'corregido' ? 'completado' : raw.estado, + mensaje, + resumen: raw.creadas != null ? { 'Esperadas': raw.esperadas, 'Creadas': raw.creadas } : + Array.isArray(raw.edocuments_creados) ? { 'EDocuments creados': raw.edocuments_creados.length } : + Array.isArray(raw.coves_creados) ? { 'COVEs creados': raw.coves_creados.length } : null, + }, + } : null); + showMessage(mensaje, 'success'); + } catch (error) { + showMessage(error.message || 'Error al corregir integridad', 'error'); + } finally { + setCorrigiendoIntegridadPedimento(null); + } + }; + + // Handler para corregir integridad a nivel organización (org-level Celery) + const handleCorregirIntegridadGlobal = async () => { + if (!globalAuditModal?.tipo_integridad || corrigiendoIntegridadGlobal) return; + const { tipo_integridad, organizacion_id } = globalAuditModal; + + const urlMap = { + partidas: 'corregir-integridad-partidas', + edocuments: 'corregir-integridad-edocuments', + coves: 'corregir-integridad-coves', + remesa: 'corregir-integridad-remesa', + }; + const path = urlMap[tipo_integridad]; + if (!path) return; + + const labelMap = { + partidas: 'Corrección de Partidas', + edocuments: 'Corrección de EDocuments', + coves: 'Corrección de COVEs', + remesa: 'Corrección de Remesas', + }; + + try { + setCorrigiendoIntegridadGlobal(true); + const response = await postWithAuth(`${API_URL}/customs/auditor/${path}/`, { organizacion_id }); + if (!response.ok) throw new Error(await extractApiError(response)); + const data = await response.json(); + const label = labelMap[tipo_integridad]; + addTask({ task_id: data.task_id, label, organizacion_id }); + setGlobalAuditModal({ label, organizacion_id, task_id: data.task_id, resultado: null }); + showMessage(`${label} iniciada`, 'success'); + } catch (error) { + showMessage(error.message || 'Error al iniciar la corrección', 'error'); + } finally { + setCorrigiendoIntegridadGlobal(false); + } + }; + const handleAnalizarPedimentoIndividual = async (pedimentoId) => { if (analizandoIndividual) return; try { @@ -1719,6 +2307,52 @@ const handleAuditarEDocumentPedimento = async (pedimentoId) => { }, 280); }; + const closeAuditResultPanel = () => { + setAuditResultClosing(true); + setAuditPanelDetalles(null); + setTimeout(() => { setAuditResultModal(null); setAuditResultClosing(false); }, 280); + }; + const closeGlobalAuditPanel = () => { + setGlobalAuditClosing(true); + setTimeout(() => { setGlobalAuditModal(null); setGlobalAuditClosing(false); }, 280); + }; + useEffect(() => { + if (!openRowDropdown) return; + const close = (e) => { + if (rowDropdownRef.current && rowDropdownRef.current.contains(e.target)) return; + setOpenRowDropdown(null); + setRowDropdownPos(null); + }; + window.addEventListener('scroll', close, true); + return () => window.removeEventListener('scroll', close, true); + }, [openRowDropdown]); + + const closeAutoCorregirPanel = () => { + setAutoCorregirClosing(true); + setTimeout(() => { setAutoCorregirModal(null); setAutoCorregirClosing(false); }, 280); + }; + + const handleCargarDetallesPeticion = async () => { + if (!auditResultModal || cargandoDetallesPeticion) return; + setCargandoDetallesPeticion(true); + try { + const response = await postWithAuth(`${API_URL}/customs/auditor/peticion-respuesta/pedimento-vu/`, { + pedimento_id: auditResultModal.pedimento_id, + vista: auditResultModal.tipo, + }); + if (!response.ok) { + showMessage(`No se encontraron documentos: ${await extractApiError(response)}`, 'warning'); + return; + } + const data = await response.json(); + setAuditPanelDetalles(data); + } catch (error) { + showMessage(error.message || 'Error al cargar documentos VUCEM', 'error'); + } finally { + setCargandoDetallesPeticion(false); + } + }; + const handleCorregirOpen = (pedimento) => { setCorregirForm({ numero_operacion: pedimento.numero_operacion || '', @@ -2380,211 +3014,147 @@ function formatXml(xml) { ) : (
+ {/* Overlay para cerrar dropdowns al hacer click fuera */} + {(openGlobalDropdown || openRowDropdown) && ( +
{ setOpenGlobalDropdown(null); setOpenRowDropdown(null); setRowDropdownPos(null); }} /> + )} -
-

- - - - Servicios de Auditoría -

-
- + {openGlobalDropdown === 'auditoria' && ( +
+
+

Auditorías Globales VUCEM

+
+ {[ + { key: 'todos', label: 'Pedimentos Completos', desc: 'Lee cada XML del PC y completa campos en la tabla de pedimentos. Si el documento tiene COVEs y E-Docs, también los registra en DB.', loading: auditando, handler: () => { handleAuditarTodos(); setOpenGlobalDropdown(null); } }, + { key: 'partidas', label: 'Partidas', desc: 'Extrae y crea todas las partidas de cada pedimento a partir de la información del XML.', loading: auditandoPartidas, handler: () => { handleAuditarPartidas(); setOpenGlobalDropdown(null); } }, + { key: 'remesas', label: 'Remesas', desc: 'Obtiene y registra los COVEs existentes en los documentos de remesa de cada pedimento.', loading: auditandoRemesas, handler: () => { handleAuditarRemesas(); setOpenGlobalDropdown(null); } }, + { key: 'acuses', label: 'Acuses de EDocs', desc: 'Verifica y registra los acuses de eDocs en VUCEM.', loading: auditandoAcuses, handler: () => { handleAuditarAcuses(); setOpenGlobalDropdown(null); } }, + { key: 'acuses_cove', label: 'Acuses de COVE', desc: 'Verifica y registra los acuses de confirmación de COVE emitidos por VUCEM.', loading: auditandoAcusesCove, handler: () => { handleAuditarAcusesCove(); setOpenGlobalDropdown(null); } }, + { key: 'edocuments', label: 'EDocuments', desc: 'Audita y registra los documentos electrónicos (eDocuments) asociados a cada pedimento.', loading: auditandoEDocuments, handler: () => { handleAuditarEDocuments(); setOpenGlobalDropdown(null); } }, + ].map(item => ( + + ))} +
)} - +
- + {openGlobalDropdown === 'integridad' && ( +
+
+

Verificación de Integridad Global

+
+ {[ + { key: 'int_pt', label: 'Integridad de Partidas', desc: 'Compara el número de partidas registradas en DB contra las que indica el XML del pedimento completo.', loading: auditandoIntegridadPartidas, handler: () => { handleAuditarIntegridadPartidas(); setOpenGlobalDropdown(null); } }, + { key: 'int_edoc', label: 'Integridad de EDocuments', desc: 'Verifica que todos los EDocuments referenciados en el PC XML existan correctamente en la base de datos.', loading: auditandoIntegridadEdocuments, handler: () => { handleAuditarIntegridadEdocuments(); setOpenGlobalDropdown(null); } }, + { key: 'int_cove', label: 'Integridad de COVEs (PC XML)', desc: 'Compara los COVEs referenciados en el pedimento completo XML contra los registrados en la base de datos.', loading: auditandoIntegridadCoves, handler: () => { handleAuditarIntegridadCoves(); setOpenGlobalDropdown(null); } }, + { key: 'int_rm', label: 'Integridad de Remesas', desc: 'Verifica que los COVEs listados en el XML de remesa estén correctamente registrados en la base de datos.', loading: auditandoIntegridadRemesa, handler: () => { handleAuditarIntegridadRemesa(); setOpenGlobalDropdown(null); } }, + ].map(item => ( + + ))} +
)} - - - - - - - - - +
+ {/* Botón: Corregir Incompletos */} -
@@ -2622,286 +3192,136 @@ function formatXml(xml) {
{/* Tabla de pedimentos */} -
- - +
+
+ - - - - - - - - - - + {loadingTable ? ( - - ) : pedimentos.map((pedimento) => ( - - - {/* PC - Pedimento Completo */} - - {/* RM - Remesas */} - + + {/* Auditorías VUCEM */} + - {/* PT - Partidas */} - + {/* Integridad */} + - {/* AC - Acuse */} - - {/* COVE */} - - {/* AC_COVE */} - - {/* EDoc */} - - {/* Auditar incompleto individual */} - - - ))} + + {/* Acciones */} + + + ); + })}
+ Pedimento - PC - Pedimento Completo + + Auditorías VUCEM + PC - RM - PT - AC - COVE - AC_COVE - EDoc - RM - Remesas + + Integridad + Partidas - EDocuments - COVEs - Remesas - PT - Partidas - - AC - Acuse - - COVE - Cove - - AC_COVE - Acuse de Cove - - EDoc - EDocument - - Corregir + + Acciones
+
Buscando...
- {pedimento.pedimento_app} - -
- -
-
-
+ ) : pedimentos.map((pedimento) => { + const auditVucemOpen = openRowDropdown?.pedimentoId === pedimento.id && openRowDropdown?.type === 'vucem'; + const integridadOpen = openRowDropdown?.pedimentoId === pedimento.id && openRowDropdown?.type === 'integridad'; + return ( +
+ + {pedimento.pedimento_app} + + - {/* nuevo botón “view” */} - - - -
+ +
- {/* nuevo botón “view” */} - - - -
- - {/* nuevo botón “view” */} - -
-
-
- - {/* nuevo botón “view” */} - -
-
-
- - {/* nuevo botón “view” */} - -
-
-
- - {/* nuevo botón “view” */} - -
-
- -
+
+ + +
+
@@ -3044,33 +3464,44 @@ function formatXml(xml) {
)} - {/* Modal de auditoría global */} - {globalAuditModal && ( - setGlobalAuditModal(null)} + closing={globalAuditClosing} + onClose={closeGlobalAuditPanel} onConsultar={handleConsultarTask} consultando={consultandoTask} onIniciarProceso={handleIniciarProcesoGlobal} iniciandoProceso={iniciandoProcesoGlobal} + onCorregirIntegridad={handleCorregirIntegridadGlobal} + corrigiendoIntegridad={corrigiendoIntegridadGlobal} /> )} - {/* Modal de resultado de auditoría individual */} - {auditResultModal && ( - setAuditResultModal(null)} + onClose={closeAuditResultPanel} onIniciarProcesamiento={handleIniciarProcesamiento} + corrigiendoIntegridad={corrigiendoIntegridadPedimento === auditResultModal?.pedimento_id} + onCorregirIntegridad={() => handleCorregirIntegridadPedimento(auditResultModal?.tipo)} + detalles={auditPanelDetalles} + cargandoDetalles={cargandoDetallesPeticion} + onCargarDetalles={handleCargarDetallesPeticion} + onPreviewDoc={previewDocument} /> )} - {/* Modal auto-corrección de pedamentos incompletos */} - {autoCorregirModal && ( - setAutoCorregirModal(null)} + closing={autoCorregirClosing} + onClose={closeAutoCorregirPanel} onConsultar={handleConsultarAutoCorregir} consultando={consultandoAutoCorregir} onEjecutar={handleEjecutarAutoCorregir} @@ -3745,6 +4176,96 @@ function formatXml(xml) { )} + {/* Dropdowns de fila fijos (escapan el overflow-x-auto de la tabla) */} + {openRowDropdown && rowDropdownPos && (() => { + const ped = pedimentos.find(p => p.id === openRowDropdown.pedimentoId); + if (!ped) return null; + const isVucem = openRowDropdown.type === 'vucem'; + + const vucemItems = [ + { key: 'pc', label: 'Pedimento Completo', desc: 'Verifica el XML del pedimento completo descargado de VUCEM y actualiza los campos en DB.', loading: procesandoPedimento === ped.id, runHandler: () => { handleAuditarPedimento(ped.id); setOpenRowDropdown(null); setRowDropdownPos(null); } }, + { key: 'rm', label: 'Remesas', desc: 'Audita los COVEs registrados en el documento de remesa XML.', loading: procesandoRemesa === ped.id, runHandler: () => { handleAuditarRemesaPedimento(ped.id); setOpenRowDropdown(null); setRowDropdownPos(null); } }, + { key: 'pt', label: 'Partidas', desc: 'Crea o verifica las partidas arancelarias del pedimento a partir del XML.', loading: procesandoPartida === ped.id, runHandler: () => { handleAuditarPartidasPedimento(ped.id); setOpenRowDropdown(null); setRowDropdownPos(null); } }, + { key: 'ac', label: 'Acuse de EDocs', desc: 'Verifica el acuse de eDocs del pedimento en VUCEM.', loading: procesandoAcuse === ped.id, runHandler: () => { handleAuditarAcusePedimento(ped.id); setOpenRowDropdown(null); setRowDropdownPos(null); } }, + { key: 'cove', label: 'COVE', desc: 'Audita los documentos COVE asociados al pedimento en VUCEM.', loading: procesandoCove === ped.id, runHandler: () => { handleAuditarCovePedimento(ped.id); setOpenRowDropdown(null); setRowDropdownPos(null); } }, + { key: 'ac_cove', label: 'Acuse de COVE', desc: 'Verifica el acuse de confirmación de COVE emitido por VUCEM.', loading: procesandoAcuseCove === ped.id, runHandler: () => { handleAuditarAcusesCovePedimento(ped.id); setOpenRowDropdown(null); setRowDropdownPos(null); } }, + { key: 'edoc', label: 'EDocument', desc: 'Audita los documentos electrónicos (EDoc) del pedimento en VUCEM.', loading: procesandoEdocumento === ped.id, runHandler: () => { handleAuditarEDocumentPedimento(ped.id); setOpenRowDropdown(null); setRowDropdownPos(null); } }, + ]; + + const integridadItems = [ + { key: 'int_pt', label: 'Partidas', desc: 'Compara partidas esperadas en XML del PC contra las registradas en DB.', loading: procesandoIntegridadPartida === ped.id, handler: () => { handleAuditarIntegridadPartidasPedimento(ped.id); setOpenRowDropdown(null); setRowDropdownPos(null); } }, + { key: 'int_edoc', label: 'EDocuments', desc: 'Verifica que todos los EDocuments del PC XML existan en la base de datos.', loading: procesandoIntegridadEdocument === ped.id, handler: () => { handleAuditarIntegridadEdocumentsPedimento(ped.id); setOpenRowDropdown(null); setRowDropdownPos(null); } }, + { key: 'int_cove', label: 'COVEs (PC XML)', desc: 'Compara COVEs referenciados en el pedimento completo XML contra los registrados en DB.', loading: procesandoIntegridadCoves === ped.id, handler: () => { handleAuditarIntegridadCovesPedimento(ped.id); setOpenRowDropdown(null); setRowDropdownPos(null); } }, + { key: 'int_rm', label: 'Remesas', desc: 'Verifica que los COVEs listados en el XML de remesa estén en la base de datos.', loading: procesandoIntegridadRemesa === ped.id, handler: () => { handleAuditarIntegridadRemesaPedimento(ped.id); setOpenRowDropdown(null); setRowDropdownPos(null); } }, + ]; + + return ( +
+
+

+ {isVucem ? `Auditorías VUCEM — ${ped.pedimento_app}` : `Integridad — ${ped.pedimento_app}`} +

+
+
+ {isVucem ? ( + vucemItems.map(item => ( +
+ +
+ )) + ) : ( + integridadItems.map(item => ( + + )) + )} +
+
+ ); + })()} + {/* Animaciones CSS */}