Compare commits
5 Commits
feature/rb
...
6e2634d11b
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e2634d11b | |||
| cbbd34a91c | |||
| d45623c99a | |||
| f60c581a02 | |||
| f6c4e0af56 |
@@ -5,6 +5,8 @@ import Vucem from './pages/Vucem';
|
||||
import Auditor from './pages/Auditor';
|
||||
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { UserProvider } from './context/UserContext';
|
||||
import { TaskProgressProvider } from './context/TaskProgressContext';
|
||||
import TaskProgressCard from './components/TaskProgressCard';
|
||||
import Navbar from './components/Navbar';
|
||||
import Layout from './components/Layout';
|
||||
import Login from './pages/Login';
|
||||
@@ -166,7 +168,10 @@ function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<UserProvider>
|
||||
<AppContent />
|
||||
<TaskProgressProvider>
|
||||
<AppContent />
|
||||
<TaskProgressCard />
|
||||
</TaskProgressProvider>
|
||||
</UserProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
@@ -56,3 +56,12 @@ export const downloadAcuseEdocument = async (edocId) => {
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
export const resetAcuseEdocument = async (edocId) => {
|
||||
const response = await fetchWithAuth(
|
||||
`${API_BASE_URL}/customs/edocuments/${edocId}/reset-acuse/`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (!response.ok) throw new Error(await extractApiError(response));
|
||||
return response.json();
|
||||
};
|
||||
|
||||
@@ -15,11 +15,18 @@ export interface TipoNotificacion {
|
||||
descripcion: string;
|
||||
}
|
||||
|
||||
export interface NotificacionDatos {
|
||||
task_id: string;
|
||||
label: string;
|
||||
resultado: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Notificacion {
|
||||
id: number;
|
||||
tipo: TipoNotificacion;
|
||||
dirigido: string;
|
||||
mensaje: string;
|
||||
datos: NotificacionDatos | null;
|
||||
fecha_envio: string;
|
||||
created_at: string;
|
||||
visto: boolean;
|
||||
@@ -47,4 +54,12 @@ export async function fetchAllNotifications({page = 1, page_size=10}): Promise<N
|
||||
const res = await fetchWithAuth(url);
|
||||
if (!res.ok) throw new Error('Error al obtener notificaciones');
|
||||
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 };
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -970,7 +970,7 @@ const downloadExpediente = async (pedimentoId, pedimentoName, setSuccess, showMe
|
||||
<input
|
||||
type="text"
|
||||
value={searchFilter}
|
||||
onChange={e => setSearchFilter(e.target.value)}
|
||||
onChange={e => { setSearchFilter(e.target.value); setCurrentPage(1); }}
|
||||
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"
|
||||
/>
|
||||
@@ -981,7 +981,7 @@ const downloadExpediente = async (pedimentoId, pedimentoName, setSuccess, showMe
|
||||
<input
|
||||
type="text"
|
||||
value={pedimentoFilter}
|
||||
onChange={e => setPedimentoFilter(e.target.value)}
|
||||
onChange={e => { setPedimentoFilter(e.target.value); setCurrentPage(1); }}
|
||||
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"
|
||||
/>
|
||||
@@ -989,7 +989,7 @@ const downloadExpediente = async (pedimentoId, pedimentoName, setSuccess, showMe
|
||||
{/* Expediente */}
|
||||
<div className="flex flex-col">
|
||||
<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">
|
||||
<option value="all">Todos</option>
|
||||
<option value="true">Con expediente</option>
|
||||
@@ -1005,6 +1005,7 @@ const downloadExpediente = async (pedimentoId, pedimentoName, setSuccess, showMe
|
||||
onChange={e => {
|
||||
setContribuyenteInput(e.target.value);
|
||||
setContribuyenteFilter(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onBlur={e => {
|
||||
setContribuyenteFilter(e.target.value);
|
||||
@@ -1027,6 +1028,7 @@ const downloadExpediente = async (pedimentoId, pedimentoName, setSuccess, showMe
|
||||
onClick={() => {
|
||||
setContribuyenteFilter(c);
|
||||
setContribuyenteInput('');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
{c}
|
||||
@@ -1042,7 +1044,7 @@ const downloadExpediente = async (pedimentoId, pedimentoName, setSuccess, showMe
|
||||
<input
|
||||
type="text"
|
||||
value={curpApoderadoFilter}
|
||||
onChange={e => setCurpApoderadoFilter(e.target.value)}
|
||||
onChange={e => { setCurpApoderadoFilter(e.target.value); setCurrentPage(1); }}
|
||||
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"
|
||||
/>
|
||||
@@ -1081,7 +1083,7 @@ const downloadExpediente = async (pedimentoId, pedimentoName, setSuccess, showMe
|
||||
<input
|
||||
type="text"
|
||||
value={patenteFilter}
|
||||
onChange={e => setPatenteFilter(e.target.value)}
|
||||
onChange={e => { setPatenteFilter(e.target.value); setCurrentPage(1); }}
|
||||
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"
|
||||
/>
|
||||
@@ -1092,7 +1094,7 @@ const downloadExpediente = async (pedimentoId, pedimentoName, setSuccess, showMe
|
||||
<input
|
||||
type="text"
|
||||
value={aduanaFilter}
|
||||
onChange={e => setAduanaFilter(e.target.value)}
|
||||
onChange={e => { setAduanaFilter(e.target.value); setCurrentPage(1); }}
|
||||
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"
|
||||
/>
|
||||
@@ -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>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
@@ -1116,7 +1118,7 @@ const downloadExpediente = async (pedimentoId, pedimentoName, setSuccess, showMe
|
||||
<input
|
||||
type="text"
|
||||
value={clavePedimentoFilter}
|
||||
onChange={e => setClavePedimentoFilter(e.target.value)}
|
||||
onChange={e => { setClavePedimentoFilter(e.target.value); setCurrentPage(1); }}
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { FileUp } from 'lucide-react';
|
||||
|
||||
// Animación fade-in/slide-up para bloques
|
||||
const fadeInSlideUp = `@keyframes fadein-slideup {
|
||||
// Animaciones globales del componente
|
||||
const globalStyles = `
|
||||
@keyframes fadein-slideup {
|
||||
0% { opacity: 0; transform: translateY(40px); }
|
||||
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')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'fadein-slideup-pedimento';
|
||||
style.innerHTML = fadeInSlideUp;
|
||||
style.innerHTML = globalStyles;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
@@ -19,10 +29,10 @@ import 'highlight.js/styles/github.css';
|
||||
hljs.registerLanguage('xml', xml);
|
||||
import { fetchPedimentoDocuments } from '../api/pedimentoDocuments';
|
||||
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 { fetchPedimentoCoves, downloadCove, downloadAcuseCove } from '../api/coves';
|
||||
import { fetchPedimentoEdocuments, downloadEdocument, downloadAcuseEdocument } from '../api/edocuments';
|
||||
import { fetchPedimentoEdocuments, downloadEdocument, downloadAcuseEdocument, resetAcuseEdocument } from '../api/edocuments';
|
||||
import { getTaskStatusLabel, getTaskStatusColor, isTaskActionable, isTaskFinal } from '../api/taskStatus';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useNotification } from '../context/NotificationContext';
|
||||
@@ -117,6 +127,10 @@ export default function PedimentoDetail() {
|
||||
const [error, setError] = useState('');
|
||||
const { id } = useParams();
|
||||
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
|
||||
const [documents, setDocuments] = useState([]);
|
||||
@@ -338,6 +352,10 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
||||
const [processingAcuseEdoc, setProcessingAcuseEdoc] = useState(null);
|
||||
// Modal de advertencia por documentos con errores en EDocs
|
||||
const [edocErrorModal, setEdocErrorModal] = useState({ open: false, edoc: null, tipo: null });
|
||||
// Modal de advertencia por documentos con errores en COVEs
|
||||
const [coveErrorModal, setCoveErrorModal] = useState({ open: false, cove: null, tipo: null });
|
||||
// Modal de confirmación para reprocesar partidas ya descargadas
|
||||
const [partidaModal, setPartidaModal] = useState({ open: false, partida: null });
|
||||
|
||||
// Agregar estado para el modal de documentos
|
||||
const [showDocumentsModal, setShowDocumentsModal] = useState(false);
|
||||
@@ -346,8 +364,9 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
||||
|
||||
// Función para manejar la visualización de documentos COVE
|
||||
const handleShowCoveDocuments = (cove) => {
|
||||
if (cove.documentos && cove.documentos.length > 0) {
|
||||
setSelectedVUDocuments(cove.documentos);
|
||||
const buenos = (cove.documentos || []).filter(d => d.document_type >= 1 && d.document_type <= 9);
|
||||
if (buenos.length > 0) {
|
||||
setSelectedVUDocuments(buenos);
|
||||
setSelectedVUNumber(cove.numero_cove);
|
||||
setShowDocumentsModal(true);
|
||||
}
|
||||
@@ -364,8 +383,9 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
||||
|
||||
// Función para manejar la visualización de Edocuments
|
||||
const handleShowEDocuments = (edocument) => {
|
||||
if (edocument.documentos && edocument.documentos.length > 0) {
|
||||
setSelectedVUDocuments(edocument.documentos);
|
||||
const buenos = (edocument.documentos || []).filter(d => d.document_type >= 1 && d.document_type <= 9);
|
||||
if (buenos.length > 0) {
|
||||
setSelectedVUDocuments(buenos);
|
||||
setSelectedVUNumber(edocument.numero_edocument);
|
||||
setShowDocumentsModal(true);
|
||||
}
|
||||
@@ -426,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
|
||||
const handlePartidaProcess = async (partida) => {
|
||||
setProcessingPartida(partida.id);
|
||||
@@ -998,12 +1059,15 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
||||
|
||||
showMessage(`Subiendo ${selectedFiles.length} archivo(s)...`, 'info');
|
||||
|
||||
const result = await postFormDataWithAuth(`${API_URL}/record/documents/bulk-upload/`, formData);
|
||||
|
||||
showMessage(
|
||||
`${result.uploaded_count || selectedFiles.length} archivo(s) subido(s) exitosamente`,
|
||||
'success'
|
||||
);
|
||||
const response = await postFormDataWithAuth(`${API_URL}/record/documents/bulk-upload/`, formData);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
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
|
||||
setSelectedFiles([]);
|
||||
@@ -2297,6 +2361,20 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Restablece acuse_descargado=False (crea doc error tipo 26 para Errores VU)
|
||||
// y lanza inmediatamente el reprocesamiento del acuse.
|
||||
const handleResetAcuse = async (edoc) => {
|
||||
try {
|
||||
await resetAcuseEdocument(edoc.id);
|
||||
showMessage(`Restableciendo acuse de ${edoc.numero_edocument}. Iniciando reprocesamiento...`, 'info');
|
||||
await handleAcuseEdocProcess(edoc);
|
||||
fetchEdocs(id, edocsPage, edocsPageSize, edocsFilters);
|
||||
} catch (error) {
|
||||
showMessage(`Error al restablecer el acuse: ${error.message}`, 'error');
|
||||
fetchEdocs(id, edocsPage, edocsPageSize, edocsFilters);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdocRequest = async (edoc) => {
|
||||
console.log('Request edoc:', edoc);
|
||||
showMessage(`Procesando petición para EDocs #${edoc.numero_edocument}...`, 'info');
|
||||
@@ -3216,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"
|
||||
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="flex items-center gap-3">
|
||||
<div className="p-2 bg-gradient-to-br from-blue-500 to-blue-700 rounded-xl">
|
||||
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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 className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gradient-to-br from-blue-500 to-blue-700 rounded-xl">
|
||||
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<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 className="p-6">
|
||||
@@ -4754,16 +4844,24 @@ useEffect(() => {
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{/* Botón Petición (solo activo si está pendiente) */}
|
||||
<button
|
||||
onClick={() => handlePartidaRequest(partida)}
|
||||
disabled={partida.descargado || processingPartida === partida.id}
|
||||
onClick={() => {
|
||||
if (partida.descargado) {
|
||||
setPartidaModal({ open: true, partida });
|
||||
} else {
|
||||
handlePartidaRequest(partida);
|
||||
}
|
||||
}}
|
||||
disabled={processingPartida === partida.id}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
partida.descargado || processingPartida === partida.id
|
||||
processingPartida === partida.id
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: partida.descargado
|
||||
? 'text-purple-400 hover:text-purple-600'
|
||||
: 'text-purple-600 hover:text-purple-900'
|
||||
}`}
|
||||
title={
|
||||
processingPartida === partida.id ? 'Procesando partida...' :
|
||||
partida.descargado ? 'No disponible - Ya descargado' :
|
||||
partida.descargado ? 'Partida ya descargada — haz clic para forzar reprocesamiento' :
|
||||
'Procesar petición'
|
||||
}
|
||||
>
|
||||
@@ -5145,16 +5243,26 @@ useEffect(() => {
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{/* Botón COVE */}
|
||||
<button
|
||||
onClick={() => handleCoveProcess(cove)}
|
||||
disabled={cove.cove_descargado || processingCove === cove.id}
|
||||
onClick={() => {
|
||||
if (cove.cove_descargado) {
|
||||
setCoveErrorModal({ open: true, cove, tipo: 'ya_descargado_cove' });
|
||||
} else if (cove.documentos?.some(d => d.document_type === 20)) {
|
||||
setCoveErrorModal({ open: true, cove, tipo: 'cove' });
|
||||
} else {
|
||||
handleCoveProcess(cove);
|
||||
}
|
||||
}}
|
||||
disabled={processingCove === cove.id}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
cove.cove_descargado
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: processingCove === cove.id
|
||||
processingCove === cove.id
|
||||
? 'text-blue-400 cursor-not-allowed'
|
||||
: cove.cove_descargado
|
||||
? 'text-gray-400 hover:text-gray-600'
|
||||
: cove.documentos?.some(d => d.document_type === 20)
|
||||
? 'text-yellow-500 hover:text-yellow-700 opacity-60'
|
||||
: 'text-blue-600 hover:text-blue-900'
|
||||
}`}
|
||||
title={cove.cove_descargado ? 'COVE ya descargado' : processingCove === cove.id ? 'Procesando COVE...' : 'Procesar COVE'}
|
||||
title={processingCove === cove.id ? 'Procesando COVE...' : cove.cove_descargado ? 'COVE ya descargado — haz clic para forzar reprocesamiento' : cove.documentos?.some(d => d.document_type === 20) ? 'COVE con errores — haz clic para más información' : 'Procesar COVE'}
|
||||
>
|
||||
{processingCove === cove.id ? (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -5169,16 +5277,26 @@ useEffect(() => {
|
||||
|
||||
{/* Botón Acuse de COVE */}
|
||||
<button
|
||||
onClick={() => handleAcuseCoveProcess(cove)}
|
||||
disabled={cove.acuse_cove_descargado || processingAcuseCove === cove.id}
|
||||
onClick={() => {
|
||||
if (cove.acuse_cove_descargado) {
|
||||
setCoveErrorModal({ open: true, cove, tipo: 'ya_descargado_acuse_cove' });
|
||||
} else if (cove.documentos?.some(d => d.document_type === 24)) {
|
||||
setCoveErrorModal({ open: true, cove, tipo: 'acuse' });
|
||||
} else {
|
||||
handleAcuseCoveProcess(cove);
|
||||
}
|
||||
}}
|
||||
disabled={processingAcuseCove === cove.id}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
cove.acuse_cove_descargado
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: processingAcuseCove === cove.id
|
||||
processingAcuseCove === cove.id
|
||||
? 'text-green-400 cursor-not-allowed'
|
||||
: cove.acuse_cove_descargado
|
||||
? 'text-gray-400 hover:text-gray-600'
|
||||
: cove.documentos?.some(d => d.document_type === 24)
|
||||
? 'text-yellow-500 hover:text-yellow-700 opacity-60'
|
||||
: 'text-green-600 hover:text-green-900'
|
||||
}`}
|
||||
title={cove.acuse_cove_descargado ? 'Acuse de COVE ya descargado' : processingAcuseCove === cove.id ? 'Procesando Acuse de COVE...' : 'Procesar Acuse de COVE'}
|
||||
title={processingAcuseCove === cove.id ? 'Procesando Acuse de COVE...' : cove.acuse_cove_descargado ? 'Acuse de COVE ya descargado — haz clic para forzar reprocesamiento' : cove.documentos?.some(d => d.document_type === 24) ? 'Acuse con errores — haz clic para más información' : 'Procesar Acuse de COVE'}
|
||||
>
|
||||
{processingAcuseCove === cove.id ? (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -5647,24 +5765,33 @@ useEffect(() => {
|
||||
{/* Botón EDoc */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const tieneError = !edoc.edocument_descargado && edoc.documentos?.some(d => d.document_type === 22);
|
||||
if (tieneError) {
|
||||
if (edoc.edocument_descargado) {
|
||||
setEdocErrorModal({ open: true, edoc, tipo: 'ya_descargado_edoc' });
|
||||
} else if (!edoc.edocument_descargado && edoc.documentos?.some(d => d.document_type === 22)) {
|
||||
setEdocErrorModal({ open: true, edoc, tipo: 'edoc' });
|
||||
} else {
|
||||
handleEdocProcess(edoc);
|
||||
}
|
||||
}}
|
||||
disabled={edoc.edocument_descargado || processingEdoc === edoc.id}
|
||||
disabled={processingEdoc === edoc.id}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
edoc.edocument_descargado
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: processingEdoc === edoc.id
|
||||
processingEdoc === edoc.id
|
||||
? 'text-blue-400 cursor-not-allowed'
|
||||
: edoc.edocument_descargado
|
||||
? 'text-gray-400 hover:text-gray-600'
|
||||
: !edoc.edocument_descargado && edoc.documentos?.some(d => d.document_type === 22)
|
||||
? 'text-yellow-500 hover:text-yellow-700 opacity-60'
|
||||
: 'text-blue-600 hover:text-blue-900'
|
||||
}`}
|
||||
title={edoc.edocument_descargado ? 'EDoc ya descargado' : processingEdoc === edoc.id ? 'Procesando EDoc...' : !edoc.edocument_descargado && edoc.documentos?.some(d => d.document_type === 22) ? 'EDoc con errores — haz clic para más información' : 'Procesar EDoc'}
|
||||
title={
|
||||
processingEdoc === edoc.id
|
||||
? 'Procesando EDoc...'
|
||||
: edoc.edocument_descargado
|
||||
? 'EDoc ya descargado — haz clic para forzar reprocesamiento'
|
||||
: !edoc.edocument_descargado && edoc.documentos?.some(d => d.document_type === 22)
|
||||
? 'EDoc con errores — haz clic para más información'
|
||||
: 'Procesar EDoc'
|
||||
}
|
||||
>
|
||||
{processingEdoc === edoc.id ? (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -5678,38 +5805,70 @@ useEffect(() => {
|
||||
</button>
|
||||
|
||||
{/* Botón Acuse de EDoc */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const tieneError = !edoc.acuse_descargado && edoc.documentos?.some(d => d.document_type === 26);
|
||||
if (tieneError) {
|
||||
{(() => {
|
||||
// Tipo 4 = Pedimento Acuse (acuse PDF válido descargado)
|
||||
const tieneAcusePdf = edoc.documentos?.some(d => d.document_type === 4);
|
||||
// Inconsistencia: marcado como descargado pero sin PDF de acuse
|
||||
const acuseConInconsistencia = edoc.acuse_descargado && !tieneAcusePdf;
|
||||
// Correcto: descargado y con PDF disponible (gris pero clickeable para forzar)
|
||||
const acuseOk = edoc.acuse_descargado && tieneAcusePdf;
|
||||
// Error previo: no descargado pero hay doc de error tipo 26
|
||||
const tieneErrorPrevio = !edoc.acuse_descargado && edoc.documentos?.some(d => d.document_type === 26);
|
||||
|
||||
const handleClick = () => {
|
||||
if (acuseConInconsistencia) {
|
||||
setEdocErrorModal({ open: true, edoc, tipo: 'acuse_inconsistencia' });
|
||||
} else if (acuseOk) {
|
||||
setEdocErrorModal({ open: true, edoc, tipo: 'ya_descargado_acuse' });
|
||||
} else if (tieneErrorPrevio) {
|
||||
setEdocErrorModal({ open: true, edoc, tipo: 'acuse' });
|
||||
} else {
|
||||
handleAcuseEdocProcess(edoc);
|
||||
}
|
||||
}}
|
||||
disabled={edoc.acuse_descargado || processingAcuseEdoc === edoc.id}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
edoc.acuse_descargado
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: processingAcuseEdoc === edoc.id
|
||||
};
|
||||
|
||||
const btnClass = `p-1 rounded transition-colors ${
|
||||
processingAcuseEdoc === edoc.id
|
||||
? 'text-green-400 cursor-not-allowed'
|
||||
: !edoc.acuse_descargado && edoc.documentos?.some(d => d.document_type === 26)
|
||||
: acuseOk
|
||||
? 'text-gray-400 hover:text-gray-600'
|
||||
: acuseConInconsistencia
|
||||
? 'text-red-500 hover:text-red-700'
|
||||
: tieneErrorPrevio
|
||||
? 'text-yellow-500 hover:text-yellow-700 opacity-60'
|
||||
: 'text-green-600 hover:text-green-900'
|
||||
}`}
|
||||
title={edoc.acuse_descargado ? 'Acuse de EDoc ya descargado' : processingAcuseEdoc === edoc.id ? 'Procesando Acuse de EDoc...' : !edoc.acuse_descargado && edoc.documentos?.some(d => d.document_type === 26) ? 'Acuse con errores — haz clic para más información' : 'Procesar Acuse de EDoc'}
|
||||
>
|
||||
{processingAcuseEdoc === edoc.id ? (
|
||||
<svg className="w-4 h-4 animate-spin" 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>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
}`;
|
||||
|
||||
const btnTitle = processingAcuseEdoc === edoc.id
|
||||
? 'Procesando Acuse de EDoc...'
|
||||
: acuseOk
|
||||
? 'Acuse ya descargado — haz clic para forzar reprocesamiento'
|
||||
: acuseConInconsistencia
|
||||
? 'Inconsistencia: acuse marcado como descargado pero el documento no está disponible — haz clic para más información'
|
||||
: tieneErrorPrevio
|
||||
? 'Acuse con errores — haz clic para más información'
|
||||
: 'Procesar Acuse de EDoc';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={processingAcuseEdoc === edoc.id}
|
||||
className={btnClass}
|
||||
title={btnTitle}
|
||||
>
|
||||
{processingAcuseEdoc === edoc.id ? (
|
||||
<svg className="w-4 h-4 animate-spin" 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>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Botón para cargar documentos al cove */}
|
||||
<button
|
||||
onClick={() => handleUploadToEdoc(edoc)}
|
||||
@@ -7428,89 +7587,496 @@ useEffect(() => {
|
||||
|
||||
{/* Modal de advertencia: EDoc/Acuse con documentos de error */}
|
||||
{edocErrorModal.open && edocErrorModal.edoc && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 animate-fade-in">
|
||||
<div className="w-full max-w-lg p-6 bg-white rounded-lg shadow-xl animate-modal-slide-up">
|
||||
|
||||
{edocErrorModal.tipo === 'acuse_inconsistencia' ? (
|
||||
/* Caso inconsistencia: acuse_descargado=True pero PDF no disponible */
|
||||
<>
|
||||
<div className="flex items-center mb-4 space-x-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-red-100 rounded-full shrink-0">
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Error en acuse</h3>
|
||||
</div>
|
||||
<p className="mb-1 text-sm text-gray-700">
|
||||
No fue posible visualizar el acuse del EDocument{' '}
|
||||
<span className="font-medium">{edocErrorModal.edoc.numero_edocument}</span>.
|
||||
El estado indica <span className="font-medium">Descargado</span> pero el documento no se encuentra disponible.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
Revise la pestaña <span className="font-semibold text-red-600">Errores VU</span> para consultar el detalle del problema.
|
||||
Al aceptar, el estado se restablecerá para que puedas reintentar el procesamiento.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => setEdocErrorModal({ open: false, edoc: null, tipo: null })}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const { edoc } = edocErrorModal;
|
||||
setEdocErrorModal({ open: false, edoc: null, tipo: null });
|
||||
handleResetAcuse(edoc);
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700"
|
||||
>
|
||||
Aceptar y reprocesar
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : edocErrorModal.tipo === 'ya_descargado_edoc' || edocErrorModal.tipo === 'ya_descargado_acuse' ? (
|
||||
/* Caso: ya descargado correctamente, usuario quiere forzar reintento */
|
||||
<>
|
||||
<div className="flex items-center mb-4 space-x-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-blue-100 rounded-full shrink-0">
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{edocErrorModal.tipo === 'ya_descargado_acuse' ? 'Acuse ya descargado' : 'EDoc ya descargado'}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mb-1 text-sm text-gray-700">
|
||||
Este documento (<span className="font-medium">{edocErrorModal.edoc.numero_edocument}</span>) ya está descargado.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
Si desea reintentar la descarga, tenga en cuenta que es <span className="font-semibold">bajo su responsabilidad</span>.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => setEdocErrorModal({ open: false, edoc: null, tipo: null })}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const { edoc, tipo } = edocErrorModal;
|
||||
setEdocErrorModal({ open: false, edoc: null, tipo: null });
|
||||
if (tipo === 'ya_descargado_acuse') {
|
||||
handleAcuseEdocProcess(edoc);
|
||||
} else {
|
||||
handleEdocProcess(edoc);
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Reintentar descarga
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Caso normal: errores previos conocidos (tipo 22/26) */
|
||||
<>
|
||||
<div className="flex items-center mb-4 space-x-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-yellow-100 rounded-full shrink-0">
|
||||
<svg className="w-5 h-5 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{edocErrorModal.tipo === 'acuse' ? 'Acuse de EDoc con errores' : 'EDoc con errores'}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mb-1 text-sm text-gray-700">
|
||||
Este EDocument (<span className="font-medium">{edocErrorModal.edoc.numero_edocument}</span>) cuenta con errores en la respuesta recibida.
|
||||
Revisa el documento de error antes de volver a intentarlo.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
Puedes continuar con el flujo de trabajo, pero se recomienda corregir el error primero.
|
||||
</p>
|
||||
{/* Lista de documentos de error */}
|
||||
{(() => {
|
||||
const tipoFiltro = edocErrorModal.tipo === 'acuse' ? 26 : 22;
|
||||
const docsError = edocErrorModal.edoc.documentos?.filter(d => d.document_type === tipoFiltro) || [];
|
||||
return docsError.length > 0 ? (
|
||||
<div className="mb-4">
|
||||
<p className="mb-2 text-xs font-medium tracking-wide text-gray-500 uppercase">Documentos de error</p>
|
||||
<ul className="space-y-2">
|
||||
{docsError.map(doc => {
|
||||
const nombreArchivo = (doc.archivo || '').split('/').pop() || 'Documento';
|
||||
return (
|
||||
<li key={doc.id} className="flex items-center justify-between p-2 rounded-md bg-gray-50">
|
||||
<span className="text-sm text-gray-700 truncate max-w-xs" title={nombreArchivo}>
|
||||
{nombreArchivo}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEdocErrorModal({ open: false, edoc: null, tipo: null });
|
||||
handlePreviewVU(doc);
|
||||
}}
|
||||
className="p-1 ml-2 text-blue-600 rounded shrink-0 hover:text-blue-900 hover:bg-blue-50"
|
||||
title="Ver documento"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => setEdocErrorModal({ open: false, edoc: null, tipo: null })}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const { edoc, tipo } = edocErrorModal;
|
||||
setEdocErrorModal({ open: false, edoc: null, tipo: null });
|
||||
if (tipo === 'acuse') {
|
||||
handleAcuseEdocProcess(edoc);
|
||||
} else {
|
||||
handleEdocProcess(edoc);
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-md hover:bg-yellow-700"
|
||||
>
|
||||
Continuar de todas formas
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de advertencia: COVE/Acuse con documentos de error */}
|
||||
{/* Modal de confirmación: Partida ya descargada */}
|
||||
{partidaModal.open && partidaModal.partida && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 animate-fade-in">
|
||||
<div className="w-full max-w-lg p-6 bg-white rounded-lg shadow-xl animate-modal-slide-up">
|
||||
<div className="flex items-center mb-4 space-x-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-yellow-100 rounded-full shrink-0">
|
||||
<svg className="w-5 h-5 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-blue-100 rounded-full shrink-0">
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{edocErrorModal.tipo === 'acuse' ? 'Acuse de EDoc con errores' : 'EDoc con errores'}
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Partida ya descargada</h3>
|
||||
</div>
|
||||
|
||||
<p className="mb-1 text-sm text-gray-700">
|
||||
Este EDocument (<span className="font-medium">{edocErrorModal.edoc.numero_edocument}</span>) cuenta con errores en la respuesta recibida.
|
||||
Revisa el documento de error antes de volver a intentarlo.
|
||||
La partida <span className="font-medium">{partidaModal.partida.numero_partida}</span> ya está descargada.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
Puedes continuar con el flujo de trabajo, pero se recomienda corregir el error primero.
|
||||
Si desea reintentar la descarga, tenga en cuenta que es <span className="font-semibold">bajo su responsabilidad</span>.
|
||||
</p>
|
||||
|
||||
{/* Lista de documentos de error */}
|
||||
{(() => {
|
||||
const tipoFiltro = edocErrorModal.tipo === 'acuse' ? 26 : 22;
|
||||
const docsError = edocErrorModal.edoc.documentos?.filter(d => d.document_type === tipoFiltro) || [];
|
||||
return docsError.length > 0 ? (
|
||||
<div className="mb-4">
|
||||
<p className="mb-2 text-xs font-medium tracking-wide text-gray-500 uppercase">Documentos de error</p>
|
||||
<ul className="space-y-2">
|
||||
{docsError.map(doc => {
|
||||
const nombreArchivo = (doc.archivo || '').split('/').pop() || 'Documento';
|
||||
return (
|
||||
<li key={doc.id} className="flex items-center justify-between p-2 rounded-md bg-gray-50">
|
||||
<span className="text-sm text-gray-700 truncate max-w-xs" title={nombreArchivo}>
|
||||
{nombreArchivo}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEdocErrorModal({ open: false, edoc: null, tipo: null });
|
||||
handlePreviewVU(doc);
|
||||
}}
|
||||
className="p-1 ml-2 text-blue-600 rounded shrink-0 hover:text-blue-900 hover:bg-blue-50"
|
||||
title="Ver documento"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => setEdocErrorModal({ open: false, edoc: null, tipo: null })}
|
||||
onClick={() => setPartidaModal({ open: false, partida: null })}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const { edoc, tipo } = edocErrorModal;
|
||||
setEdocErrorModal({ open: false, edoc: null, tipo: null });
|
||||
if (tipo === 'acuse') {
|
||||
handleAcuseEdocProcess(edoc);
|
||||
} else {
|
||||
handleEdocProcess(edoc);
|
||||
}
|
||||
const { partida } = partidaModal;
|
||||
setPartidaModal({ open: false, partida: null });
|
||||
handlePartidaRequest(partida);
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-md hover:bg-yellow-700"
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Continuar de todas formas
|
||||
Reintentar descarga
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{coveErrorModal.open && coveErrorModal.cove && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 animate-fade-in">
|
||||
<div className="w-full max-w-lg p-6 bg-white rounded-lg shadow-xl animate-modal-slide-up">
|
||||
|
||||
{coveErrorModal.tipo === 'ya_descargado_cove' || coveErrorModal.tipo === 'ya_descargado_acuse_cove' ? (
|
||||
/* Caso: ya descargado correctamente, usuario quiere forzar reintento */
|
||||
<>
|
||||
<div className="flex items-center mb-4 space-x-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-blue-100 rounded-full shrink-0">
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{coveErrorModal.tipo === 'ya_descargado_acuse_cove' ? 'Acuse de COVE ya descargado' : 'COVE ya descargado'}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mb-1 text-sm text-gray-700">
|
||||
Este documento (<span className="font-medium">{coveErrorModal.cove.numero_cove}</span>) ya está descargado.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
Si desea reintentar la descarga, tenga en cuenta que es <span className="font-semibold">bajo su responsabilidad</span>.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => setCoveErrorModal({ open: false, cove: null, tipo: null })}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const { cove, tipo } = coveErrorModal;
|
||||
setCoveErrorModal({ open: false, cove: null, tipo: null });
|
||||
if (tipo === 'ya_descargado_acuse_cove') {
|
||||
handleAcuseCoveProcess(cove);
|
||||
} else {
|
||||
handleCoveProcess(cove);
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Reintentar descarga
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Caso normal: errores previos conocidos (tipo 20/24) */
|
||||
<>
|
||||
<div className="flex items-center mb-4 space-x-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-yellow-100 rounded-full shrink-0">
|
||||
<svg className="w-5 h-5 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{coveErrorModal.tipo === 'acuse' ? 'Acuse de COVE con errores' : 'COVE con errores'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="mb-1 text-sm text-gray-700">
|
||||
Este COVE (<span className="font-medium">{coveErrorModal.cove.numero_cove}</span>) cuenta con errores en la respuesta recibida.
|
||||
Revisa el documento de error antes de volver a intentarlo.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
Puedes continuar con el flujo de trabajo, pero se recomienda corregir el error primero.
|
||||
</p>
|
||||
|
||||
{/* Lista de documentos de error */}
|
||||
{(() => {
|
||||
const tipoFiltro = coveErrorModal.tipo === 'acuse' ? 24 : 20;
|
||||
const docsError = coveErrorModal.cove.documentos?.filter(d => d.document_type === tipoFiltro) || [];
|
||||
return docsError.length > 0 ? (
|
||||
<div className="mb-4">
|
||||
<p className="mb-2 text-xs font-medium tracking-wide text-gray-500 uppercase">Documentos de error</p>
|
||||
<ul className="space-y-2">
|
||||
{docsError.map(doc => {
|
||||
const nombreArchivo = (doc.archivo || '').split('/').pop() || 'Documento';
|
||||
return (
|
||||
<li key={doc.id} className="flex items-center justify-between p-2 rounded-md bg-gray-50">
|
||||
<span className="text-sm text-gray-700 truncate max-w-xs" title={nombreArchivo}>
|
||||
{nombreArchivo}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCoveErrorModal({ open: false, cove: null, tipo: null });
|
||||
handlePreviewVU(doc);
|
||||
}}
|
||||
className="p-1 ml-2 text-blue-600 rounded shrink-0 hover:text-blue-900 hover:bg-blue-50"
|
||||
title="Ver documento"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => setCoveErrorModal({ open: false, cove: null, tipo: null })}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const { cove, tipo } = coveErrorModal;
|
||||
setCoveErrorModal({ open: false, cove: null, tipo: null });
|
||||
if (tipo === 'acuse') {
|
||||
handleAcuseCoveProcess(cove);
|
||||
} else {
|
||||
handleCoveProcess(cove);
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-md hover:bg-yellow-700"
|
||||
>
|
||||
Continuar de todas formas
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user