Compare commits

...

56 Commits

Author SHA1 Message Date
Dulce
75885dc3a9 fix/T2025-09-056 corregir reportes de datastage para cargar en un solo documento 2026-05-18 11:41:10 -06:00
Dulce
078297cd61 feature/T2026-05-030 modificar modulo auditorias para poder inicializar procesos de pedimentos 2026-05-18 11:39:46 -06:00
Dulce
06f2485336 feature/T2026-05-031 crear usuarios 2026-05-18 11:37:49 -06:00
Dulce
1374dc22a3 fix/remplazar nombre de var en certificados para extraer el nuevo link de minIO 2026-05-04 09:14:36 -06:00
7e1d6ff05b Merge pull request 'datastage' (#23) from refactor/value-url-change into main
Reviewed-on: #23
2026-04-22 17:15:41 +00:00
Dulce
0fc3b66090 datastage 2026-04-22 09:44:25 -06:00
f8a81a4ef6 Merge pull request 'fix/boton para poder forzar el procesamiento de un pedimento que no puso ser cargado en el proceso inicial' (#22) from fix/procesar-pedimentos into main
Reviewed-on: #22
2026-04-16 13:23:38 +00:00
Dulce
b0ac0ce06b fix/boton para poder forzar el procesamiento de un pedimento que no puso ser cargado en el proceso inicial 2026-04-16 07:18:34 -06:00
80701159fb Merge pull request 'fix: se agrega como para seleccion de organizaciones para filtrar los procesos por organizacion y ejecutar los procesos por el filtro de organizacion establecido.' (#21) from T2026-01-032 into main
Reviewed-on: #21
2026-02-05 16:09:33 +00:00
b29c586524 fix: se agrega como para seleccion de organizaciones para filtrar los procesos por organizacion y ejecutar los procesos por el filtro de organizacion establecido. 2026-02-03 16:41:06 -07:00
2fd3ab5483 Merge pull request 'fix: se crea boton para ejecutar los procesos de consulta a ventanilla unica.' (#20) from req--T2025-08-098 into main
Reviewed-on: #20
2026-02-03 17:55:17 +00:00
f9ece2923f fix: se crea boton para ejecutar los procesos de consulta a ventanilla unica. 2026-02-03 10:23:00 -07:00
96e0b4eea2 Merge pull request 'fix: se agrega nueva pestaña para visualizar los archivos de error que devuelve vucem en detalle pedimento.' (#19) from T2025-09-004 into main
Reviewed-on: #19
2026-01-29 18:09:37 +00:00
7c40275381 fix: se agrega nueva pestaña para visualizar los archivos de error que devuelve vucem en detalle pedimento. 2026-01-29 08:07:52 -07:00
407597c959 Merge pull request 'fix: se ajusta mensaje de error cuando se sube una carpeta, zip o rar con archivos.' (#18) from fix-T2025-09-007 into main
Reviewed-on: #18
2026-01-27 17:03:44 +00:00
07facb8ae2 fix: se ajusta mensaje de error cuando se sube una carpeta, zip o rar con archivos. 2026-01-26 15:54:20 -07:00
0d60156b7f Merge pull request 'actualizar el formulario de vucem para permitir agregar organizacion' (#17) from vucem-formulario into main
Reviewed-on: #17
2026-01-16 16:55:45 +00:00
Dulce
5a0e9a8259 actualizar el formulario de vucem para permitir agregar organizacion 2026-01-16 09:54:04 -07:00
a36178df1b Merge pull request 'se instala libreia de iconos y se modifica vista para habilitar selccion multiple y carga de archivos.' (#16) from T2025-10-152-001 into main
Reviewed-on: #16
2026-01-07 22:07:45 +00:00
50716e363d se instala libreia de iconos y se modifica vista para habilitar selccion multiple y carga de archivos. 2026-01-07 14:56:00 -07:00
4b66b4944a Merge pull request 'Fix--Auditor' (#15) from Fix--Auditor into main
Reviewed-on: #15
2026-01-07 17:36:16 +00:00
4414923d04 Nuevos Ajustes en la vista de modal de Peticiones y Respuestas 2026-01-02 07:39:28 -07:00
a78ed6f51b Fix: Se ajusta vista para ver los archivos de peticion y error en cada uno de las consultas realizadas a VU, asi como agregar codigo que manda llamar al backend que ya estaba hecho para auditar cada seccion de pedimento completo, partidas, etc. 2025-12-31 08:37:05 -07:00
dfddb418d0 Merge pull request 'modificacion reportes' (#14) from correccion-reportes into main
Reviewed-on: #14
2025-12-16 15:14:24 +00:00
Dulce
d46ea97340 modificacion reportes 2025-12-16 08:13:00 -07:00
29e0f1a68d Merge pull request 'Fix: se ajusta campo de fuente del pedimento detalle.' (#13) from T2025-10-021 into main
Reviewed-on: #13
2025-12-16 14:33:24 +00:00
3f01709952 Fix: se ajusta campo de fuente del pedimento detalle. 2025-12-16 07:24:40 -07:00
5580c3cc91 Merge pull request 'fix: Se agrega deteccion de pestaña seleccionada para mostrarla en el modal de documentos.' (#12) from T2025-10-152 into main
Reviewed-on: #12
2025-12-12 22:00:39 +00:00
bb59d1e487 Merge pull request 'fix/landingpage-ui-adjustment' (#11) from fix/landingpage-ui-adjustment into main
Reviewed-on: #11
2025-12-12 22:00:01 +00:00
769a1fd4e8 fix: Se agrega deteccion de pestaña seleccionada para mostrarla en el modal de documentos. 2025-12-10 11:18:19 -07:00
06ec641691 feat/Landingpage 2025-12-09 16:22:46 -07:00
a91ef2f11a style: update landing page 2025-12-08 10:17:23 -07:00
70f0b38e93 fix/Se modifica vista principal de detalle de pedimento y se ajusta la visualizacion de los documentos respectivos a cada pestaña. 2025-12-05 08:22:45 -07:00
d8c23dcf09 Remove .env from Git tracking 2025-11-26 08:59:23 -07:00
97a39a6d37 Merge pull request 'Subo archivo JSX sin afectar rama principal' (#10) from fixed/subir-expediente-jsx into main
Reviewed-on: #10
2025-11-26 15:52:56 +00:00
756815983d Subo archivo JSX sin afectar rama principal 2025-11-26 08:48:50 -07:00
33ca76c054 Merge pull request 'retirar debug mode' (#9) from reportes into main
Reviewed-on: #9
2025-11-26 14:48:22 +00:00
Dulce
6acb55c563 retirar debug mode 2025-11-25 16:25:57 -07:00
bdabc94974 Merge pull request 'reportes update' (#8) from reportes into main
Reviewed-on: #8
2025-11-25 22:24:39 +00: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
24 changed files with 10684 additions and 3168 deletions

5
.env
View File

@@ -1,5 +0,0 @@
VITE_DEBUG_MODE=true
VITE_EFC_API_URL=http://192.168.1.79:8000/api/v1
VITE_EFC_MICROSERVICE_URL=http://192.168.1.79:8001/api/v1
VITE_EFC_MICROSERVICE_URL_2=http://192.168.1.79:8001/api/v2

1
.gitignore vendored
View File

@@ -23,3 +23,4 @@ dist-ssr
*.sln
*.sw?
.env
*.bak

105
package-lock.json generated
View File

@@ -14,6 +14,8 @@
"@tanstack/react-query": "^5.62.7",
"chart.js": "^4.5.0",
"highlight.js": "^11.11.1",
"jszip": "^3.10.1",
"lucide-react": "^0.562.0",
"react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0",
@@ -1706,6 +1708,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 +2428,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 +2443,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 +2508,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 +2557,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",
@@ -2557,6 +2604,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.562.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -2703,6 +2759,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 +2981,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 +3097,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 +3152,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 +3171,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 +3831,6 @@
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 MiB

View File

@@ -14,6 +14,7 @@ import LandingAnimated from './pages/LandingAnimated';
import Expedientes from './pages/Expedientes';
import Organization from './pages/Organization';
import Users from './pages/Users';
import UserForm from './pages/UserForm';
import Reports from './pages/Reports';
import Settings from './pages/Settings';
import Importers from './pages/Importers';
@@ -76,6 +77,16 @@ function AppContent() {
<Users />
</RequireAuth>
} />
<Route path="/users/new" element={
<RequireAuth>
<UserForm />
</RequireAuth>
} />
<Route path="/users/:id/edit" element={
<RequireAuth>
<UserForm />
</RequireAuth>
} />
<Route path="/reports" element={
<RequireAuth>
<Reports />

View File

@@ -0,0 +1,68 @@
// src\api\pedimentoCompleto.ts
import { fetchWithAuth } from '../fetchWithAuth';
export interface PedimentoCompleto {
id: string;
organizacion: string;
pedimento: string;
pedimento_numero: string;
archivo: string;
document_type: number;
size: number;
extension: string;
fuente: number;
created_at: string;
updated_at: string;
}
export interface PedimentoCompletoResponse {
count: number;
next: string | null;
previous: string | null;
results: PedimentoCompleto[];
}
export interface DocumentFilters {
document_type?: string;
archivo__icontains?: string;
extension?: string;
created_at__date?: string;
ordering?: string;
}
const API_URL = (import.meta as any).env.VITE_EFC_API_URL;
export async function fetchPedimentoCompleto(
pedimentoId: string,
page: number = 1,
pageSize: number = 10,
filters: DocumentFilters = {}
): Promise<PedimentoCompletoResponse> {
try {
// Construir URL con filtros
const params = new URLSearchParams({
page: page.toString(),
page_size: pageSize.toString(),
pedimento: pedimentoId
});
// Agregar filtros si existen
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
params.append(key, value.toString());
}
});
// const res = await fetchWithAuth(`${API_URL}/record/documents/?${params.toString()}`);
const res = await fetchWithAuth(`${API_URL}/record/pedimento-documents/?${params.toString()}`);
if (!res.ok) {
throw new Error('No autorizado o error en la petición');
}
return res.json();
} catch (error) {
console.error('Error in fetchPedimentoCompleto:', error);
throw error;
}
}

View File

@@ -1,31 +1,29 @@
import { fetchWithAuth } from '../fetchWithAuth';
import { fetchWithAuth, postWithAuth } 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,79 @@ export async function fetchProcesamientoPedimentos(
}
});
const res = await fetchWithAuth(`${API_URL}/customs/procesamientopedimentos/?${params.toString()}`);
console.log('Params:', 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;
}
}
// Interfaz para la respuesta del comando
export interface ComandoResponse {
message?: string;
error?: string;
}
// Interfaz para los parámetros de ejecución
export interface EjecutarComandoParams {
procesamiento?: string;
organizacionid?: string;
todos?: boolean;
}
// API para ejecutar comando de procesamiento
export async function ejecutarComando(
params: EjecutarComandoParams
): Promise<ComandoResponse> {
try {
const API_URL = (import.meta as any).env.VITE_EFC_API_URL;
console.log('API_URL:', API_URL);
// Preparar los datos para la petición POST
const requestData: any = {};
if (params.procesamiento !== undefined) {
requestData.procesamiento = params.procesamiento;
}
if (params.organizacionid !== undefined) {
requestData.organizacionid = params.organizacionid;
}
if (params.todos !== undefined) {
requestData.todos = params.todos;
}
// const res = await fetchWithAuth(`${API_URL}/customs/procesamientopedimentos-ejecutar-comando/`, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify(requestData),
// });
const res = await postWithAuth(`${API_URL}/customs/procesamientopedimentos-ejecutar-comando/`, requestData);
if (!res.ok) {
// Intentar obtener el mensaje de error del servidor
try {
const errorData = await res.json();
throw new Error(errorData.message || errorData.error || `Error ${res.status}: ${res.statusText}`);
} catch {
throw new Error(`Error ${res.status}: ${res.statusText}`);
}
}
return await res.json();
} catch (error) {
// console.error('Error in ejecutarComando:', 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"

File diff suppressed because it is too large Load Diff

View File

@@ -381,11 +381,11 @@ export default function Datastage() {
<tr key={item.id} className="hover:bg-slate-50 transition-colors">
<td className="border px-2 py-2 text-center">{item.id}</td>
<td className="border px-2 py-2 max-w-xs truncate">
{item.archivo ? (
{item.download_url ? (
<span className="flex items-center gap-1 text-xs text-gray-700 truncate font-mono">
{(() => {
try {
const url = new URL(item.archivo);
const url = new URL(item.download_url);
return decodeURIComponent(url.pathname.split('/').pop() || '');
} catch {
return '';
@@ -399,7 +399,7 @@ export default function Datastage() {
item.id,
(() => {
try {
const url = new URL(item.archivo);
const url = new URL(item.download_url);
return decodeURIComponent(url.pathname.split('/').pop() || '');
} catch {
return '';
@@ -507,16 +507,16 @@ export default function Datastage() {
</span>
</div>
<div className="flex items-center gap-1 text-xs text-gray-700 break-all font-mono mb-1">
{item.archivo ? (
{item.download_url ? (
<span className="flex items-center gap-1">
{(() => { try { const url = new URL(item.archivo); return decodeURIComponent(url.pathname.split('/').pop() || ''); } catch { return ''; } })()}
{(() => { try { const url = new URL(item.download_url); return decodeURIComponent(url.pathname.split('/').pop() || ''); } catch { return ''; } })()}
<button
type="button"
className="inline-flex items-center justify-center w-6 h-6 rounded bg-blue-100 border border-blue-200 text-blue-700 hover:bg-blue-200 hover:border-blue-300 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-400 ml-1"
title="Descargar archivo"
onClick={() => downloadDatastageFile(
item.id,
(() => { try { const url = new URL(item.archivo); return decodeURIComponent(url.pathname.split('/').pop() || ''); } catch { return ''; } })()
(() => { try { const url = new URL(item.download_url); return decodeURIComponent(url.pathname.split('/').pop() || ''); } catch { return ''; } })()
)}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -803,7 +803,7 @@ export default function Datastage() {
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
<div className="bg-white rounded-xl shadow-2xl border border-blue-200 p-8 max-w-sm w-full flex flex-col animate-fade-in">
<h3 className="text-lg font-bold mb-2 text-blue-900">Detalle de Datastage #{selected.id}</h3>
<div className="mb-1"><b>Archivo:</b> {selected.archivo ? <a href={selected.archivo} target="_blank" rel="noopener noreferrer" className="text-blue-600 underline break-all">Descargar</a> : <span className="text-gray-400">Sin archivo</span>}</div>
{/* <div className="mb-1"><b>Archivo:</b> {selected.download_url ? <a href={selected.download_url} target="_blank" rel="noopener noreferrer" className="text-blue-600 underline break-all">Descargar</a> : <span className="text-gray-400">Sin archivo</span>}</div> */}
<div className="mb-1"><b>Contribuyente:</b> {selected.contribuyente}</div>
<div className="mb-1"><b>Procesado:</b> <span className={selected.procesado ? 'bg-green-100 text-green-700 px-2 py-0.5 rounded-full text-xs' : 'bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs'}>{selected.procesado ? 'Sí' : 'No'}</span></div>
<div className="mb-1"><b>Organización:</b> {selected.organizacion}</div>

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

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ export default function LandingAnimated() {
message: ''
});
const currentYear = new Date().getFullYear();
const observerRef = useRef(null);
const sectionsRef = useRef({});
@@ -101,7 +102,7 @@ export default function LandingAnimated() {
// Estadísticas animadas
const stats = [
{ number: '500+', label: 'Agentes Aduanales', icon: '🏢' },
{ number: '350+', label: 'Clientes', icon: '🏢' },
{ number: '15,000+', label: 'Pedimentos Procesados', icon: '📋' },
{ number: '99.9%', label: 'Uptime Garantizado', icon: '⚡' },
{ number: '24/7', label: 'Soporte Especializado', icon: '🛡️' }
@@ -115,24 +116,31 @@ export default function LandingAnimated() {
? 'bg-white/95 backdrop-blur-md shadow-lg border-b border-gray-200'
: 'bg-transparent'
}`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-4">
<div className="px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div className="flex items-center justify-between py-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<h1 className="text-2xl font-bold">
<span
className="bg-clip-text text-transparent"
className="text-transparent bg-clip-text"
style={{
background: `linear-gradient(to right, #1B2A41, #4DA6FF)`,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
color: isScrolled
? 'transparent'
: '#FFFFFF',
background: isScrolled
? 'linear-gradient(to right, #1B2A41, #4DA6FF)'
: 'none',
WebkitBackgroundClip: isScrolled ? 'text' : 'unset',
WebkitTextFillColor: isScrolled ? 'transparent' : '#FFFFFF',
backgroundClip: isScrolled ? 'text' : 'unset'
}}
>
EFC
</span>
</h1>
</div>
<nav className="hidden md:flex ml-10 space-x-8">
<nav className="hidden ml-10 space-x-8 md:flex">
{[
{ id: 'inicio', label: 'Inicio' },
{ id: 'estadisticas', label: 'Confianza' },
@@ -141,36 +149,52 @@ export default function LandingAnimated() {
{ id: 'precios', label: 'Precios' },
{ id: 'contacto', label: 'Contacto' }
].map((item) => (
<button
key={item.id}
onClick={() => scrollToSection(item.id)}
className={`relative text-sm font-medium transition-all duration-300 hover:scale-105 group`}
style={{
color: activeSection === item.id
? '#1B2A41'
: isScrolled
? '#333333'
: 'white'
}}
onMouseEnter={(e) => {
if (activeSection !== item.id) {
e.target.style.color = isScrolled ? '#1B2A41' : '#4DA6FF';
}
}}
onMouseLeave={(e) => {
if (activeSection !== item.id) {
e.target.style.color = isScrolled ? '#333333' : 'white';
}
}}
>
{item.label}
<span
className={`absolute -bottom-1 left-0 h-0.5 transition-all duration-300 ${
activeSection === item.id ? 'w-full' : 'w-0 group-hover:w-full'
}`}
style={{ backgroundColor: '#1B2A41' }}
></span>
</button>
<button
key={item.id}
onClick={() => scrollToSection(item.id)}
className={`relative text-sm font-medium transition-all duration-300 hover:scale-105 group`}
style={{
color: item.id === 'inicio'
? (isScrolled
? (activeSection === 'inicio' ? '#1B2A41' : '#333333')
: (activeSection === 'inicio' ? 'white' : 'white'))
: (activeSection === item.id
? '#1B2A41'
: isScrolled
? '#333333'
: 'white')
}}
onMouseEnter={(e) => {
if (activeSection !== item.id) {
if (item.id === 'inicio') {
e.target.style.color = isScrolled ? '#1B2A41' : '#4DA6FF';
} else {
e.target.style.color = isScrolled ? '#1B2A41' : '#4DA6FF';
}
}
}}
onMouseLeave={(e) => {
if (activeSection !== item.id) {
if (item.id === 'inicio') {
e.target.style.color = isScrolled ? '#333333' : 'white';
} else {
e.target.style.color = isScrolled ? '#333333' : 'white';
}
}
}}
>
{item.label}
<span
className={`absolute -bottom-1 left-0 h-0.5 transition-all duration-300 ${
activeSection === item.id ? 'w-full' : 'w-0 group-hover:w-full'
}`}
style={{
backgroundColor: item.id === 'inicio' && !isScrolled && activeSection === 'inicio'
? 'white'
: '#1B2A41'
}}
></span>
</button>
))}
</nav>
</div>
@@ -196,21 +220,33 @@ export default function LandingAnimated() {
</header>
{/* Hero Section con efectos de gradiente animado */}
<section id="inicio" className="relative min-h-screen flex items-center overflow-hidden">
{/* Background con gradientes animados */}
<div
className="absolute inset-0"
style={{
background: 'linear-gradient(135deg, #1B2A41 0%, #263549 50%, #1976D2 100%)'
}}
>
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent"></div>
<section id="inicio" className="relative flex items-center min-h-screen overflow-hidden">
{/* Background con imagen */}
<div className="absolute inset-0">
{/* Imagen de fondo */}
<div
className="absolute inset-0 bg-center bg-no-repeat bg-cover"
style={{
backgroundImage: 'url("images/empresaria001.webp")', // Cambia esta ruta
backgroundColor: '#1B2A41', // Color de respaldo
}}
>
{/* Overlay para oscurecer la imagen y mejorar legibilidad */}
<div
className="absolute inset-0"
style={{
background: 'linear-gradient(to bottom, rgba(27, 42, 65, 0.85), rgba(27, 42, 65, 0.7))',
}}
></div>
</div>
{/* Efectos adicionales */}
<div className="absolute inset-0 opacity-20">
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent animate-pulse"></div>
</div>
</div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-32 text-center">
<div className="relative px-4 py-32 mx-auto text-center max-w-7xl sm:px-6 lg:px-8">
<div className="space-y-8">
<h1
data-animate="hero-title"
@@ -222,7 +258,7 @@ export default function LandingAnimated() {
>
<span className="block">
<span
className="bg-clip-text text-transparent"
className="text-transparent bg-clip-text"
style={{
background: 'linear-gradient(to right, white, #64B5F6)',
WebkitBackgroundClip: 'text',
@@ -233,7 +269,7 @@ export default function LandingAnimated() {
</span>
</span>
<span
className="block text-3xl sm:text-4xl md:text-5xl mt-4 bg-clip-text text-transparent"
className="block mt-4 text-3xl text-transparent sm:text-4xl md:text-5xl bg-clip-text"
style={{
background: 'linear-gradient(to right, #64B5F6, white)',
WebkitBackgroundClip: 'text',
@@ -243,7 +279,7 @@ export default function LandingAnimated() {
Para Agentes Aduanales
</span>
<span
className="block text-3xl sm:text-4xl md:text-5xl bg-clip-text text-transparent"
className="block text-3xl text-transparent sm:text-4xl md:text-5xl bg-clip-text"
style={{
background: 'linear-gradient(to right, white, #64B5F6)',
WebkitBackgroundClip: 'text',
@@ -264,8 +300,8 @@ export default function LandingAnimated() {
style={{ color: '#64B5F6' }}
>
La plataforma líder desarrollada por
<span className="font-bold text-white"> @AduanaSoft</span> para
<span className="font-semibold" style={{ color: '#FF9800' }}> digitalizar y optimizar</span>
<span className="font-bold text-white"> Aduanasoft®</span> para
<span className="font-semibold" style={{ color: '#FFFFFF' }}> digitalizar y optimizar</span>
{' '}todos tus procesos de comercio exterior con tecnología de vanguardia
</p>
@@ -279,7 +315,7 @@ export default function LandingAnimated() {
>
<Link
to="/login"
className="group inline-flex items-center px-8 py-4 text-lg font-semibold rounded-full transition-all duration-300 shadow-2xl hover:shadow-3xl transform hover:-translate-y-1 hover:scale-105"
className="inline-flex items-center px-8 py-4 text-lg font-semibold transition-all duration-300 transform rounded-full shadow-2xl group hover:shadow-3xl hover:-translate-y-1 hover:scale-105"
style={{
color: '#1B2A41',
background: 'linear-gradient(to right, white, #F2F4F7)'
@@ -292,15 +328,15 @@ export default function LandingAnimated() {
}}
>
<span>Comenzar Ahora</span>
<svg className="ml-2 w-5 h-5 group-hover:translate-x-1 transition-transform duration-200" fill="currentColor" viewBox="0 0 20 20">
<svg className="w-5 h-5 ml-2 transition-transform duration-200 group-hover:translate-x-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</Link>
<button
onClick={() => scrollToSection('caracteristicas')}
className="group inline-flex items-center px-8 py-4 text-lg font-semibold rounded-full text-white bg-transparent border-2 border-white/30 hover:border-white hover:bg-white/10 transition-all duration-300 backdrop-blur-sm"
className="inline-flex items-center px-8 py-4 text-lg font-semibold text-white transition-all duration-300 bg-transparent border-2 rounded-full group border-white/30 hover:border-white hover:bg-white/10 backdrop-blur-sm"
>
<svg className="mr-2 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-6 4h.01M19 10a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Ver Demo</span>
@@ -308,11 +344,11 @@ export default function LandingAnimated() {
</div>
{/* Floating cards con efectos */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
<div className="grid max-w-4xl grid-cols-1 gap-6 mx-auto md:grid-cols-3">
{[
{ icon: '🚀', title: 'Rápido', desc: 'Procesamiento instantáneo' },
{ icon: '🔒', title: 'Seguro', desc: 'Cifrado de nivel bancario' },
{ icon: '📊', title: 'Inteligente', desc: 'IA para optimización' }
{ icon: '📊', title: 'Inteligente', desc: 'IA para optimización' },
].map((feature, index) => (
<div
key={index}
@@ -324,8 +360,8 @@ export default function LandingAnimated() {
}`}
style={{ transitionDelay: `${700 + index * 200}ms` }}
>
<div className="text-4xl mb-3">{feature.icon}</div>
<h3 className="text-white font-semibold text-lg mb-2">{feature.title}</h3>
<div className="mb-3 text-4xl">{feature.icon}</div>
<h3 className="mb-2 text-lg font-semibold text-white">{feature.title}</h3>
<p className="text-sm" style={{ color: '#64B5F6' }}>{feature.desc}</p>
</div>
))}
@@ -334,10 +370,10 @@ export default function LandingAnimated() {
</div>
{/* Scroll indicator animado */}
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce">
<div className="absolute transform -translate-x-1/2 bottom-8 left-1/2 animate-bounce">
<button
onClick={() => scrollToSection('estadisticas')}
className="text-white/70 hover:text-white transition-colors duration-200"
className="transition-colors duration-200 text-white/70 hover:text-white"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
@@ -348,7 +384,7 @@ export default function LandingAnimated() {
{/* Sección de Estadísticas y Confianza */}
<section id="estadisticas" className="py-20" style={{ background: 'linear-gradient(to right, #F2F4F7, white)' }}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div
data-animate="stats-header"
className={`text-center mb-16 transition-all duration-1000 ${
@@ -357,17 +393,17 @@ export default function LandingAnimated() {
: 'opacity-0 translate-y-10'
}`}
>
<h2 className="text-4xl font-extrabold mb-4" style={{ color: '#333333' }}>
Más de <span style={{ color: '#1B2A41' }}>500 empresas</span> confían en nosotros
<h2 className="mb-4 text-4xl font-extrabold" style={{ color: '#333333' }}>
Más de <span style={{ color: '#1B2A41' }}>350 empresas</span> confían en nosotros
</h2>
<p className="text-xl max-w-3xl mx-auto" style={{ color: '#7A7A7A' }}>
Desarrollado por <span className="font-bold" style={{ color: '#1B2A41' }}>@AduanaSoft</span>,
líderes en tecnología aduanal con más de 10 años de experiencia
<p className="max-w-3xl mx-auto text-xl" style={{ color: '#7A7A7A' }}>
Desarrollado por <span className="font-bold" style={{ color: '#1B2A41' }}>Aduanasoft®</span>,
líderes en tecnología aduanal con más de 29 años de experiencia
</p>
</div>
{/* Stats con animaciones */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8 mb-16">
<div className="grid grid-cols-2 gap-8 mb-16 lg:grid-cols-4">
{stats.map((stat, index) => (
<div
key={index}
@@ -379,8 +415,8 @@ export default function LandingAnimated() {
}`}
style={{ transitionDelay: `${index * 200}ms` }}
>
<div className="text-4xl mb-4 animate-pulse">{stat.icon}</div>
<div className="text-3xl font-bold mb-2" style={{ color: '#1B2A41' }}>{stat.number}</div>
<div className="mb-4 text-4xl animate-pulse">{stat.icon}</div>
<div className="mb-2 text-3xl font-bold" style={{ color: '#1B2A41' }}>{stat.number}</div>
<div className="font-medium" style={{ color: '#7A7A7A' }}>{stat.label}</div>
</div>
))}
@@ -396,18 +432,18 @@ export default function LandingAnimated() {
}`}
style={{ background: 'linear-gradient(to right, #1B2A41, #263549)' }}
>
<div className="grid md:grid-cols-2 gap-8 items-center">
<div className="grid items-center gap-8 md:grid-cols-2">
<div>
<h3 className="text-3xl font-bold mb-6">Acerca de AduanaSoft</h3>
<h3 className="mb-6 text-3xl font-bold">Acerca de Aduanasoft</h3>
<div className="space-y-4 text-indigo-100">
{[
"10+ años especializados en software aduanal",
"29+ años especializados en software aduanal",
"Equipo experto en comercio exterior y tecnología",
"Certificación SAT y cumplimiento normativo total",
"Soporte 24/7 con especialistas aduanales"
].map((item, idx) => (
<div key={idx} className="flex items-start space-x-3">
<div className="flex-shrink-0 w-6 h-6 bg-white/20 rounded-full flex items-center justify-center mt-1">
<div className="flex items-center justify-center flex-shrink-0 w-6 h-6 mt-1 rounded-full bg-white/20">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
@@ -418,9 +454,9 @@ export default function LandingAnimated() {
</div>
</div>
<div className="text-center">
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-8 border border-white/20">
<div className="text-6xl mb-4">🏆</div>
<h4 className="text-2xl font-bold mb-2">Líder del Mercado</h4>
<div className="p-8 border bg-white/10 backdrop-blur-md rounded-2xl border-white/20">
<div className="mb-4 text-6xl">🏆</div>
<h4 className="mb-2 text-2xl font-bold">Líder del Mercado</h4>
<p className="text-indigo-100">
Reconocidos como la mejor solución tecnológica para agentes aduanales en México
</p>
@@ -433,7 +469,7 @@ export default function LandingAnimated() {
{/* Características con efectos interactivos */}
<section id="caracteristicas" className="py-20 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div
data-animate="features-header"
className={`text-center mb-16 transition-all duration-1000 ${
@@ -442,7 +478,7 @@ export default function LandingAnimated() {
: 'opacity-0 translate-y-10'
}`}
>
<h2 className="text-4xl font-extrabold text-gray-900 mb-4">
<h2 className="mb-4 text-4xl font-extrabold text-gray-900">
Soluciones Especializadas para Comercio Exterior
</h2>
<p className="text-xl text-gray-600">
@@ -450,7 +486,7 @@ export default function LandingAnimated() {
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{[
{
icon: '📋',
@@ -492,7 +528,7 @@ export default function LandingAnimated() {
}`}>
{feature.icon}
</div>
<h3 className="text-2xl font-bold mb-4 transition-colors duration-300" style={{
<h3 className="mb-4 text-2xl font-bold transition-colors duration-300" style={{
color: visibleElements.has(`feature-${index}`) ? '#1B2A41' : '#333333'
}}>
{feature.title}
@@ -503,7 +539,7 @@ export default function LandingAnimated() {
<ul className="space-y-2">
{feature.features.map((item, idx) => (
<li key={idx} className="flex items-center text-sm" style={{ color: '#7A7A7A' }}>
<svg className="w-4 h-4 mr-2 flex-shrink-0" style={{ color: '#2E7D32' }} fill="currentColor" viewBox="0 0 20 20">
<svg className="flex-shrink-0 w-4 h-4 mr-2" style={{ color: '#2E7D32' }} fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
{item}
@@ -514,7 +550,7 @@ export default function LandingAnimated() {
<div className="p-4 transition-colors duration-300" style={{
background: 'linear-gradient(to right, #F2F4F7, #FFFFFF)'
}}>
<button className="font-semibold text-sm transition-colors duration-200" style={{
<button className="text-sm font-semibold transition-colors duration-200" style={{
color: '#1B2A41'
}}>
Conocer más
@@ -528,7 +564,7 @@ export default function LandingAnimated() {
{/* Precios */}
<section id="precios" className="py-20 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div
data-animate="pricing-header"
className={`text-center mb-16 transition-all duration-1000 ${
@@ -537,7 +573,7 @@ export default function LandingAnimated() {
: 'opacity-0 translate-y-10'
}`}
>
<h2 className="text-4xl font-extrabold text-gray-900 mb-4">
<h2 className="mb-4 text-4xl font-extrabold text-gray-900">
Planes diseñados para tu crecimiento
</h2>
<p className="text-xl text-gray-600">
@@ -545,7 +581,7 @@ export default function LandingAnimated() {
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
{[
{
name: 'Starter',
@@ -614,8 +650,8 @@ export default function LandingAnimated() {
}}
>
{plan.popular && (
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
<span className="text-white px-4 py-2 rounded-full text-sm font-semibold animate-pulse" style={{
<div className="absolute transform -translate-x-1/2 -top-4 left-1/2">
<span className="px-4 py-2 text-sm font-semibold text-white rounded-full animate-pulse" style={{
background: 'linear-gradient(to right, #1B2A41, #263549)'
}}>
Más Popular
@@ -623,8 +659,8 @@ export default function LandingAnimated() {
</div>
)}
<div className="text-center mb-8">
<h3 className="text-2xl font-bold mb-2" style={{ color: '#333333' }}>{plan.name}</h3>
<div className="mb-8 text-center">
<h3 className="mb-2 text-2xl font-bold" style={{ color: '#333333' }}>{plan.name}</h3>
<p className="mb-4" style={{ color: '#7A7A7A' }}>{plan.description}</p>
{/*
<div className="mb-4">
@@ -634,10 +670,10 @@ export default function LandingAnimated() {
*/}
</div>
<ul className="space-y-3 mb-8">
<ul className="mb-8 space-y-3">
{plan.features.map((feature, idx) => (
<li key={idx} className="flex items-center">
<svg className="w-5 h-5 text-green-500 mr-3" fill="currentColor" viewBox="0 0 20 20">
<svg className="w-5 h-5 mr-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span className="text-gray-700">{feature}</span>
@@ -662,32 +698,50 @@ export default function LandingAnimated() {
</section>
{/* Contacto */}
<section id="contacto" className="relative py-20 overflow-hidden" style={{
background: 'linear-gradient(135deg, #1B2A41 0%, #263549 50%, #1B2A41 100%)'
}}>
{/* Background Pattern */}
<div className="absolute inset-0 opacity-10">
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fillRule='evenodd'%3E%3Cg fill='%234DA6FF' fillOpacity='0.3'%3E%3Ccircle cx='30' cy='30' r='2'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
}}></div>
</div>
{/* Floating elements */}
<div className="absolute top-10 left-10 w-20 h-20 rounded-full opacity-20" style={{ backgroundColor: '#4DA6FF' }}></div>
<div className="absolute bottom-10 right-10 w-32 h-32 rounded-full opacity-15" style={{ backgroundColor: '#F57C00' }}></div>
<div className="absolute top-1/2 left-1/4 w-16 h-16 rounded-full opacity-10" style={{ backgroundColor: '#2E7D32' }}></div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-4xl md:text-5xl font-extrabold text-white mb-6">
¿Listo para <span style={{ color: '#4DA6FF' }}>transformar</span> tu operación aduanal?
</h2>
<p className="text-xl md:text-2xl max-w-3xl mx-auto" style={{ color: '#64B5F6' }}>
Únete a más de 500 empresas que ya optimizaron sus procesos con EFC
</p>
</div>
<section id="contacto" className="relative py-20 overflow-hidden">
{/* Background con imagen */}
<div className="absolute inset-0">
{/* Misma imagen de fondo que la sección inicio */}
<div
className="absolute inset-0 bg-center bg-no-repeat bg-cover"
style={{
backgroundImage: 'url("images/sistemagestion002.webp")',
backgroundColor: '#1B2A41',
}}
>
{/* Overlay más oscuro para mejor legibilidad en formulario */}
<div
className="absolute inset-0"
style={{
background: 'linear-gradient(to bottom, rgba(27, 42, 65, 0.85), rgba(27, 42, 65, 0.7))',
}}
></div>
</div>
{/* Background Pattern */}
<div className="absolute inset-0 opacity-10">
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fillRule='evenodd'%3E%3Cg fill='%234DA6FF' fillOpacity='0.3'%3E%3Ccircle cx='30' cy='30' r='2'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
}}></div>
</div>
</div>
{/* Floating elements */}
{/* <div className="absolute w-20 h-20 rounded-full top-10 left-10 opacity-20" style={{ backgroundColor: '#4DA6FF' }}></div>
<div className="absolute w-32 h-32 rounded-full bottom-10 right-10 opacity-15" style={{ backgroundColor: '#F57C00' }}></div>
<div className="absolute w-16 h-16 rounded-full top-1/2 left-1/4 opacity-10" style={{ backgroundColor: '#2E7D32' }}></div> */}
<div className="relative px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div className="mb-16 text-center">
<h2 className="mb-6 text-4xl font-extrabold text-white md:text-5xl">
¿Listo para <span style={{ color: '#4DA6FF' }}>transformar</span> tu operación aduanal?
</h2>
<p className="max-w-3xl mx-auto text-xl md:text-2xl" style={{ color: '#64B5F6' }}>
Únete a más de 350 empresas que ya optimizaron sus procesos con EFC
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-start">
<div className="grid items-start grid-cols-1 gap-12 lg:grid-cols-2">
<div
data-animate="contact-info"
className={`transition-all duration-1000 ${
@@ -697,9 +751,9 @@ export default function LandingAnimated() {
}`}
>
{/* Card de información de contacto */}
<div className="bg-white/10 backdrop-blur-md rounded-3xl p-8 border border-white/20">
<div className="p-8 border bg-white/10 backdrop-blur-md rounded-3xl border-white/20">
<div className="mb-8">
<h3 className="text-2xl font-bold text-white mb-4">
<h3 className="mb-4 text-2xl font-bold text-white">
Hablemos de tu proyecto
</h3>
<p className="text-lg" style={{ color: '#64B5F6' }}>
@@ -728,15 +782,15 @@ export default function LandingAnimated() {
subtitle: 'Visitas con cita previa'
}
].map((contact, idx) => (
<div key={idx} className="flex items-start space-x-4 p-4 rounded-xl bg-white/5 hover:bg-white/10 transition-all duration-300">
<div className="w-14 h-14 flex items-center justify-center rounded-full" style={{ backgroundColor: '#4DA6FF' }}>
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div key={idx} className="flex items-start p-4 space-x-4 transition-all duration-300 rounded-xl bg-white/5 hover:bg-white/10">
<div className="flex items-center justify-center rounded-full w-14 h-14" style={{ backgroundColor: '#4DA6FF' }}>
<svg className="text-white w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d={contact.icon} />
</svg>
</div>
<div className="flex-1">
<h4 className="font-bold text-white text-lg">{contact.title}</h4>
<p className="font-semibold mb-1" style={{ color: '#4DA6FF' }}>{contact.info}</p>
<h4 className="text-lg font-bold text-white">{contact.title}</h4>
<p className="mb-1 font-semibold" style={{ color: '#4DA6FF' }}>{contact.info}</p>
<p className="text-sm" style={{ color: '#64B5F6' }}>{contact.subtitle}</p>
</div>
</div>
@@ -744,10 +798,10 @@ export default function LandingAnimated() {
</div>
{/* Botón adicional para WhatsApp */}
<div className="mt-8 pt-6 border-t border-white/20">
<div className="pt-6 mt-8 border-t border-white/20">
<a
href="#"
className="flex items-center justify-center w-full py-4 px-6 rounded-xl font-semibold text-white transition-all duration-300 transform hover:scale-105"
className="flex items-center justify-center w-full px-6 py-4 font-semibold text-white transition-all duration-300 transform rounded-xl hover:scale-105"
style={{
background: 'linear-gradient(45deg, #25D366, #128C7E)'
}}
@@ -769,10 +823,10 @@ export default function LandingAnimated() {
: 'opacity-0 translate-x-10'
}`}
>
<h3 className="text-2xl font-bold mb-6" style={{ color: '#333333' }}>Solicita una demostración</h3>
<h3 className="mb-6 text-2xl font-bold" style={{ color: '#333333' }}>Solicita una demostración</h3>
<form onSubmit={handleContactSubmit} className="space-y-4">
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#333333' }}>
<label className="block mb-2 text-sm font-semibold" style={{ color: '#333333' }}>
Nombre completo
</label>
<input
@@ -780,7 +834,7 @@ export default function LandingAnimated() {
required
value={contactForm.name}
onChange={(e) => setContactForm({...contactForm, name: e.target.value})}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:border-transparent transition-all duration-200"
className="w-full px-4 py-3 transition-all duration-200 border border-gray-300 rounded-lg focus:ring-2 focus:border-transparent"
style={{
focusRingColor: '#4DA6FF',
outline: 'none'
@@ -792,7 +846,7 @@ export default function LandingAnimated() {
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#333333' }}>
<label className="block mb-2 text-sm font-semibold" style={{ color: '#333333' }}>
Email corporativo
</label>
<input
@@ -800,7 +854,7 @@ export default function LandingAnimated() {
required
value={contactForm.email}
onChange={(e) => setContactForm({...contactForm, email: e.target.value})}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:border-transparent transition-all duration-200"
className="w-full px-4 py-3 transition-all duration-200 border border-gray-300 rounded-lg focus:ring-2 focus:border-transparent"
onFocus={(e) => e.target.style.borderColor = '#4DA6FF'}
onBlur={(e) => e.target.style.borderColor = '#d1d5db'}
placeholder="tu@empresa.com"
@@ -808,7 +862,7 @@ export default function LandingAnimated() {
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#333333' }}>
<label className="block mb-2 text-sm font-semibold" style={{ color: '#333333' }}>
Empresa
</label>
<input
@@ -816,7 +870,7 @@ export default function LandingAnimated() {
required
value={contactForm.company}
onChange={(e) => setContactForm({...contactForm, company: e.target.value})}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:border-transparent transition-all duration-200"
className="w-full px-4 py-3 transition-all duration-200 border border-gray-300 rounded-lg focus:ring-2 focus:border-transparent"
onFocus={(e) => e.target.style.borderColor = '#4DA6FF'}
onBlur={(e) => e.target.style.borderColor = '#d1d5db'}
placeholder="Nombre de tu empresa"
@@ -824,14 +878,14 @@ export default function LandingAnimated() {
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#333333' }}>
<label className="block mb-2 text-sm font-semibold" style={{ color: '#333333' }}>
Mensaje
</label>
<textarea
rows="4"
value={contactForm.message}
onChange={(e) => setContactForm({...contactForm, message: e.target.value})}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:border-transparent transition-all duration-200"
className="w-full px-4 py-3 transition-all duration-200 border border-gray-300 rounded-lg focus:ring-2 focus:border-transparent"
onFocus={(e) => e.target.style.borderColor = '#4DA6FF'}
onBlur={(e) => e.target.style.borderColor = '#d1d5db'}
placeholder="Cuéntanos sobre tu operación aduanal..."
@@ -860,11 +914,11 @@ export default function LandingAnimated() {
</section>
{/* Footer */}
<footer className="text-white py-12" style={{ backgroundColor: '#1B2A41' }}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<footer className="py-12 text-white" style={{ backgroundColor: '#1B2A41' }}>
<div className="px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div className="grid grid-cols-1 gap-8 md:grid-cols-4">
<div className="col-span-1 md:col-span-2">
<h3 className="text-2xl font-bold mb-4">
<h3 className="mb-4 text-2xl font-bold">
<span style={{
background: 'linear-gradient(to right, #4DA6FF, #64B5F6)',
WebkitBackgroundClip: 'text',
@@ -873,13 +927,13 @@ export default function LandingAnimated() {
EFC
</span>
</h3>
<p className="mb-2 max-w-md" style={{ color: '#7A7A7A' }}>
{/* <p className="max-w-md mb-2" style={{ color: '#7A7A7A' }}>
Uso correcto <span className="font-semibold" style={{ color: '#4DA6FF' }}>Aduanasoft®</span>
</p>
<ul className="mb-4 text-sm text-gray-300 space-y-1">
</p> */}
{/* <ul className="mb-4 space-y-1 text-sm text-gray-300">
<li><span className="font-bold text-white">+350</span> clientes en todo el país</li>
<li><span className="font-bold text-white">29 años</span> de experiencia</li>
</ul>
</ul> */}
<div className="flex space-x-4">
<a href="https://www.facebook.com/AduanaSoftMX" target="_blank" rel="noopener noreferrer" className="transition-colors duration-200" style={{ color: '#7A7A7A' }} onMouseEnter={e => e.target.style.color = 'white'} onMouseLeave={e => e.target.style.color = '#7A7A7A'} title="Facebook">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M22.675 0h-21.35C.595 0 0 .592 0 1.326v21.348C0 23.408.595 24 1.325 24h11.495v-9.294H9.692v-3.622h3.128V8.413c0-3.1 1.893-4.788 4.659-4.788 1.325 0 2.463.099 2.797.143v3.24l-1.918.001c-1.504 0-1.797.715-1.797 1.763v2.313h3.587l-.467 3.622h-3.12V24h6.116C23.406 24 24 23.408 24 22.674V1.326C24 .592 23.406 0 22.675 0"/></svg>
@@ -903,29 +957,29 @@ export default function LandingAnimated() {
</div>
<div>
<h4 className="font-semibold mb-4">Producto</h4>
<h4 className="mb-4 font-semibold">Producto</h4>
<ul className="space-y-2 text-gray-400">
<li><button onClick={() => scrollToSection('caracteristicas')} className="hover:text-white transition-colors duration-200">Características</button></li>
<li><button onClick={() => scrollToSection('precios')} className="hover:text-white transition-colors duration-200">Precios</button></li>
<li><a href="#" className="hover:text-white transition-colors duration-200">Integraciónes</a></li>
<li><a href="#" className="hover:text-white transition-colors duration-200">API</a></li>
<li><button onClick={() => scrollToSection('caracteristicas')} className="transition-colors duration-200 hover:text-white">Características</button></li>
<li><button onClick={() => scrollToSection('precios')} className="transition-colors duration-200 hover:text-white">Precios</button></li>
<li><a href="#" className="transition-colors duration-200 hover:text-white">Integraciónes</a></li>
<li><a href="#" className="transition-colors duration-200 hover:text-white">API</a></li>
</ul>
</div>
<div>
<h4 className="font-semibold mb-4">Soporte</h4>
<h4 className="mb-4 font-semibold">Soporte</h4>
<ul className="space-y-2 text-gray-400">
<li><button onClick={() => scrollToSection('contacto')} className="hover:text-white transition-colors duration-200">Contacto</button></li>
<li><a href="#" className="hover:text-white transition-colors duration-200">Documentación</a></li>
<li><a href="#" className="hover:text-white transition-colors duration-200">Centro de Ayuda</a></li>
<li><a href="#" className="hover:text-white transition-colors duration-200">Status</a></li>
<li><button onClick={() => scrollToSection('contacto')} className="transition-colors duration-200 hover:text-white">Contacto</button></li>
<li><a href="#" className="transition-colors duration-200 hover:text-white">Documentación</a></li>
<li><a href="#" className="transition-colors duration-200 hover:text-white">Centro de Ayuda</a></li>
<li><a href="#" className="transition-colors duration-200 hover:text-white">Status</a></li>
</ul>
</div>
</div>
<div className="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
<div className="pt-8 mt-8 text-center text-gray-400 border-t border-gray-800">
<p>
&copy; 2025 EFC by <span className="font-semibold text-indigo-400">@AduanaSoft</span>.
&copy; {currentYear} EFC by <span className="font-semibold text-white">Aduanasoft®</span>.
Todos los derechos reservados. | Solución especializada para Agentes Aduanales e Importadores.
</p>
</div>

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

770
src/pages/UserForm.jsx Normal file
View File

@@ -0,0 +1,770 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { createUser, updateUser } from '../api/users.ts';
import { useNotification } from '../context/NotificationContext';
const initialForm = {
username: '',
email: '',
first_name: '',
last_name: '',
password: '',
confirmPassword: '',
rfc: [],
userType: 'agente', // 'agente' | 'importador'
groups: [],
is_active: true,
};
// Perfiles disponibles en el sistema
const AVAILABLE_GROUPS = [
{ id: 1, label: 'Admin', description: 'Administrador del sistema' },
{ id: 2, label: 'Developer', description: 'Desarrollador' },
{ id: 3, label: 'User', description: 'Acceso base (requerido)' },
{ id: 4, label: 'Agente Aduanal', description: 'Agente aduanal' },
{ id: 5, label: 'Importador', description: 'Importador general' }
];
export default function UserForm() {
const { id } = useParams(); // presente si es edición
const isEditing = Boolean(id);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { showMessage } = useNotification();
// Preseleccionar tipo desde query param (?type=agente|importador)
const initialType = searchParams.get('type') === 'importador' ? 'importador' : 'agente';
const [form, setForm] = useState({
...initialForm,
userType: initialType,
groups: initialType === 'importador' ? [3, 5] : [4, 3],
});
const [importadores, setImportadores] = useState([]);
const [submitting, setSubmitting] = useState(false);
const [loadingUser, setLoadingUser] = useState(isEditing);
// Validación de contraseña
const [passwordValidation, setPasswordValidation] = useState({
length: false, uppercase: false, lowercase: false, number: false, special: false,
});
const [showPasswordValidation, setShowPasswordValidation] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [passwordsMatch, setPasswordsMatch] = useState(true);
const [showPasswordMatchValidation, setShowPasswordMatchValidation] = useState(false);
// Inyectar animaciones
useEffect(() => {
if (typeof window !== 'undefined' && !document.getElementById('users-animations')) {
const style = document.createElement('style');
style.id = 'users-animations';
style.innerHTML = `
@keyframes fadeInUpUsers {
0% { opacity: 0; transform: translateY(32px); }
100% { opacity: 1; transform: translateY(0); }
}
.fade-in-up-users { animation: fadeInUpUsers 0.7s cubic-bezier(0.22, 1, 0.36, 1) both; }
@keyframes bounce-slow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.animate-bounce-slow { animation: bounce-slow 2.2s infinite; }
`;
document.head.appendChild(style);
}
}, []);
// Cargar importadores
useEffect(() => {
const access = localStorage.getItem('access');
if (!access) { window.location.href = '/login'; return; }
fetch(`${import.meta.env.VITE_EFC_API_URL}/customs/importadores/`, {
headers: { Authorization: `Bearer ${access}` },
})
.then(r => r.json())
.then(data => setImportadores(Array.isArray(data) ? data : []))
.catch(() => setImportadores([]));
}, []);
// Cargar datos del usuario si es edición
useEffect(() => {
if (!isEditing) return;
const access = localStorage.getItem('access');
fetch(`${import.meta.env.VITE_EFC_API_URL}/user/users/${id}/`, {
headers: { Authorization: `Bearer ${access}` },
})
.then(r => r.json())
.then(data => {
const isImportador = data.is_importador === true ||
(Array.isArray(data.groups) && data.groups.includes(5));
setForm({
username: data.username || '',
email: data.email || '',
first_name: data.first_name || '',
last_name: data.last_name || '',
password: '',
confirmPassword: '',
// rfc es M2M: viene como array de PKs (strings de RFC)
rfc: Array.isArray(data.rfc) ? data.rfc : (data.rfc ? [data.rfc] : []),
userType: isImportador ? 'importador' : 'agente',
groups: Array.isArray(data.groups) ? data.groups : [],
is_active: data.is_active !== false,
});
setLoadingUser(false);
})
.catch(() => {
showMessage('Error al cargar datos del usuario', 'error');
setLoadingUser(false);
});
}, [id, isEditing, showMessage]);
const validatePassword = (password) => {
const v = {
length: password.length >= 8,
uppercase: /[A-Z]/.test(password),
lowercase: /[a-z]/.test(password),
number: /\d/.test(password),
special: /[!@#$%^&*(),.?":{}|<>]/.test(password),
};
setPasswordValidation(v);
setShowPasswordValidation(password.length > 0);
};
const validatePasswordMatch = (password, confirm) => {
setPasswordsMatch(password === confirm);
setShowPasswordMatchValidation(confirm.length > 0);
};
const isPasswordValid = () => Object.values(passwordValidation).every(Boolean);
const isFormValid = () => {
if (!isEditing) {
return isPasswordValid() && passwordsMatch &&
form.password.length > 0 && form.confirmPassword.length > 0;
}
// En edición la contraseña es opcional
if (form.password.length > 0) {
return isPasswordValid() && passwordsMatch && form.confirmPassword.length > 0;
}
return true;
};
const handleChange = (e) => {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
if (name === 'password') {
validatePassword(value);
if (form.confirmPassword) validatePasswordMatch(value, form.confirmPassword);
}
if (name === 'confirmPassword') validatePasswordMatch(form.password, value);
};
const handleUserTypeChange = (type) => {
setForm(prev => ({
...prev,
userType: type,
// Limpiar RFCs si cambia a agente
rfc: type === 'agente' ? [] : prev.rfc,
// Preseleccionar perfiles por defecto según tipo
groups: type === 'importador' ? [3, 5] : [4, 3],
}));
};
const handleGroupToggle = (groupId) => {
setForm(prev => {
const groups = prev.groups.includes(groupId)
? prev.groups.filter(g => g !== groupId)
: [...prev.groups, groupId];
return { ...prev, groups };
});
};
const handleRfcToggle = (rfc) => {
setForm(prev => {
const current = Array.isArray(prev.rfc) ? prev.rfc : [];
const next = current.includes(rfc)
? current.filter(r => r !== rfc)
: [...current, rfc];
return { ...prev, rfc: next };
});
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!isFormValid()) return;
setSubmitting(true);
try {
const payload = {
username: form.username,
email: form.email,
first_name: form.first_name,
last_name: form.last_name,
groups: form.groups,
is_importador: form.userType === 'importador',
is_active: form.is_active,
};
if (form.userType === 'importador') {
payload.rfc = Array.isArray(form.rfc) ? form.rfc : [];
}
if (form.password) payload.password = form.password;
if (isEditing) {
await updateUser(id, payload);
showMessage('Usuario actualizado exitosamente', 'success');
} else {
await createUser(payload);
showMessage('Usuario creado exitosamente', 'success');
}
navigate('/users');
} catch (err) {
showMessage(err.message, 'error');
} finally {
setSubmitting(false);
}
};
const isImportador = form.userType === 'importador';
if (loadingUser) {
return (
<div className="min-h-screen p-4 sm:p-6 bg-gradient-to-br from-gray-50 to-blue-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="min-h-screen p-4 sm:p-6 bg-gradient-to-br from-gray-50 to-blue-50">
<div className="max-w-3xl mx-auto">
{/* Header */}
<div className="mb-8 relative overflow-hidden rounded-2xl shadow bg-gradient-to-r from-blue-600 via-blue-700 to-blue-800 border border-blue-200 p-6 sm:p-8 flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-6">
<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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div className="flex-1">
<h1 className="text-2xl sm:text-3xl font-extrabold text-white tracking-tight mb-1">
{isEditing ? 'Editar Usuario' : 'Nuevo Usuario'}
</h1>
<p className="text-sm sm:text-base text-white/80 font-medium">
{isEditing ? 'Modifica los datos del usuario seleccionado' : 'Registro en el Sistema de Gestión de Usuarios'}
</p>
</div>
{/* Botón regresar */}
<button
type="button"
onClick={() => navigate('/users')}
className="flex-shrink-0 inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white text-sm font-medium rounded-lg border border-white/30 transition-all duration-200"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Regresar
</button>
<div className="absolute -top-10 -right-10 opacity-20 pointer-events-none select-none">
<svg width="120" height="120" viewBox="0 0 120 120" fill="none">
<circle cx="60" cy="60" r="50" fill="white" fillOpacity="0.15" />
</svg>
</div>
</div>
{/* Formulario */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Tipo de usuario — solo en creación */}
{!isEditing && (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 fade-in-up-users">
<div className="flex items-center mb-4 pb-3 border-b border-gray-200">
<div className="bg-purple-600 rounded-lg p-2 mr-3 shadow-sm">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
</svg>
</div>
<div>
<h4 className="text-sm font-semibold text-slate-800">Tipo de Usuario</h4>
<p className="text-xs text-slate-500">Define el rol principal del nuevo usuario</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<button
type="button"
onClick={() => handleUserTypeChange('agente')}
className={`relative flex flex-col items-start p-4 rounded-xl border-2 transition-all duration-200 ${
!isImportador
? 'border-blue-500 bg-blue-50 shadow-md'
: 'border-gray-200 bg-white hover:border-blue-300 hover:bg-blue-50/50'
}`}
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center mb-3 ${!isImportador ? 'bg-blue-600' : 'bg-gray-200'}`}>
<svg className={`w-5 h-5 ${!isImportador ? 'text-white' : 'text-gray-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<span className={`text-sm font-semibold ${!isImportador ? 'text-blue-800' : 'text-gray-700'}`}>Agente Aduanal</span>
<span className="text-xs text-gray-500 mt-1">Gestión de trámites aduaneros</span>
{!isImportador && (
<div className="absolute top-3 right-3 w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</button>
<button
type="button"
onClick={() => handleUserTypeChange('importador')}
className={`relative flex flex-col items-start p-4 rounded-xl border-2 transition-all duration-200 ${
isImportador
? 'border-blue-500 bg-blue-50 shadow-md'
: 'border-gray-200 bg-white hover:border-blue-300 hover:bg-blue-50/50'
}`}
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center mb-3 ${isImportador ? 'bg-blue-600' : 'bg-gray-200'}`}>
<svg className={`w-5 h-5 ${isImportador ? 'text-white' : 'text-gray-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<span className={`text-sm font-semibold ${isImportador ? 'text-blue-800' : 'text-gray-700'}`}>Importador</span>
<span className="text-xs text-gray-500 mt-1">Empresa con RFCs asociados</span>
{isImportador && (
<div className="absolute top-3 right-3 w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</button>
</div>
</div>
)}
{/* Información Personal */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 fade-in-up-users" style={{ animationDelay: '0.05s' }}>
<div className="flex items-center mb-4 pb-3 border-b border-gray-200">
<div className="bg-blue-600 rounded-lg p-2 mr-3 shadow-sm">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div>
<h4 className="text-sm font-semibold text-slate-800">Información Personal</h4>
<p className="text-xs text-slate-500">Datos de identificación del usuario</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="block text-xs font-semibold text-slate-700">
Nombre de usuario <span className="text-red-600">*</span>
</label>
<input
type="text"
name="username"
value={form.username}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 bg-white text-slate-900 placeholder-slate-400 text-sm"
placeholder="nombre_usuario"
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-slate-700">
Correo electrónico <span className="text-red-600">*</span>
</label>
<input
type="email"
name="email"
value={form.email}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 bg-white text-slate-900 placeholder-slate-400 text-sm"
placeholder="usuario@ejemplo.com"
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-slate-700">Nombre</label>
<input
type="text"
name="first_name"
value={form.first_name}
onChange={handleChange}
className="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 bg-white text-slate-900 placeholder-slate-400 text-sm"
placeholder="Nombre del usuario"
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-slate-700">Apellido</label>
<input
type="text"
name="last_name"
value={form.last_name}
onChange={handleChange}
className="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 bg-white text-slate-900 placeholder-slate-400 text-sm"
placeholder="Apellido del usuario"
/>
</div>
</div>
</div>
{/* RFC — solo para importadores */}
{isImportador && (
<div className="bg-white rounded-2xl shadow-lg border border-blue-100 p-6 fade-in-up-users" style={{ animationDelay: '0.1s' }}>
<div className="flex items-center mb-4 pb-3 border-b border-blue-200">
<div className="bg-blue-700 rounded-lg p-2 mr-3 shadow-sm">
<svg className="w-4 h-4 text-white" 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>
</div>
<div>
<h4 className="text-sm font-semibold text-slate-800">Información Fiscal</h4>
<p className="text-xs text-slate-500">RFCs de importadores asociados al usuario (puede ser más de uno)</p>
</div>
</div>
{importadores.length === 0 ? (
<p className="text-sm text-gray-500 italic">No hay importadores disponibles en el catálogo.</p>
) : (
<div className="flex flex-col sm:flex-row gap-3 items-stretch">
{/* Columna izquierda — disponibles */}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-slate-600 uppercase tracking-wide">Disponibles</span>
<span className="text-xs text-slate-400 font-mono">
{importadores.filter(imp => !(Array.isArray(form.rfc) && form.rfc.includes(imp.rfc))).length}
</span>
</div>
<div className="border border-slate-200 rounded-xl bg-slate-50 overflow-hidden flex flex-col" style={{ minHeight: '200px', maxHeight: '280px' }}>
<div className="overflow-y-auto flex-1">
{importadores.filter(imp => !(Array.isArray(form.rfc) && form.rfc.includes(imp.rfc))).length === 0 ? (
<div className="flex items-center justify-center h-full py-8 text-xs text-slate-400 italic">
Todos los RFC han sido asignados
</div>
) : (
importadores
.filter(imp => !(Array.isArray(form.rfc) && form.rfc.includes(imp.rfc)))
.map(imp => (
<div
key={imp.rfc}
onDoubleClick={() => handleRfcToggle(imp.rfc)}
className="flex items-center justify-between px-3 py-2 border-b border-slate-100 last:border-b-0 text-xs font-mono text-slate-700 hover:bg-blue-50 hover:text-blue-800 cursor-pointer select-none group transition-colors duration-100"
title="Doble clic para agregar"
>
<span className="uppercase truncate">{imp.rfc}</span>
<svg className="w-3.5 h-3.5 text-slate-300 group-hover:text-blue-400 flex-shrink-0 ml-2 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
</svg>
</div>
))
)}
</div>
</div>
</div>
{/* Botones centrales */}
<div className="flex sm:flex-col items-center justify-center gap-2 py-2 sm:py-0">
<button
type="button"
title="Agregar todos"
onClick={() => setForm(prev => ({ ...prev, rfc: importadores.map(i => i.rfc) }))}
className="w-8 h-8 rounded-lg border border-slate-300 bg-white hover:bg-blue-50 hover:border-blue-400 flex items-center justify-center transition-colors shadow-sm"
>
<svg className="w-4 h-4 text-slate-500 hover:text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
<button
type="button"
title="Quitar todos"
onClick={() => setForm(prev => ({ ...prev, rfc: [] }))}
className="w-8 h-8 rounded-lg border border-slate-300 bg-white hover:bg-red-50 hover:border-red-400 flex items-center justify-center transition-colors shadow-sm"
>
<svg className="w-4 h-4 text-slate-500 hover:text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
</div>
{/* Columna derecha — seleccionados */}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-blue-700 uppercase tracking-wide">Asignados</span>
<span className="text-xs text-blue-500 font-mono font-semibold">
{Array.isArray(form.rfc) ? form.rfc.length : 0}
</span>
</div>
<div className="border border-blue-200 rounded-xl bg-blue-50/40 overflow-hidden flex flex-col" style={{ minHeight: '200px', maxHeight: '280px' }}>
<div className="overflow-y-auto flex-1">
{!Array.isArray(form.rfc) || form.rfc.length === 0 ? (
<div className="flex items-center justify-center h-full py-8 text-xs text-slate-400 italic">
Sin RFC asignados
</div>
) : (
form.rfc.map(r => (
<div
key={r}
onDoubleClick={() => handleRfcToggle(r)}
className="flex items-center justify-between px-3 py-2 border-b border-blue-100 last:border-b-0 text-xs font-mono text-blue-800 hover:bg-red-50 hover:text-red-700 cursor-pointer select-none group transition-colors duration-100"
title="Doble clic para quitar"
>
<svg className="w-3.5 h-3.5 text-blue-300 group-hover:text-red-400 flex-shrink-0 mr-2 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 19l-7-7 7-7" />
</svg>
<span className="uppercase truncate flex-1">{r}</span>
</div>
))
)}
</div>
</div>
</div>
</div>
)}
</div>
)}
{/* Perfiles */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 fade-in-up-users" style={{ animationDelay: '0.15s' }}>
<div className="flex items-center mb-4 pb-3 border-b border-gray-200">
<div className="bg-blue-600 rounded-lg p-2 mr-3 shadow-sm">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div>
<h4 className="text-sm font-semibold text-slate-800">Perfiles</h4>
<p className="text-xs text-slate-500">Asigna los perfiles a los que pertenecerá el usuario</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{AVAILABLE_GROUPS.map(group => {
const active = form.groups.includes(group.id);
return (
<button
key={group.id}
type="button"
onClick={() => handleGroupToggle(group.id)}
className={`relative flex flex-col items-start p-3 rounded-xl border-2 text-left transition-all duration-200 ${
active
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50'
}`}
>
<span className={`text-xs font-semibold ${active ? 'text-blue-800' : 'text-gray-700'}`}>
{group.label}
</span>
<span className="text-xs text-gray-400 mt-0.5">{group.description}</span>
{active && (
<div className="absolute top-2 right-2 w-4 h-4 bg-blue-600 rounded-full flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</button>
);
})}
</div>
</div>
{/* Credenciales */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 fade-in-up-users" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center mb-4 pb-3 border-b border-gray-200">
<div className="bg-red-600 rounded-lg p-2 mr-3 shadow-sm">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<h4 className="text-sm font-semibold text-slate-800">Credenciales de Acceso</h4>
<p className="text-xs text-slate-500">
{isEditing ? 'Deja en blanco para mantener la contraseña actual' : 'Configura la contraseña de acceso'}
</p>
</div>
</div>
<div className="space-y-4">
{/* Estado del usuario */}
<div className="flex items-center justify-between p-3 rounded-xl border border-slate-200 bg-slate-50">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${form.is_active ? 'bg-blue-100' : 'bg-slate-200'}`}>
<svg className={`w-4 h-4 ${form.is_active ? 'text-blue-600' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d={form.is_active ? 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' : 'M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636'} />
</svg>
</div>
<div>
<p className="text-xs font-semibold text-slate-700">Estado de la cuenta</p>
<p className={`text-xs ${form.is_active ? 'text-blue-600' : 'text-slate-400'}`}>
{form.is_active ? 'Activo — el usuario puede iniciar sesión' : 'Inactivo — acceso bloqueado'}
</p>
</div>
</div>
<button
type="button"
onClick={() => setForm(prev => ({ ...prev, is_active: !prev.is_active }))}
className={`relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${form.is_active ? 'bg-blue-600' : 'bg-slate-300'}`}
role="switch"
aria-checked={form.is_active}
>
<span className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform duration-200 ${form.is_active ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
</div>
{/* Contraseña */}
<div className="space-y-1">
<label className="block text-xs font-semibold text-slate-700">
Contraseña {!isEditing && <span className="text-red-600">*</span>}
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
name="password"
value={form.password}
onChange={handleChange}
required={!isEditing}
className={`w-full px-3 py-2 pr-10 border rounded-md shadow-sm focus:ring-2 transition-all duration-200 bg-white text-slate-900 placeholder-slate-400 text-sm ${
showPasswordValidation && isPasswordValid()
? 'border-green-300 focus:ring-green-500 focus:border-green-500'
: showPasswordValidation
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: 'border-slate-300 focus:ring-red-500 focus:border-red-500'
}`}
placeholder={isEditing ? 'Dejar vacío para mantener actual' : 'Contraseña segura del usuario'}
/>
<button
type="button"
onClick={() => setShowPassword(v => !v)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-slate-400 hover:text-slate-600"
>
{showPassword ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L8.464 8.464m1.414 1.414L21.536 21.536" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
{/* Indicadores de validación */}
{showPasswordValidation && (
<div className="mt-3 p-3 bg-slate-100 rounded-lg border">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-slate-700">Requisitos de contraseña:</span>
{isPasswordValid() && (
<div className="flex items-center text-green-600">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-xs font-medium">Válida</span>
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{[
{ key: 'length', label: 'Mínimo 8 caracteres' },
{ key: 'uppercase', label: 'Una letra mayúscula' },
{ key: 'lowercase', label: 'Una letra minúscula' },
{ key: 'number', label: 'Un número' },
{ key: 'special', label: 'Un carácter especial (!@#$%^&*)', span: true },
].map(({ key, label, span }) => (
<div key={key} className={`flex items-center text-xs ${passwordValidation[key] ? 'text-green-600' : 'text-red-500'} ${span ? 'sm:col-span-2' : ''}`}>
<svg className="w-3 h-3 mr-1.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d={passwordValidation[key] ? 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' : 'M6 18L18 6M6 6l12 12'} />
</svg>
{label}
</div>
))}
</div>
</div>
)}
</div>
{/* Confirmar contraseña */}
<div className="space-y-1">
<label className="block text-xs font-semibold text-slate-700">
Confirmar Contraseña {!isEditing && <span className="text-red-600">*</span>}
</label>
<div className="relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
name="confirmPassword"
value={form.confirmPassword}
onChange={handleChange}
required={!isEditing}
className={`w-full px-3 py-2 pr-10 border rounded-md shadow-sm focus:ring-2 transition-all duration-200 bg-white text-slate-900 placeholder-slate-400 text-sm ${
showPasswordMatchValidation && passwordsMatch && form.confirmPassword.length > 0
? 'border-green-300 focus:ring-green-500 focus:border-green-500'
: showPasswordMatchValidation && !passwordsMatch
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: 'border-slate-300 focus:ring-red-500 focus:border-red-500'
}`}
placeholder="Confirme la contraseña"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(v => !v)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-slate-400 hover:text-slate-600"
>
{showConfirmPassword ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L8.464 8.464m1.414 1.414L21.536 21.536" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
{showPasswordMatchValidation && (
<div className={`mt-2 flex items-center text-xs ${passwordsMatch ? 'text-green-600' : 'text-red-500'}`}>
<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={passwordsMatch ? 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' : 'M6 18L18 6M6 6l12 12'} />
</svg>
<span className="font-medium">
{passwordsMatch ? 'Las contraseñas coinciden' : 'Las contraseñas no coinciden'}
</span>
</div>
)}
</div>
</div>
</div>
{/* Botones de acción */}
<div className="flex flex-col sm:flex-row justify-end gap-3 pb-8 fade-in-up-users" style={{ animationDelay: '0.25s' }}>
<button
type="button"
onClick={() => navigate('/users')}
disabled={submitting}
className="w-full sm:w-auto px-6 py-2.5 border border-slate-300 rounded-lg shadow-sm text-sm font-semibold text-slate-700 bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500 transition-all duration-200 disabled:opacity-50"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting || (showPasswordValidation && !isFormValid())}
className="w-full sm:w-auto px-6 py-2.5 border border-transparent rounded-lg shadow-lg text-sm font-semibold text-white bg-gradient-to-r from-blue-700 to-blue-900 hover:from-blue-800 hover:to-blue-950 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d={isEditing ? 'M5 13l4 4L19 7' : 'M12 6v6m0 0v6m0-6h6m-6 0H6'} />
</svg>
<span>
{submitting
? (isEditing ? 'Actualizando...' : 'Creando...')
: (isEditing ? 'Actualizar Usuario' : 'Crear Usuario')}
</span>
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { fetchUsers, createUser, updateUser, deleteUser, getCurrentUser } from '../api/users.ts';
import { useNotification } from '../context/NotificationContext';
@@ -47,6 +48,7 @@ export default function Users() {
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const { showMessage } = useNotification();
const navigate = useNavigate();
// Estados para validación de contraseña
const [passwordValidation, setPasswordValidation] = useState({
@@ -552,44 +554,17 @@ export default function Users() {
</div>
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
<button
onClick={() => { setShowCreateModal(true); setCreateType('agente'); }}
onClick={() => navigate('/users/new')}
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 transform hover:scale-105"
>
<svg className="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span className="hidden sm:inline">Nuevo Agente</span>
<span className="sm:hidden">Agente</span>
</button>
<button
onClick={async () => {
setCreateType('importador');
// Fetch importadores RFC
try {
const res = await fetch(`${import.meta.env.VITE_EFC_API_URL}/customs/importadores/`, { method: 'GET', headers: { 'Authorization': `Bearer ${localStorage.getItem('access')}` } });
const data = await res.json();
if (Array.isArray(data)) {
setImportadores(data);
} else {
setImportadores([]);
}
} catch {
setImportadores([]);
}
setShowCreateModal(true);
}}
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-lg shadow-sm text-sm font-medium 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"
>
<svg className="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span className="hidden sm:inline">Nuevo Importador</span>
<span className="sm:hidden">Importador</span>
<span className="hidden sm:inline">Nuevo Usuario</span>
<span className="sm:hidden">Usuario</span>
</button>
</div>
{/* Modal para crear usuario (agente o importador) eliminado */}
</div>
{/* Filtros avanzados */}
@@ -777,6 +752,16 @@ export default function Users() {
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<div className="flex justify-center space-x-2">
<button
onClick={() => navigate(`/users/${user.id}/edit`)}
className="inline-flex items-center px-3 py-1.5 border border-blue-300 shadow-sm text-xs font-medium rounded-lg text-blue-700 bg-white hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 transform hover:scale-105"
title="Editar usuario"
>
<svg className="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Editar
</button>
<button
onClick={() => { setShowDeleteModal(true); setUserToDelete(user); }}
disabled={user.username === localStorage.getItem('username')}
@@ -874,16 +859,27 @@ export default function Users() {
<div className="text-xs text-gray-500 mt-1">ID: {user.id}</div>
</div>
</div>
<button
onClick={() => { setShowDeleteModal(true); setUserToDelete(user); }}
disabled={user.username === localStorage.getItem('username')}
className={`inline-flex items-center px-3 py-2 border border-red-300 shadow-sm text-xs font-medium rounded-lg text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-all duration-200 ${user.username === localStorage.getItem('username') ? 'opacity-50 cursor-not-allowed' : ''}`}
title={user.username === localStorage.getItem('username') ? 'No puedes eliminar tu propia cuenta' : 'Eliminar usuario'}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<div className="flex items-center gap-2">
<button
onClick={() => navigate(`/users/${user.id}/edit`)}
className="inline-flex items-center px-3 py-2 border border-blue-300 shadow-sm text-xs font-medium rounded-lg text-blue-700 bg-white hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200"
title="Editar usuario"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => { setShowDeleteModal(true); setUserToDelete(user); }}
disabled={user.username === localStorage.getItem('username')}
className={`inline-flex items-center px-3 py-2 border border-red-300 shadow-sm text-xs font-medium rounded-lg text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-all duration-200 ${user.username === localStorage.getItem('username') ? 'opacity-50 cursor-not-allowed' : ''}`}
title={user.username === localStorage.getItem('username') ? 'No puedes eliminar tu propia cuenta' : 'Eliminar usuario'}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-xs">
<div>

View File

@@ -202,10 +202,13 @@ function RelacionarImportadoresModal({ open, onClose, vucem }) {
}
import React, { useEffect, useState } from 'react';
import { fetchWithAuth, postWithAuth, putWithAuth, deleteWithAuth, putFormDataWithAuth, postFormDataWithAuth, patchWithAuth } from '../fetchWithAuth';
import { useUser } from '../context/UserContext';
const API_URL = import.meta.env.VITE_EFC_API_URL;
export default function Vucem() {
// Estado para modal de relacionar importadores
const isDebugMode = import.meta.env.VITE_DEBUG_MODE === 'true';
const [showRelacionarModal, setShowRelacionarModal] = useState(false);
const [selectedVucem, setSelectedVucem] = useState(null);
const [vucemList, setVucemList] = useState([]);
@@ -216,10 +219,13 @@ export default function Vucem() {
const [editVucem, setEditVucem] = useState(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleteVucem, setDeleteVucem] = useState(null);
const [organizaciones, setOrganizaciones] = useState([]);
const { user: currentUser } = useUser();
// Estado para formulario de creación/edición
const initialForm = {
usuario: '',
organizacion: '',
password: '',
patente: '',
efirma: '',
@@ -260,6 +266,22 @@ export default function Vucem() {
}));
};
useEffect(() => {
const fetchOrganizaciones = async () => {
try {
const url = `${import.meta.env.VITE_EFC_API_URL}/organization/organizaciones/`;
const res = await fetchWithAuth(url); // ← USA fetchWithAuth
if (!res.ok) throw new Error('Error al obtener las organizaciones');
const data = await res.json();
setOrganizaciones(data);
} catch (err) {
console.error('Error fetching organizaciones:', err);
setOrganizaciones([]); // ← Asegurar que siempre sea un array
}
};
fetchOrganizaciones();
}, []);
// Funciones para alternar visibilidad en modal de edición
const toggleEditPasswordVisibility = () => {
setShowEditPassword(!showEditPassword);
@@ -358,6 +380,7 @@ export default function Vucem() {
const res = await fetchWithAuth(`${API_URL}/vucem/vucem/`);
if (!res.ok) throw new Error('Error al cargar Ventanilla Unica');
const data = await res.json();
// console.log('data > ', data);
setVucemList(data);
setError(null);
} catch (err) {
@@ -1272,12 +1295,13 @@ export default function Vucem() {
formData.append('password', form.password);
formData.append('patente', form.patente);
formData.append('efirma', form.efirma);
if (form.key) formData.append('key', form.key);
if (form.cer) formData.append('cer', form.cer);
formData.append('is_importador', form.is_importador);
formData.append('is_active', form.is_active);
formData.append('acusecove', form.acusecove);
formData.append('acuseedocument', form.acuseedocument);
formData.append('is_active', form.is_active);
formData.append('organizacion_id', form.organizacion);
if (form.key) formData.append('key', form.key);
if (form.cer) formData.append('cer', form.cer);
try {
const res = await postFormDataWithAuth(`${API_URL}/vucem/vucem/`, formData);
if (!res.ok) throw new Error('Error al crear');
@@ -1553,6 +1577,54 @@ export default function Vucem() {
</div>
</div>
{/* Seccion de organizacion para super_users */}
{currentUser.is_superuser ? (
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<div className="flex items-center mb-3 pb-2 border-b border-slate-300">
<div className="bg-amber-600 rounded-lg p-2 mr-3 shadow-sm">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<h4 className="text-sm font-semibold text-slate-800">Configuracion de Organizaciones</h4>
<p className="text-xs text-slate-600">Asignar la Organizacion a la que pertenece.</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-1 gap-4">
<div className="space-y-1">
<label className="block text-xs font-semibold text-slate-700">
Seleccionar Organizacion <span className="text-red-600">*</span>
</label>
<div className="relative rounded-lg shadow-sm">
<select
name="organizacion"
value={form.organizacion || ''}
onChange={handleInputChange}
className="block w-full rounded-lg border-gray-300 pl-3 pr-10 py-2.5 text-gray-900 placeholder-gray-500
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm
transition-all duration-200 bg-white appearance-none"
>
<option value="">Todas las organizaciones</option>
{organizaciones.results && organizaciones.results.map(org => (
<option key={org.id} value={org.id}>
{org.nombre}
</option>
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<svg className="h-5 w-5 text-gray-400 group-focus-within:text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200"></div>
)}
{/* Botones de acción */}
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4 border-t border-slate-200">
<button

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