Compare commits
1 Commits
d45623c99a
...
feature/SS
| Author | SHA1 | Date | |
|---|---|---|---|
| cbbd34a91c |
@@ -5,6 +5,8 @@ import Vucem from './pages/Vucem';
|
|||||||
import Auditor from './pages/Auditor';
|
import Auditor from './pages/Auditor';
|
||||||
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
|
||||||
import { UserProvider } from './context/UserContext';
|
import { UserProvider } from './context/UserContext';
|
||||||
|
import { TaskProgressProvider } from './context/TaskProgressContext';
|
||||||
|
import TaskProgressCard from './components/TaskProgressCard';
|
||||||
import Navbar from './components/Navbar';
|
import Navbar from './components/Navbar';
|
||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
@@ -166,7 +168,10 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<AppContent />
|
<TaskProgressProvider>
|
||||||
|
<AppContent />
|
||||||
|
<TaskProgressCard />
|
||||||
|
</TaskProgressProvider>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,11 +15,18 @@ export interface TipoNotificacion {
|
|||||||
descripcion: string;
|
descripcion: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NotificacionDatos {
|
||||||
|
task_id: string;
|
||||||
|
label: string;
|
||||||
|
resultado: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Notificacion {
|
export interface Notificacion {
|
||||||
id: number;
|
id: number;
|
||||||
tipo: TipoNotificacion;
|
tipo: TipoNotificacion;
|
||||||
dirigido: string;
|
dirigido: string;
|
||||||
mensaje: string;
|
mensaje: string;
|
||||||
|
datos: NotificacionDatos | null;
|
||||||
fecha_envio: string;
|
fecha_envio: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
visto: boolean;
|
visto: boolean;
|
||||||
@@ -47,4 +54,12 @@ export async function fetchAllNotifications({page = 1, page_size=10}): Promise<N
|
|||||||
const res = await fetchWithAuth(url);
|
const res = await fetchWithAuth(url);
|
||||||
if (!res.ok) throw new Error('Error al obtener notificaciones');
|
if (!res.ok) throw new Error('Error al obtener notificaciones');
|
||||||
return await res.json();
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNotificacionByTaskId(taskId: string): Promise<Notificacion | null> {
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
158
src/components/TaskProgressCard.jsx
Normal file
158
src/components/TaskProgressCard.jsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
onClick={task.resultado ? handleVerResultado : undefined}
|
||||||
|
className={`flex flex-col gap-2 p-3 rounded-xl border shadow-md text-sm w-72 ${STATUS_COLOR[task.status] ?? STATUS_COLOR.submitted} ${task.resultado ? 'cursor-pointer hover:shadow-lg transition-shadow' : ''}`}
|
||||||
|
>
|
||||||
|
{/* Encabezado */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex flex-col gap-0.5 min-w-0">
|
||||||
|
<span className="font-semibold truncate">{task.label}</span>
|
||||||
|
<span className={`self-start px-1.5 py-0.5 rounded text-xs font-medium ${BADGE_COLOR[task.status]}`}>
|
||||||
|
{STATUS_LABEL[task.status] ?? task.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); dismissTask(task.task_id); }}
|
||||||
|
aria-label="Cerrar"
|
||||||
|
className="text-gray-400 hover:text-gray-600 flex-shrink-0 mt-0.5"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mensaje */}
|
||||||
|
{task.message && (
|
||||||
|
<p className="text-xs opacity-75 truncate">{task.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Barra de progreso */}
|
||||||
|
{isActive && (
|
||||||
|
<div className="w-full bg-blue-100 rounded-full h-1.5 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-1.5 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${task.progress ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Aviso de navegación libre cuando está en progreso */}
|
||||||
|
{isActive && (
|
||||||
|
<p className="text-xs text-blue-600 font-medium">
|
||||||
|
Puedes seguir navegando — te avisamos cuando termine.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Botón "Ver resultado" cuando completó */}
|
||||||
|
{task.status === 'completed' && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleVerResultado(); }}
|
||||||
|
className="mt-1 w-full py-1.5 px-3 rounded-lg bg-green-600 hover:bg-green-700 text-white text-xs font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
Ver resultado en Panel de Auditoría →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mensaje de error */}
|
||||||
|
{task.status === 'failed' && (
|
||||||
|
<p className="text-xs text-red-600 font-medium">
|
||||||
|
La tarea falló. Revisa el Panel de Auditoría.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) => (
|
||||||
|
<TaskSSEConnector key={task.task_id} task={task} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Cards flotantes en esquina inferior derecha */}
|
||||||
|
<div
|
||||||
|
className="fixed bottom-4 right-4 flex flex-col gap-2 z-50"
|
||||||
|
style={{ maxHeight: '80vh', overflowY: 'auto' }}
|
||||||
|
>
|
||||||
|
{visibleTasks.map((task) => (
|
||||||
|
<SingleTaskCard key={task.task_id} task={task} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
src/context/TaskProgressContext.jsx
Normal file
161
src/context/TaskProgressContext.jsx
Normal file
@@ -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 (
|
||||||
|
<TaskProgressContext.Provider
|
||||||
|
value={{
|
||||||
|
tasks,
|
||||||
|
visibleTasks,
|
||||||
|
addTask,
|
||||||
|
updateTask,
|
||||||
|
dismissTask,
|
||||||
|
openTaskInAuditor,
|
||||||
|
pendingOpenTaskId,
|
||||||
|
clearPendingOpen,
|
||||||
|
getTask,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TaskProgressContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
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 { useNotification } from '../context/NotificationContext';
|
||||||
|
import { useTaskProgress } from '../context/TaskProgressContext';
|
||||||
|
import { useServerSentEvents } from '../hooks/useServerSentEvents';
|
||||||
import hljs from 'highlight.js/lib/core';
|
import hljs from 'highlight.js/lib/core';
|
||||||
import xml from 'highlight.js/lib/languages/xml';
|
import xml from 'highlight.js/lib/languages/xml';
|
||||||
import 'highlight.js/styles/github.css';
|
import 'highlight.js/styles/github.css';
|
||||||
@@ -488,14 +490,31 @@ function GlobalAuditModal({ modal, onClose, onConsultar, consultando, onIniciarP
|
|||||||
{/* Cuerpo scrolleable */}
|
{/* Cuerpo scrolleable */}
|
||||||
<div className="px-6 py-5 space-y-4 overflow-y-auto">
|
<div className="px-6 py-5 space-y-4 overflow-y-auto">
|
||||||
{!resultado ? (
|
{!resultado ? (
|
||||||
/* Fase: tarea lanzada, esperando resultado */
|
/* Fase: tarea en progreso — SSE actualiza en tiempo real */
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-center w-12 h-12 mx-auto mb-1 bg-slate-100 rounded-full">
|
<div className="flex items-center justify-center w-12 h-12 mx-auto mb-1 bg-blue-50 rounded-full">
|
||||||
<svg className="w-6 h-6 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6 text-blue-500 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-gray-600 text-sm">La tarea fue enviada al worker. Consulta el resultado cuando haya finalizado.</p>
|
{modal._message && (
|
||||||
|
<p className="text-center text-blue-700 text-sm font-medium">{modal._message}</p>
|
||||||
|
)}
|
||||||
|
{modal._progress != null && (
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-2 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${modal._progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!modal._message && (
|
||||||
|
<p className="text-center text-gray-600 text-sm">La tarea está procesando. El resultado aparecerá automáticamente al terminar.</p>
|
||||||
|
)}
|
||||||
|
<p className="text-center text-blue-600 text-xs font-medium">
|
||||||
|
Puedes navegar libremente — te notificaremos cuando termine.
|
||||||
|
</p>
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<span className="text-xs text-gray-400">Task ID:</span>
|
<span className="text-xs text-gray-400">Task ID:</span>
|
||||||
<span className="font-mono text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded select-all">{task_id}</span>
|
<span className="font-mono text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded select-all">{task_id}</span>
|
||||||
@@ -521,25 +540,85 @@ function GlobalAuditModal({ modal, onClose, onConsultar, consultando, onIniciarP
|
|||||||
|
|
||||||
<p className="text-center text-gray-700 font-medium text-sm">{resultado.mensaje}</p>
|
<p className="text-center text-gray-700 font-medium text-sm">{resultado.mensaje}</p>
|
||||||
|
|
||||||
{/* Grid de cifras */}
|
{/* Grid de cifras — auto-corrección */}
|
||||||
<div className="grid grid-cols-4 gap-2">
|
{resultado.result?.total_revisados != null ? (
|
||||||
<div className="flex flex-col items-center p-3 bg-gray-50 rounded-xl border border-gray-100">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<span className="text-2xl font-bold text-gray-900">{resultado.result?.total_pedimentos ?? '—'}</span>
|
<div className="flex flex-col items-center p-3 bg-gray-50 rounded-xl border border-gray-100">
|
||||||
<span className="text-xs text-gray-500 mt-0.5 text-center">Total</span>
|
<span className="text-2xl font-bold text-gray-900">{resultado.result.total_revisados}</span>
|
||||||
|
<span className="text-xs text-gray-500 mt-0.5 text-center">Revisados</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center p-3 bg-green-50 rounded-xl border border-green-100">
|
||||||
|
<span className="text-2xl font-bold text-green-700">{resultado.result.corregidos}</span>
|
||||||
|
<span className="text-xs text-green-600 mt-0.5 text-center">Corregidos</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center p-3 bg-gray-50 rounded-xl border border-gray-100">
|
||||||
|
<span className="text-2xl font-bold text-gray-700">{resultado.result.ignorados}</span>
|
||||||
|
<span className="text-xs text-gray-500 mt-0.5 text-center">Sin cambios</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center p-3 bg-green-50 rounded-xl border border-green-100">
|
) : (
|
||||||
<span className="text-2xl font-bold text-green-700">{resultado.result?.completados ?? '—'}</span>
|
/* Grid de cifras — auditoría */
|
||||||
<span className="text-xs text-green-600 mt-0.5 text-center">Completos</span>
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
<div className="flex flex-col items-center p-3 bg-gray-50 rounded-xl border border-gray-100">
|
||||||
|
<span className="text-2xl font-bold text-gray-900">{resultado.result?.total_pedimentos ?? '—'}</span>
|
||||||
|
<span className="text-xs text-gray-500 mt-0.5 text-center">Total</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center p-3 bg-green-50 rounded-xl border border-green-100">
|
||||||
|
<span className="text-2xl font-bold text-green-700">{resultado.result?.completados ?? '—'}</span>
|
||||||
|
<span className="text-xs text-green-600 mt-0.5 text-center">Completos</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center p-3 bg-amber-50 rounded-xl border border-amber-100">
|
||||||
|
<span className="text-2xl font-bold text-amber-700">{resultado.result?.con_pendientes ?? '—'}</span>
|
||||||
|
<span className="text-xs text-amber-600 mt-0.5 text-center">Pendientes</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center p-3 bg-red-50 rounded-xl border border-red-100">
|
||||||
|
<span className="text-2xl font-bold text-red-700">{resultado.result?.con_errores ?? '—'}</span>
|
||||||
|
<span className="text-xs text-red-600 mt-0.5 text-center">Errores</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center p-3 bg-amber-50 rounded-xl border border-amber-100">
|
)}
|
||||||
<span className="text-2xl font-bold text-amber-700">{resultado.result?.con_pendientes ?? '—'}</span>
|
|
||||||
<span className="text-xs text-amber-600 mt-0.5 text-center">Pendientes</span>
|
{/* Detalle de auto-corrección */}
|
||||||
</div>
|
{resultado.result?.detalles && resultado.result.detalles.length > 0 && (
|
||||||
<div className="flex flex-col items-center p-3 bg-red-50 rounded-xl border border-red-100">
|
<details className="rounded-xl border border-green-100 overflow-hidden" open>
|
||||||
<span className="text-2xl font-bold text-red-700">{resultado.result?.con_errores ?? '—'}</span>
|
<summary className="flex items-center justify-between px-4 py-2.5 bg-green-50 text-sm font-semibold text-green-800 cursor-pointer list-none select-none">
|
||||||
<span className="text-xs text-red-600 mt-0.5 text-center">Errores</span>
|
<span>Detalle de correcciones ({resultado.result.detalles.length})</span>
|
||||||
</div>
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</div>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-green-100 text-xs">
|
||||||
|
<thead className="bg-green-50/60">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold text-green-900">Pedimento</th>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold text-green-900">Acción</th>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold text-green-900">Campos</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-green-50 bg-white">
|
||||||
|
{resultado.result.detalles.map((item, i) => (
|
||||||
|
<tr key={i} className="hover:bg-green-50/40">
|
||||||
|
<td className="px-4 py-2 font-mono font-semibold text-gray-800 whitespace-nowrap">{item.pedimento}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${item.accion === 'corregido' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{item.accion}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(item.campos_pedimento ?? []).map((c, j) => (
|
||||||
|
<span key={j} className="inline-block px-1.5 py-0.5 bg-blue-50 text-blue-700 rounded font-mono text-xs">{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tabla de pedimentos con pendientes */}
|
{/* Tabla de pedimentos con pendientes */}
|
||||||
{resultado.result?.detalle_pendientes && resultado.result.detalle_pendientes.length > 0 && (
|
{resultado.result?.detalle_pendientes && resultado.result.detalle_pendientes.length > 0 && (
|
||||||
@@ -629,32 +708,7 @@ function GlobalAuditModal({ modal, onClose, onConsultar, consultando, onIniciarP
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className={`px-6 py-4 flex-shrink-0 flex gap-3 border-t border-gray-100 bg-gray-50 ${hayPendientes ? 'justify-between' : 'justify-end'}`}>
|
<div className={`px-6 py-4 flex-shrink-0 flex gap-3 border-t border-gray-100 bg-gray-50 ${hayPendientes ? 'justify-between' : 'justify-end'}`}>
|
||||||
{/* Botón "Consultar estado" cuando aún no hay resultado */}
|
{/* Fase en progreso: sin botón de consulta — SSE actualiza automáticamente */}
|
||||||
{!resultado && (
|
|
||||||
<button
|
|
||||||
onClick={onConsultar}
|
|
||||||
disabled={consultando}
|
|
||||||
className={`inline-flex items-center gap-2 px-5 py-2.5 rounded-xl font-semibold text-sm text-white transition-all duration-200 shadow-sm
|
|
||||||
${consultando ? 'bg-slate-400 cursor-not-allowed' : 'bg-slate-600 hover:bg-slate-700 hover:shadow-md active:scale-95'}`}
|
|
||||||
>
|
|
||||||
{consultando ? (
|
|
||||||
<>
|
|
||||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Consultando...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
Consultar estado
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Botón "Iniciar Proceso" cuando hay resultado con pendientes/errores */}
|
{/* Botón "Iniciar Proceso" cuando hay resultado con pendientes/errores */}
|
||||||
{hayPendientes && (
|
{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 ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-semibold bg-gray-200 text-gray-600">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-gray-400"></span>Analizando
|
||||||
|
</span>
|
||||||
|
) : hayCorregibles ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-700">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-amber-500"></span>{resultado.corregibles} corregibles
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-semibold bg-green-100 text-green-700">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-green-500"></span>Sin cambios pendientes
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-2xl bg-white rounded-2xl shadow-2xl border border-gray-100 overflow-hidden flex flex-col max-h-[90vh]">
|
||||||
|
<div className={`px-6 py-4 flex-shrink-0 ${headerBg}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Análisis — Auto-corrección</p>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 mt-0.5">Pedimentos Incompletos</h3>
|
||||||
|
</div>
|
||||||
|
{badge}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-5 space-y-4 overflow-y-auto">
|
||||||
|
{!resultado ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 mx-auto mb-1 bg-blue-50 rounded-full">
|
||||||
|
<svg className="w-6 h-6 text-blue-500 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{modal._message
|
||||||
|
? <p className="text-center text-blue-700 text-sm font-medium">{modal._message}</p>
|
||||||
|
: <p className="text-center text-gray-600 text-sm">Analizando pedimentos con consultar_vucem=False...</p>
|
||||||
|
}
|
||||||
|
<p className="text-center text-blue-600 text-xs font-medium">Puedes navegar libremente — te notificaremos cuando termine.</p>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<span className="text-xs text-gray-400">Task ID:</span>
|
||||||
|
<span className="font-mono text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded select-all">{task_id}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<div className="flex flex-col items-center p-3 bg-gray-50 rounded-xl border border-gray-100">
|
||||||
|
<span className="text-2xl font-bold text-gray-900">{resultado.total_revisados ?? '—'}</span>
|
||||||
|
<span className="text-xs text-gray-500 mt-0.5 text-center">Revisados</span>
|
||||||
|
</div>
|
||||||
|
<div className={`flex flex-col items-center p-3 rounded-xl border ${hayCorregibles ? 'bg-amber-50 border-amber-100' : 'bg-green-50 border-green-100'}`}>
|
||||||
|
<span className={`text-2xl font-bold ${hayCorregibles ? 'text-amber-700' : 'text-green-700'}`}>{resultado.corregibles ?? '—'}</span>
|
||||||
|
<span className={`text-xs mt-0.5 text-center ${hayCorregibles ? 'text-amber-600' : 'text-green-600'}`}>Corregibles</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center p-3 bg-gray-50 rounded-xl border border-gray-100">
|
||||||
|
<span className="text-2xl font-bold text-gray-500">{resultado.sin_xml_o_ilegible ?? '—'}</span>
|
||||||
|
<span className="text-xs text-gray-500 mt-0.5 text-center">Sin XML</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<div className="flex flex-col items-center p-2 bg-gray-50 rounded-lg border border-gray-100">
|
||||||
|
<span className="text-lg font-bold text-gray-700">{resultado.xml_no_es_pedimento_completo ?? '—'}</span>
|
||||||
|
<span className="text-xs text-gray-500 mt-0.5 text-center leading-tight">XML sin PC</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center p-2 bg-gray-50 rounded-lg border border-gray-100">
|
||||||
|
<span className="text-lg font-bold text-gray-700">{resultado.numero_pedimento_no_coincide ?? '—'}</span>
|
||||||
|
<span className="text-xs text-gray-500 mt-0.5 text-center leading-tight">Núm. no coincide</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center p-2 bg-gray-50 rounded-lg border border-gray-100">
|
||||||
|
<span className="text-lg font-bold text-gray-700">{resultado.con_error_vucem ?? '—'}</span>
|
||||||
|
<span className="text-xs text-gray-500 mt-0.5 text-center leading-tight">Error VUCEM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{resultado.pedimentos?.length > 0 && (
|
||||||
|
<details className="rounded-xl border border-amber-100 overflow-hidden" open>
|
||||||
|
<summary className="flex items-center justify-between px-4 py-2.5 bg-amber-50 text-sm font-semibold text-amber-800 cursor-pointer list-none select-none">
|
||||||
|
<span>Pedimentos corregibles ({resultado.pedimentos.length})</span>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-amber-100 text-xs">
|
||||||
|
<thead className="bg-amber-50/60">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold text-amber-900">Pedimento</th>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold text-amber-900">Campos a corregir</th>
|
||||||
|
<th className="px-4 py-2 text-left font-semibold text-amber-900">Doc. nuevo</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-amber-50 bg-white">
|
||||||
|
{resultado.pedimentos.map((item, i) => (
|
||||||
|
<tr key={i} className="hover:bg-amber-50/40">
|
||||||
|
<td className="px-4 py-2 font-mono font-semibold text-gray-800 whitespace-nowrap">{item.pedimento_app}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{item.campos_a_corregir?.length > 0 ? (
|
||||||
|
item.campos_a_corregir.map((c, j) => (
|
||||||
|
<span key={j} className="inline-block px-1.5 py-0.5 bg-blue-100 text-blue-800 rounded font-mono text-xs">{c.campo}</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 italic">solo reclasificación</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 font-mono text-gray-500 text-xs">
|
||||||
|
{item.documento_nuevo_nombre
|
||||||
|
? item.documento_nuevo_nombre.split('/').pop()
|
||||||
|
: <span className="text-gray-300">—</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{esVacio && (
|
||||||
|
<p className="text-center text-gray-500 text-sm py-2">No hay pedimentos corregibles en este momento.</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`px-6 py-4 flex-shrink-0 flex gap-3 border-t border-gray-100 bg-gray-50 ${resultado && hayCorregibles ? 'justify-between' : 'justify-end'}`}>
|
||||||
|
{resultado && hayCorregibles && (
|
||||||
|
<button
|
||||||
|
onClick={onEjecutar}
|
||||||
|
disabled={ejecutando}
|
||||||
|
className={`inline-flex items-center gap-2 px-5 py-2.5 rounded-xl font-semibold text-sm text-white transition-all duration-200 shadow-sm
|
||||||
|
${ejecutando ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700 hover:shadow-md active:scale-95'}`}
|
||||||
|
>
|
||||||
|
{ejecutando ? (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Encolando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
Ejecutar Auto-corrección
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{!resultado && (
|
||||||
|
<button
|
||||||
|
onClick={onConsultar}
|
||||||
|
disabled={consultando}
|
||||||
|
className={`inline-flex items-center gap-2 px-4 py-2.5 rounded-xl font-semibold text-sm border transition-colors
|
||||||
|
${consultando
|
||||||
|
? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed'
|
||||||
|
: 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
{consultando ? (
|
||||||
|
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
Consultar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-5 py-2.5 rounded-xl font-semibold text-sm text-gray-700 bg-white border border-gray-200 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Auditor() {
|
function Auditor() {
|
||||||
const { showMessage } = useNotification();
|
const { showMessage } = useNotification();
|
||||||
|
const { addTask, pendingOpenTaskId, clearPendingOpen, getTask } = useTaskProgress();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadingTable, setLoadingTable] = useState(false);
|
const [loadingTable, setLoadingTable] = useState(false);
|
||||||
const isFirstLoad = useRef(true);
|
const isFirstLoad = useRef(true);
|
||||||
@@ -722,6 +980,15 @@ function Auditor() {
|
|||||||
const [auditandoEDocuments, setAuditandoEDocuments] = useState(false);
|
const [auditandoEDocuments, setAuditandoEDocuments] = useState(false);
|
||||||
const [auditandoAcuses, setAuditandoAcuses] = useState(false);
|
const [auditandoAcuses, setAuditandoAcuses] = useState(false);
|
||||||
const [showXmlModal, setShowXmlModal] = 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);
|
const [xmlData, setXmlData] = useState(null);
|
||||||
|
|
||||||
// Estados para modal de preview
|
// Estados para modal de preview
|
||||||
@@ -755,6 +1022,70 @@ function Auditor() {
|
|||||||
const [consultandoTask, setConsultandoTask] = useState(false);
|
const [consultandoTask, setConsultandoTask] = useState(false);
|
||||||
const [iniciandoProcesoGlobal, setIniciandoProcesoGlobal] = 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 () => {
|
const handleIniciarProcesoGlobal = async () => {
|
||||||
if (!globalAuditModal?.procesamiento || !globalAuditModal?.organizacion_id || iniciandoProcesoGlobal) return;
|
if (!globalAuditModal?.procesamiento || !globalAuditModal?.organizacion_id || iniciandoProcesoGlobal) return;
|
||||||
try {
|
try {
|
||||||
@@ -846,6 +1177,7 @@ function Auditor() {
|
|||||||
throw new Error(await extractApiError(response));
|
throw new Error(await extractApiError(response));
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
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 });
|
setGlobalAuditModal({ label: 'Acuses', procesamiento: 'acuses', organizacion_id: organizacionId, task_id: data.task_id, resultado: null });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showMessage(error.message || 'Error al iniciar la auditoría de acuses', '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));
|
throw new Error(await extractApiError(response));
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
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 });
|
setGlobalAuditModal({ label: 'EDocuments', procesamiento: 'edocs', organizacion_id: organizacionId, task_id: data.task_id, resultado: null });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showMessage(error.message || 'Error al iniciar la auditoría de EDocuments', '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));
|
throw new Error(await extractApiError(response));
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
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 });
|
setGlobalAuditModal({ label: 'Acuses de COVE', procesamiento: 'acuse_coves', organizacion_id: organizacionId, task_id: data.task_id, resultado: null });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showMessage(error.message || 'Error al iniciar la auditoría de acuses cove', '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();
|
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 });
|
setGlobalAuditModal({ label: 'Remesas', procesamiento: 'remesas', organizacion_id: organizacionId, task_id: data.task_id, resultado: null });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1019,6 +1354,7 @@ function Auditor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
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 });
|
setGlobalAuditModal({ label: 'Partidas', procesamiento: 'partidas', organizacion_id: organizacionId, task_id: data.task_id, resultado: null });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1048,6 +1384,7 @@ function Auditor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
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 });
|
setGlobalAuditModal({ label: 'Pedimentos', organizacion_id: organizacionId, task_id: data.task_id, resultado: null });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1164,7 +1501,93 @@ const handleAuditarEDocumentPedimento = async (pedimentoId) => {
|
|||||||
{
|
{
|
||||||
setPeticionPedimentoVU(null);
|
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) => {
|
// 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 getVistaNombre = (vista) => {
|
||||||
const nombres = {
|
const nombres = {
|
||||||
'pc': 'Pedimento Completo',
|
'pc': 'Pedimento Completo',
|
||||||
@@ -2091,6 +2556,35 @@ function formatXml(xml) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleAnalizarIncompletos}
|
||||||
|
disabled={analizandoIncompletos || pedimentos.length === 0}
|
||||||
|
className={`inline-flex items-center px-3 py-2 text-sm rounded-lg shadow-sm text-white transition-all duration-200
|
||||||
|
${analizandoIncompletos || pedimentos.length === 0
|
||||||
|
? 'bg-blue-300 cursor-not-allowed'
|
||||||
|
: 'bg-blue-600 hover:bg-blue-700 hover:shadow-md'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{analizandoIncompletos ? (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4 mr-2 text-white animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Analizando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
<span className="hidden sm:inline">Corregir </span>Incompletos
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2163,6 +2657,9 @@ function formatXml(xml) {
|
|||||||
EDoc
|
EDoc
|
||||||
<span className="block text-xs text-gray-500">EDocument</span>
|
<span className="block text-xs text-gray-500">EDocument</span>
|
||||||
</th>
|
</th>
|
||||||
|
<th scope="col" className="px-3 py-3.5 text-center text-sm font-semibold text-gray-900">
|
||||||
|
<span className="block text-xs text-gray-500">Corregir</span>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
@@ -2383,6 +2880,26 @@ function formatXml(xml) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
{/* Auditar incompleto individual */}
|
||||||
|
<td className="px-3 py-4 text-sm text-center text-gray-500 whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAnalizarPedimentoIndividual(pedimento.id)}
|
||||||
|
disabled={analizandoIndividual !== null}
|
||||||
|
title="Analizar pedimento incompleto"
|
||||||
|
className="inline-flex items-center justify-center w-8 h-8 transition-all duration-200 bg-white border border-indigo-300 rounded-full shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-400 hover:scale-110 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{analizandoIndividual === pedimento.id ? (
|
||||||
|
<svg className="w-4 h-4 text-indigo-500 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -2549,6 +3066,18 @@ function formatXml(xml) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modal auto-corrección de pedamentos incompletos */}
|
||||||
|
{autoCorregirModal && (
|
||||||
|
<AutoCorregirResultModal
|
||||||
|
modal={autoCorregirModal}
|
||||||
|
onClose={() => setAutoCorregirModal(null)}
|
||||||
|
onConsultar={handleConsultarAutoCorregir}
|
||||||
|
consultando={consultandoAutoCorregir}
|
||||||
|
onEjecutar={handleEjecutarAutoCorregir}
|
||||||
|
ejecutando={ejecutandoAutoCorregir}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Modal de Vista Previa XML */}
|
{/* Modal de Vista Previa XML */}
|
||||||
{/* {showXmlModal && xmlData && (
|
{/* {showXmlModal && xmlData && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
|
||||||
@@ -3066,6 +3595,156 @@ function formatXml(xml) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Drawer — Corregir Pedimento */}
|
||||||
|
{corregirModal && (
|
||||||
|
<>
|
||||||
|
{/* Overlay */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/40"
|
||||||
|
style={{ zIndex: 9998 }}
|
||||||
|
onClick={closeCorregirDrawer}
|
||||||
|
/>
|
||||||
|
{/* Panel lateral derecho */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-y-0 right-0 flex flex-col w-full max-w-md bg-white shadow-2xl"
|
||||||
|
style={{
|
||||||
|
zIndex: 9999,
|
||||||
|
animation: `${corregirClosing ? 'slide-out-right' : 'slide-in-right'} 0.3s cubic-bezier(0.22,1,0.36,1) forwards`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 bg-blue-50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-blue-100 rounded-xl">
|
||||||
|
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-gray-900">Corregir Pedimento</h3>
|
||||||
|
<p className="text-xs text-gray-500">{corregirModal.pedimento_app}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={closeCorregirDrawer}
|
||||||
|
className="p-1.5 text-gray-400 rounded-lg hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form — cuerpo scrollable + footer fijo */}
|
||||||
|
<form onSubmit={handleCorregirSubmit} className="flex flex-col flex-1 min-h-0">
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">Número de Operación</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={corregirForm.numero_operacion}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">
|
||||||
|
Aduana <span className="text-gray-400 text-xs">(3 dígitos)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={corregirForm.aduana}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">
|
||||||
|
Patente <span className="text-gray-400 text-xs">(4 dígitos)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={corregirForm.patente}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">
|
||||||
|
Pedimento <span className="text-gray-400 text-xs">(7 dígitos)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={corregirForm.pedimento}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">Fecha de Pago</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={corregirForm.fecha_pago}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">Régimen</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={corregirForm.regimen}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">Clave Pedimento</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={corregirForm.clave_pedimento}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer fijo */}
|
||||||
|
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeCorregirDrawer}
|
||||||
|
disabled={savingCorregir}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={savingCorregir}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{savingCorregir && (
|
||||||
|
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
Guardar cambios
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Animaciones CSS */}
|
{/* Animaciones CSS */}
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes bounce-slow {
|
@keyframes bounce-slow {
|
||||||
@@ -3086,6 +3765,10 @@ function formatXml(xml) {
|
|||||||
from { opacity: 0; transform: scale(0.9) translateY(10px); }
|
from { opacity: 0; transform: scale(0.9) translateY(10px); }
|
||||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
}
|
}
|
||||||
|
@keyframes slide-in-right {
|
||||||
|
from { transform: translateX(100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
.animate-fade-in {
|
.animate-fade-in {
|
||||||
animation: fade-in 0.8s ease-out;
|
animation: fade-in 0.8s ease-out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -970,7 +970,7 @@ const downloadExpediente = async (pedimentoId, pedimentoName, setSuccess, showMe
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchFilter}
|
value={searchFilter}
|
||||||
onChange={e => setSearchFilter(e.target.value)}
|
onChange={e => { setSearchFilter(e.target.value); setCurrentPage(1); }}
|
||||||
placeholder="Buscar pedimento, contribuyente..."
|
placeholder="Buscar pedimento, contribuyente..."
|
||||||
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
||||||
/>
|
/>
|
||||||
@@ -981,7 +981,7 @@ const downloadExpediente = async (pedimentoId, pedimentoName, setSuccess, showMe
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={pedimentoFilter}
|
value={pedimentoFilter}
|
||||||
onChange={e => setPedimentoFilter(e.target.value)}
|
onChange={e => { setPedimentoFilter(e.target.value); setCurrentPage(1); }}
|
||||||
placeholder="Número de pedimento..."
|
placeholder="Número de pedimento..."
|
||||||
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
||||||
/>
|
/>
|
||||||
@@ -989,7 +989,7 @@ const downloadExpediente = async (pedimentoId, pedimentoName, setSuccess, showMe
|
|||||||
{/* Expediente */}
|
{/* Expediente */}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<label className="text-xs font-semibold text-gray-700 mb-1.5">Expediente</label>
|
<label className="text-xs font-semibold text-gray-700 mb-1.5">Expediente</label>
|
||||||
<select value={expedienteFilter} onChange={e => setExpedienteFilter(e.target.value)}
|
<select value={expedienteFilter} onChange={e => { setExpedienteFilter(e.target.value); setCurrentPage(1); }}
|
||||||
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md">
|
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md">
|
||||||
<option value="all">Todos</option>
|
<option value="all">Todos</option>
|
||||||
<option value="true">Con expediente</option>
|
<option value="true">Con expediente</option>
|
||||||
@@ -1005,6 +1005,7 @@ const downloadExpediente = async (pedimentoId, pedimentoName, setSuccess, showMe
|
|||||||
onChange={e => {
|
onChange={e => {
|
||||||
setContribuyenteInput(e.target.value);
|
setContribuyenteInput(e.target.value);
|
||||||
setContribuyenteFilter(e.target.value);
|
setContribuyenteFilter(e.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
onBlur={e => {
|
onBlur={e => {
|
||||||
setContribuyenteFilter(e.target.value);
|
setContribuyenteFilter(e.target.value);
|
||||||
@@ -1027,6 +1028,7 @@ const downloadExpediente = async (pedimentoId, pedimentoName, setSuccess, showMe
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setContribuyenteFilter(c);
|
setContribuyenteFilter(c);
|
||||||
setContribuyenteInput('');
|
setContribuyenteInput('');
|
||||||
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{c}
|
{c}
|
||||||
@@ -1042,7 +1044,7 @@ const downloadExpediente = async (pedimentoId, pedimentoName, setSuccess, showMe
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={curpApoderadoFilter}
|
value={curpApoderadoFilter}
|
||||||
onChange={e => setCurpApoderadoFilter(e.target.value)}
|
onChange={e => { setCurpApoderadoFilter(e.target.value); setCurrentPage(1); }}
|
||||||
placeholder="CURP del apoderado..."
|
placeholder="CURP del apoderado..."
|
||||||
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
||||||
/>
|
/>
|
||||||
@@ -1081,7 +1083,7 @@ const downloadExpediente = async (pedimentoId, pedimentoName, setSuccess, showMe
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={patenteFilter}
|
value={patenteFilter}
|
||||||
onChange={e => setPatenteFilter(e.target.value)}
|
onChange={e => { setPatenteFilter(e.target.value); setCurrentPage(1); }}
|
||||||
placeholder="Patente..."
|
placeholder="Patente..."
|
||||||
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
||||||
/>
|
/>
|
||||||
@@ -1092,7 +1094,7 @@ const downloadExpediente = async (pedimentoId, pedimentoName, setSuccess, showMe
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={aduanaFilter}
|
value={aduanaFilter}
|
||||||
onChange={e => setAduanaFilter(e.target.value)}
|
onChange={e => { setAduanaFilter(e.target.value); setCurrentPage(1); }}
|
||||||
placeholder="Aduana..."
|
placeholder="Aduana..."
|
||||||
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
||||||
/>
|
/>
|
||||||
@@ -1102,7 +1104,7 @@ const downloadExpediente = async (pedimentoId, pedimentoName, setSuccess, showMe
|
|||||||
<label className="text-xs font-semibold text-gray-700 mb-1.5">Tipo de operación</label>
|
<label className="text-xs font-semibold text-gray-700 mb-1.5">Tipo de operación</label>
|
||||||
<select
|
<select
|
||||||
value={tipoOperacionFilter}
|
value={tipoOperacionFilter}
|
||||||
onChange={e => setTipoOperacionFilter(e.target.value)}
|
onChange={e => { setTipoOperacionFilter(e.target.value); setCurrentPage(1); }}
|
||||||
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
||||||
>
|
>
|
||||||
<option value="">Todos</option>
|
<option value="">Todos</option>
|
||||||
@@ -1116,7 +1118,7 @@ const downloadExpediente = async (pedimentoId, pedimentoName, setSuccess, showMe
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={clavePedimentoFilter}
|
value={clavePedimentoFilter}
|
||||||
onChange={e => setClavePedimentoFilter(e.target.value)}
|
onChange={e => { setClavePedimentoFilter(e.target.value); setCurrentPage(1); }}
|
||||||
placeholder="Clave pedimento..."
|
placeholder="Clave pedimento..."
|
||||||
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
import { FileUp } from 'lucide-react';
|
import { FileUp } from 'lucide-react';
|
||||||
|
|
||||||
// Animación fade-in/slide-up para bloques
|
// Animaciones globales del componente
|
||||||
const fadeInSlideUp = `@keyframes fadein-slideup {
|
const globalStyles = `
|
||||||
|
@keyframes fadein-slideup {
|
||||||
0% { opacity: 0; transform: translateY(40px); }
|
0% { opacity: 0; transform: translateY(40px); }
|
||||||
100% { opacity: 1; transform: translateY(0); }
|
100% { opacity: 1; transform: translateY(0); }
|
||||||
}`;
|
}
|
||||||
|
@keyframes slide-in-right {
|
||||||
|
from { transform: translateX(100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
@keyframes slide-out-right {
|
||||||
|
from { transform: translateX(0); }
|
||||||
|
to { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
if (typeof document !== 'undefined' && !document.getElementById('fadein-slideup-pedimento')) {
|
if (typeof document !== 'undefined' && !document.getElementById('fadein-slideup-pedimento')) {
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
style.id = 'fadein-slideup-pedimento';
|
style.id = 'fadein-slideup-pedimento';
|
||||||
style.innerHTML = fadeInSlideUp;
|
style.innerHTML = globalStyles;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
}
|
}
|
||||||
import hljs from 'highlight.js/lib/core';
|
import hljs from 'highlight.js/lib/core';
|
||||||
@@ -19,7 +29,7 @@ import 'highlight.js/styles/github.css';
|
|||||||
hljs.registerLanguage('xml', xml);
|
hljs.registerLanguage('xml', xml);
|
||||||
import { fetchPedimentoDocuments } from '../api/pedimentoDocuments';
|
import { fetchPedimentoDocuments } from '../api/pedimentoDocuments';
|
||||||
import { fetchPedimentoCompleto} from '../api/pedimentoCompleto';
|
import { fetchPedimentoCompleto} from '../api/pedimentoCompleto';
|
||||||
import { fetchWithAuth, postWithAuth, putWithAuth, postFormDataWithAuth } from '../fetchWithAuth';
|
import { fetchWithAuth, postWithAuth, putWithAuth, postFormDataWithAuth, patchWithAuth } from '../fetchWithAuth';
|
||||||
import { fetchTasks } from '../api/procesos.ts';
|
import { fetchTasks } from '../api/procesos.ts';
|
||||||
import { fetchPedimentoCoves, downloadCove, downloadAcuseCove } from '../api/coves';
|
import { fetchPedimentoCoves, downloadCove, downloadAcuseCove } from '../api/coves';
|
||||||
import { fetchPedimentoEdocuments, downloadEdocument, downloadAcuseEdocument, resetAcuseEdocument } from '../api/edocuments';
|
import { fetchPedimentoEdocuments, downloadEdocument, downloadAcuseEdocument, resetAcuseEdocument } from '../api/edocuments';
|
||||||
@@ -117,6 +127,10 @@ export default function PedimentoDetail() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { showMessage } = useNotification();
|
const { showMessage } = useNotification();
|
||||||
|
const [showCorregirModal, setShowCorregirModal] = useState(false);
|
||||||
|
const [corregirClosing, setCorregirClosing] = useState(false);
|
||||||
|
const [corregirForm, setCorregirForm] = useState({});
|
||||||
|
const [savingCorregir, setSavingCorregir] = useState(false);
|
||||||
|
|
||||||
// Estados para documentos
|
// Estados para documentos
|
||||||
const [documents, setDocuments] = useState([]);
|
const [documents, setDocuments] = useState([]);
|
||||||
@@ -432,6 +446,47 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeCorregirDrawer = () => {
|
||||||
|
setCorregirClosing(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowCorregirModal(false);
|
||||||
|
setCorregirClosing(false);
|
||||||
|
}, 280);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCorregirOpen = () => {
|
||||||
|
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 || '',
|
||||||
|
});
|
||||||
|
setShowCorregirModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCorregirSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSavingCorregir(true);
|
||||||
|
try {
|
||||||
|
const response = await patchWithAuth(`${API_URL}/customs/pedimentos/${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();
|
||||||
|
setPedimento(updated);
|
||||||
|
closeCorregirDrawer();
|
||||||
|
showMessage('Pedimento corregido exitosamente', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
showMessage(`Error al corregir: ${error.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setSavingCorregir(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Función para procesar partida
|
// Función para procesar partida
|
||||||
const handlePartidaProcess = async (partida) => {
|
const handlePartidaProcess = async (partida) => {
|
||||||
setProcessingPartida(partida.id);
|
setProcessingPartida(partida.id);
|
||||||
@@ -1004,12 +1059,15 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
|||||||
|
|
||||||
showMessage(`Subiendo ${selectedFiles.length} archivo(s)...`, 'info');
|
showMessage(`Subiendo ${selectedFiles.length} archivo(s)...`, 'info');
|
||||||
|
|
||||||
const result = await postFormDataWithAuth(`${API_URL}/record/documents/bulk-upload/`, formData);
|
const response = await postFormDataWithAuth(`${API_URL}/record/documents/bulk-upload/`, formData);
|
||||||
|
|
||||||
showMessage(
|
if (!response.ok) {
|
||||||
`${result.uploaded_count || selectedFiles.length} archivo(s) subido(s) exitosamente`,
|
const errorData = await response.json().catch(() => null);
|
||||||
'success'
|
throw new Error(errorData?.detail || errorData?.message || `Error ${response.status}: ${response.statusText}`);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
showMessage(result.message || `${result.uploaded_count || selectedFiles.length} archivo(s) subido(s) exitosamente`, 'success');
|
||||||
|
|
||||||
// Limpiar archivos seleccionados y cerrar modal
|
// Limpiar archivos seleccionados y cerrar modal
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
@@ -3236,13 +3294,25 @@ useEffect(() => {
|
|||||||
<div className="mb-6 border shadow-xl opacity-0 bg-white/80 backdrop-blur-xl rounded-2xl border-blue-100/50 animate-fadein-slideup"
|
<div className="mb-6 border shadow-xl opacity-0 bg-white/80 backdrop-blur-xl rounded-2xl border-blue-100/50 animate-fadein-slideup"
|
||||||
style={{ animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.15s forwards' }}>
|
style={{ animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.15s forwards' }}>
|
||||||
<div className="px-6 py-4 border-b border-gray-200/50">
|
<div className="px-6 py-4 border-b border-gray-200/50">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-between">
|
||||||
<div className="p-2 bg-gradient-to-br from-blue-500 to-blue-700 rounded-xl">
|
<div className="flex items-center gap-3">
|
||||||
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div className="p-2 bg-gradient-to-br from-blue-500 to-blue-700 rounded-xl">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Información General</h2>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-bold text-gray-900">Información General</h2>
|
<button
|
||||||
|
onClick={handleCorregirOpen}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
title="Corregir datos del pedimento"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
Corregir
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
@@ -7857,6 +7927,156 @@ useEffect(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Drawer — Corregir Pedimento */}
|
||||||
|
{showCorregirModal && (
|
||||||
|
<>
|
||||||
|
{/* Overlay */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/40"
|
||||||
|
style={{ zIndex: 9998 }}
|
||||||
|
onClick={closeCorregirDrawer}
|
||||||
|
/>
|
||||||
|
{/* Panel lateral derecho */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-y-0 right-0 flex flex-col w-full max-w-md bg-white shadow-2xl"
|
||||||
|
style={{
|
||||||
|
zIndex: 9999,
|
||||||
|
animation: `${corregirClosing ? 'slide-out-right' : 'slide-in-right'} 0.3s cubic-bezier(0.22,1,0.36,1) forwards`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 bg-blue-50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-blue-100 rounded-xl">
|
||||||
|
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-gray-900">Corregir Pedimento</h3>
|
||||||
|
<p className="text-xs text-gray-500">{pedimento?.pedimento_app}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={closeCorregirDrawer}
|
||||||
|
className="p-1.5 text-gray-400 rounded-lg hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form — cuerpo scrollable + footer fijo */}
|
||||||
|
<form onSubmit={handleCorregirSubmit} className="flex flex-col flex-1 min-h-0">
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">Número de Operación</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={corregirForm.numero_operacion}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">
|
||||||
|
Aduana <span className="text-gray-400 text-xs">(3 dígitos)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={corregirForm.aduana}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">
|
||||||
|
Patente <span className="text-gray-400 text-xs">(4 dígitos)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={corregirForm.patente}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">
|
||||||
|
Pedimento <span className="text-gray-400 text-xs">(7 dígitos)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={corregirForm.pedimento}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">Fecha de Pago</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={corregirForm.fecha_pago}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">Régimen</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={corregirForm.regimen}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">Clave Pedimento</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={corregirForm.clave_pedimento}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer fijo */}
|
||||||
|
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeCorregirDrawer}
|
||||||
|
disabled={savingCorregir}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={savingCorregir}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{savingCorregir && (
|
||||||
|
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
Guardar cambios
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user