6 Commits

Author SHA1 Message Date
4660ed59a7 feat: Implementar subida avanzada de expedientes con compresión automática
-  Agregar selección múltiple de carpetas con validación de nomenclatura
-  Implementar compresión automática de carpetas usando JSZip
-  Permitir selección múltiple de archivos ZIP y RAR
- 🎨 Mejorar UI del modal con scroll y gestión de archivos individual
- 🔧 Agregar barras de progreso para compresión y subida
- 🔧 Corregir endpoint a bulk-create y formato FormData
- 🔧 Resolver warnings de React con webkitdirectory
- 📦 Instalar JSZip como dependencia

Funcionalidades nuevas:
- Modal responsivo con altura máxima y scroll interno
- Compresión automática de carpetas antes de envío
- Interfaz consistente para carpetas, ZIP y RAR
- Eliminar archivos/carpetas individualmente
- Contador visual de archivos seleccionados
- Validación flexible de nomenclatura de expedientes
2025-10-14 14:07:17 -05:00
791bd2f87e Merge pull request 'refactor: centralize download functions into utils' (#2) from feature/download-utils-clean into main
Reviewed-on: #2
2025-10-13 19:38:33 +00:00
5f4a797c3c refactor: centralize download functions into utils
- Create src/utils/downloadUtils.js with downloadFile and downloadBulkZip
- Remove duplicated download functions from PedimentoDetail.jsx
- Remove duplicated download functions from Documents.jsx
- Add proper imports to use centralized functions
- Improve code reusability and maintainability
- Ensure consistent download behavior across components
2025-10-13 14:25:01 -05:00
5c8380a772 se agrego referencia a pedimento 2025-10-12 08:04:10 -06:00
03a9f793b1 se modifico auditor y pagina de procesos 2025-10-12 07:52:06 -06:00
c924aab9c1 Merge pull request 'feat: Add checkbox selection and bulk operations' (#1) from feature/checkbox-bulk-operations into main
Reviewed-on: #1
2025-10-10 01:38:42 +00:00
10 changed files with 2064 additions and 1485 deletions

95
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.62.7",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"jszip": "^3.10.1",
"react": "^19.1.0", "react": "^19.1.0",
"react-chartjs-2": "^5.3.0", "react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
@@ -1706,6 +1707,12 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true "dev": true
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2420,6 +2427,12 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/imurmurhash": { "node_modules/imurmurhash": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -2429,6 +2442,12 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-binary-path": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"dev": true, "dev": true,
@@ -2488,6 +2507,12 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
@@ -2531,6 +2556,27 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lilconfig": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -2703,6 +2749,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/path-parse": { "node_modules/path-parse": {
"version": "1.0.7", "version": "1.0.7",
"license": "MIT" "license": "MIT"
@@ -2919,6 +2971,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/react": { "node_modules/react": {
"version": "19.1.1", "version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
@@ -3029,6 +3087,21 @@
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"dev": true, "dev": true,
@@ -3069,6 +3142,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.26.0", "version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
@@ -3082,11 +3161,26 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/shallowequal": { "node_modules/shallowequal": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="
}, },
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string-width-cjs": { "node_modules/string-width-cjs": {
"name": "string-width", "name": "string-width",
"version": "4.2.3", "version": "4.2.3",
@@ -3727,7 +3821,6 @@
}, },
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {

View File

@@ -16,6 +16,7 @@
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.62.7",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"jszip": "^3.10.1",
"react": "^19.1.0", "react": "^19.1.0",
"react-chartjs-2": "^5.3.0", "react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",

View File

@@ -1,31 +1,29 @@
import { fetchWithAuth } from '../fetchWithAuth'; import { fetchWithAuth } from '../fetchWithAuth';
// Tipos para la respuesta y registros // Tipos para la respuesta y registros
export interface ProcesamientoPedimento { export interface Task {
id: number; task_id: string;
created_at: string; timestamp: string;
updated_at: string; message: string;
organizacion: string; status: string;
organizacion_name: string;
estado: number;
tipo_procesamiento: number;
pedimento: string; pedimento: string;
organizacion: string;
servicio: number; servicio: number;
} }
export interface ProcesamientoPedimentosResponse { export interface TasksResponse {
count: number; count: number;
next: string | null; next: string | null;
previous: string | null; previous: string | null;
results: ProcesamientoPedimento[]; results: Task[];
} }
// API para customs/procesamientopedimentos/ // API para tasks/tasks/
export async function fetchProcesamientoPedimentos( export async function fetchTasks(
page: number = 1, page: number = 1,
pageSize: number = 20, pageSize: number = 20,
filters: Record<string, any> = {} filters: Record<string, any> = {}
): Promise<ProcesamientoPedimentosResponse> { ): Promise<TasksResponse> {
try { try {
const API_URL = (import.meta as any).env.VITE_EFC_API_URL; const API_URL = (import.meta as any).env.VITE_EFC_API_URL;
@@ -41,15 +39,15 @@ export async function fetchProcesamientoPedimentos(
} }
}); });
const res = await fetchWithAuth(`${API_URL}/customs/procesamientopedimentos/?${params.toString()}`); const res = await fetchWithAuth(`${API_URL}/tasks/tasks/?${params.toString()}`);
if (!res.ok) { if (!res.ok) {
throw new Error('Error al obtener procesamiento de pedimentos'); throw new Error('Error al obtener tareas');
} }
return await res.json(); return await res.json();
} catch (error) { } catch (error) {
console.error('Error in fetchProcesamientoPedimentos:', error); console.error('Error in fetchTasks:', error);
throw error; throw error;
} }
} }

36
src/api/taskStatus.js Normal file
View File

