18 Commits

Author SHA1 Message Date
Dulce
6acb55c563 retirar debug mode 2025-11-25 16:25:57 -07:00
Dulce
5387eb25cf reportes update 2025-11-25 13:25:29 -07:00
06c5d32ae0 Merge pull request 'prod' (#7) from repoCumplimiento into main
Reviewed-on: #7
2025-10-22 03:41:10 +00:00
fd4fe5dc2b prod 2025-10-21 21:40:33 -06:00
0c4a48a60b Merge pull request 'repoCumplimiento' (#6) from repoCumplimiento into main
Reviewed-on: #6
2025-10-22 03:35:28 +00:00
f845629b81 Cumplimiento a prod 2025-10-21 21:35:03 -06:00
5e50d6bac0 Reporte de cumplimiento 2025-10-21 21:32:37 -06:00
539954eb41 Merge pull request 'Cambios en admin' (#5) from admin into main
Reviewed-on: #5
2025-10-16 01:42:15 +00:00
e69fca99c0 Cambios en admin 2025-10-15 19:41:21 -06:00
63ab45856f Merge pull request 'feat: Implementar subida avanzada de expedientes con compresión automática' (#4) from feature/document-upload-updated into main
Reviewed-on: #4
2025-10-15 14:21:16 +00:00
e371af3706 Merge pull request 'Modificaciones a pedimento Detail' (#3) from PedimentoDetail into main
Reviewed-on: #3
2025-10-15 00:07:47 +00:00
3f640307f8 Modificaciones a pedimento Detail 2025-10-14 18:05:02 -06:00
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
13 changed files with 3386 additions and 2271 deletions

95
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@tanstack/react-query": "^5.62.7",
"chart.js": "^4.5.0",
"highlight.js": "^11.11.1",
"jszip": "^3.10.1",
"react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0",
@@ -1706,6 +1707,12 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"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": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2420,6 +2427,12 @@
"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": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -2429,6 +2442,12 @@
"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": {
"version": "2.1.0",
"dev": true,
@@ -2488,6 +2507,12 @@
"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": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
@@ -2531,6 +2556,27 @@
"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": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -2703,6 +2749,12 @@
"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": {
"version": "1.0.7",
"license": "MIT"
@@ -2919,6 +2971,12 @@
"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": {
"version": "19.1.1",
"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"
}
},
"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": {
"version": "3.6.0",
"dev": true,
@@ -3069,6 +3142,12 @@
"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": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
@@ -3082,11 +3161,26 @@
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
"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": {
"name": "string-width",
"version": "4.2.3",
@@ -3727,7 +3821,6 @@
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {

View File

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

View File

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

@@ -312,72 +312,6 @@ export default function Admin() {
</div>
</div>
</div>
{/* Análisis de actividad de usuario */}
{!(typeof window !== 'undefined' && localStorage.getItem('user_is_importador') === 'true') && !isGroup35 && (
<div className="bg-white rounded-3xl shadow-2xl border border-gray-100 p-4 sm:p-6 mb-6 sm:mb-8 animate-fadein-slideup opacity-0 relative overflow-hidden"
style={{
animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.65s forwards',
}}
>
<div className="absolute inset-0 bg-gradient-to-br from-indigo-500/3 to-purple-500/3"></div>
<div className="relative z-10">
<div className="flex items-center gap-3 mb-4 sm:mb-6">
<div className="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-full p-3 shadow-lg">
<svg className="h-6 w-6 sm:h-7 sm:w-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 8v8m-4-5v5m-4-2v2m-2 4h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-xl sm:text-2xl font-bold text-gray-900">Actividad de Usuarios</h3>
</div>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
<span className="ml-3 text-gray-500">Cargando...</span>
</div>
) : error ? (
<div className="text-red-600 bg-red-50 p-4 rounded-xl border border-red-200">{error}</div>
) : userActivity ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 sm:gap-8">
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-2xl p-4 sm:p-6 border border-blue-100">
<h4 className="font-bold text-gray-800 mb-4 flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
Resumen de acciones
</h4>
<div className="space-y-3">
{Object.entries(userActivity.actions_count).map(([action, count]) => (
<div key={action} className="flex justify-between items-center bg-white rounded-xl p-3 shadow-sm border border-blue-100">
<span className="capitalize text-gray-700 font-medium">{action}</span>
<span className="font-mono text-blue-700 bg-blue-100 px-2 py-1 rounded-lg text-sm font-bold">{count}</span>
</div>
))}
<div className="flex justify-between items-center bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-xl p-3 shadow-lg font-semibold">
<span>Total actividades</span>
<span className="font-mono bg-white/20 px-2 py-1 rounded-lg">{userActivity.actividades_filtradas}</span>
</div>
</div>
</div>
<div className="bg-gradient-to-br from-green-50 to-emerald-50 rounded-2xl p-4 sm:p-6 border border-green-100">
<h4 className="font-bold text-gray-800 mb-4 flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
Top usuarios
</h4>
<div className="space-y-3">
{userActivity.top_users.map((user, idx) => (
<div key={user.username} className="flex justify-between items-center bg-white rounded-xl p-3 shadow-sm border border-green-100">
<div className="flex items-center gap-3">
<span className="bg-green-100 text-green-800 rounded-full w-6 h-6 flex items-center justify-center text-xs font-bold">{idx + 1}</span>
<span className="text-gray-700 font-medium">{user.username}</span>
</div>
<span className="font-mono text-green-700 bg-green-100 px-2 py-1 rounded-lg text-sm font-bold">{user.activity_count}</span>
</div>
))}
</div>
</div>
</div>
) : null}
</div>
</div>
)}
{/* Tabla de últimos documentos */}
<div className="bg-white rounded-3xl shadow-2xl border border-gray-100 p-4 sm:p-6 mb-6 sm:mb-8 animate-fadein-slideup opacity-0 relative overflow-hidden"

View File

@@ -1,4 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { getWithAuth, postWithAuth } from '../fetchWithAuth';
const API_URL = import.meta.env.VITE_EFC_API_URL;
@@ -15,13 +17,92 @@ function Auditor() {
const [auditandoRemesas, setAuditandoRemesas] = useState(false);
const [procesandoPedimento, setProcesandoPedimento] = 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) => {
if (procesandoRemesa) return;
try {
setProcesandoRemesa(pedimentoId);
const response = await postWithAuth(`${API_URL}/customs/auditor/auditar-procesamiento-remesa/pedimento/`, {
pedimento_id: pedimentoId
});
@@ -43,12 +124,12 @@ function Auditor() {
const handleAuditarRemesas = async () => {
if (auditandoRemesas) return;
try {
setAuditandoRemesas(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');
}
@@ -74,10 +155,10 @@ function Auditor() {
const handleAuditarPartidasPedimento = async (pedimentoId) => {
if (procesandoPedimento) return;
try {
setProcesandoPedimento(pedimentoId);
const response = await postWithAuth(`${API_URL}/customs/auditor/crear-partidas/pedimento/`, {
pedimento_id: pedimentoId
});
@@ -99,12 +180,12 @@ function Auditor() {
const handleAuditarPartidas = async () => {
if (auditandoPartidas) return;
try {
setAuditandoPartidas(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');
}
@@ -130,12 +211,12 @@ function Auditor() {
const handleAuditarTodos = async () => {
if (auditando) return;
try {
setAuditando(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');
}
@@ -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(() => {
const fetchPedimentos = async () => {
setLoading(true);
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');
const data = await response.json();
setPedimentos(data.results);
@@ -174,14 +298,20 @@ function Auditor() {
setLoading(false);
}
};
fetchPedimentos();
}, [page, itemsPerPage]);
// Aplicar debounce al fetchPedimentos
const timeoutId = setTimeout(() => {
fetchPedimentos();
}, 300);
return () => clearTimeout(timeoutId);
}, [page, itemsPerPage, pedimentoFilter]);
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="max-w-7xl mx-auto">
{/* Header mejorado y responsivo */}
<div className="mb-6 sm:mb-8 relative overflow-hidden rounded-3xl shadow-2xl bg-gradient-to-r from-blue-600 via-blue-700 to-blue-800 p-6 sm:p-8 flex items-center gap-4 sm:gap-6 animate-fadein-slideup opacity-0"
<div className="mb-6 sm:mb-8 relative overflow-hidden rounded-3xl shadow-2xl bg-gradient-to-r from-blue-600 via-blue-700 to-blue-800 p-6 sm:p-8 flex items-center gap-4 sm:gap-6 animate-fadein-slideup opacity-0"
style={{ animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.05s forwards' }}>
<div className="flex-shrink-0 bg-white/20 backdrop-blur-sm rounded-full p-3 sm:p-4 shadow-lg animate-bounce-slow">
<svg className="h-8 w-8 sm:h-10 sm:w-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -223,9 +353,9 @@ function Auditor() {
</div>
{/* Contenido principal */}
<div className="bg-white rounded-3xl shadow-2xl border border-gray-100 p-4 sm:p-6 lg:p-8 animate-fadein-slideup opacity-0"
<div className="bg-white rounded-3xl shadow-2xl border border-gray-100 p-4 sm:p-6 lg:p-8 animate-fadein-slideup opacity-0"
style={{ animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.15s forwards' }}>
{loading ? (
<div className="flex flex-col items-center justify-center py-12">
<div className="relative">
@@ -247,6 +377,7 @@ function Auditor() {
) : (
<div className="space-y-6">
<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">
<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">
@@ -254,8 +385,91 @@ function Auditor() {
</svg>
Servicios de Auditoría
</h3>
<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
onClick={handleAuditarTodos}
disabled={auditando || auditandoPartidas || pedimentos.length === 0}
@@ -342,6 +556,39 @@ function Auditor() {
</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 */}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-300">
@@ -384,23 +631,23 @@ function Auditor() {
{pedimentos.map((pedimento) => (
<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">
{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>
{/* PC - Pedimento Completo */}
<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">
<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>
</button>
</td>
{/* RM - Remesas */}
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 text-center">
<button
<button
onClick={() => handleAuditarRemesaPedimento(pedimento.id)}
disabled={procesandoRemesa === pedimento.id}
className={`inline-flex items-center justify-center w-8 h-8 rounded-full bg-white border border-gray-200
${procesandoRemesa === pedimento.id
${procesandoRemesa === 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'
}
@@ -413,18 +660,18 @@ function Auditor() {
</svg>
) : (
<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>
)}
</button>
</td>
{/* PT - Partidas */}
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 text-center">
<button
<button
onClick={() => handleAuditarPartidasPedimento(pedimento.id)}
disabled={procesandoPedimento === pedimento.id}
className={`inline-flex items-center justify-center w-8 h-8 rounded-full bg-white border border-gray-200
${procesandoPedimento === pedimento.id
${procesandoPedimento === 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'
}
@@ -437,32 +684,60 @@ function Auditor() {
</svg>
) : (
<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>
)}
</button>
</td>
{/* AC - Acuse */}
<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">
<svg className="h-4 w-4 text-green-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
<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">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
</td>
{/* COVE */}
<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">
<svg className="h-4 w-4 text-green-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
<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">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
</td>
{/* AC_COVE */}
<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">
<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>
</button>
</td>
@@ -470,7 +745,7 @@ function Auditor() {
<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">
<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>
</button>
</td>

View File

@@ -13,68 +13,11 @@ import { fetchPedimentoDocuments } from '../api/documentos.ts';
import { useNotification } from '../context/NotificationContext';
// import { usePolling } from '../hooks/usePolling';
import { Link } from 'react-router-dom';
import { downloadFile, downloadBulkZip } from '../utils/downloadUtils';
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() {
const focusKeeperRef = useRef(null);

View File

@@ -1,5 +1,6 @@
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
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')) {
@@ -42,6 +43,7 @@ const downloadFile = async (id, filename = 'archivo', setSuccess, setError, show
export default function Documents() {
const focusKeeperRef = useRef(null);
const fileInputRef = useRef(null);
const [success, setSuccess] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
@@ -66,6 +68,20 @@ export default function Documents() {
// Estado para modal de confirmación de eliminación
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
const [showAnimation, setShowAnimation] = 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
useEffect(() => {
if (currentDocuments.length > 0) {
@@ -550,6 +803,18 @@ export default function Documents() {
)}
</div>
<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
onClick={refetch}
disabled={loading}
@@ -562,7 +827,7 @@ export default function Documents() {
</button>
<button
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">
<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>
{/* 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 */}
{showDeleteModal && (
<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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react';
import fetchWithAuth from '../fetchWithAuth';
const initialFilters = {
pedimento_app: '',
aduana: '',
@@ -12,11 +11,48 @@ const initialFilters = {
fecha_pago_lte: '',
contribuyente__rfc: '',
};
export default function TableroAlmacenamiento() {
const [filters, setFilters] = useState(initialFilters);
const [summary, setSummary] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [reports, setReports] = useState([]);
const handleGenerateReport = async () => {
try {
const params = Object.entries(filters)
.filter(([_, v]) => v)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&');
const url = `${import.meta.env.VITE_EFC_API_URL}/reports/table-summary/${params ? `?${params}` : ''}`;
const res = await fetchWithAuth(url, { method: 'POST' });
if (!res.ok) throw new Error('Error al generar el reporte');
alert('Reporte solicitado correctamente. Aparecerá en el historial cuando esté listo.');
} catch (err) {
alert('No se pudo generar el reporte.');
}
};
const handleDownloadReport = async (reportId) => {
try {
const url = `${import.meta.env.VITE_EFC_API_URL}/reports/report-document-download/${reportId}/`;
const res = await fetchWithAuth(url);
if (!res.ok) throw new Error('Error al descargar el reporte');
const blob = await res.blob();
let filename = `reporte_${reportId}.csv`;
const disposition = res.headers.get('Content-Disposition');
if (disposition && disposition.includes('filename=')) {
filename = disposition.split('filename=')[1].replace(/"/g, '').trim();
}
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (err) {
alert('No se pudo descargar el reporte.');
}
};
// Fetch summary data
const fetchSummary = async () => {
@@ -36,27 +72,31 @@ export default function TableroAlmacenamiento() {
setIsLoading(false);
};
// Fetch initial data
// Fetch report list from API
useEffect(() => {
const fetchReports = async () => {
try {
const url = `${import.meta.env.VITE_EFC_API_URL}/reports/report-document-list/`;
const res = await fetchWithAuth(url);
if (!res.ok) throw new Error('Error al obtener el historial de reportes');
const data = await res.json();
setReports(data);
} catch (err) {
setReports([]);
}
};
fetchReports();
}, []);
useEffect(() => {
fetchSummary();
}, []);
// Handle filter changes
const handleFilterChange = (e) => {
setFilters({ ...filters, [e.target.name]: e.target.value });
};
// Card components for different sizes
const Card = ({ title, children, icon, small }) => (
<div className={`bg-white rounded-lg shadow-sm border border-slate-200 p-4 flex flex-col w-full ${small ? 'min-h-[120px]' : 'min-h-[200px]'}`}>
<div className="flex items-center gap-2 mb-2">
{icon && <span className={`${small ? 'text-slate-600' : 'text-blue-600'}`}>{icon}</span>}
<span className={`text-sm font-semibold ${small ? 'text-slate-600' : 'text-slate-700'}`}>{title}</span>
</div>
<div className="flex-1">{children}</div>
</div>
);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100">
{/* Header */}
@@ -97,12 +137,21 @@ export default function TableroAlmacenamiento() {
))}
</div>
<div className="flex justify-end">
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Aplicar Filtros
</button>
<div className="flex gap-2">
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Aplicar Filtros
</button>
<button
type="button"
className="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition-colors"
onClick={() => alert('Generar reporte (implementación pendiente)')}
>
Generar Reporte
</button>
</div>
</div>
</form>
</div>
@@ -115,203 +164,60 @@ export default function TableroAlmacenamiento() {
<span className="text-slate-600">Cargando resumen...</span>
</div>
) : summary ? (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
{/* Pedimentos */}
<Card
title="Pedimentos"
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 17l4 4 4-4m-4-5v9" /></svg>}
>
<div className="text-2xl font-bold text-blue-700">{summary.pedimentos?.total ?? '-'}</div>
<div className="grid grid-cols-2 gap-2 mt-2 text-xs">
<div>
<span className="block text-slate-500">Completos</span>
<span className="block font-semibold">{summary.pedimentos?.completos ?? '-'}</span>
</div>
<div>
<span className="block text-slate-500">Pendientes</span>
<span className="block font-semibold">{summary.pedimentos?.pendientes ?? '-'}</span>
</div>
<>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
{/* ...Tarjetas existentes... */}
{/* ...aquí van las Card como antes... */}
{/* ...no se repite para brevedad... */}
</div>
{/* Tabla de reportes debajo de las tarjetas */}
<div className="mt-10">
<h2 className="text-lg font-bold text-slate-700 mb-4">Historial de Reportes</h2>
<div className="overflow-x-auto">
<table className="min-w-full bg-white rounded-lg shadow border border-slate-200">
<thead>
<tr className="bg-slate-100">
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-600">ID</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-600">Estado</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-600">Creado</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-600">Finalizado</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-600">Error</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-600">Descargar</th>
</tr>
</thead>
<tbody>
{reports.length > 0 ? (
reports.map((r) => (
<tr key={r.report_id}>
<td className="px-4 py-2 text-xs text-slate-700">{r.report_id}</td>
<td className="px-4 py-2 text-xs text-slate-700">{r.status}</td>
<td className="px-4 py-2 text-xs text-slate-700">{r.created_at}</td>
<td className="px-4 py-2 text-xs text-slate-700">{r.finished_at}</td>
<td className="px-4 py-2 text-xs text-red-500">{r.error_message ? r.error_message : '-'}</td>
<td className="px-4 py-2 text-xs">
{r.status === 'ready' ? (
<button
className="text-blue-600 hover:underline"
onClick={() => handleDownloadReport(r.report_id)}
>
Descargar
</button>
) : (
<span className="text-slate-400">-</span>
)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={6} className="px-4 py-2 text-center text-slate-400">No hay reportes disponibles.</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="mt-2">
<span className="block text-xs text-slate-500 mb-1">Cumplimiento</span>
<div className="w-full bg-slate-100 rounded h-2">
<div
className="bg-blue-600 h-2 rounded"
style={{ width: `${summary.pedimentos?.cumplimiento ?? 0}%` }}
/>
</div>
<span className="block text-xs text-right text-blue-700 font-semibold mt-1">
{summary.pedimentos?.cumplimiento ?? 0}%
</span>
</div>
<div className="mt-2 pt-2 border-t grid grid-cols-2 gap-4">
{/* Documentos */}
<div>
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 16h8M8 12h8M8 8h8" />
</svg>
<span className="text-xs font-semibold text-slate-600">Documentos</span>
</div>
<div className="mt-1">
<div className="text-lg font-bold text-slate-700">{summary.documentos?.descargados ?? '-'}</div>
<span className="block text-xs text-slate-500">Descargados</span>
</div>
</div>
{/* Remesas */}
<div>
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4l3 3" />
</svg>
<span className="text-xs font-semibold text-slate-600">Remesas</span>
</div>
<div className="mt-1">
<div className="text-lg font-bold text-slate-700">{summary.remesas?.total ?? '-'}</div>
<span className="block text-xs text-slate-500">Total</span>
</div>
</div>
</div>
</Card>
{/* Partidas */}
<Card
title="Partidas"
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" /></svg>}
>
<div className="text-2xl font-bold text-blue-700">{summary.partidas?.total ?? '-'}</div>
<div className="grid grid-cols-2 gap-2 mt-2 text-xs">
<div>
<span className="block text-slate-500">Descargadas</span>
<span className="block font-semibold">{summary.partidas?.partidas_descargadas ?? '-'}</span>
</div>
<div>
<span className="block text-slate-500">Pendientes</span>
<span className="block font-semibold">{summary.partidas?.partidas_pendientes ?? '-'}</span>
</div>
</div>
<div className="mt-2">
<span className="block text-xs text-slate-500 mb-1">Cumplimiento</span>
<div className="w-full bg-slate-100 rounded h-2">
<div
className="bg-blue-600 h-2 rounded"
style={{ width: `${summary.partidas?.cumplimiento ?? 0}%` }}
/>
</div>
<span className="block text-xs text-right text-blue-700 font-semibold mt-1">
{summary.partidas?.cumplimiento ?? 0}%
</span>
</div>
</Card>
{/* COVES */}
<Card
title="COVES"
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 8h10M7 12h10M7 16h10" /></svg>}
>
<div className="text-2xl font-bold text-blue-700">{summary.coves?.total ?? '-'}</div>
<div className="grid grid-cols-2 gap-2 mt-2 text-xs">
<div>
<span className="block text-slate-500">Procesados</span>
<span className="block font-semibold">{summary.coves?.coves_procesados ?? '-'}</span>
</div>
<div>
<span className="block text-slate-500">Pendientes</span>
<span className="block font-semibold">{summary.coves?.coves_pendientes ?? '-'}</span>
</div>
</div>
<div className="mt-2">
<span className="block text-xs text-slate-500 mb-1">Cumplimiento</span>
<div className="w-full bg-slate-100 rounded h-2">
<div
className="bg-blue-600 h-2 rounded"
style={{ width: `${summary.coves?.coves_cumplimiento ?? 0}%` }}
/>
</div>
<span className="block text-xs text-right text-blue-700 font-semibold mt-1">
{summary.coves?.coves_cumplimiento ?? 0}%
</span>
</div>
<div className="mt-2 border-t pt-2">
<span className="block text-xs text-slate-500 mb-1">Acuses</span>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="block text-slate-500">Procesados</span>
<span className="block font-semibold">{summary.coves?.acuse_coves_procesados ?? '-'}</span>
</div>
<div>
<span className="block text-slate-500">Pendientes</span>
<span className="block font-semibold">{summary.coves?.acuse_coves_pendientes ?? '-'}</span>
</div>
</div>
<div className="w-full bg-slate-100 rounded h-2 mt-2">
<div
className="bg-blue-400 h-2 rounded"
style={{ width: `${summary.coves?.acuse_coves_cumplimiento ?? 0}%` }}
/>
</div>
<span className="block text-xs text-right text-blue-400 font-semibold mt-1">
{summary.coves?.acuse_coves_cumplimiento ?? 0}%
</span>
</div>
</Card>
{/* EDocuments */}
<Card
title="EDocuments"
icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v16h16V4H4zm4 4h8v8H8V8z" /></svg>}
>
<div className="text-2xl font-bold text-blue-700">{summary.edocuments?.total ?? '-'}</div>
<div className="grid grid-cols-2 gap-2 mt-2 text-xs">
<div>
<span className="block text-slate-500">asd</span>
<span className="block font-semibold">{summary.edocuments?.edocs_descargados ?? '-'}</span>
</div>
<div>
<span className="block text-slate-500">Pendientes</span>
<span className="block font-semibold">{summary.edocuments?.edocs_pendientes ?? '-'}</span>
</div>
</div>
<div className="mt-2">
<span className="block text-xs text-slate-500 mb-1">Cumplimiento</span>
<div className="w-full bg-slate-100 rounded h-2">
<div
className="bg-blue-600 h-2 rounded"
style={{ width: `${summary.edocuments?.edocs_cumplimiento ?? 0}%` }}
/>
</div>
<span className="block text-xs text-right text-blue-700 font-semibold mt-1">
{summary.edocuments?.edocs_cumplimiento ?? 0}%
</span>
</div>
<div className="mt-2 border-t pt-2">
<span className="block text-xs text-slate-500 mb-1">Acuses</span>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="block text-slate-500">Descargados</span>
<span className="block font-semibold">{summary.edocuments.acuse_descargados ?? '-'}</span>
</div>
<div>
<span className="block text-slate-500">Pendientes</span>
<span className="block font-semibold">{summary.edocuments.acuses_pendientes ?? '-'}</span>
</div>
</div>
<div className="w-full bg-slate-100 rounded h-2 mt-2">
<div
className="bg-blue-400 h-2 rounded"
style={{ width: `${summary.edocuments.acuses_cumplimiento ?? 0}%` }}
/>
</div>
<span className="block text-xs text-right text-blue-400 font-semibold mt-1">
{summary.edocuments?.acuses_cumplimiento ?? 0}%
</span>
</div>
</Card>
</div>
</div>
</>
) : (
<div className="text-center text-slate-500 py-12">No hay datos para mostrar.</div>
)}

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');
}
};