@@ -0,0 +1,36 @@
// Helper functions for task status display
export const getTaskStatusLabel = (status) => {
const statuses = {
'submitted': 'Enviado',
'pending': 'Pendiente',
'processing': 'Procesando',
'completed': 'Completado',
'failed': 'Error',
'cancelled': 'Cancelado'
};
return statuses[status] || `Estado ${status}`;
};
export const getTaskStatusColor = (status) => {
const colors = {
'submitted': 'bg-blue-100 text-blue-800',
'pending': 'bg-yellow-100 text-yellow-800',
'processing': 'bg-indigo-100 text-indigo-800',
'completed': 'bg-green-100 text-green-800',
'failed': 'bg-red-100 text-red-800',
'cancelled': 'bg-gray-100 text-gray-800'
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
// Ayuda a determinar si el estado permite ciertas acciones
export const isTaskActionable = (status) => {
const nonActionableStatuses = ['processing', 'completed', 'cancelled'];
return !nonActionableStatuses.includes(status);
};
export const isTaskFinal = (status) => {
const finalStatuses = ['completed', 'failed', 'cancelled'];
return finalStatuses.includes(status);
};

View File

@@ -1,4 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { getWithAuth, postWithAuth } from '../fetchWithAuth'; import { getWithAuth, postWithAuth } from '../fetchWithAuth';
const API_URL = import.meta.env.VITE_EFC_API_URL; const API_URL = import.meta.env.VITE_EFC_API_URL;
@@ -15,6 +17,85 @@ function Auditor() {
const [auditandoRemesas, setAuditandoRemesas] = useState(false); const [auditandoRemesas, setAuditandoRemesas] = useState(false);
const [procesandoPedimento, setProcesandoPedimento] = useState(null); const [procesandoPedimento, setProcesandoPedimento] = useState(null);
const [procesandoRemesa, setProcesandoRemesa] = useState(null); const [procesandoRemesa, setProcesandoRemesa] = useState(null);
const [procesandoAcuse, setProcesandoAcuse] = useState(null);
const [procesandoCove, setProcesandoCove] = useState(null);
const [auditandoAcusesCove, setAuditandoAcusesCove] = useState(false);
const [pedimentoFilter, setPedimentoFilter] = useState('');
const [auditandoEDocuments, setAuditandoEDocuments] = useState(false);
const [auditandoAcuses, setAuditandoAcuses] = useState(false);
// Handler para auditar acuses (general)
const handleAuditarAcuses = async () => {
if (auditandoAcuses) return;
try {
setAuditandoAcuses(true);
const organizacionId = pedimentos[0]?.organizacion;
if (!organizacionId) {
throw new Error('No hay organización disponible para auditar');
}
const response = await postWithAuth(`${API_URL}/customs/auditor/auditar-acuse/`, {
organizacion_id: organizacionId
});
if (!response.ok) {
throw new Error('Error al iniciar la auditoría de acuses');
}
alert('La auditoría de acuses se ha iniciado correctamente');
} catch (error) {
console.error('Error:', error);
alert(error.message);
} finally {
setAuditandoAcuses(false);
}
};
// Handler para auditar EDocuments (general)
const handleAuditarEDocuments = async () => {
if (auditandoEDocuments) return;
try {
setAuditandoEDocuments(true);
const organizacionId = pedimentos[0]?.organizacion;
if (!organizacionId) {
throw new Error('No hay organización disponible para auditar');
}
const response = await postWithAuth(`${API_URL}/customs/auditor/auditar-edocuments/`, {
organizacion_id: organizacionId
});
if (!response.ok) {
throw new Error('Error al iniciar la auditoría de EDocuments');
}
alert('La auditoría de EDocuments se ha iniciado correctamente');
} catch (error) {
console.error('Error:', error);
alert(error.message);
} finally {
setAuditandoEDocuments(false);
}
};
// Handler para auditar acuses cove (general)
const handleAuditarAcusesCove = async () => {
if (auditandoAcusesCove) return;
try {
setAuditandoAcusesCove(true);
// Obtener el organizacion_id del primer pedimento
const organizacionId = pedimentos[0]?.organizacion;
if (!organizacionId) {
throw new Error('No hay organización disponible para auditar');
}
const response = await postWithAuth(`${API_URL}/customs/auditor/auditar-acuse-cove/`, {
organizacion_id: organizacionId
});
if (!response.ok) {
throw new Error('Error al iniciar la auditoría de acuses cove');
}
alert('La auditoría de acuses cove se ha iniciado correctamente');
} catch (error) {
console.error('Error:', error);
alert(error.message);
} finally {
setAuditandoAcusesCove(false);
}
};
const handleAuditarRemesaPedimento = async (pedimentoId) => { const handleAuditarRemesaPedimento = async (pedimentoId) => {
if (procesandoRemesa) return; if (procesandoRemesa) return;
@@ -159,11 +240,54 @@ function Auditor() {
} }
}; };
const handleAuditarAcusePedimento = async (pedimentoId) => {
if (procesandoAcuse) return;
try {
setProcesandoAcuse(pedimentoId);
const response = await postWithAuth(`${API_URL}/customs/auditor/auditar-acuse/pedimento/`, {
pedimento_id: pedimentoId
});
if (!response.ok) {
throw new Error('Error al auditar acuse del pedimento');
}
alert('El acuse del pedimento se está auditando');
} catch (error) {
console.error('Error:', error);
alert(error.message);
} finally {
setProcesandoAcuse(null);
}
};
const handleAuditarCovePedimento = async (pedimentoId) => {
if (procesandoCove) return;
try {
setProcesandoCove(pedimentoId);
const response = await postWithAuth(`${API_URL}/customs/auditor/auditar-cove/pedimento/`, {
pedimento_id: pedimentoId
});
if (!response.ok) {
throw new Error('Error al auditar COVE del pedimento');
}
alert('El COVE del pedimento se está auditando');
} catch (error) {
console.error('Error:', error);
alert(error.message);
} finally {
setProcesandoCove(null);
}
};
useEffect(() => { useEffect(() => {
const fetchPedimentos = async () => { const fetchPedimentos = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await getWithAuth(`${API_URL}/customs/pedimentos/?page=${page}&page_size=${itemsPerPage}`); const queryParams = new URLSearchParams({
page: page.toString(),
page_size: itemsPerPage.toString(),
...(pedimentoFilter && { pedimento_app: pedimentoFilter })
});
const response = await getWithAuth(`${API_URL}/customs/pedimentos/?${queryParams}`);
if (!response.ok) throw new Error('Error al cargar los pedimentos'); if (!response.ok) throw new Error('Error al cargar los pedimentos');
const data = await response.json(); const data = await response.json();
setPedimentos(data.results); setPedimentos(data.results);
@@ -174,8 +298,14 @@ function Auditor() {
setLoading(false); setLoading(false);
} }
}; };
// Aplicar debounce al fetchPedimentos
const timeoutId = setTimeout(() => {
fetchPedimentos(); fetchPedimentos();
}, [page, itemsPerPage]); }, 300);
return () => clearTimeout(timeoutId);
}, [page, itemsPerPage, pedimentoFilter]);
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 p-4 sm:p-6 lg:p-8"> <div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 p-4 sm:p-6 lg:p-8">
@@ -247,6 +377,7 @@ function Auditor() {
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
<div className="bg-white rounded-2xl p-4 sm:p-6 border border-gray-200 shadow-sm"> <div className="bg-white rounded-2xl p-4 sm:p-6 border border-gray-200 shadow-sm">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-semibold text-gray-900 flex items-center gap-2"> <h3 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -254,8 +385,91 @@ function Auditor() {
</svg> </svg>
Servicios de Auditoría Servicios de Auditoría
</h3> </h3>
<div className="flex gap-4"> <div className="flex gap-4">
<button
onClick={handleAuditarAcusesCove}
disabled={auditandoAcusesCove || pedimentos.length === 0}
className={`inline-flex items-center px-4 py-2 rounded-lg shadow-sm text-white transition-all duration-200
${auditandoAcusesCove
? 'bg-gray-400 cursor-not-allowed'
: 'bg-pink-600 hover:bg-pink-700 hover:shadow-md transform hover:scale-105'
}
`}
>
{auditandoAcusesCove ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" 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>
Auditando Acuses Cove...
</>
) : (
<>
<svg className="mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Auditar Acuses Cove
</>
)}
</button>
<button
onClick={handleAuditarEDocuments}
disabled={auditandoEDocuments || pedimentos.length === 0}
className={`inline-flex items-center px-4 py-2 rounded-lg shadow-sm text-white transition-all duration-200
${auditandoEDocuments
? 'bg-gray-400 cursor-not-allowed'
: 'bg-yellow-600 hover:bg-yellow-700 hover:shadow-md transform hover:scale-105'
}
`}
>
{auditandoEDocuments ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" 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>
Auditando EDocuments...
</>
) : (
<>
<svg className="mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Auditar EDocuments
</>
)}
</button>
<button
onClick={handleAuditarAcuses}
disabled={auditandoAcuses || pedimentos.length === 0}
className={`inline-flex items-center px-4 py-2 rounded-lg shadow-sm text-white transition-all duration-200
${auditandoAcuses
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700 hover:shadow-md transform hover:scale-105'
}
`}
>
{auditandoAcuses ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" 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>
Auditando Acuses...
</>
) : (
<>
<svg className="mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Auditar Acuses
</>
)}
</button>
<button <button
onClick={handleAuditarTodos} onClick={handleAuditarTodos}
disabled={auditando || auditandoPartidas || pedimentos.length === 0} disabled={auditando || auditandoPartidas || pedimentos.length === 0}
@@ -342,6 +556,39 @@ function Auditor() {
</div> </div>
</div> </div>
{/* Sección de Filtros */}
<div className="mt-6 bg-white rounded-lg border border-gray-200 p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Contenedor para los filtros - esperando instrucciones */}
<div className="col-span-full">
<h3 className="text-lg font-medium text-gray-900 mb-4">
Filtros de Búsqueda
</h3>
</div>
<div className="col-span-1">
<label htmlFor="pedimento" className="block text-sm font-medium text-gray-700 mb-2">
Número de Pedimento
</label>
<div className="relative rounded-md shadow-sm">
<input
type="text"
name="pedimento"
id="pedimento"
value={pedimentoFilter}
onChange={(e) => setPedimentoFilter(e.target.value)}
className="focus:ring-indigo-500 focus:border-indigo-500 block w-full pl-3 pr-10 py-2 sm:text-sm border-gray-300 rounded-md"
placeholder="Buscar por pedimento..."
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
</div>
</div>
</div>
{/* Tabla de pedimentos */} {/* Tabla de pedimentos */}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-300"> <table className="min-w-full divide-y divide-gray-300">
@@ -384,7 +631,7 @@ function Auditor() {
{pedimentos.map((pedimento) => ( {pedimentos.map((pedimento) => (
<tr key={pedimento.id} className="hover:bg-gray-50"> <tr key={pedimento.id} className="hover:bg-gray-50">
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900"> <td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900">
{pedimento.pedimento_app} <Link to={`/expedientes/pedimento/${pedimento.id}`} className='hover:text-blue-500 hover:text-bold hover:text-underline'>{pedimento.pedimento_app}</Link>
</td> </td>
{/* PC - Pedimento Completo */} {/* PC - Pedimento Completo */}
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 text-center"> <td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 text-center">
@@ -444,18 +691,46 @@ function Auditor() {
</td> </td>
{/* AC - Acuse */} {/* AC - Acuse */}
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 text-center"> <td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 text-center">
<button className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-white border border-gray-200 shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200 hover:scale-110"> <button
onClick={() => handleAuditarAcusePedimento(pedimento.id)}
disabled={procesandoAcuse === pedimento.id}
className={`inline-flex items-center justify-center w-8 h-8 rounded-full bg-white border border-gray-200 ${procesandoAcuse === pedimento.id
? 'opacity-50 cursor-not-allowed'
: 'shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200 hover:scale-110'
}`}
>
{procesandoAcuse === pedimento.id ? (
<svg className="h-4 w-4 text-gray-600 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="h-4 w-4 text-green-600" fill="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4 text-green-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" /> <path d="M8 5v14l11-7z" />
</svg> </svg>
)}
</button> </button>
</td> </td>
{/* COVE */} {/* COVE */}
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 text-center"> <td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 text-center">
<button className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-white border border-gray-200 shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200 hover:scale-110"> <button
onClick={() => handleAuditarCovePedimento(pedimento.id)}
disabled={procesandoCove === pedimento.id}
className={`inline-flex items-center justify-center w-8 h-8 rounded-full bg-white border border-gray-200 ${procesandoCove === pedimento.id
? 'opacity-50 cursor-not-allowed'
: 'shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200 hover:scale-110'
}`}
>
{procesandoCove === pedimento.id ? (
<svg className="h-4 w-4 text-gray-600 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="h-4 w-4 text-green-600" fill="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4 text-green-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" /> <path d="M8 5v14l11-7z" />
</svg> </svg>
)}
</button> </button>
</td> </td>
{/* AC_COVE */} {/* AC_COVE */}

View File

@@ -13,68 +13,11 @@ import { fetchPedimentoDocuments } from '../api/documentos.ts';
import { useNotification } from '../context/NotificationContext'; import { useNotification } from '../context/NotificationContext';
// import { usePolling } from '../hooks/usePolling'; // import { usePolling } from '../hooks/usePolling';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { downloadFile, downloadBulkZip } from '../utils/downloadUtils';
const API_URL = import.meta.env.VITE_EFC_API_URL; const API_URL = import.meta.env.VITE_EFC_API_URL;
// Descarga individual
const downloadFile = async (id, filename = 'archivo', setSuccess, setError, showMessage) => {
try {
const res = await fetchWithAuth(`${API_URL}/record/documents/descargar/${id}/`);
if (!res.ok) {
alert('No autorizado o error en la descarga');
return;
}
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
if (setSuccess) setSuccess('Descarga exitosa');
} catch (error) {
console.error('Error downloading file:', error);
showMessage('Error al descargar el archivo', 'error');
}
};
// Descarga masiva (bulk)
const downloadBulkZip = async (ids, showMessage, setSuccess, nombreZip = 'documentos') => {
if (!ids.length) {
showMessage('Selecciona al menos un documento.', 'error');
return;
}
try {
const res = await postWithAuth(`${API_URL}/record/documents/bulk-download/`, {
document_ids: ids,
pedimento_nombre: nombreZip
});
if (!res.ok) {
showMessage('No autorizado o error en la descarga masiva', 'error');
return;
}
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${nombreZip || 'documentos'}.zip`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
if (setSuccess) setSuccess('Descarga(s) completada(s)');
} catch (error) {
console.error('Error in bulk download:', error);
showMessage('Error en la descarga masiva', 'error');
}
};
export default function Documents() { export default function Documents() {
const focusKeeperRef = useRef(null); const focusKeeperRef = useRef(null);

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState, useLayoutEffect, useRef } from 'react'; import React, { useEffect, useState, useLayoutEffect, useRef } from 'react';
import { fetchWithAuth, postWithAuth } from '../fetchWithAuth'; import { fetchWithAuth, postWithAuth, postFormDataWithAuth } from '../fetchWithAuth';
import JSZip from 'jszip';
// Animación fade-in/slide-up para bloques // Animación fade-in/slide-up para bloques
const fadeInSlideUp = `@keyframes fadein-slideup { 0% { opacity: 0; transform: translateY(40px); } 100% { opacity: 1; transform: translateY(0); } }`; const fadeInSlideUp = `@keyframes fadein-slideup { 0% { opacity: 0; transform: translateY(40px); } 100% { opacity: 1; transform: translateY(0); } }`;
if (typeof document !== 'undefined' && !document.getElementById('fadein-slideup-documents')) { if (typeof document !== 'undefined' && !document.getElementById('fadein-slideup-documents')) {
@@ -42,6 +43,7 @@ const downloadFile = async (id, filename = 'archivo', setSuccess, setError, show
export default function Documents() { export default function Documents() {
const focusKeeperRef = useRef(null); const focusKeeperRef = useRef(null);
const fileInputRef = useRef(null);
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
@@ -66,6 +68,20 @@ export default function Documents() {
// Estado para modal de confirmación de eliminación // Estado para modal de confirmación de eliminación
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
// Estados para subir expedientes
const [showUploadModal, setShowUploadModal] = useState(false);
const [selectedFiles, setSelectedFiles] = useState([]);
const [uploadType, setUploadType] = useState('folders'); // 'folders', 'zip', 'rar'
const [uploadingFiles, setUploadingFiles] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [isCompressing, setIsCompressing] = useState(false);
const [compressionProgress, setCompressionProgress] = useState(0);
const [validationErrors, setValidationErrors] = useState([]);
const [importadores, setImportadores] = useState([]);
const [selectedContributor, setSelectedContributor] = useState('');
const [loadingContributors, setLoadingContributors] = useState(false);
// Estado para controlar la animación de entrada // Estado para controlar la animación de entrada
const [showAnimation, setShowAnimation] = useState(false); const [showAnimation, setShowAnimation] = useState(false);
const [hasAnimated, setHasAnimated] = useState(false); const [hasAnimated, setHasAnimated] = useState(false);
@@ -255,6 +271,243 @@ export default function Documents() {
} }
}; };
// Funciones para subir expedientes
const fetchImportadores = async () => {
if (importadores.length > 0) return; // Ya están cargados
setLoadingContributors(true);
try {
const response = await fetchWithAuth(`${API_URL}/customs/importadores/`);
if (response.ok) {
const data = await response.json();
// La API devuelve directamente el array, no un objeto con results
setImportadores(Array.isArray(data) ? data : []);
} else {
throw new Error('Error al cargar importadores');
}
} catch (error) {
console.error('Error loading importadores:', error);
showMessage('Error al cargar la lista de importadores', 'error');
} finally {
setLoadingContributors(false);
}
};
const handleFileSelect = (event) => {
const files = Array.from(event.target.files);
setSelectedFiles(files);
};
const validateFolderNomenclature = (files) => {
// Patrón flexible: [AÑO(2 dígitos opcional)]-[ADUANA(2-3 dígitos)]-[PATENTE(4 dígitos)]-[PEDIMENTO(7 dígitos)]
// El año puede estar presente o ausente, si está presente debe ser 2 dígitos
// La aduana puede ser 2 o 3 dígitos
const pattern = /^(\d{2}-)?(\d{2,3})-(\d{4})-(\d{7})$/;
const invalidFolders = [];
for (const file of files) {
const pathParts = file.webkitRelativePath.split('/');
if (pathParts.length > 1) {
const folderName = pathParts[0];
if (!pattern.test(folderName)) {
invalidFolders.push(folderName);
}
}
}
return invalidFolders;
};
// Función para comprimir carpetas en ZIP
const compressFoldersToZip = async (files) => {
const zip = new JSZip();
const folderGroups = {};
// Agrupar archivos por carpeta
files.forEach(file => {
const pathParts = file.webkitRelativePath.split('/');
const folderName = pathParts[0];
if (!folderGroups[folderName]) {
folderGroups[folderName] = [];
}
folderGroups[folderName].push(file);
});
const folderNames = Object.keys(folderGroups);
const compressedFiles = [];
setIsCompressing(true);
setCompressionProgress(0);
try {
// Comprimir cada carpeta individualmente
for (let i = 0; i < folderNames.length; i++) {
const folderName = folderNames[i];
const folderFiles = folderGroups[folderName];
const folderZip = new JSZip();
// Agregar archivos al ZIP de la carpeta
folderFiles.forEach(file => {
// Mantener la estructura de subcarpetas dentro de la carpeta principal
const relativePath = file.webkitRelativePath.substring(folderName.length + 1);
folderZip.file(relativePath, file);
});
// Generar el ZIP de la carpeta
const zipBlob = await folderZip.generateAsync(
{ type: "blob" },
(metadata) => {
// Actualizar progreso de compresión
const folderProgress = metadata.percent;
const totalProgress = ((i * 100) + folderProgress) / folderNames.length;
setCompressionProgress(Math.round(totalProgress));
}
);
// Crear un archivo File a partir del blob
const zipFile = new File([zipBlob], `${folderName}.zip`, { type: 'application/zip' });
compressedFiles.push(zipFile);
}
return compressedFiles;
} finally {
setIsCompressing(false);
setCompressionProgress(0);
}
};
// Función para manejar la selección de archivos
const handleFileSelection = (event) => {
const files = Array.from(event.target.files);
if (uploadType === 'folders') {
// Para carpetas, agregar a los archivos existentes (acumular)
setSelectedFiles(prevFiles => [...prevFiles, ...files]);
setValidationErrors([]);
// Validar nomenclatura de todas las carpetas después de agregar
setTimeout(() => {
setSelectedFiles(currentFiles => {
const invalidFolders = validateFolderNomenclature(currentFiles);
if (invalidFolders.length > 0) {
setValidationErrors([
`Las siguientes carpetas no siguen la nomenclatura correcta ([AÑO]-ADUANA-PATENTE-PEDIMENTO): ${invalidFolders.join(', ')}`
]);
}
return currentFiles;
});
}, 100);
} else {
// Para ZIP/RAR, también permitir acumular múltiples archivos
setSelectedFiles(prevFiles => [...prevFiles, ...files]);
setValidationErrors([]);
}
// Limpiar el input para permitir seleccionar la misma carpeta nuevamente
event.target.value = '';
};
// Función para eliminar una carpeta específica
const removeFolderFromSelection = (folderNameToRemove) => {
setSelectedFiles(prevFiles =>
prevFiles.filter(file => !file.webkitRelativePath.startsWith(folderNameToRemove + '/'))
);
setValidationErrors([]);
};
// Función para eliminar un archivo específico (ZIP/RAR)
const removeFileFromSelection = (fileIndex) => {
setSelectedFiles(prevFiles =>
prevFiles.filter((_, index) => index !== fileIndex)
);
setValidationErrors([]);
};
const handleUploadFiles = async () => {
if (!selectedContributor) {
showMessage('Por favor selecciona un importador', 'warning');
return;
}
if (selectedFiles.length === 0) {
showMessage('Por favor selecciona al menos un archivo', 'warning');
return;
}
// Validar nomenclatura si es tipo carpeta
if (uploadType === 'folders') {
const invalidFolders = validateFolderNomenclature(selectedFiles);
if (invalidFolders.length > 0) {
setValidationErrors([
`Las siguientes carpetas no siguen la nomenclatura correcta ([AÑO]-ADUANA-PATENTE-PEDIMENTO): ${invalidFolders.join(', ')}`
]);
return;
}
}
setIsUploading(true);
setUploadProgress(0);
try {
const formData = new FormData();
formData.append('contribuyente', selectedContributor);
let filesToUpload = selectedFiles;
// Comprimir carpetas automáticamente
if (uploadType === 'folders') {
showMessage('Comprimiendo carpetas...', 'info');
filesToUpload = await compressFoldersToZip(selectedFiles);
formData.append('tipo', 'zip'); // Cambiar tipo a zip después de comprimir
} else {
formData.append('tipo', uploadType);
}
// Agregar archivos al FormData
if (uploadType === 'folders') {
// Para carpetas comprimidas, agregar como múltiples archivos ZIP
filesToUpload.forEach((file, index) => {
formData.append(`archivos`, file);
});
} else {
// Para ZIP/RAR múltiples, agregar cada archivo
filesToUpload.forEach((file, index) => {
formData.append(`archivos`, file);
});
}
const fileCount = uploadType === 'folders' ? filesToUpload.length : selectedFiles.length;
showMessage(`Subiendo ${fileCount} archivo(s)...`, 'info');
const uploadEndpoint = `${API_URL}/customs/pedimentos/bulk-create/`;
const result = await postFormDataWithAuth(uploadEndpoint, formData);
showMessage(
`${result.uploaded_count || fileCount} archivo(s) subido(s) exitosamente`,
'success'
);
// Limpiar archivos seleccionados y cerrar modal
setSelectedFiles([]);
setSelectedContributor('');
setUploadProgress(0);
setCompressionProgress(0);
setValidationErrors([]);
setShowUploadModal(false);
// Refrescar la lista
refetch();
} catch (error) {
console.error('Error durante la subida:', error);
showMessage(`Error durante la subida: ${error.message}`, 'error');
} finally {
setIsUploading(false);
}
};
// Actualizar isSelectAll cuando cambia la selección // Actualizar isSelectAll cuando cambia la selección
useEffect(() => { useEffect(() => {
if (currentDocuments.length > 0) { if (currentDocuments.length > 0) {
@@ -550,6 +803,18 @@ export default function Documents() {
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button
onClick={() => {
setShowUploadModal(true);
fetchImportadores();
}}
className="inline-flex items-center px-4 py-2.5 border border-transparent text-sm font-medium rounded-xl text-white bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200 transform hover:scale-105 shadow-lg"
>
<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="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
Agregar Expedientes
</button>
<button <button
onClick={refetch} onClick={refetch}
disabled={loading} disabled={loading}
@@ -562,7 +827,7 @@ export default function Documents() {
</button> </button>
<button <button
onClick={() => {}} onClick={() => {}}
className="inline-flex items-center px-4 py-2.5 border border-transparent text-sm font-medium rounded-xl text-white bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200 transform hover:scale-105 shadow-lg" className="inline-flex items-center px-4 py-2.5 border border-transparent text-sm font-medium rounded-xl text-white bg-gradient-to-r from-purple-600 to-purple-700 hover:from-purple-700 hover:to-purple-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all duration-200 transform hover:scale-105 shadow-lg"
> >
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> <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" />
@@ -946,6 +1211,373 @@ export default function Documents() {
</div> </div>
</div> </div>
{/* Modal de subida de expedientes */}
{showUploadModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-2xl shadow-2xl max-w-lg w-full mx-4 transform transition-all duration-300 scale-100 max-h-[90vh] flex flex-col">
{/* Header del modal */}
<div className="px-6 py-4 border-b border-gray-200 flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="bg-blue-100 rounded-full p-3">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">Subir Expedientes</h3>
<p className="text-sm text-gray-600">Selecciona archivos, carpetas o ZIP</p>
</div>
</div>
<button
onClick={() => {
setShowUploadModal(false);
setSelectedFiles([]);
setUploadProgress(0);
setIsUploading(false);
setValidationErrors([]);
}}
className="text-gray-400 hover:text-gray-600 transition-colors duration-200"
>
<svg className="w-6 h-6" 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>
</div>
{/* Contenido del modal - con scroll */}
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
{/* Selector de tipo de subida */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Tipo de subida
</label>
<div className="grid grid-cols-3 gap-2">
<button
onClick={() => setUploadType('folders')}
className={`p-3 rounded-lg border text-sm font-medium transition-all duration-200 ${
uploadType === 'folders'
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
}`}
>
<svg className="w-5 h-5 mx-auto mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
Carpeta
</button>
<button
onClick={() => setUploadType('zip')}
className={`p-3 rounded-lg border text-sm font-medium transition-all duration-200 ${
uploadType === 'zip'
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
}`}
>
<svg className="w-5 h-5 mx-auto mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 12l2 2 4-4" />
</svg>
ZIP
</button>
<button
onClick={() => setUploadType('rar')}
className={`p-3 rounded-lg border text-sm font-medium transition-all duration-200 ${
uploadType === 'rar'
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
}`}
>
<svg className="w-5 h-5 mx-auto mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
RAR
</button>
</div>
</div>
{/* Área de selección de archivos */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
{uploadType === 'folders' && 'Seleccionar carpetas'}
{uploadType === 'zip' && 'Seleccionar archivos ZIP'}
{uploadType === 'rar' && 'Seleccionar archivos RAR'}
</label>
{uploadType === 'folders' ? (
<div className="space-y-3">
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors duration-200">
<svg className="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<p className="text-sm text-gray-600 mb-3">
Selecciona una carpeta. Después puedes hacer clic nuevamente para agregar más carpetas.
</p>
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelection}
multiple
webkitdirectory="true"
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 transition-colors duration-200"
>
<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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Agregar Carpeta
{selectedFiles.length > 0 && (
<span className="ml-2 bg-blue-500 text-white text-xs px-2 py-1 rounded-full">
{[...new Set(selectedFiles.map(file => file.webkitRelativePath.split('/')[0]))].length}
</span>
)}
</button>
</div>
{/* Lista de carpetas seleccionadas - con altura máxima y scroll */}
{selectedFiles.length > 0 && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Carpetas seleccionadas:</h4>
<div className="max-h-40 overflow-y-auto space-y-2">
{[...new Set(selectedFiles.map(file => file.webkitRelativePath.split('/')[0]))].map((folderName, index) => (
<div key={index} className="flex items-center justify-between bg-white p-3 rounded border">
<div className="flex items-center">
<svg className="w-4 h-4 text-blue-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="text-sm font-medium">{folderName}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">
{selectedFiles.filter(file => file.webkitRelativePath.startsWith(folderName + '/')).length} archivos
</span>
<button
onClick={() => removeFolderFromSelection(folderName)}
className="text-red-500 hover:text-red-700 p-1"
title="Eliminar carpeta"
>
<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>
</div>
))}
</div>
<button
onClick={() => setSelectedFiles([])}
className="mt-3 text-sm text-red-600 hover:text-red-800"
>
Limpiar todas las carpetas
</button>
</div>
)}
</div>
) : (
<div className="space-y-3">
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors duration-200">
<svg className="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-sm text-gray-600 mb-3">
{uploadType === 'zip' && 'Selecciona archivos ZIP. Puedes hacer clic nuevamente para agregar más archivos.'}
{uploadType === 'rar' && 'Selecciona archivos RAR. Puedes hacer clic nuevamente para agregar más archivos.'}
</p>
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelection}
accept={uploadType === 'zip' ? '.zip' : '.rar'}
multiple
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 transition-colors duration-200"
>
<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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Agregar {uploadType === 'zip' ? 'ZIP' : 'RAR'}
{selectedFiles.length > 0 && (
<span className="ml-2 bg-blue-500 text-white text-xs px-2 py-1 rounded-full">
{selectedFiles.length}
</span>
)}
</button>
</div>
{/* Lista de archivos seleccionados */}
{selectedFiles.length > 0 && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Archivos seleccionados:</h4>
<div className="max-h-40 overflow-y-auto space-y-2">
{selectedFiles.map((file, index) => (
<div key={index} className="flex items-center justify-between bg-white p-3 rounded border">
<div className="flex items-center">
<svg className="w-4 h-4 text-blue-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="text-sm font-medium">{file.name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">
{(file.size / 1024 / 1024).toFixed(2)} MB
</span>
<button
onClick={() => removeFileFromSelection(index)}
className="text-red-500 hover:text-red-700 p-1"
title="Eliminar archivo"
>
<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>
</div>
))}
</div>
<button
onClick={() => setSelectedFiles([])}
className="mt-3 text-sm text-red-600 hover:text-red-800"
>
Limpiar todos los archivos
</button>
</div>
)}
</div>
)}
</div>
{/* Selector de contribuyente */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Contribuyente
</label>
<select
value={selectedContributor}
onChange={(e) => setSelectedContributor(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={loadingContributors}
>
<option value="">Seleccionar contribuyente</option>
{importadores.map((imp) => (
<option key={imp.rfc} value={imp.rfc}>
{imp.rfc}
</option>
))}
</select>
{loadingContributors && (
<p className="text-sm text-gray-500 mt-1">Cargando contribuyentes...</p>
)}
</div>
{/* Errores de validación */}
{validationErrors.length > 0 && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-medium text-red-800">Errores de validación:</span>
</div>
<ul className="text-sm text-red-700 space-y-1">
{validationErrors.map((error, index) => (
<li key={index}> {error}</li>
))}
</ul>
</div>
)}
{/* Información de nomenclatura */}
{uploadType === 'folders' && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-blue-500" 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>
<span className="text-sm font-medium text-blue-800">Nomenclatura de carpetas:</span>
</div>
<p className="text-sm text-blue-700">
Las carpetas deben seguir el formato: <strong>[AÑO]-ADUANA-PATENTE-PEDIMENTO</strong>
<br />
AÑO: 2 dígitos (opcional, ej: 24-)
<br />
ADUANA: 2 o 3 dígitos (ej: 01, 001)
<br />
PATENTE: 4 dígitos (ej: 3206)
<br />
PEDIMENTO: 7 dígitos (ej: 1234567)
<br />
<strong>Ejemplos válidos:</strong> <em>24-01-3206-1234567</em>, <em>001-3206-1234567</em>
</p>
</div>
)}
{/* Barras de progreso */}
{isCompressing && (
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Comprimiendo carpetas...</span>
<span className="text-sm text-gray-500">{compressionProgress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-green-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${compressionProgress}%` }}
></div>
</div>
</div>
)}
{isUploading && (
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Subiendo...</span>
<span className="text-sm text-gray-500">{uploadProgress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
></div>
</div>
</div>
)}
</div>
{/* Footer del modal - fijo */}
<div className="flex-shrink-0 px-6 py-4 border-t border-gray-200 flex gap-3 justify-end">
<button
onClick={() => {
setShowUploadModal(false);
setSelectedFiles([]);
setUploadProgress(0);
setCompressionProgress(0);
setIsUploading(false);
setIsCompressing(false);
setValidationErrors([]);
}}
disabled={isUploading || isCompressing}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancelar
</button>
<button
onClick={handleUploadFiles}
disabled={isUploading || isCompressing || selectedFiles.length === 0 || !selectedContributor || validationErrors.length > 0}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isCompressing ? 'Comprimiendo...' : isUploading ? 'Subiendo...' : 'Subir expedientes'}
</button>
</div>
</div>
</div>
)}
{/* Modal de confirmación para eliminación */} {/* Modal de confirmación para eliminación */}
{showDeleteModal && ( {showDeleteModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">

View File

@@ -16,12 +16,14 @@ import xml from 'highlight.js/lib/languages/xml';
import 'highlight.js/styles/github.css'; 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 { fetchWithAuth, postWithAuth, putWithAuth } from '../fetchWithAuth'; import { fetchWithAuth, postWithAuth, putWithAuth, postFormDataWithAuth } from '../fetchWithAuth';
import { fetchProcesamientoPedimentos } 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 } from '../api/edocuments'; import { fetchPedimentoEdocuments, downloadEdocument, downloadAcuseEdocument } from '../api/edocuments';
import { getTaskStatusLabel, getTaskStatusColor, isTaskActionable, isTaskFinal } from '../api/taskStatus';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { useNotification } from '../context/NotificationContext'; import { useNotification } from '../context/NotificationContext';
import { downloadFile, downloadBulkZip } from '../utils/downloadUtils';
const API_URL = import.meta.env.VITE_EFC_API_URL; const API_URL = import.meta.env.VITE_EFC_API_URL;
const MICROSERVICE_URL = import.meta.env.VITE_EFC_MICROSERVICE_URL; const MICROSERVICE_URL = import.meta.env.VITE_EFC_MICROSERVICE_URL;
@@ -101,7 +103,7 @@ function formatXml(xml) {
return formatted.trim(); return formatted.trim();
} }
// Funci\u00f3n auxiliar para descargas individuales\nconst downloadFile = async (id, filename = 'archivo', showMessage) => {\n try {\n const res = await fetchWithAuth(`${API_URL}/record/documents/descargar/${id}/`);\n \n if (!res.ok) {\n showMessage('Error en la descarga del archivo', 'error');\n return;\n }\n \n const blob = await res.blob();\n const url = window.URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = filename;\n document.body.appendChild(a);\n a.click();\n a.remove();\n window.URL.revokeObjectURL(url);\n } catch (error) {\n console.error('Error downloading file:', error);\n if (error.message === 'SESSION_EXPIRED') {\n showMessage('Tu sesi\u00f3n ha expirado, por favor inicia sesi\u00f3n de nuevo.', 'error');\n } else {\n showMessage('Error al descargar el archivo', 'error');\n }\n }\n};\n\n// Funci\u00f3n auxiliar para descargas\nconst downloadBulkZip = async (ids, showMessage, pedimentoName) => {\n try {\n const response = await fetchWithAuth(`${API_URL}/record/documents/bulk-download/`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ document_ids: ids })\n });\n \n if (!response.ok) throw new Error('Error en la descarga');\n \n const blob = await response.blob();\n const url = window.URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = `documentos_${pedimentoName || 'pedimento'}.zip`;\n document.body.appendChild(a);\n a.click();\n window.URL.revokeObjectURL(url);\n document.body.removeChild(a);\n \n showMessage('Descarga iniciada exitosamente', 'success');\n } catch (error) {\n showMessage('Error en la descarga: ' + error.message, 'error');\n }\n};
export default function PedimentoDetail() { export default function PedimentoDetail() {
// Estados principales // Estados principales
@@ -129,6 +131,11 @@ export default function PedimentoDetail() {
const [selectedDocuments, setSelectedDocuments] = useState([]); const [selectedDocuments, setSelectedDocuments] = useState([]);
const [isSelectAllDocs, setIsSelectAllDocs] = useState(false); const [isSelectAllDocs, setIsSelectAllDocs] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showUploadModal, setShowUploadModal] = useState(false);
// Estados para subir documentos
const [selectedFiles, setSelectedFiles] = useState([]);
const [uploadingDocuments, setUploadingDocuments] = useState(false);
const [dashboardSummary, setDashboardSummary] = useState(null); const [dashboardSummary, setDashboardSummary] = useState(null);
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
@@ -370,12 +377,22 @@ export default function PedimentoDetail() {
const [procesosPage, setProcesosPage] = useState(1); const [procesosPage, setProcesosPage] = useState(1);
const [procesosPageSize, setProcesosPageSize] = useState(10); const [procesosPageSize, setProcesosPageSize] = useState(10);
const [procesosFilters, setProcesosFilters] = useState({}); const [procesosFilters, setProcesosFilters] = useState({});
const [sortField, setSortField] = useState('');
const [sortOrder, setSortOrder] = useState('asc');
const [selectedProcesos, setSelectedProcesos] = useState([]);
const [isSelectAll, setIsSelectAll] = useState(false);
// Estados para las acciones de procesos // Estados para las acciones de procesos
const [executingId, setExecutingId] = useState(null); const [executingId, setExecutingId] = useState(null);
const [changingStateId, setChangingStateId] = useState(null); const [changingStateId, setChangingStateId] = useState(null);
const [creatingService, setCreatingService] = useState(null); const [creatingService, setCreatingService] = useState(null);
// Ref para rastrear valores previos de filtros
const prevFiltersRef = useRef({
sortField: '',
sortOrder: 'asc'
});
// Estados para modal de preview // Estados para modal de preview
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
const [previewUrl, setPreviewUrl] = useState(''); const [previewUrl, setPreviewUrl] = useState('');
@@ -448,7 +465,7 @@ export default function PedimentoDetail() {
setSelected(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]); setSelected(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
}; };
const handleSelectAll = () => { const handleSelectAllDocs = () => {
if (allSelected) setSelected([]); if (allSelected) setSelected([]);
else setSelected(allDocIds); else setSelected(allDocIds);
}; };
@@ -661,6 +678,56 @@ export default function PedimentoDetail() {
} }
}; };
// Funciones para subir documentos
const handleFileSelect = (event) => {
const files = Array.from(event.target.files);
setSelectedFiles(files);
};
const handleUploadDocuments = async () => {
if (selectedFiles.length === 0) {
showMessage('Por favor selecciona al menos un archivo', 'warning');
return;
}
setUploadingDocuments(true);
try {
const formData = new FormData();
// Agregar el ID del pedimento
formData.append('pedimento_id', id);
// Agregar archivos al FormData
selectedFiles.forEach((file) => {
formData.append('files', file);
});
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'
);
// Limpiar archivos seleccionados y cerrar modal
setSelectedFiles([]);
setShowUploadModal(false);
// Forzar recarga de documentos
const currentPage = page;
setPage(0);
setTimeout(() => setPage(currentPage), 100);
} catch (error) {
console.error('Error durante la subida:', error);
showMessage(`Error durante la subida: ${error.message}`, 'error');
} finally {
setUploadingDocuments(false);
}
};
// Efecto para actualizar isSelectAllDocs cuando cambia la selección // Efecto para actualizar isSelectAllDocs cuando cambia la selección
useEffect(() => { useEffect(() => {
if (documents.length > 0) { if (documents.length > 0) {
@@ -824,10 +891,22 @@ export default function PedimentoDetail() {
}, [edocsFilters]); }, [edocsFilters]);
// Funciones para acciones de Procesos // Funciones para acciones de Procesos
// Map legacy estado values to new status values
const mapEstadoToStatus = (estado) => {
const mapping = {
1: 'pending', // Pendiente
2: 'processing', // En Proceso
3: 'completed', // Completado
4: 'failed', // Error
5: 'cancelled' // Cancelado
};
return mapping[estado] || 'pending';
};
const updateProcesoEstado = (procId, nuevoEstado) => { const updateProcesoEstado = (procId, nuevoEstado) => {
setProcesos(procesos => setProcesos(procesos =>
procesos.map(proc => procesos.map(proc =>
proc.id === procId ? { ...proc, estado: nuevoEstado } : proc proc.id === procId ? { ...proc, status: mapEstadoToStatus(nuevoEstado) } : proc
) )
); );
}; };
@@ -850,7 +929,7 @@ export default function PedimentoDetail() {
organizacion: pedimentoData?.organizacion || 1 // Usar la organización del pedimento o default organizacion: pedimentoData?.organizacion || 1 // Usar la organización del pedimento o default
}; };
const res = await postWithAuth(`${API_URL}/customs/procesamientopedimentos/`, body); const res = await postWithAuth(`${API_URL}/tasks/tasks/`, body);
if (!res.ok) { if (!res.ok) {
throw new Error(`Error al crear el servicio: ${nombreServicio}`); throw new Error(`Error al crear el servicio: ${nombreServicio}`);
@@ -871,7 +950,7 @@ export default function PedimentoDetail() {
...procesosFilters, ...procesosFilters,
pedimento: id pedimento: id
}; };
fetchProcesamientoPedimentos(procesosPage, procesosPageSize, filters) fetchTasks(procesosPage, procesosPageSize, filters)
.then((data) => { .then((data) => {
setProcesos(data.results); setProcesos(data.results);
setProcesosCount(data.count); setProcesosCount(data.count);
@@ -893,33 +972,33 @@ export default function PedimentoDetail() {
setChangingStateId(proc.id); setChangingStateId(proc.id);
// Cambiar estado visual a "Procesando" inmediatamente // Cambiar estado visual a "Procesando" inmediatamente
updateProcesoEstado(proc.id, 2); // 2 = Procesando updateProcesoEstado(proc.id, 'processing');
try { try {
const body = { const body = {
estado: 1, // Cambiar a En Espera status: 'pending', // Cambiar a Pendiente
tipo_procesamiento: 2, tipo_procesamiento: 2,
pedimento: typeof proc.pedimento === 'object' && proc.pedimento !== null ? proc.pedimento.id : proc.pedimento, pedimento: typeof proc.pedimento === 'object' && proc.pedimento !== null ? proc.pedimento.id : proc.pedimento,
servicio: proc.servicio, servicio: proc.servicio,
organizacion: proc.organizacion_id || proc.organizacion || proc.organizacionId organizacion: proc.organizacion_id || proc.organizacion || proc.organizacionId
}; };
const res = await putWithAuth(`${API_URL}/customs/procesamientopedimentos/${proc.id}/`, body); const res = await putWithAuth(`${API_URL}/tasks/tasks/${proc.task_id}/`, body);
if (!res.ok) { if (!res.ok) {
// Si falla, revertir a estado Error // Si falla, cambiar a estado Error
updateProcesoEstado(proc.id, 4); // 4 = Error updateProcesoEstado(proc.id, 'failed'); // Failed
throw new Error('Error al cambiar el estado del proceso'); throw new Error('Error al cambiar el estado del proceso');
} }
// Cambiar estado visual a "En Espera" si fue exitoso // Cambiar estado visual a "En Espera" si fue exitoso
updateProcesoEstado(proc.id, 1); // 1 = En Espera updateProcesoEstado(proc.id, 'pending');
showMessage('Estado cambiado a "En Espera" correctamente', 'success'); showMessage('Estado cambiado a "En Espera" correctamente', 'success');
} catch (err) { } catch (err) {
console.error('Error cambiando estado:', err); console.error('Error cambiando estado:', err);
updateProcesoEstado(proc.id, 4); // 4 = Error updateProcesoEstado(proc.id, 'failed');
showMessage('Error al cambiar el estado del proceso: ' + err.message, 'error'); showMessage('Error al cambiar el estado del proceso: ' + err.message, 'error');
} finally { } finally {
setChangingStateId(null); setChangingStateId(null);
@@ -930,7 +1009,7 @@ export default function PedimentoDetail() {
setExecutingId(proc.id); setExecutingId(proc.id);
// Cambiar estado visual a "Procesando" inmediatamente // Cambiar estado visual a "Procesando" inmediatamente
updateProcesoEstado(proc.id, 2); // 2 = Procesando updateProcesoEstado(proc.id, 'processing');
let endpoint = ''; let endpoint = '';
// Determinar endpoint según el tipo de servicio // Determinar endpoint según el tipo de servicio
@@ -958,7 +1037,7 @@ export default function PedimentoDetail() {
break; break;
default: default:
// Revertir estado si el servicio no es soportado // Revertir estado si el servicio no es soportado
updateProcesoEstado(proc.id, proc.estado); // Revertir al estado original updateProcesoEstado(proc.id, proc.status); // Revertir al estado original
showMessage('Este servicio no es compatible para ejecución directa.', 'error'); showMessage('Este servicio no es compatible para ejecución directa.', 'error');
setExecutingId(null); setExecutingId(null);
return; return;
@@ -974,17 +1053,17 @@ export default function PedimentoDetail() {
if (!res.ok) { if (!res.ok) {
// Si falla, cambiar estado a Error // Si falla, cambiar estado a Error
updateProcesoEstado(proc.id, 4); // 4 = Error updateProcesoEstado(proc.id, 'failed');
throw new Error('Error al ejecutar el servicio'); throw new Error('Error al ejecutar el servicio');
} }
// Si es exitoso, cambiar estado a Finalizado // Si es exitoso, cambiar estado a Finalizado
updateProcesoEstado(proc.id, 3); // 3 = Finalizado updateProcesoEstado(proc.id, 'completed');
showMessage('El servicio se ha ejecutado correctamente', 'success'); showMessage('El servicio se ha ejecutado correctamente', 'success');
} catch (err) { } catch (err) {
// Cambiar estado a Error en caso de excepción // Cambiar estado a Error en caso de excepción
updateProcesoEstado(proc.id, 4); // 4 = Error updateProcesoEstado(proc.id, 'failed');
if (err.message === 'SESSION_EXPIRED') { if (err.message === 'SESSION_EXPIRED') {
showMessage('Tu sesión ha expirado. Por favor, inicia sesión nuevamente.', 'error'); showMessage('Tu sesión ha expirado. Por favor, inicia sesión nuevamente.', 'error');
@@ -1258,6 +1337,57 @@ export default function PedimentoDetail() {
} }
}; };
// Funciones de manejo de procesos
// Removed duplicate definitions
// Handle select all process checkboxes
const handleSelectAllProcesos = (e) => {
if (e.target.checked) {
const availableProcesos = procesos
.filter(proc => proc.status !== 'running' && proc.status !== 'completed')
.map(proc => proc.task_id);
setSelectedProcesos(availableProcesos);
setIsSelectAll(true);
} else {
setSelectedProcesos([]);
setIsSelectAll(false);
}
};
// Handle select individual process checkbox
const handleSelectProceso = (id, checked) => {
if (checked) {
setSelectedProcesos(prev => [...prev, id]);
} else {
setSelectedProcesos(prev => prev.filter(i => i !== id));
}
// Update isSelectAll
const availableProcesos = procesos
.filter(proc => proc.status !== 'running' && proc.status !== 'completed')
.map(proc => proc.task_id);
setIsSelectAll(availableProcesos.length === selectedProcesos.length + (checked ? 1 : -1));
};
const handlePasarPaginaAEspera = async () => {
const failedProcesses = procesos.filter(proc => proc.status === 'failed');
if (failedProcesses.length === 0) return;
for (const proceso of failedProcesses) {
await handlePasarAEspera(proceso);
}
};
const handlePasarSeleccionadosAEspera = async () => {
const failedSelectedProcesses = procesos.filter(
proc => selectedProcesos.includes(proc.task_id) && proc.status === 'failed'
);
if (failedSelectedProcesses.length === 0) return;
for (const proceso of failedSelectedProcesses) {
await handlePasarAEspera(proceso);
}
};
// Función para verificar servicios creados // Función para verificar servicios creados
const handleVerificarServiciosCreados = async () => { const handleVerificarServiciosCreados = async () => {
try { try {
@@ -1348,16 +1478,47 @@ export default function PedimentoDetail() {
useEffect(() => { useEffect(() => {
if (!id || activeTab !== 'procesos') return; if (!id || activeTab !== 'procesos') return;
// Detectar si algún filtro cambió
const currentFilters = {
sortField,
sortOrder
};
const filtersChanged = Object.keys(currentFilters).some(
key => currentFilters[key] !== prevFiltersRef.current[key]
);
// Si los filtros cambiaron y no estamos en la página 1, resetear página
if (filtersChanged && procesosPage !== 1) {
setProcesosPage(1);
// Actualizar ref con valores actuales
prevFiltersRef.current = { ...currentFilters };
return; // Salir temprano, el efecto se ejecutará de nuevo con page = 1
}
// Actualizar ref con valores actuales
prevFiltersRef.current = { ...currentFilters };
setProcesosLoading(true); setProcesosLoading(true);
setProcesosError(''); setProcesosError('');
// Crear filtros incluyendo el pedimento // Construir filtros
const filters = { const filters = {
...procesosFilters, ...procesosFilters,
pedimento: id // Filtrar por el pedimento actual pedimento: id // Filtrar por el pedimento actual
}; };
fetchProcesamientoPedimentos(procesosPage, procesosPageSize, filters) if (sortField) {
const fieldMapping = {
'id': 'task_id',
'estado': 'status',
'pedimento': 'pedimento_app'
};
const mappedField = fieldMapping[sortField] || sortField;
filters['ordering'] = (sortOrder === 'desc' ? '-' : '') + mappedField;
}
fetchTasks(procesosPage, procesosPageSize, filters)
.then((data) => { .then((data) => {
setProcesos(data.results); setProcesos(data.results);
setProcesosCount(data.count); setProcesosCount(data.count);
@@ -1372,7 +1533,7 @@ export default function PedimentoDetail() {
} }
setProcesosLoading(false); setProcesosLoading(false);
}); });
}, [id, activeTab, procesosPage, procesosPageSize, showMessage]); }, [id, activeTab, procesosPage, procesosPageSize, sortField, sortOrder, procesosFilters, showMessage]);
// Resetear página de Procesos cuando cambie el pedimento // Resetear página de Procesos cuando cambie el pedimento
useEffect(() => { useEffect(() => {
@@ -2027,8 +2188,20 @@ export default function PedimentoDetail() {
</span> </span>
</div> </div>
{documents.length > 0 && (
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3"> <div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
<button
onClick={() => setShowUploadModal(true)}
className="inline-flex items-center justify-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
<svg className="w-4 h-4 mr-1 sm:mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<span className="hidden sm:inline">Subir Documentos</span>
<span className="sm:hidden">Subir</span>
</button>
{documents.length > 0 && (
<>
<button <button
onClick={downloadAll} onClick={downloadAll}
disabled={downloadingAll} disabled={downloadingAll}
@@ -2064,9 +2237,10 @@ export default function PedimentoDetail() {
<span className="hidden sm:inline">{showFilters ? 'Ocultar Filtros' : 'Mostrar Filtros'}</span> <span className="hidden sm:inline">{showFilters ? 'Ocultar Filtros' : 'Mostrar Filtros'}</span>
<span className="sm:hidden">Filtros</span> <span className="sm:hidden">Filtros</span>
</button> </button>
</div> </>
)} )}
</div> </div>
</div>
{/* Filtros expandibles */} {/* Filtros expandibles */}
{showFilters && ( {showFilters && (
@@ -3610,7 +3784,18 @@ export default function PedimentoDetail() {
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{procesos.map((proceso, index) => ( {procesos.map((proceso, index) => (
<tr key={`${proceso.id}-${index}`} className="hover:bg-gray-50"> <tr key={`${proceso.task_id}-${index}`} className={`hover:bg-gray-50 ${selectedProcesos.includes(proceso.task_id) ? 'bg-blue-50' : ''}`}>
<td className="px-3 py-4">
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={selectedProcesos.includes(proceso.task_id)}
onChange={(e) => handleSelectProceso(proceso.task_id, e.target.checked)}
disabled={proceso.status === 'running' || proceso.status === 'completed'}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded disabled:opacity-50"
/>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10"> <div className="flex-shrink-0 h-10 w-10">
@@ -3622,7 +3807,10 @@ export default function PedimentoDetail() {
</div> </div>
<div className="ml-4"> <div className="ml-4">
<div className="text-sm font-medium text-gray-900"> <div className="text-sm font-medium text-gray-900">
#{proceso.id} #{proceso.task_id}
</div>
<div className="text-sm text-gray-500">
{proceso.pedimento_app}
</div> </div>
</div> </div>
</div> </div>
@@ -3633,9 +3821,17 @@ export default function PedimentoDetail() {
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getEstadoColor(proceso.estado)}`}> <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getTaskStatusColor(proceso.status)}`}>
{getEstadoLabel(proceso.estado)} {getTaskStatusLabel(proceso.status)}
</span> </span>
{proceso.status === 'running' && (
<span className="ml-2 inline-flex items-center">
<svg className="animate-spin h-4 w-4 text-blue-600" 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>
</span>
)}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{proceso.organizacion_name || 'N/A'} {proceso.organizacion_name || 'N/A'}
@@ -3649,81 +3845,58 @@ export default function PedimentoDetail() {
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{/* Botón Play (Ejecutar Servicio) */} {/* Botón Play (Ejecutar Servicio) */}
{isTaskActionable(proceso.status) && (
<button <button
onClick={() => handleEjecutarServicio(proceso)} onClick={() => handleEjecutarProceso(proceso)}
disabled={ disabled={executingId === proceso.task_id || proceso.status === 'running'}
executingId === proceso.id || className={`group inline-flex items-center justify-center w-8 h-8 rounded-lg transition-all duration-200 ${
proceso.estado === 2 || // En Proceso executingId === proceso.task_id || proceso.status === 'running'
proceso.estado === 3 || // Completado ? 'bg-gray-100 text-gray-400 cursor-not-allowed'
proceso.estado === 5 // Cancelado : 'text-green-700 bg-green-100 hover:bg-green-200 hover:text-green-800'
}
className={`inline-flex items-center p-2 border border-transparent rounded-md transition-colors duration-200 ${
executingId === proceso.id ||
proceso.estado === 2 ||
proceso.estado === 3 ||
proceso.estado === 5
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-green-100 text-green-700 hover:bg-green-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500'
}`} }`}
title={ title={
executingId === proceso.id ? 'Ejecutando...' : executingId === proceso.task_id ? 'Ejecutando...' :
proceso.estado === 2 ? 'No disponible - En proceso' : proceso.status === 'running' ? 'En proceso...' :
proceso.estado === 3 ? 'No disponible - Completado' :
proceso.estado === 5 ? 'No disponible - Cancelado' :
'Ejecutar servicio' 'Ejecutar servicio'
} }
> >
{executingId === proceso.id ? ( {executingId === proceso.task_id ? (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"> <svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> <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> <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>
) : ( ) : (
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4 transition-transform group-hover:scale-110" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/> <path d="M8 5v14l11-7z"/>
</svg> </svg>
)} )}
</button> </button>
)}
{/* Botón Refresh (Pasar a Espera) */} {/* Botón Retry (Reintentar) */}
{proceso.status === 'failed' && (
<button <button
onClick={() => handlePasarAEspera(proceso)} onClick={() => handlePasarAEspera(proceso)}
disabled={ disabled={changingStateId === proceso.task_id}
changingStateId === proceso.id || className={`group inline-flex items-center justify-center w-8 h-8 rounded-lg transition-all duration-200 ${
proceso.estado === 1 || // Pendiente changingStateId === proceso.task_id
proceso.estado === 2 || // En Proceso ? 'bg-gray-100 text-gray-400 cursor-not-allowed'
proceso.estado === 3 || // Completado : 'text-yellow-700 bg-yellow-100 hover:bg-yellow-200 hover:text-yellow-800'
proceso.estado === 5 // Cancelado
}
className={`inline-flex items-center p-2 border border-transparent rounded-md transition-colors duration-200 ${
changingStateId === proceso.id ||
proceso.estado === 1 ||
proceso.estado === 2 ||
proceso.estado === 3 ||
proceso.estado === 5
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500'
}`} }`}
title={ title={changingStateId === proceso.task_id ? 'Reiniciando...' : 'Reintentar proceso'}
changingStateId === proceso.id ? 'Cambiando estado...' :
proceso.estado === 1 ? 'No disponible - Use Play para ejecutar' :
proceso.estado === 2 ? 'No disponible - En proceso' :
proceso.estado === 3 ? 'No disponible - Completado' :
proceso.estado === 5 ? 'No disponible - Cancelado' :
'Pasar a espera'
}
> >
{changingStateId === proceso.id ? ( {changingStateId === proceso.task_id ? (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"> <svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> <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> <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>
) : ( ) : (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4 transition-transform group-hover:scale-110" 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" /> <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>
)} )}
</button> </button>
)}
</div> </div>
</td> </td>
</tr> </tr>
@@ -4622,6 +4795,109 @@ export default function PedimentoDetail() {
</div> </div>
)} )}
{/* Modal para subir documentos */}
{showUploadModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-2xl shadow-2xl max-w-lg w-full mx-4 transform transition-all duration-300 scale-100">
{/* Header del modal */}
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center gap-3">
<div className="bg-green-100 rounded-full p-3">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">
Subir Documentos
</h3>
<p className="text-sm text-gray-600">Selecciona los archivos a subir</p>
</div>
</div>
</div>
{/* Contenido del modal */}
<div className="px-6 py-4">
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Seleccionar archivos
</label>
<input
type="file"
multiple
onChange={handleFileSelect}
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
{selectedFiles.length > 0 && (
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
<p className="text-sm font-medium text-gray-700 mb-2">
Archivos seleccionados ({selectedFiles.length}):
</p>
<div className="max-h-32 overflow-y-auto">
{selectedFiles.map((file, index) => (
<div key={index} className="text-xs text-gray-600 py-1 border-b border-gray-200 last:border-b-0">
{file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)
</div>
))}
</div>
</div>
)}
<div className="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" 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>
<p className="text-sm font-medium text-blue-800">Información</p>
<p className="text-sm text-blue-700 mt-1">
Los archivos se subirán al pedimento actual. Se aceptan múltiples formatos de archivo.
</p>
</div>
</div>
</div>
</div>
</div>
{/* Botones del modal */}
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
<button
onClick={() => {
setShowUploadModal(false);
setSelectedFiles([]);
}}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-200"
>
Cancelar
</button>
<button
onClick={handleUploadDocuments}
disabled={selectedFiles.length === 0 || uploadingDocuments}
className="px-4 py-2 text-sm font-medium text-white bg-green-600 border border-transparent rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-colors duration-200 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{uploadingDocuments ? (
<>
<svg className="animate-spin w-4 h-4" 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>
Subiendo...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
Subir {selectedFiles.length} archivo{selectedFiles.length !== 1 ? 's' : ''}
</>
)}
</button>
</div>
</div>
</div>
)}
{/* Modal de confirmación para eliminación */} {/* Modal de confirmación para eliminación */}
{showDeleteModal && ( {showDeleteModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
import { fetchWithAuth } from '../fetchWithAuth';
const API_URL = import.meta.env.VITE_EFC_API_URL;
/**
* Función auxiliar para descargas individuales
* @param {string} id - ID del documento
* @param {string} filename - Nombre del archivo (opcional)
* @param {function} showMessage - Función para mostrar mensajes
*/
export const downloadFile = async (id, filename = 'archivo', showMessage) => {
try {
const res = await fetchWithAuth(`${API_URL}/record/documents/descargar/${id}/`);
if (!res.ok) {
showMessage('Error en la descarga del archivo', 'error');
return;
}
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Error downloading file:', error);
if (error.message === 'SESSION_EXPIRED') {
showMessage('Tu sesión ha expirado, por favor inicia sesión de nuevo.', 'error');
} else {
showMessage('Error al descargar el archivo', 'error');
}
}
};
/**
* Función auxiliar para descargas masivas en ZIP
* @param {array} ids - Array de IDs de documentos
* @param {function} showMessage - Función para mostrar mensajes
* @param {string} pedimentoName - Nombre del pedimento para el archivo ZIP (opcional)
*/
export const downloadBulkZip = async (ids, showMessage, pedimentoName) => {
try {
const response = await fetchWithAuth(`${API_URL}/record/documents/bulk-download/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ document_ids: ids })
});
if (!response.ok) throw new Error('Error en la descarga');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `documentos_${pedimentoName || 'pedimento'}.zip`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showMessage('Descarga iniciada exitosamente', 'success');
} catch (error) {
showMessage('Error en la descarga: ' + error.message, 'error');
}
};