Primera version de frontend

This commit is contained in:
2025-07-28 11:00:25 -06:00
parent 748e37cbcc
commit 0dac802736
78 changed files with 18757 additions and 0 deletions

4
.env Normal file
View File

@@ -0,0 +1,4 @@
DEBUG_MODE=true
VITE_EFC_API_URL=http://192.168.1.195:8000/api/v1
VITE_EFC_MICROSERVICE_URL=http://192.168.1.195:8001/api/v1

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# Etapa de desarrollo para Vite + React
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
COPY vite.config.* ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]

22
Dockerfile.prod Normal file
View File

@@ -0,0 +1,22 @@
# Dockerfile de producción para Vite + React
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
COPY vite.config.* ./
RUN npm install --frozen-lockfile
COPY . .
RUN npm run build
# Etapa final: Nginx para servir archivos estáticos
FROM nginx:alpine
# Copia los archivos estáticos generados a la carpeta de Nginx
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

15
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,15 @@
version: '3.8'
services:
frontend:
build:
context: .
dockerfile: Dockerfile.prod
image: efc_frontend_prod:latest
ports:
- "80:80"
restart: unless-stopped
environment:
- NODE_ENV=production
# Si necesitas montar archivos estáticos personalizados, descomenta y ajusta:
# volumes:
# - ./nginx.conf:/etc/nginx/nginx.conf:ro

33
eslint.config.js Normal file
View File

@@ -0,0 +1,33 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

29
nginx.conf Normal file
View File

@@ -0,0 +1,29 @@
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
}

4418
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/roboto": "^5.2.6",
"@tanstack/react-query": "^5.62.7",
"chart.js": "^4.5.0",
"highlight.js": "^11.11.1",
"react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.2",
"react-window": "^1.8.11",
"socket.io-client": "^4.8.1",
"styled-components": "^6.1.19"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"vite": "^6.3.5"
}
}

6
postcss.config.cjs Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

0
public/debug.html Normal file
View File

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

131
src/App.jsx Normal file
View File

@@ -0,0 +1,131 @@
import Documents from './pages/Documents';
import Vucem from './pages/Vucem';
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
import { UserProvider } from './context/UserContext';
import Navbar from './components/Navbar';
import Layout from './components/Layout';
import Login from './pages/Login';
import Admin from './pages/Admin';
import RequireAuth from './components/RequireAuth';
import LandingAnimated from './pages/LandingAnimated';
import Expedientes from './pages/Expedientes';
import Organization from './pages/Organization';
import Users from './pages/Users';
import Reports from './pages/Reports';
import Settings from './pages/Settings';
import Importers from './pages/Importers';
import PedimentoDetail from './pages/PedimentoDetail';
import Procesos from './pages/Procesos';
import TableroAlmacenamiento from './pages/TableroAlmacenamiento';
import Notificaciones from './pages/Notificaciones';
import ForgotPassword from './pages/ForgotPassword';
import PasswordResetConfirm from './pages/PasswordResetConfirm';
// Componente para manejar el layout condicional
function AppContent() {
const location = useLocation();
const isAuthPage = location.pathname === '/login' || location.pathname === '/' || location.pathname === '/forgot-password' || location.pathname.startsWith('/user/password-reset-confirm/');
console.log('🚀 AppContent renderizado');
console.log('📍 Ubicación actual:', location.pathname);
console.log('🔐 Es página de auth:', isAuthPage);
console.log('🎫 Token en localStorage:', !!localStorage.getItem('access'));
if (isAuthPage) {
return (
<>
<Routes>
<Route path="/" element={<LandingAnimated />} />
<Route path="/login" element={<Login />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/user/password-reset-confirm/:uid/:token/" element={<PasswordResetConfirm />} />
</Routes>
</>
);
}
return (
<Layout>
<Routes>
<Route path="/admin" element={
<RequireAuth>
<Admin />
</RequireAuth>
} />
<Route path="/expedientes" element={
<RequireAuth>
<Expedientes />
</RequireAuth>
} />
<Route path="/documents" element={
<RequireAuth>
<Documents />
</RequireAuth>
} />
<Route path="/expedientes/pedimento/:id" element={
<RequireAuth>
<PedimentoDetail />
</RequireAuth>
} />
<Route path="/organization" element={
<RequireAuth>
<Organization />
</RequireAuth>
} />
<Route path="/users" element={
<RequireAuth>
<Users />
</RequireAuth>
} />
<Route path="/reports" element={
<RequireAuth>
<Reports />
</RequireAuth>
} />
<Route path="/settings" element={
<RequireAuth>
<Settings />
</RequireAuth>
} />
<Route path="/notificaciones" element={<Notificaciones />} />
{/* Ruta para importadores */}
<Route path="/importers" element={
<RequireAuth>
<Importers />
</RequireAuth>
} />
{/* Ruta para procesos */}
<Route path="/procesos" element={
<RequireAuth>
<Procesos />
</RequireAuth>
} />
{/* Ruta para Uso de Almacenamiento */}
<Route path="/tablero/almacenamiento" element={
<RequireAuth>
<TableroAlmacenamiento />
</RequireAuth>
} />
{/* Ruta para Vucem */}
<Route path="/vucem" element={
<RequireAuth>
<Vucem />
</RequireAuth>
} />
</Routes>
</Layout>
);
}
function App() {
return (
<BrowserRouter>
<UserProvider>
<AppContent />
</UserProvider>
</BrowserRouter>
);
}
export default App;

25
src/api/auth.js Normal file
View File

@@ -0,0 +1,25 @@
const API_URL = import.meta.env.VITE_EFC_API_URL;
export async function login(username, password) {
const response = await fetch(`${API_URL}/token/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
console.log('API URL:', `${API_URL}/token/`);
if (!response.ok) {
throw new Error('Credenciales inválidas');
}
return response.json(); // { access, refresh }
}
export async function refreshToken(refresh) {
const res = await fetch(`${API_URL}/token/refresh/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh }),
});
if (!res.ok) throw new Error('SESSION_EXPIRED');
return res.json(); // { access: '...' }
}

60
src/api/documentos.ts Normal file
View File

@@ -0,0 +1,60 @@
// src/api/pedimentoDocuments.ts
export interface PedimentoDocument {
id: string;
organizacion: string;
pedimento: string;
pedimento_numero:string;
archivo: string;
document_type: number;
size: number;
extension: string;
created_at: string;
updated_at: string;
}
export interface PedimentoDocumentsResponse {
count: number;
next: string | null;
previous: string | null;
results: PedimentoDocument[];
}
const API_URL = import.meta.env.VITE_EFC_API_URL;
export async function fetchPedimentoDocuments(
token: string,
pedimentoId: string = '',
page: number = 1,
pageSize: number = 10,
filters: {
pedimento_numero?: string;
extension?: string;
document_type?: string | number;
created_at?: string;
} = {}
): Promise<PedimentoDocumentsResponse> {
const params = new URLSearchParams();
params.append('page', String(page));
params.append('page_size', String(pageSize));
if (pedimentoId) params.append('pedimento', pedimentoId);
if (filters.pedimento_numero) params.append('pedimento_numero', filters.pedimento_numero);
if (filters.extension) params.append('extension', filters.extension);
if (filters.document_type) params.append('document_type', String(filters.document_type));
if (filters.created_at) params.append('created_at', filters.created_at);
const res = await fetch(
`${API_URL}/record/documents/?${params.toString()}`,
{
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
}
);
if (res.status === 401) {
throw new Error('SESSION_EXPIRED');
}
if (!res.ok) throw new Error('No autorizado o error en la petición');
return res.json();
}

102
src/api/documents.js Normal file
View File

@@ -0,0 +1,102 @@
/**
* @typedef {Object} Document
* @property {string} id
* @property {string} organizacion
* @property {string} pedimento
* @property {string} archivo
* @property {number} document_type
* @property {number} size
* @property {string} extension
* @property {string} created_at
* @property {string} updated_at
*/
/**
* @typedef {Object} DocumentsResponse
* @property {number} count
* @property {string|null} next
* @property {string|null} previous
* @property {Document[]} results
*/
import { refreshToken } from './auth';
const API_URL = import.meta.env.VITE_EFC_API_URL;
/**
* Obtiene la lista de documentos (pedimentos)
* @param {string} token
* @returns {Promise<DocumentsResponse>}
*/
export async function fetchDocuments(token, queryString = '') {
let url = `${API_URL}/customs/pedimentos/`;
if (queryString) {
url += `?${queryString}`;
}
let res = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (res.status === 401) {
// Intentar refrescar el token
const refresh = localStorage.getItem('refresh');
if (refresh) {
try {
const data = await refreshToken(refresh);
localStorage.setItem('access', data.access);
// Reintenta la petición con el nuevo access token
res = await fetch(`${API_URL}/customs/pedimentos/`, {
headers: {
'Authorization': `Bearer ${data.access}`,
'Content-Type': 'application/json',
},
});
} catch (err) {
throw new Error('SESSION_EXPIRED');
}
} else {
throw new Error('SESSION_EXPIRED');
}
}
if (!res.ok) throw new Error('No autorizado o error en la petición');
return res.json(); // Tipado por JSDoc: Promise<DocumentsResponse>
}
/**
* Obtiene los documentos por id de pedimento
* @param {string} token
* @param {string} id
* @returns {Promise<DocumentsResponse>}
*/
export async function fetchDocumentById(token, id) {
let res = await fetch(`${API_URL}/record/documents/?page=1&page_size=10&pedimento=${id}/`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (res.status === 401) {
// Intentar refrescar el token
const refresh = localStorage.getItem('refresh');
if (refresh) {
try {
const data = await refreshToken(refresh);
localStorage.setItem('access', data.access);
// Reintenta la petición con el nuevo access token
res = await fetch(`${API_URL}/record/documents/?page=1&page_size=10&pedimento=${id}/`, {
headers: {
'Authorization': `Bearer ${data.access}`,
'Content-Type': 'application/json',
},
});
} catch (err) {
throw new Error('SESSION_EXPIRED');
}
} else {
throw new Error('SESSION_EXPIRED');
}
}
if (!res.ok) throw new Error('No autorizado o error en la petición');
return res.json(); // Tipado por JSDoc: Promise<DocumentsResponse>
}

0
src/api/documents.ts Normal file
View File

115
src/api/expedientes.ts Normal file
View File

@@ -0,0 +1,115 @@
export interface Document {
id: string;
organizacion: string;
pedimento: string;
archivo: string;
document_type: number;
size: number;
extension: string;
created_at: string;
updated_at: string;
}
export interface DocumentsResponse {
count: number;
next: string | null;
previous: string | null;
results: Document[];
}
import { refreshToken } from './auth';
const API_URL = import.meta.env.VITE_EFC_API_URL;
// Obtiene la lista de documentos (pedimentos)
export interface PedimentosFilters {
search?: string;
pedimento?: string;
existe_expediente?: string | boolean;
alerta?: string | boolean;
contribuyente?: string;
curp_apoderado?: string;
fecha_pago?: string;
patente?: string;
aduana?: string;
tipo_operacion?: string;
clave_pedimento?: string;
}
export async function fetchDocuments(
token: string,
page: number = 1,
pageSize: number = 10,
filters: PedimentosFilters = {}
): Promise<DocumentsResponse> {
const params = new URLSearchParams();
params.append('page', String(page));
params.append('page_size', String(pageSize));
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
params.append(key, String(value));
}
});
let res = await fetch(`${API_URL}/customs/pedimentos/?${params.toString()}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (res.status === 401) {
// Intentar refrescar el token
const refresh = localStorage.getItem('refresh');
if (refresh) {
try {
const data = await refreshToken(refresh);
localStorage.setItem('access', data.access);
// Reintenta la petición con el nuevo access token
res = await fetch(`${API_URL}/customs/pedimentos/?page=${page}&page_size=${pageSize}`, {
headers: {
'Authorization': `Bearer ${data.access}`,
'Content-Type': 'application/json',
},
});
} catch (err) {
throw new Error('SESSION_EXPIRED');
}
} else {
throw new Error('SESSION_EXPIRED');
}
}
if (!res.ok) throw new Error('No autorizado o error en la petición');
return res.json();
}
// Obtiene los documentos por id de pedimento
export async function fetchDocumentById(token: string, id: string): Promise<DocumentsResponse> {
let res = await fetch(`${API_URL}/record/documents/?page=1&page_size=10&pedimento=${id}/`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (res.status === 401) {
// Intentar refrescar el token
const refresh = localStorage.getItem('refresh');
if (refresh) {
try {
const data = await refreshToken(refresh);
localStorage.setItem('access', data.access);
// Reintenta la petición con el nuevo access token
res = await fetch(`${API_URL}/record/documents/?page=1&page_size=10&pedimento=${id}/`, {
headers: {
'Authorization': `Bearer ${data.access}`,
'Content-Type': 'application/json',
},
});
} catch (err) {
throw new Error('SESSION_EXPIRED');
}
} else {
throw new Error('SESSION_EXPIRED');
}
}
if (!res.ok) throw new Error('No autorizado o error en la petición');
return res.json();
}

68
src/api/notificaciones.ts Normal file
View File

@@ -0,0 +1,68 @@
// PUT para marcar una notificación como vista
export async function marcarNotificacionComoVista(id: number): Promise<Notificacion> {
const token = localStorage.getItem('access');
const url = `${API_URL}/notificaciones/notificaciones/${id}/`;
const headers = new Headers();
if (token) headers.append('Authorization', `Bearer ${token}`);
headers.append('Content-Type', 'application/json');
const res = await fetch(url, {
method: 'PUT',
headers,
body: JSON.stringify({ visto: true })
});
if (!res.ok) throw new Error('Error al actualizar notificación');
return await res.json();
}
// src/api/notificaciones.ts
export interface TipoNotificacion {
id: number;
tipo: string;
descripcion: string;
}
export interface Notificacion {
id: number;
tipo: TipoNotificacion;
dirigido: string;
mensaje: string;
fecha_envio: string;
created_at: string;
visto: boolean;
}
export interface NotificacionesResponse {
count: number;
next: string | null;
previous: string | null;
results: Notificacion[];
}
const API_URL = import.meta.env.VITE_EFC_API_URL;
export async function fetchNotificaciones({ page = 1, pageSize = 10, visto = false } = {}): Promise<NotificacionesResponse> {
const token = localStorage.getItem('access');
const url = `${API_URL}/notificaciones/notificaciones/?page=${page}&page_size=${pageSize}&visto=${visto}`;
const headers = new Headers();
if (token) headers.append('Authorization', `Bearer ${token}`);
headers.append('Content-Type', 'application/json');
const res = await fetch(url, {
headers,
});
if (!res.ok) throw new Error('Error al obtener notificaciones');
return await res.json();
}
export async function fetchAllNotifications({page = 1, page_size=10}): Promise<NotificacionesResponse>{
const token = localStorage.getItem('access');
const url = `${API_URL}/notificaciones/notificaciones/?page=${page}&page_size=${page_size}`;
const headers = new Headers();
if (token) headers.append('Authorization', `Bearer ${token}`);
headers.append('Content-Type', 'application/json');
const res = await fetch(url, {
headers,
});
if (!res.ok) throw new Error('Error al obtener notificaciones');
return await res.json();
}

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

@@ -0,0 +1,36 @@
import { refreshToken } from './auth';
const API_URL = import.meta.env.VITE_EFC_API_URL;
export async function fetchOrganizationUsage(token) {
let res = await fetch(`${API_URL}/organization/uso-almacenamiento/mi_organizacion/`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (res.status === 401) {
// Intentar refrescar el token
const refresh = localStorage.getItem('refresh');
if (refresh) {
try {
const data = await refreshToken(refresh);
localStorage.setItem('access', data.access);
// Reintenta la petición con el nuevo access token
res = await fetch(`${API_URL}/organization/uso-almacenamiento/mi_organizacion/`, {
headers: {
'Authorization': `Bearer ${data.access}`,
'Content-Type': 'application/json',
},
});
if (res.status === 401) throw new Error('SESSION_EXPIRED');
} catch (err) {
throw new Error('SESSION_EXPIRED');
}
} else {
throw new Error('SESSION_EXPIRED');
}
}
if (!res.ok) throw new Error('No autorizado o error en la petición');
return res.json();
}

33
src/api/organization.ts Normal file
View File

@@ -0,0 +1,33 @@
// organization.ts
// Tipos para la respuesta del endpoint de uso de almacenamiento de organización
export interface OrganizationUsage {
organizacion: string;
limite_almacenamiento_gb: number;
espacio_utilizado_bytes: number;
espacio_utilizado_gb: number;
espacio_disponible_bytes: number;
porcentaje_utilizado: number;
total_documentos: number;
total_pedimentos: number;
total_usuarios: number;
}
const API_URL = import.meta.env.VITE_EFC_API_URL;
// Ejemplo de función para obtener la información tipada
export async function fetchOrganizationUsage(token: string): Promise<OrganizationUsage> {
const res = await fetch(`${API_URL}/organization/uso-almacenamiento/mi_organizacion/`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (res.status === 401) {
throw new Error('SESSION_EXPIRED');
}
if (!res.ok) {
throw new Error('Error al obtener información de la organización');
}
return res.json();
}

View File

@@ -0,0 +1,44 @@
// src/api/pedimentoDocuments.ts
export interface PedimentoDocument {
id: string;
organizacion: string;
pedimento: string;
archivo: string;
document_type: number;
size: number;
extension: string;
created_at: string;
updated_at: string;
}
export interface PedimentoDocumentsResponse {
count: number;
next: string | null;
previous: string | null;
results: PedimentoDocument[];
}
const API_URL = import.meta.env.VITE_EFC_API_URL;
export async function fetchPedimentoDocuments(
token: string,
pedimentoId: string,
page: number = 1,
pageSize: number = 10
): Promise<PedimentoDocumentsResponse> {
const res = await fetch(
`${API_URL}/record/documents/?page=${page}&page_size=${pageSize}&pedimento=${pedimentoId}`,
{
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
}
);
if (res.status === 401) {
throw new Error('SESSION_EXPIRED');
}
if (!res.ok) throw new Error('No autorizado o error en la petición');
return res.json();
}

33
src/api/procesos.ts Normal file
View File

@@ -0,0 +1,33 @@
// Tipos para la respuesta y registros
export interface ProcesamientoPedimento {
id: number;
created_at: string;
updated_at: string;
organizacion: string;
organizacion_name: string;
estado: number;
tipo_procesamiento: number;
pedimento: string;
servicio: number;
}
export interface ProcesamientoPedimentosResponse {
count: number;
next: string | null;
previous: string | null;
results: ProcesamientoPedimento[];
}
// API para customs/procesamientopedimentos/
export async function fetchProcesamientoPedimentos(
token: string | null,
page: number = 1,
pageSize: number = 20
): Promise<ProcesamientoPedimentosResponse> {
const API_URL = import.meta.env.VITE_EFC_API_URL;
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_URL}/customs/procesamientopedimentos/?page=${page}&page_size=${pageSize}`, { headers });
if (!res.ok) throw new Error('Error al obtener procesamiento de pedimentos');
return await res.json();
}

83
src/api/users.js Normal file
View File

@@ -0,0 +1,83 @@
const API_URL = import.meta.env.VITE_EFC_API_URL || 'http://localhost:8000';
async function handleResponse(response, operation = 'operación') {
if (response.status === 401) {
throw new Error('SESSION_EXPIRED');
}
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Error ${response.status}: ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
throw new Error('El servidor no devolvió JSON válido');
}
return response.json();
}
export async function fetchUsers(token) {
const url = `${API_URL}/user/users/`;
const res = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
return handleResponse(res, 'Fetch Users');
}
export async function createUser(token, userData) {
const url = `${API_URL}/user/users/`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(userData),
});
return handleResponse(res, 'Create User');
}
export async function updateUser(token, id, userData) {
const url = `${API_URL}/user/users/${id}/`;
const res = await fetch(url, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(userData),
});
return handleResponse(res, 'Update User');
}
export async function deleteUser(token, id) {
const url = `${API_URL}/user/users/${id}/`;
const res = await fetch(url, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
if (res.status === 401) throw new Error('SESSION_EXPIRED');
if (!res.ok) throw new Error(`Error ${res.status}: ${res.statusText}`);
return true;
}
export async function getCurrentUser(token) {
const url = `${API_URL}/user/users/me/`;
const res = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
return handleResponse(res, 'Get Current User');
}

172
src/api/users.ts Normal file
View File

@@ -0,0 +1,172 @@
const API_URL = import.meta.env.VITE_EFC_API_URL || 'http://localhost:8000';
// Función helper para manejar respuestas
async function handleResponse(response, operation = 'operación') {
console.log(`📡 ${operation} response:`, response.status, response.statusText);
if (response.status === 401) {
console.error('❌ Unauthorized - session expired');
throw new Error('SESSION_EXPIRED');
}
if (!response.ok) {
const errorText = await response.text();
console.error(`${operation} error:`, response.status, errorText);
throw new Error(`Error ${response.status}: ${response.statusText}`);
}
// Verificar que la respuesta es JSON
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
const text = await response.text();
console.error('❌ Response is not JSON:', text.substring(0, 200));
throw new Error('El servidor no devolvió JSON válido');
}
return response.json();
}
export async function fetchUsers(token) {
try {
const url = `${API_URL}/user/users/`;
console.log('👥 Fetching users from:', url);
const res = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
const data = await handleResponse(res, 'Fetch Users');
console.log('✅ Users data received');
return data;
} catch (error) {
console.error('❌ Error in fetchUsers:', error);
if (error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error('Error de conexión al servidor');
}
throw error;
}
}
export async function createUser(token, userData) {
try {
const url = `${API_URL}/user/users/`;
console.log(' Creating user at:', url);
const res = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(userData),
});
const data = await handleResponse(res, 'Create User');
console.log('✅ User created successfully');
return data;
} catch (error) {
console.error('❌ Error in createUser:', error);
if (error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error('Error de conexión al servidor');
}
throw error;
}
}
export async function updateUser(token, id, userData) {
try {
const url = `${API_URL}/user/users/${id}/`;
console.log('✏️ Updating user at:', url);
const res = await fetch(url, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(userData),
});
const data = await handleResponse(res, 'Update User');
console.log('✅ User updated successfully');
return data;
} catch (error) {
console.error('❌ Error in updateUser:', error);
if (error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error('Error de conexión al servidor');
}
throw error;
}
}
export async function deleteUser(token, id) {
try {
const url = `${API_URL}/user/users/${id}/`;
console.log('🗑️ Deleting user at:', url);
const res = await fetch(url, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
if (res.status === 401) {
console.error('❌ Unauthorized - session expired');
throw new Error('SESSION_EXPIRED');
}
if (!res.ok) {
const errorText = await res.text();
console.error('❌ Delete User error:', res.status, errorText);
throw new Error(`Error ${res.status}: ${res.statusText}`);
}
console.log('✅ User deleted successfully');
return true; // DELETE suele no devolver contenido
} catch (error) {
console.error('❌ Error in deleteUser:', error);
if (error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error('Error de conexión al servidor');
}
throw error;
}
}
export async function getCurrentUser(token) {
try {
const url = `${API_URL}/user/users/me/`;
console.log('👤 Fetching current user from:', url);
const res = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
const data = await handleResponse(res, 'Get Current User');
console.log('✅ Current user data received:', data);
return data;
} catch (error) {
console.error('❌ Error in getCurrentUser:', error);
if (error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error('Error de conexión al servidor');
}
throw error;
}
}

View File

@@ -0,0 +1,19 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
@keyframes fadein-slideup {
0% {
opacity: 0;
transform: translateY(24px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadein-slideup {
animation: fadein-slideup 0.7s cubic-bezier(0.4,0,0.2,1) both;
}
}

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

17
src/components/Layout.jsx Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import Sidebar from './Sidebar';
import NotificationBell from './NotificationBell';
export default function Layout({ children }) {
return (
<div className="flex h-screen bg-light-gray overflow-hidden">
<Sidebar />
<main className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto overflow-x-hidden">
{children}
</div>
</main>
<NotificationBell />
</div>
);
}

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
export default function LogoutButton() {
const navigate = useNavigate();
const handleLogout = () => {
localStorage.removeItem('access');
localStorage.removeItem('refresh');
// Disparar evento para actualizar el navbar
window.dispatchEvent(new CustomEvent('authStateChanged'));
navigate('/login');
};
return (
<button
onClick={handleLogout}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition duration-200"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Cerrar sesión
</button>
);
}

186
src/components/Navbar.jsx Normal file
View File

@@ -0,0 +1,186 @@
import React, { useState, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { colors } from '../theme';
export default function Navbar() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const location = useLocation();
useEffect(() => {
const checkAuthStatus = () => {
const token = localStorage.getItem('access');
setIsLoggedIn(!!token);
};
checkAuthStatus();
window.addEventListener('authStateChanged', checkAuthStatus);
return () => window.removeEventListener('authStateChanged', checkAuthStatus);
}, []);
const logout = () => {
localStorage.removeItem('access');
localStorage.removeItem('refresh');
window.dispatchEvent(new CustomEvent('authStateChanged'));
window.location.href = '/';
};
const navLinks = [
{ path: '/', name: 'Inicio', public: true },
{ path: '/admin', name: 'Dashboard', public: false },
{ path: '/documents', name: 'Documentos', public: false },
{ path: '/mi-organizacion', name: 'Organización', public: false },
{ path: '/usuarios', name: 'Usuarios', public: false },
{ path: '/reportes', name: 'Reportes', public: false },
];
const isActiveLink = (path) => {
return location.pathname === path;
};
return (
<nav className="bg-white shadow-xl border-b border-gray-200 sticky top-0 z-50 backdrop-blur-lg bg-white/95">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<div className="flex-shrink-0">
<Link to="/" className="flex items-center group">
<div className="w-10 h-10 bg-gradient-to-br from-navy-500 to-navy-600 rounded-xl flex items-center justify-center shadow-lg mr-3 transition-all duration-200 group-hover:shadow-xl group-hover:scale-105">
<span className="text-lg font-bold text-white">E</span>
</div>
<div className="hidden sm:block">
<span className="text-2xl font-bold bg-gradient-to-r from-navy-900 to-navy-700 bg-clip-text text-transparent">EFC</span>
<span className="ml-2 text-sm text-text-secondary">
Export & Finance Control
</span>
</div>
</Link>
</div>
{/* Desktop Navigation */}
<div className="hidden md:block">
<div className="ml-10 flex items-baseline space-x-2">
{navLinks
.filter(link => link.public || isLoggedIn)
.map((link) => (
<Link
key={link.path}
to={link.path}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
isActiveLink(link.path)
? 'bg-gradient-to-r from-navy-600 to-navy-700 text-white shadow-lg transform scale-105'
: 'text-text-primary hover:bg-gradient-to-r hover:from-light-gray-100 hover:to-light-gray-200 hover:text-navy-700 hover:shadow-md hover:transform hover:scale-105'
}`}
>
{link.name}
</Link>
))}
</div>
</div>
{/* Auth buttons */}
<div className="hidden md:block">
<div className="ml-4 flex items-center md:ml-6">
{isLoggedIn ? (
<div className="flex items-center space-x-4">
<span className="text-text-secondary text-sm font-medium">
Bienvenido
</span>
<button
onClick={logout}
className="bg-gradient-to-r from-danger-600 to-danger-700 hover:from-danger-700 hover:to-danger-800 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 flex items-center space-x-2 shadow-lg hover:shadow-xl transform hover:scale-105"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>Salir</span>
</button>
</div>
) : (
<Link
to="/login"
className="bg-gradient-to-r from-navy-600 to-navy-700 hover:from-navy-700 hover:to-navy-800 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 flex items-center space-x-2 shadow-lg hover:shadow-xl transform hover:scale-105"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>Ingresar</span>
</Link>
)}
</div>
</div>
{/* Mobile menu button */}
<div className="md:hidden">
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="inline-flex items-center justify-center p-2 rounded-md text-text-primary hover:text-navy hover:bg-light-gray focus:outline-none focus:ring-2 focus:ring-inset focus:ring-navy"
>
<svg
className={`${isMenuOpen ? 'hidden' : 'block'} h-6 w-6`}
stroke="currentColor"
fill="none"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg
className={`${isMenuOpen ? 'block' : 'hidden'} h-6 w-6`}
stroke="currentColor"
fill="none"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
{/* Mobile menu */}
<div className={`md:hidden ${isMenuOpen ? 'block' : 'hidden'}`}>
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3 bg-light-gray border-t border-gray-200">
{navLinks
.filter(link => link.public || isLoggedIn)
.map((link) => (
<Link
key={link.path}
to={link.path}
className={`block px-3 py-2 rounded-md text-base font-medium transition-colors duration-200 ${
isActiveLink(link.path)
? 'bg-navy text-white'
: 'text-text-primary hover:bg-white hover:text-navy'
}`}
onClick={() => setIsMenuOpen(false)}
>
{link.name}
</Link>
))}
{/* Mobile auth section */}
<div className="border-t border-gray-300 pt-4 pb-3">
{isLoggedIn ? (
<button
onClick={() => {
logout();
setIsMenuOpen(false);
}}
className="block w-full text-left px-3 py-2 rounded-md text-base font-medium text-error hover:bg-white transition-colors duration-200"
>
Salir
</button>
) : (
<Link
to="/login"
className="block px-3 py-2 rounded-md text-base font-medium text-navy hover:bg-white transition-colors duration-200"
onClick={() => setIsMenuOpen(false)}
>
Ingresar
</Link>
)}
</div>
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,222 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { fetchNotificaciones, marcarNotificacionComoVista } from '../api/notificaciones';
export default function NotificationBell() {
const navigate = useNavigate();
const [notifications, setNotifications] = useState([]);
const [showPanel, setShowPanel] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
setError(null);
fetchNotificaciones({ page: 1, pageSize: 50 })
.then(data => {
setNotifications(data.results);
setUnreadCount(data.results.filter(n => !n.visto).length);
})
.catch(err => {
setError('Error al cargar notificaciones');
})
.finally(() => setLoading(false));
}, []);
// Opcional: aquí podrías hacer una petición PATCH/POST si es necesario
const markAsRead = async (notificationId) => {
try {
await marcarNotificacionComoVista(notificationId);
setNotifications(prev =>
prev.map(notification =>
notification.id === notificationId
? { ...notification, visto: true }
: notification
)
);
setUnreadCount(prev => Math.max(0, prev - 1));
} catch (e) {
// Opcional: mostrar error
}
};
const markAllAsRead = async () => {
const notVistas = notifications.filter(n => !n.visto);
try {
await Promise.all(notVistas.map(n => marcarNotificacionComoVista(n.id)));
setNotifications(prev => prev.map(notification => ({ ...notification, visto: true })));
setUnreadCount(0);
} catch (e) {
// Opcional: mostrar error
}
};
const getNotificationIcon = (tipo) => {
switch (tipo) {
case 'success':
return (
<div className="flex-shrink-0 w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-green-600" 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>
</div>
);
case 'warning':
return (
<div className="flex-shrink-0 w-8 h-8 bg-yellow-100 rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
);
default:
return (
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
);
}
};
const formatTimestamp = (isoString) => {
const timestamp = new Date(isoString);
const now = new Date();
const diff = now.getTime() - timestamp.getTime();
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (minutes < 1) return 'Ahora';
if (minutes < 60) return `${minutes}m`;
if (hours < 24) return `${hours}h`;
return `${days}d`;
};
return (
<>
{/* Campanita flotante */}
<div className="fixed bottom-6 right-6 z-50">
<button
onClick={() => setShowPanel(!showPanel)}
className="relative bg-indigo-600 hover:bg-indigo-700 text-white p-3 rounded-full shadow-lg transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 17h5l-5-5v5zM4 4h5l-5 5v-5z" />
</svg>
{/* Contador de notificaciones no leídas */}
{unreadCount > 0 && (
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
</div>
{/* Panel de notificaciones */}
{showPanel && (
<div className="fixed bottom-20 right-6 z-50 w-80 bg-white rounded-lg shadow-xl border border-gray-200">
{/* Header del panel */}
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Notificaciones</h3>
<div className="flex items-center space-x-2">
{unreadCount > 0 && (
<button
onClick={markAllAsRead}
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
>
Marcar todas como leídas
</button>
)}
<button
onClick={() => setShowPanel(false)}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Lista de notificaciones */}
<div className="max-h-96 overflow-y-auto">
{loading ? (
<div className="p-6 text-center text-blue-500">Cargando notificaciones...</div>
) : error ? (
<div className="p-6 text-center text-red-500">{error}</div>
) : notifications.length === 0 ? (
<div className="p-6 text-center">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 17h5l-5 5v-5zM13 3l-4 9h6l-4 9" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No hay notificaciones</h3>
<p className="mt-1 text-sm text-gray-500">Cuando tengas nuevas notificaciones aparecerán aquí.</p>
</div>
) : (
<div className="divide-y divide-gray-200">
{notifications.map((notification) => (
<div
key={notification.id}
className={`p-4 hover:bg-gray-50 cursor-pointer transition-colors ${
!notification.visto ? 'bg-blue-50' : ''
}`}
onClick={() => markAsRead(notification.id)}
>
<div className="flex space-x-3">
{getNotificationIcon(notification.tipo?.tipo)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className={`text-sm font-medium ${
!notification.visto ? 'text-gray-900' : 'text-gray-700'
}`}>
{notification.tipo?.descripcion || notification.tipo?.tipo || 'Notificación'}
</p>
<span className="text-xs text-gray-500">
{formatTimestamp(notification.fecha_envio || notification.created_at)}
</span>
</div>
<p className="text-sm text-gray-600 mt-1">{notification.mensaje}</p>
{!notification.visto && (
<div className="mt-2">
<span className="inline-block w-2 h-2 bg-blue-600 rounded-full"></span>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Footer del panel */}
{notifications.length > 0 && (
<div className="p-3 border-t border-gray-200 bg-gray-50 rounded-b-lg">
<button
className="w-full text-center text-sm text-indigo-600 hover:text-indigo-800 font-medium"
onClick={() => {
setShowPanel(false);
navigate('/notificaciones');
}}
>
Ver todas las notificaciones
</button>
</div>
)}
</div>
)}
{/* Overlay para cerrar el panel al hacer clic fuera */}
{showPanel && (
<div
className="fixed inset-0 z-40"
onClick={() => setShowPanel(false)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
// Esta función verifica si el usuario está autenticado (por ejemplo, si hay un token en localStorage)
function isAuthenticated() {
const token = localStorage.getItem('access');
console.log('🔐 Verificando autenticación, token:', token ? 'presente' : 'ausente');
return !!token;
}
export default function RequireAuth({ children }) {
const authenticated = isAuthenticated();
console.log('🛡️ RequireAuth - usuario autenticado:', authenticated);
if (!authenticated) {
console.log('❌ No autenticado, redirigiendo a /login');
return <Navigate to="/login" replace />;
}
console.log('✅ Usuario autenticado, mostrando contenido');
return children;
}

370
src/components/Sidebar.jsx Normal file
View File

@@ -0,0 +1,370 @@
import React, { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useUser } from '../context/UserContext';
export default function Sidebar() {
// Leer si el usuario es importador desde localStorage
const isImportador = typeof window !== 'undefined' && localStorage.getItem('user_is_importador') === 'true';
const [isCollapsed, setIsCollapsed] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const { user: currentUser, loading } = useUser();
const handleLogout = () => {
localStorage.removeItem('access');
localStorage.removeItem('refresh');
window.dispatchEvent(new CustomEvent('authStateChanged'));
navigate('/login');
};
// El usuario y loading ahora vienen del contexto global
// Definir todas las secciones
const allMenuSections = [
{
title: 'Organización',
items: [
{
name: 'Home',
path: '/admin',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
)
},
{
name: 'Mi Organización',
path: '/organization',
icon: (
<svg className="w-5 h-5" 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>
)
}
]
},
{
title: 'Servicios',
items: [
{
name: 'Procesos',
path: '/procesos',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4l3 3m6 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
}
]
},
{
title: 'Documentación',
items: [
{
name: 'Reportes',
path: '/reports',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
)
},
{
name: 'Expedientes',
path: '/expedientes',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="7" width="18" height="13" rx="2" ry="2" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M16 3H8a2 2 0 00-2 2v2h12V5a2 2 0 00-2-2z" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
},
{
name: 'Documentos',
path: '/documents',
icon: (
<svg className="w-5 h-5" 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>
)
}
]
},
// Nueva sección Tableros
{
title: 'Tableros',
items: [
{
name: 'Uso de Almacenamiento',
path: '/tablero/almacenamiento',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
)
}
]
},
{
title: 'Acceso a Usuarios',
items: [
...(
isImportador
? []
: [
{
name: 'Usuarios',
path: '/users',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
)
}
]
),
{
name: 'Vucem',
path: '/vucem',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 12h8M12 8v8" />
</svg>
)
}
]
}
];
// Filtrar secciones según si es importador
// Modificar items según si es importador
const menuSections = allMenuSections
.map(section => {
if (section.title === 'Organización') {
return {
...section,
items: section.items.filter(item => !(isImportador && item.name === 'Mi Organización'))
};
}
// Para Acceso a Usuarios, no filtrar la sección, solo los items ya están condicionados arriba
if (section.title === 'Tableros' && isImportador) {
return null;
}
return section;
})
.filter(Boolean);
return (
<div className={`bg-slate-900 text-white transition-all duration-300 ${isCollapsed ? 'w-16' : 'w-64'} h-screen flex flex-col shadow-xl`}>
{/* Header - Logo y colapsar */}
<div className="p-4 border-b border-slate-700 flex-shrink-0">
<div className="flex items-center justify-between">
{!isCollapsed && (
<div className="flex items-center">
{/* Logo de la organización */}
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-3 shadow-lg">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="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>
<h1 className="text-lg font-bold text-white">EFC Dashboard</h1>
</div>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="p-1.5 rounded-lg hover:bg-slate-700 transition-all duration-200 hover:shadow-md"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{isCollapsed ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
)}
</svg>
</button>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-3 space-y-4 overflow-y-auto overflow-x-hidden">
{menuSections.map((section, sectionIndex) => (
<div key={sectionIndex} className="space-y-1">
{/* Título de la sección */}
{!isCollapsed && (
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider px-3 py-1">
{section.title}
</h3>
)}
{/* Items de la sección */}
<div className="space-y-1">
{section.items.map((item) => {
const isActive = location.pathname === item.path;
const isDisabled = item.disabled;
return (
<Link
key={item.path}
to={isDisabled ? '#' : item.path}
className={`flex items-center px-3 py-2.5 text-sm rounded-lg transition-all duration-200 group relative ${
isDisabled
? 'opacity-50 cursor-not-allowed pointer-events-none bg-slate-800 text-gray-500'
: isActive
? 'bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg transform scale-[1.02]'
: 'text-gray-300 hover:bg-slate-700 hover:text-white hover:transform hover:scale-[1.01]'
}`}
title={isCollapsed ? item.name : ''}
tabIndex={isDisabled ? -1 : 0}
aria-disabled={isDisabled ? 'true' : 'false'}
>
{/* Indicador activo lateral */}
{isActive && !isDisabled && (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-cyan-400 rounded-r-full"></div>
)}
<div className="flex-shrink-0">
{item.icon}
</div>
{!isCollapsed && (
<span className="ml-3 font-medium">{item.name}</span>
)}
</Link>
);
})}
</div>
{/* Línea separadora entre secciones (excepto la última) */}
{!isCollapsed && sectionIndex < menuSections.length - 1 && (
<div className="border-b border-slate-600 mx-3 my-3"></div>
)}
</div>
))}
</nav>
{/* Configuración */}
<div className="p-3 border-t border-slate-700 flex-shrink-0">
<Link
to="/settings"
className={`flex items-center px-3 py-2.5 text-sm rounded-lg transition-all duration-200 group ${
location.pathname === '/settings'
? 'bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg'
: 'text-gray-300 hover:bg-slate-700 hover:text-white hover:shadow-md'
}`}
title={isCollapsed ? 'Configuración' : ''}
>
<div className="flex-shrink-0">
<svg className="w-5 h-5" 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>
{!isCollapsed && (
<span className="ml-3 font-medium">Configuración</span>
)}
</Link>
</div>
{/* Footer - Perfil del Usuario */}
<div className="p-3 border-t border-slate-700 flex-shrink-0">
{!isCollapsed ? (
<div className="space-y-3">
{/* Información del usuario */}
<div className="flex items-center space-x-3 p-2 bg-slate-800 rounded-lg">
<div className="flex-shrink-0">
{currentUser?.profile_picture ? (
<img
className="w-10 h-10 rounded-full ring-2 ring-blue-500 shadow-lg"
src={currentUser.profile_picture}
alt="Avatar del usuario"
/>
) : (
<div className="w-10 h-10 bg-gradient-to-br from-gray-500 to-gray-600 rounded-full ring-2 ring-blue-500 flex items-center justify-center shadow-lg">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="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>
<div className="flex-1 min-w-0">
{loading ? (
<div className="animate-pulse">
<div className="h-4 bg-slate-600 rounded w-24 mb-1"></div>
<div className="h-3 bg-slate-700 rounded w-16"></div>
</div>
) : currentUser ? (
<>
<p className="text-sm font-medium text-white truncate">
{currentUser.first_name} {currentUser.last_name}
</p>
<p className="text-xs text-gray-400 truncate">
{currentUser.username}
</p>
</>
) : (
<>
<p className="text-sm font-medium text-white truncate">
Usuario
</p>
<p className="text-xs text-gray-400 truncate">
Sin datos
</p>
</>
)}
{/* Debug temporal */}
{process.env.NODE_ENV === 'development' && (
<div className="text-xs text-orange-400 mt-1">
Debug: {loading ? 'Cargando...' : currentUser ? 'Datos OK' : 'Sin datos'}
</div>
)}
</div>
</div>
{/* Botón de logout más pequeño */}
<div className="pt-1">
<button
onClick={handleLogout}
className="w-full inline-flex items-center justify-center px-3 py-2.5 border border-transparent text-xs font-medium rounded-lg text-white bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 focus:ring-offset-slate-900 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-[1.02]"
>
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Cerrar sesión
</button>
</div>
</div>
) : (
<div className="flex flex-col items-center space-y-3">
{/* Avatar pequeño */}
{currentUser?.profile_picture ? (
<img
className="w-10 h-10 rounded-full ring-2 ring-blue-500 shadow-lg hover:ring-blue-400 transition-all duration-200"
src={currentUser.profile_picture}
alt="Avatar del usuario"
title={currentUser ? `${currentUser.first_name} ${currentUser.last_name} - ${currentUser.username}` : 'Usuario'}
/>
) : (
<div
className="w-10 h-10 bg-gradient-to-br from-gray-500 to-gray-600 rounded-full ring-2 ring-blue-500 flex items-center justify-center shadow-lg hover:ring-blue-400 transition-all duration-200"
title={currentUser ? `${currentUser.first_name} ${currentUser.last_name} - ${currentUser.username}` : 'Usuario'}
>
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="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>
)}
{/* Botón de logout compacto */}
<button
onClick={handleLogout}
className="p-2 text-gray-300 hover:text-white bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-105"
title="Cerrar sesión"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,211 @@
import React, { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
export default function Sidebar() {
const [isCollapsed, setIsCollapsed] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const handleLogout = () => {
localStorage.removeItem('access');
localStorage.removeItem('refresh');
// Disparar evento para actualizar el navbar
window.dispatchEvent(new CustomEvent('authStateChanged'));
navigate('/login');
};
export default function Sidebar() {
const [isCollapsed, setIsCollapsed] = useState(false);
const location = useLocation();
const menuItems = [
{
name: 'Mi Organización',
path: '/organization',
icon: (
<svg className="w-5 h-5" 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>
)
},
{
name: 'Importadores',
path: '/importers',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
)
},
{
name: 'Usuarios',
path: '/users',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
)
},
{
name: 'Reportes',
path: '/reports',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
)
},
{
name: 'Documentos',
path: '/documents',
icon: (
<svg className="w-5 h-5" 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>
)
}
];
return (
<div className={`bg-gray-900 text-white transition-all duration-300 ${isCollapsed ? 'w-16' : 'w-64'} min-h-screen flex flex-col`}>
{/* Header - Logo y colapsar */}
<div className="p-4 border-b border-gray-700">
<div className="flex items-center justify-between">
{!isCollapsed && (
<div className="flex items-center">
{/* Logo de la organización */}
<div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center mr-3">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="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>
<h1 className="text-lg font-bold text-white">EFC Dashboard</h1>
</div>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="p-1.5 rounded-lg hover:bg-gray-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{isCollapsed ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
)}
</svg>
</button>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-3 space-y-1">
{menuItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center px-3 py-2.5 text-sm rounded-lg transition-colors group ${
isActive
? 'bg-indigo-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`}
title={isCollapsed ? item.name : ''}
>
<div className="flex-shrink-0">
{item.icon}
</div>
{!isCollapsed && (
<span className="ml-3 font-medium">{item.name}</span>
)}
</Link>
);
})}
</nav>
{/* Configuración */}
<div className="p-3 border-t border-gray-700">
<Link
to="/settings"
className={`flex items-center px-3 py-2.5 text-sm rounded-lg transition-colors group ${
location.pathname === '/settings'
? 'bg-indigo-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`}
title={isCollapsed ? 'Configuración' : ''}
>
<div className="flex-shrink-0">
<svg className="w-5 h-5" 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>
{!isCollapsed && (
<span className="ml-3 font-medium">Configuración</span>
)}
</Link>
</div>
{/* Footer - Perfil del Usuario */}
<div className="p-3 border-t border-gray-700">
{!isCollapsed ? (
<div className="space-y-3">
{/* Información del usuario */}
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
<img
className="w-8 h-8 rounded-full ring-2 ring-gray-700"
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
alt="Avatar del usuario"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
Juan Pérez
</p>
<p className="text-xs text-gray-400 truncate">
Administrador
</p>
</div>
</div>
{/* Botón de logout más pequeño */}
<div className="pt-2">
<button
onClick={handleLogout}
className="w-full inline-flex items-center justify-center px-3 py-2 border border-transparent text-xs font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition duration-200"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Cerrar sesión
</button>
</div>
</div>
) : (
<div className="flex flex-col items-center space-y-2">
{/* Avatar pequeño */}
<img
className="w-8 h-8 rounded-full ring-2 ring-gray-700"
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
alt="Avatar del usuario"
title="Juan Pérez - Administrador"
/>
{/* Botón de logout compacto */}
<button
onClick={handleLogout}
className="p-1.5 text-gray-400 hover:text-white hover:bg-red-600 rounded transition-colors"
title="Cerrar sesión"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,207 @@
import React, { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
export default function Sidebar() {
const [isCollapsed, setIsCollapsed] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const handleLogout = () => {
localStorage.removeItem('access');
localStorage.removeItem('refresh');
// Disparar evento para actualizar el navbar
window.dispatchEvent(new CustomEvent('authStateChanged'));
navigate('/login');
};
const menuItems = [
{
name: 'Mi Organización',
path: '/organization',
icon: (
<svg className="w-5 h-5" 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>
)
},
{
name: 'Importadores',
path: '/importers',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
)
},
{
name: 'Usuarios',
path: '/users',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
)
},
{
name: 'Reportes',
path: '/reports',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
)
},
{
name: 'Documentos',
path: '/documents',
icon: (
<svg className="w-5 h-5" 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>
)
}
];
return (
<div className={`bg-gray-900 text-white transition-all duration-300 ${isCollapsed ? 'w-16' : 'w-64'} min-h-screen flex flex-col`}>
{/* Header - Logo y colapsar */}
<div className="p-4 border-b border-gray-700">
<div className="flex items-center justify-between">
{!isCollapsed && (
<div className="flex items-center">
{/* Logo de la organización */}
<div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center mr-3">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="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>
<h1 className="text-lg font-bold text-white">EFC Dashboard</h1>
</div>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="p-1.5 rounded-lg hover:bg-gray-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{isCollapsed ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
)}
</svg>
</button>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-3 space-y-1">
{menuItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center px-3 py-2.5 text-sm rounded-lg transition-colors group ${
isActive
? 'bg-indigo-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`}
title={isCollapsed ? item.name : ''}
>
<div className="flex-shrink-0">
{item.icon}
</div>
{!isCollapsed && (
<span className="ml-3 font-medium">{item.name}</span>
)}
</Link>
);
})}
</nav>
{/* Configuración */}
<div className="p-3 border-t border-gray-700">
<Link
to="/settings"
className={`flex items-center px-3 py-2.5 text-sm rounded-lg transition-colors group ${
location.pathname === '/settings'
? 'bg-indigo-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`}
title={isCollapsed ? 'Configuración' : ''}
>
<div className="flex-shrink-0">
<svg className="w-5 h-5" 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>
{!isCollapsed && (
<span className="ml-3 font-medium">Configuración</span>
)}
</Link>
</div>
{/* Footer - Perfil del Usuario */}
<div className="p-3 border-t border-gray-700">
{!isCollapsed ? (
<div className="space-y-3">
{/* Información del usuario */}
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
<img
className="w-8 h-8 rounded-full ring-2 ring-gray-700"
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
alt="Avatar del usuario"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
Juan Pérez
</p>
<p className="text-xs text-gray-400 truncate">
Administrador
</p>
</div>
</div>
{/* Botón de logout más pequeño */}
<div className="pt-2">
<button
onClick={handleLogout}
className="w-full inline-flex items-center justify-center px-3 py-2 border border-transparent text-xs font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition duration-200"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Cerrar sesión
</button>
</div>
</div>
) : (
<div className="flex flex-col items-center space-y-2">
{/* Avatar pequeño */}
<img
className="w-8 h-8 rounded-full ring-2 ring-gray-700"
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
alt="Avatar del usuario"
title="Juan Pérez - Administrador"
/>
{/* Botón de logout compacto */}
<button
onClick={handleLogout}
className="p-1.5 text-gray-400 hover:text-white hover:bg-red-600 rounded transition-colors"
title="Cerrar sesión"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
export default function SuccessModal({ open, onClose, message = 'Descarga exitosa' }) {
if (!open) return null;
return (
<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-green-200 p-8 max-w-sm w-full flex flex-col items-center animate-fade-in">
<svg className="h-12 w-12 text-green-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
<h2 className="text-2xl font-bold text-green-700 mb-2">{message}</h2>
<button
onClick={onClose}
className="mt-4 px-6 py-2 bg-green-600 text-white rounded-lg font-semibold shadow hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-400"
>
Cerrar
</button>
</div>
<style>{`
@keyframes fade-in { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
.animate-fade-in { animation: fade-in 0.3s ease; }
`}</style>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import React from 'react';
export default function TestTailwind() {
return (
<div className="bg-blue-500 text-white p-4 rounded-lg shadow-lg">
<h1 className="text-2xl font-bold">¡Tailwind CSS funciona!</h1>
<p className="mt-2">Si ves este texto en azul con padding y sombra, Tailwind está trabajando correctamente.</p>
</div>
);
}

0
src/components/card.jsx Normal file
View File

View File

@@ -0,0 +1,94 @@
import React, { createContext, useState, useContext } from 'react';
import { colors } from '../theme';
const NotificationContext = createContext();
export function useNotification() {
return useContext(NotificationContext);
}
export function NotificationProvider({ children }) {
const [message, setMessage] = useState('');
const [type, setType] = useState('info'); // 'info', 'error', 'success', 'warning'
const showMessage = (msg, msgType = 'info') => {
setMessage(msg);
setType(msgType);
setTimeout(() => setMessage(''), 4000);
};
const getNotificationStyles = (type) => {
const baseStyles = {
position: 'fixed',
top: 20,
left: '50%',
transform: 'translateX(-50%)',
color: '#FFFFFF',
padding: '12px 24px',
borderRadius: '12px',
zIndex: 9999,
boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
fontWeight: '500',
fontSize: '14px',
maxWidth: '400px',
textAlign: 'center',
animation: 'slideInDown 0.3s ease-out'
};
const typeStyles = {
error: { background: '#C62828' },
success: { background: '#2E7D32' },
warning: { background: '#F57C00' },
info: { background: '#4DA6FF' }
};
return { ...baseStyles, ...typeStyles[type] };
};
return (
<NotificationContext.Provider value={{ showMessage }}>
{children}
{message && (
<>
<div style={getNotificationStyles(type)}>
<div className="flex items-center justify-center space-x-2">
{type === 'success' && (
<svg className="w-5 h-5" 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>
)}
{type === 'error' && (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
)}
{type === 'warning' && (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
)}
{type === 'info' && (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
)}
<span>{message}</span>
</div>
</div>
<style jsx>{`
@keyframes slideInDown {
from {
transform: translate(-50%, -100%);
opacity: 0;
}
to {
transform: translate(-50%, 0);
opacity: 1;
}
}
`}</style>
</>
)}
</NotificationContext.Provider>
);
}

View File

@@ -0,0 +1,90 @@
import React, { createContext, useContext, useEffect, useState, useRef } from 'react';
import { getCurrentUser } from '../api/users.ts';
import { refreshToken } from '../api/auth.js';
const UserContext = createContext({
user: null,
loading: true,
error: null,
refreshUser: () => {},
});
export function UserProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchedOnce = useRef(false);
const fetchUser = async () => {
if (fetchedOnce.current && loading) return;
setLoading(true);
setError(null);
let token = localStorage.getItem('access');
let triedRefresh = false;
while (true) {
try {
if (token) {
const userData = await getCurrentUser(token);
setUser(userData);
} else {
setUser(null);
}
break;
} catch (err) {
// Si el token expiró, intenta refrescarlo una vez
if (!triedRefresh && (err.message === 'SESSION_EXPIRED' || err.message.includes('401'))) {
triedRefresh = true;
const refresh = localStorage.getItem('refresh');
if (refresh) {
try {
const data = await refreshToken(refresh);
if (data.access) {
localStorage.setItem('access', data.access);
token = data.access;
continue; // Reintenta con el nuevo token
} else {
throw new Error('No se pudo refrescar el token');
}
} catch (refreshErr) {
setError(refreshErr);
setUser(null);
localStorage.removeItem('access');
localStorage.removeItem('refresh');
window.dispatchEvent(new CustomEvent('authStateChanged'));
break;
}
} else {
setUser(null);
break;
}
} else {
setError(err);
setUser(null);
break;
}
}
}
setLoading(false);
fetchedOnce.current = true;
};
useEffect(() => {
fetchUser();
const handler = () => {
fetchedOnce.current = false;
fetchUser();
};
window.addEventListener('authStateChanged', handler);
return () => window.removeEventListener('authStateChanged', handler);
}, []);
return (
<UserContext.Provider value={{ user, loading, error, refreshUser: fetchUser }}>
{children}
</UserContext.Provider>
);
}
export function useUser() {
return useContext(UserContext);
}

0
src/fetchWithAuth.js Normal file
View File

73
src/hooks/usePolling.js Normal file
View File

@@ -0,0 +1,73 @@
import { useState, useEffect, useRef } from 'react';
export function usePolling(fetchFunction, interval = 30000, dependencies = []) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const intervalRef = useRef(null);
const isActiveRef = useRef(true);
const fetchData = async (showLoading = false) => {
if (showLoading) setLoading(true);
setError(null);
try {
const result = await fetchFunction();
if (isActiveRef.current) {
setData(result);
setLoading(false);
}
} catch (err) {
if (isActiveRef.current) {
setError(err);
setLoading(false);
}
}
};
const startPolling = () => {
if (intervalRef.current) return; // Ya está corriendo
fetchData(true); // Fetch inicial
intervalRef.current = setInterval(() => {
if (isActiveRef.current) {
fetchData(false); // Fetch sin loading para no molestar al usuario
}
}, interval);
};
const stopPolling = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
const refetch = () => {
fetchData(true);
};
useEffect(() => {
isActiveRef.current = true;
startPolling();
// Parar polling cuando el componente se desmonta o la pestaña no está visible
const handleVisibilityChange = () => {
if (document.hidden) {
stopPolling();
} else {
startPolling();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
isActiveRef.current = false;
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, dependencies);
return { data, loading, error, refetch, startPolling, stopPolling };
}

49
src/hooks/useWebSocket.js Normal file
View File

@@ -0,0 +1,49 @@
import { useEffect, useState, useRef } from 'react';
import { io } from 'socket.io-client';
export function useWebSocket(url, events = {}) {
const [isConnected, setIsConnected] = useState(false);
const socketRef = useRef(null);
useEffect(() => {
// Conectar WebSocket
socketRef.current = io(url, {
transports: ['websocket'],
auth: {
token: localStorage.getItem('access')
}
});
const socket = socketRef.current;
socket.on('connect', () => {
console.log('WebSocket conectado');
setIsConnected(true);
});
socket.on('disconnect', () => {
console.log('WebSocket desconectado');
setIsConnected(false);
});
// Registrar eventos personalizados
Object.entries(events).forEach(([eventName, handler]) => {
socket.on(eventName, handler);
});
return () => {
Object.keys(events).forEach(eventName => {
socket.off(eventName);
});
socket.disconnect();
};
}, [url]);
const emit = (eventName, data) => {
if (socketRef.current && isConnected) {
socketRef.current.emit(eventName, data);
}
};
return { isConnected, emit };
}

21
src/index.css Normal file
View File

@@ -0,0 +1,21 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
button:hover {
border-color: #646cff;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

19
src/main.jsx Normal file
View File

@@ -0,0 +1,19 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import { NotificationProvider } from './context/NotificationContext';
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
createRoot(document.getElementById('root')).render(
<StrictMode>
<NotificationProvider>
<App />
</NotificationProvider>
</StrictMode>,
)

373
src/pages/Admin.jsx Normal file
View File

@@ -0,0 +1,373 @@
import React, { useEffect, useState } from 'react';
// Animación fade-in/slide-up para cards
const fadeInSlideUp = `@keyframes fadein-slideup {
0% { opacity: 0; transform: translateY(40px); }
100% { opacity: 1; transform: translateY(0); }
}`;
// Inyectar animación global si no existe
if (typeof document !== 'undefined' && !document.getElementById('fadein-slideup-admin')) {
const style = document.createElement('style');
style.id = 'fadein-slideup-admin';
style.innerHTML = fadeInSlideUp;
document.head.appendChild(style);
}
import TestTailwind from '../components/TestTailwind';
import { colors } from '../theme';
const API_URL = import.meta.env.VITE_EFC_API_URL;
export default function Admin() {
// Estado de servicios
const [services, setServices] = useState(null);
// Estado de descargas
const [downloads, setDownloads] = useState(null);
// Últimos documentos
const [latestDocs, setLatestDocs] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Estado para análisis de actividad de usuario
const [userActivity, setUserActivity] = useState(null);
useEffect(() => {
async function fetchData() {
setLoading(true);
setError('');
try {
const token = localStorage.getItem('access');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
// Servicios
const resServices = await fetch(`${API_URL}/cards/services-util-information/`, { headers });
if (!resServices.ok) throw new Error('Error al obtener estados de servicios');
const dataServices = await resServices.json();
setServices(dataServices);
// Descargas
const resDownloads = await fetch(`${API_URL}/cards/document-util-information/`, { headers });
if (!resDownloads.ok) throw new Error('Error al obtener información de descargas');
const dataDownloads = await resDownloads.json();
setDownloads(dataDownloads);
// Últimos documentos
const resDocs = await fetch(`${API_URL}/cards/downloaded-documents/`, { headers });
if (!resDocs.ok) throw new Error('Error al obtener últimos documentos');
const dataDocs = await resDocs.json();
setLatestDocs(dataDocs.documentos);
// Análisis de actividad de usuario
const resUserActivity = await fetch(`${API_URL}/cards/user-activity-analysis/`, { headers });
if (!resUserActivity.ok) throw new Error('Error al obtener análisis de actividad de usuario');
const dataUserActivity = await resUserActivity.json();
setUserActivity(dataUserActivity);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
fetchData();
}, []);
// Helper para nombre de archivo
// Helper para nombre de archivo
function getFileName(path) {
return path.split('/').pop() || path;
}
return (
<div className="p-6 bg-gray-50">
<div className="max-w-7xl mx-auto">
{/* Header + Estado del Sistema alineados horizontalmente */}
<div className="mb-8 flex flex-col md:flex-row md:items-stretch md:gap-6">
{/* Header principal mejorado */}
<div className="relative overflow-hidden rounded-2xl shadow bg-gradient-to-r from-blue-50 via-white to-indigo-50 border border-blue-100 p-8 flex items-center gap-6 flex-1 min-w-0 md:w-[65%] animate-fadein-slideup opacity-0"
style={{
animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.05s forwards',
}}
>
<div className="flex-shrink-0 bg-blue-100 rounded-full p-4 shadow-md animate-bounce-slow">
<svg className="h-10 w-10 text-blue-600" 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>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight mb-1 flex items-center gap-2">
Panel de Administración
{services && (
<span className="inline-block bg-blue-200 text-blue-800 text-xs font-semibold px-2 py-0.5 rounded-full ml-2 animate-fade-in">
{services.en_espera} en espera
</span>
)}
</h1>
<p className="text-lg text-blue-700/80 font-medium">
{typeof window !== 'undefined' && localStorage.getItem('user_is_importador') === 'true'
? 'Dashboard principal para gestión de Expediente electrónico'
: 'Dashboard principal para gestión de agencia aduanal'}
</p>
</div>
{/* Efecto decorativo de fondo */}
<div className="absolute -top-10 -right-10 opacity-30 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="url(#grad1)" />
<defs>
<linearGradient id="grad1" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse">
<stop stopColor="#3b82f6" stopOpacity="0.15" />
<stop offset="1" stopColor="#6366f1" stopOpacity="0.10" />
</linearGradient>
</defs>
</svg>
</div>
{/* Animación personalizada para el icono y contador */}
<style>{`
@keyframes bounce-slow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.animate-bounce-slow {
animation: bounce-slow 2.2s infinite;
}
@keyframes fade-in {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
.animate-fade-in {
animation: fade-in 0.7s ease;
}
`}</style>
</div>
{/* Estado del Sistema card a la derecha */}
<div className="mt-6 md:mt-0 md:w-[35%] min-w-[270px] flex-shrink-0 animate-fadein-slideup opacity-0"
style={{
animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.15s forwards',
}}
>
<div className="relative overflow-hidden rounded-2xl shadow bg-gradient-to-br from-green-50 via-white to-blue-50 border border-green-100 p-6 h-full flex flex-col justify-between">
<div className="flex items-center gap-3 mb-4">
<div className="bg-green-100 rounded-full p-3 shadow-md animate-bounce-slow">
<svg className="h-7 w-7 text-green-600" 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>
</div>
<h3 className="text-2xl font-extrabold text-green-900 tracking-tight flex-1">Estado del Sistema</h3>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-gray-700 font-medium">API Backend</span>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200 gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
Conectado
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-700 font-medium">API Servicios</span>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200 gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
Conectado
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-700 font-medium">Última Actualización</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 border border-blue-200">
Hace 2 min
</span>
</div>
</div>
{/* Efecto decorativo de fondo */}
<div className="absolute -top-8 -right-8 opacity-20 pointer-events-none select-none">
<svg width="80" height="80" viewBox="0 0 120 120" fill="none">
<circle cx="60" cy="60" r="50" fill="url(#grad2)" />
<defs>
<linearGradient id="grad2" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse">
<stop stopColor="#22c55e" stopOpacity="0.18" />
<stop offset="1" stopColor="#3b82f6" stopOpacity="0.10" />
</linearGradient>
</defs>
</svg>
</div>
{/* Animación personalizada para el icono */}
<style>{`
@keyframes bounce-slow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.animate-bounce-slow {
animation: bounce-slow 2.2s infinite;
}
`}</style>
</div>
</div>
</div>
{/* Stats Cards con datos de endpoints */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{/* Estados de servicios */}
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-6 hover:shadow-xl transition-all duration-300 transform hover:scale-105 animate-fadein-slideup opacity-0"
style={{
animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.25s forwards',
}}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center shadow-md">
<svg className="w-6 h-6 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>
<div className="ml-5">
<p className="text-sm font-medium text-gray-500">Procesos en Espera</p>
<p className="text-2xl font-bold text-gray-900">{services ? services.en_espera : '-'}</p>
<p className="text-sm text-gray-400">Total: {services ? services.procesos_filtrados : '-'}</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-6 hover:shadow-xl transition-all duration-300 transform hover:scale-105 animate-fadein-slideup opacity-0"
style={{
animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.35s forwards',
}}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-lg flex items-center justify-center shadow-md">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
</div>
</div>
<div className="ml-5">
<p className="text-sm font-medium text-gray-500">En Proceso</p>
<p className="text-2xl font-bold text-gray-900">{services ? services.en_proceso : '-'}</p>
<p className="text-sm text-gray-400">Finalizados: {services ? services.finalizados : '-'}</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-6 hover:shadow-xl transition-all duration-300 transform hover:scale-105 animate-fadein-slideup opacity-0"
style={{
animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.45s forwards',
}}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-gradient-to-br from-orange-500 to-orange-600 rounded-lg flex items-center justify-center shadow-md">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
</div>
<div className="ml-5">
<p className="text-sm font-medium text-gray-500">Con Error</p>
<p className="text-2xl font-bold text-gray-900">{services ? services.con_error : '-'}</p>
<p className="text-sm text-gray-400">Finalizados: {services ? services.finalizados : '-'}</p>
</div>
</div>
</div>
{/* Descargas */}
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-6 hover:shadow-xl transition-all duration-300 transform hover:scale-105 animate-fadein-slideup opacity-0"
style={{
animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.55s forwards',
}}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-gradient-to-br from-blue-700 to-blue-900 rounded-lg flex items-center justify-center shadow-md">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
<div className="ml-5">
<p className="text-sm font-medium text-gray-500">Descargados 1 día</p>
<p className="text-2xl font-bold text-gray-900">{downloads ? downloads.archivos_ultimas_1_dia : '-'}</p>
<p className="text-sm text-gray-400">7 días: {downloads ? downloads.archivos_ultimos_7_dias : '-'} | 30 días: {downloads ? downloads.archivos_ultimos_30_dias : '-'}</p>
</div>
</div>
</div>
</div>
{/* Análisis de actividad de usuario */}
{!(typeof window !== 'undefined' && localStorage.getItem('user_is_importador') === 'true') && (
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-6 mb-4 animate-fadein-slideup opacity-0"
style={{
animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.65s forwards',
}}
>
<h3 className="text-lg font-bold text-gray-900 mb-4">Actividad de Usuarios</h3>
{loading ? (
<div className="text-gray-500">Cargando...</div>
) : error ? (
<div className="text-danger-600">{error}</div>
) : userActivity ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="font-semibold text-gray-700 mb-2">Resumen de acciones</h4>
<ul className="text-sm text-gray-700 space-y-1">
{Object.entries(userActivity.actions_count).map(([action, count]) => (
<li key={action} className="flex justify-between border-b border-gray-100 py-1">
<span className="capitalize">{action}</span>
<span className="font-mono text-blue-700">{count}</span>
</li>
))}
<li className="flex justify-between font-semibold pt-2">
<span>Total actividades</span>
<span className="font-mono text-blue-900">{userActivity.actividades_filtradas}</span>
</li>
</ul>
</div>
<div>
<h4 className="font-semibold text-gray-700 mb-2">Top usuarios</h4>
<ol className="text-sm text-gray-700 space-y-1 list-decimal list-inside">
{userActivity.top_users.map((user, idx) => (
<li key={user.username} className="flex justify-between border-b border-gray-100 py-1">
<span>{user.username}</span>
<span className="font-mono text-green-700">{user.activity_count}</span>
</li>
))}
</ol>
</div>
</div>
) : null}
</div>
)}
{/* Tabla de últimos documentos */}
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-6 mb-8 animate-fadein-slideup opacity-0"
style={{
animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.75s forwards',
}}
>
<h3 className="text-lg font-bold text-gray-900 mb-4">Últimos documentos agregados</h3>
{loading ? (
<div className="text-gray-500">Cargando...</div>
) : error ? (
<div className="text-danger-600">{error}</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-semibold text-gray-600">Archivo</th>
<th className="px-4 py-2 text-left font-semibold text-gray-600">Pedimento</th>
<th className="px-4 py-2 text-left font-semibold text-gray-600">Organización</th>
<th className="px-4 py-2 text-left font-semibold text-gray-600">Fecha</th>
</tr>
</thead>
<tbody>
{latestDocs.map(doc => (
<tr key={doc.id} className="hover:bg-blue-50">
<td className="px-4 py-2 font-mono text-blue-800 truncate max-w-xs" title={getFileName(doc.archivo)}>{getFileName(doc.archivo)}</td>
<td className="px-4 py-2">{doc.pedimento}</td>
<td className="px-4 py-2">{doc.organizacion}</td>
<td className="px-4 py-2">{new Date(doc.created_at).toLocaleString('es-MX')}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}

13
src/pages/Debug.jsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
export default function Debug() {
return (
<div style={{ padding: '20px', backgroundColor: '#f0f0f0', minHeight: '100vh' }}>
<h1 style={{ color: 'red', fontSize: '24px' }}>🐛 Debug Page</h1>
<p>Si ves esto, React está funcionando correctamente.</p>
<p>Fecha y hora: {new Date().toLocaleString()}</p>
<p>Token en localStorage: {localStorage.getItem('access') ? 'SÍ' : 'NO'}</p>
<p>URL actual: {window.location.href}</p>
</div>
);
}

645
src/pages/Documents.jsx Normal file
View File

@@ -0,0 +1,645 @@
import React, { useEffect, useState, useLayoutEffect, useRef } from 'react';
import SuccessModal from '../components/SuccessModal.jsx';
// Animación fade-in/slide-up para bloques
const fadeInSlideUp = `@keyframes fadein-slideup { 0% { opacity: 0; transform: translateY(40px); } 100% { opacity: 1; transform: translateY(0); } }`;
if (typeof document !== 'undefined' && !document.getElementById('fadein-slideup-documents')) {
const style = document.createElement('style');
style.id = 'fadein-slideup-documents';
style.innerHTML = fadeInSlideUp;
document.head.appendChild(style);
}
import { fetchPedimentoDocuments } from '../api/documentos.ts';
import { useNotification } from '../context/NotificationContext';
// import { usePolling } from '../hooks/usePolling';
import { Link } from 'react-router-dom';
const API_URL = import.meta.env.VITE_EFC_API_URL;
// Descarga individual
const downloadFile = async (id, filename = 'archivo', setSuccess, setError, showMessage) => {
const token = localStorage.getItem('access');
const res = await fetch(`${API_URL}/record/documents/descargar/${id}/`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (res.status === 401) {
showMessage('Tu sesión ha expirado, por favor inicia sesión de nuevo.', 'error');
localStorage.removeItem('access');
localStorage.removeItem('refresh');
setTimeout(() => {
window.location.href = '/login';
}, 2000);
return;
}
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');
};
// Descarga masiva (bulk)
const downloadBulkZip = async (ids, showMessage, setSuccess, nombreZip = 'documentos') => {
if (!ids.length) {
showMessage('Selecciona al menos un documento.', 'error');
return;
}
const token = localStorage.getItem('access');
const res = await fetch(`${API_URL}/record/documents/bulk-download/`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ document_ids: ids, pedimento_nombre: nombreZip }),
});
if (res.status === 401) {
showMessage('Tu sesión ha expirado, por favor inicia sesión de nuevo.', 'error');
localStorage.removeItem('access');
localStorage.removeItem('refresh');
setTimeout(() => {
window.location.href = '/login';
}, 2000);
return;
}
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)');
};
export default function Documents() {
const focusKeeperRef = useRef(null);
const [success, setSuccess] = useState('');
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [extensionFilter, setExtensionFilter] = useState('');
const [documentTypeFilter, setDocumentTypeFilter] = useState('');
const [createdAtFilter, setCreatedAtFilter] = useState('');
const [pedimentoNumeroFilter, setPedimentoNumeroFilter] = useState('');
const { showMessage } = useNotification();
// Estado para controlar la animación de entrada
const [showAnimation, setShowAnimation] = useState(false);
const [hasAnimated, setHasAnimated] = useState(false);
useLayoutEffect(() => {
// Forzar un render antes de activar la animación
setShowAnimation(true);
}, []);
useEffect(() => {
if (showAnimation && !hasAnimated) {
const timeout = setTimeout(() => {
setHasAnimated(true);
setShowAnimation(false);
}, 700); // Duración igual a la animación
return () => clearTimeout(timeout);
}
}, [showAnimation, hasAnimated]);
// Estado local para los datos, loading y error
const [docsData, setDocsData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// Fetch de datos solo al cargar la página o cuando cambian los filtros/paginación
useEffect(() => {
let isMounted = true;
const fetchDocsData = async () => {
setLoading(true);
setError(null);
try {
const token = localStorage.getItem('access');
const data = await fetchPedimentoDocuments(token, '', currentPage, itemsPerPage, {
pedimento_numero: pedimentoNumeroFilter,
extension: extensionFilter,
document_type: documentTypeFilter,
created_at: createdAtFilter,
});
if (isMounted) setDocsData(data);
} catch (err) {
if (isMounted) setError(err);
} finally {
if (isMounted) setLoading(false);
}
};
fetchDocsData();
return () => { isMounted = false; };
}, [currentPage, itemsPerPage, pedimentoNumeroFilter, extensionFilter, documentTypeFilter, createdAtFilter]);
// Refetch manual (si se quiere usar en el futuro)
const refetch = () => {
setCurrentPage(1); // Esto forzará el useEffect a recargar
};
// Manejo de errores de sesión
useEffect(() => {
if (error && error.message === 'SESSION_EXPIRED') {
localStorage.removeItem('access');
localStorage.removeItem('refresh');
showMessage('Tu sesión ha expirado, por favor inicia sesión de nuevo.', 'error');
setTimeout(() => {
window.location.href = '/login';
}, 2000);
} else if (error) {
showMessage(error.message, 'error');
}
}, [error, showMessage]);
// Cálculos de paginación usando la estructura tipada
const documentsArray = docsData && docsData.results ? docsData.results : [];
const totalDocuments = docsData && typeof docsData.count === 'number' ? docsData.count : 0;
const totalPages = totalDocuments > 0 ? Math.ceil(totalDocuments / itemsPerPage) : 1;
const currentDocuments = documentsArray;
// Selección de documentos
const [selectedDocs, setSelectedDocs] = useState([]);
// allSelected: todos los docs de la página actual están seleccionados
const allSelected = currentDocuments.length > 0 && selectedDocs.length === currentDocuments.length;
// someSelected: hay al menos uno seleccionado pero no todos
const someSelected = selectedDocs.length > 0 && selectedDocs.length < currentDocuments.length;
// Handlers para selección
const handleSelectOne = (id) => {
setSelectedDocs(prev => prev.includes(id) ? prev.filter(d => d !== id) : [...prev, id]);
};
const handleSelectAll = () => {
if (allSelected) {
setSelectedDocs([]);
} else {
setSelectedDocs(currentDocuments.map(doc => doc.id));
}
};
// Descargar seleccionados (bulk) con prompt para nombre del zip
const handleDownloadSelected = async () => {
const ids = currentDocuments.filter(doc => selectedDocs.includes(doc.id)).map(doc => doc.id);
if (ids.length === 1) {
// Si solo hay uno, descarga individual
const doc = currentDocuments.find(doc => doc.id === ids[0]);
await downloadFile(doc.id, doc.archivo ? doc.archivo.split('/').pop() : 'archivo', () => {
setSuccess('Descarga exitosa');
setShowSuccessModal(true);
}, null, showMessage);
} else if (ids.length > 1) {
let nombreZip = window.prompt('¿Qué nombre quieres para el archivo zip?', 'documentos_seleccionados');
if (!nombreZip) nombreZip = 'documentos_seleccionados';
await downloadBulkZip(ids, showMessage, () => {
setSuccess('Descarga exitosa');
setShowSuccessModal(true);
}, nombreZip);
}
};
// Descargar todos los de la página (bulk) con prompt para nombre del zip
const handleDownloadAll = async () => {
const ids = currentDocuments.map(doc => doc.id);
if (ids.length === 1) {
const doc = currentDocuments[0];
await downloadFile(doc.id, doc.archivo ? doc.archivo.split('/').pop() : 'archivo', () => {
setSuccess('Descarga exitosa');
setShowSuccessModal(true);
}, null, showMessage);
} else if (ids.length > 1) {
let nombreZip = window.prompt('¿Qué nombre quieres para el archivo zip?', 'documentos_pagina');
if (!nombreZip) nombreZip = 'documentos_pagina';
await downloadBulkZip(ids, showMessage, () => {
setSuccess('Descarga exitosa');
setShowSuccessModal(true);
}, nombreZip);
}
};
// Limpiar selección al cambiar de página o documentos
useEffect(() => {
setSelectedDocs([]);
}, [currentPage, itemsPerPage, pedimentoNumeroFilter, extensionFilter, documentTypeFilter, createdAtFilter, docsData]);
// Obtener lista única de contribuyentes para el combobox (de la página actual)
const contribuyentes = Array.from(new Set(currentDocuments.map(d => d.contribuyente).filter(Boolean)));
// Refuerza la paginación SPA: nunca recarga la página, solo cambia el estado local
const handlePageChange = (newPage, e) => {
if (e && typeof e.preventDefault === 'function') e.preventDefault();
if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
if (newPage < 1 || newPage > totalPages || newPage === currentPage) return;
setCurrentPage(newPage);
// Quitar el foco del botón activo para evitar salto de scroll
if (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
};
// Forzar foco al div invisible para evitar saltos por enfoque automático
useLayoutEffect(() => {
if (focusKeeperRef.current) {
focusKeeperRef.current.focus();
}
}, [currentPage]);
const handleItemsPerPageChange = (newItemsPerPage) => {
setItemsPerPage(newItemsPerPage);
setCurrentPage(1); // Reset a la primera página
};
// El layout principal y la tabla siempre se renderizan, loader/error/empty solo dentro del área de la tabla
return (
<div className="p-6 bg-gray-50">
<div ref={focusKeeperRef} tabIndex={-1} style={{position:'absolute',width:0,height:0,overflow:'hidden',outline:'none'}} aria-hidden="true"></div>
<div className="max-w-7xl mx-auto">
{/* Header mejorado y decorativo */}
<div className={
"mb-8 relative overflow-hidden rounded-2xl shadow bg-gradient-to-r from-blue-50 via-white to-indigo-50 border border-blue-100 p-8 flex items-center gap-6"+
(showAnimation && !hasAnimated ? ' animate-fadein-slideup opacity-0' : '')
}
style={showAnimation && !hasAnimated ? { animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.05s forwards' } : undefined}>
<div className="flex-shrink-0 bg-blue-100 rounded-full p-4 shadow-md animate-bounce-slow">
<svg className="h-10 w-10 text-blue-600" 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>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight mb-1 flex items-center gap-2">
Documentos
<span className="inline-block bg-blue-200 text-blue-800 text-xs font-semibold px-2 py-0.5 rounded-full ml-2 animate-fade-in">{totalDocuments}</span>
</h1>
<p className="text-lg text-blue-700/80 font-medium">Descarga los documentos de tus pedimentos.</p>
</div>
{/* Efecto decorativo de fondo */}
<div className="absolute -top-10 -right-10 opacity-30 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="url(#grad1)" />
<defs>
<linearGradient id="grad1" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse">
<stop stopColor="#3b82f6" stopOpacity="0.15" />
<stop offset="1" stopColor="#6366f1" stopOpacity="0.10" />
</linearGradient>
</defs>
</svg>
</div>
</div>
{/* Animación personalizada para el icono y contador */}
<style>{`
@keyframes bounce-slow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.animate-bounce-slow {
animation: bounce-slow 2.2s infinite;
}
@keyframes fade-in {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
.animate-fade-in {
animation: fade-in 0.7s ease;
}
`}</style>
<div className={
"bg-white shadow-lg rounded-xl border border-gray-200"+
(showAnimation && !hasAnimated ? ' animate-fadein-slideup opacity-0' : '')
}
style={showAnimation && !hasAnimated ? { animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.15s forwards' } : undefined}>
<div className="px-6 py-6 border-b border-gray-200">
<div className="overflow-x-auto" id="tabla-documentos">
{/* Header de Documentos Relacionados arriba de los filtros */}
<div className="px-8 pt-8 pb-2 border-b border-gray-200">
<h2 className="text-2xl font-extrabold text-blue-800 tracking-tight mb-1">
Todos los Documentos
</h2>
<div className="h-1 w-10 bg-blue-400 rounded mb-2"></div>
</div>
{/* Filtros de query parameters */}
<div className="px-6 py-6 border-b border-gray-200">
{/* Filtros avanzados */}
<div className="mb-4 flex flex-wrap gap-4 items-end justify-between">
{/* Pedimento Número */}
<div className="flex flex-col flex-1 min-w-[150px]">
<label className="text-xs font-semibold text-gray-700 mb-1">Pedimento Número</label>
<input
type="text"
value={pedimentoNumeroFilter}
onChange={e => setPedimentoNumeroFilter(e.target.value)}
placeholder="Buscar por número de pedimento..."
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
/>
</div>
{/* Extensión */}
<div className="flex flex-col flex-1 min-w-[150px]">
<label className="text-xs font-semibold text-gray-700 mb-1">Extensión</label>
<select
value={extensionFilter}
onChange={e => setExtensionFilter(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
>
<option value="">Todas</option>
<option value="pdf">PDF</option>
<option value="xml">XML</option>
<option value="jpg">JPG</option>
<option value="jpeg">JPEG</option>
<option value="png">PNG</option>
<option value="xls">XLS</option>
<option value="xlsx">XLSX</option>
<option value="doc">DOC</option>
<option value="docx">DOCX</option>
<option value="txt">TXT</option>
<option value="zip">ZIP</option>
<option value="rar">RAR</option>
</select>
</div>
{/* Tipo de documento */}
<div className="flex flex-col flex-1 min-w-[150px]">
<label className="text-xs font-semibold text-gray-700 mb-1">Tipo de documento</label>
<select
value={documentTypeFilter}
onChange={e => setDocumentTypeFilter(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
>
<option value="">Todos</option>
<option value="1">Pedimento Partida</option>
<option value="2">Pedimento Completo</option>
<option value="3">Pedimento Remesas</option>
<option value="4">Pedimento Acuse</option>
<option value="5">Pedimento EDocument</option>
<option value="6">Estado Pedimento</option>
</select>
</div>
{/* Fecha de creación */}
<div className="flex flex-col flex-1 min-w-[150px]">
<label className="text-xs font-semibold text-gray-700 mb-1">Fecha de creación</label>
<input
type="date"
value={createdAtFilter}
onChange={e => setCreatedAtFilter(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
/>
</div>
</div>
{/* Botón de actualizar eliminado por solicitud */}
<SuccessModal open={showSuccessModal} onClose={() => setShowSuccessModal(false)} message={success || 'Descarga exitosa'} />
{/* Botones de descarga */}
{currentDocuments.length > 0 && (
<div className="flex space-x-3 mb-2">
<button
onClick={handleDownloadAll}
disabled={currentDocuments.length === 0}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md 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 shadow-lg"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
Descargar todos
</button>
<button
onClick={handleDownloadSelected}
disabled={selectedDocs.length === 0}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200 transform hover:scale-105 shadow-lg"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Descargar seleccionados ({selectedDocs.length})
</button>
</div>
)}
</div>
<div style={{ minHeight: 'calc(6 * 56px)', maxHeight: 'calc(6 * 56px)', overflowY: currentDocuments.length > 6 ? 'auto' : 'hidden', position: 'relative' }}>
<table className="min-w-full divide-y divide-gray-200 rounded-lg overflow-hidden text-xs">
<thead className="bg-gradient-to-r from-gray-50 sticky top-0 z-20">
<tr>
<th className="px-2 py-2 text-center font-bold text-blue-700 uppercase tracking-wider border-b border-gray-200 whitespace-nowrap">
<input
type="checkbox"
checked={allSelected}
ref={el => { if (el) el.indeterminate = someSelected; }}
onChange={handleSelectAll}
className="h-3.5 w-3.5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded align-middle"
style={{ minWidth: '14px', minHeight: '14px' }}
/>
</th>
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider border-b border-gray-200">Pedimento</th>
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider border-b border-gray-200">Archivo</th>
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider border-b border-gray-200">Tipo</th>
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider border-b border-gray-200">Tamaño</th>
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider border-b border-gray-200">Extensión</th>
<th className="px-6 py-3 text-center text-xs font-bold text-blue-700 uppercase tracking-wider border-b border-gray-200">Acciones</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100" style={{ position: 'relative', minHeight: 'calc(8 * 56px)' }}>
{/* Loader/Error/Empty state dentro del área de la tabla, sin cambiar el layout */}
{loading ? (
<tr>
<td colSpan={10} style={{ height: 'calc(8 * 56px)', padding: 0 }}>
<div className="flex items-center justify-center h-full w-full absolute left-0 top-0" style={{ minHeight: 'calc(8 * 56px)', background: 'rgba(255,255,255,0.7)', zIndex: 10 }}>
<span className="text-gray-500 text-lg">Cargando documentos...</span>
</div>
</td>
</tr>
) : error ? (
<tr>
<td colSpan={10} style={{ height: 'calc(8 * 56px)', padding: 0 }}>
<div className="flex items-center justify-center h-full w-full absolute left-0 top-0" style={{ minHeight: 'calc(8 * 56px)', background: 'rgba(255,255,255,0.7)', zIndex: 10 }}>
<span className="text-danger-600 text-lg">Error: {error.message || 'Error al cargar documentos'}</span>
</div>
</td>
</tr>
) : currentDocuments.length > 0 ? (
<>
{currentDocuments.map(doc => (
<tr key={doc.id} className="transition-all duration-200 hover:bg-blue-100 hover:shadow-lg">
<td className="px-2 py-2 text-center align-middle">
<input
type="checkbox"
checked={selectedDocs.includes(doc.id)}
onChange={() => handleSelectOne(doc.id)}
className="h-3.5 w-3.5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded align-middle"
style={{ minWidth: '14px', minHeight: '14px' }}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap align-middle font-medium text-blue-900">{doc.pedimento_numero}</td>
<td className="px-6 py-4 whitespace-nowrap align-middle text-gray-800">{doc.archivo ? doc.archivo.split('/').pop() : ''}</td>
<td className="px-6 py-4 whitespace-nowrap align-middle text-gray-700">{
(() => {
switch (String(doc.document_type)) {
case '1': return 'Pedimento Partida';
case '2': return 'Pedimento Completo';
case '3': return 'Pedimento Remesas';
case '4': return 'Pedimento Acuse';
case '5': return 'Pedimento EDocument';
case '6': return 'Estado Pedimento';
default: return doc.document_type || '';
}
})()
}</td>
<td className="px-6 py-4 whitespace-nowrap align-middle text-gray-700">{doc.size}</td>
<td className="px-6 py-4 whitespace-nowrap align-middle text-gray-700">{doc.extension}</td>
<td className="px-6 py-4 whitespace-nowrap text-center align-middle">
<button
className="inline-flex items-center px-3 py-1 border border-transparent text-xs font-semibold rounded-md 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 shadow"
title="Descargar"
onClick={async () => {
await downloadFile(
doc.id,
doc.archivo ? doc.archivo.split('/').pop() : 'archivo',
() => {
setSuccess('Descarga exitosa');
setShowSuccessModal(true);
},
null,
showMessage
);
}}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
</button>
</td>
</tr>
))}
{/* Rellenar con filas vacías si hay menos de 8 */}
{currentDocuments.length < 8 && !loading && !error && Array.from({length: 8 - currentDocuments.length}).map((_, idx) => (
<tr key={`empty-${idx}`} className="">
<td className="px-2 py-4" />
<td className="px-6 py-4 whitespace-nowrap" colSpan={5}>&nbsp;</td>
</tr>
))}
</>
) : (
<tr>
<td colSpan={10} style={{ height: 'calc(8 * 56px)', padding: 0 }}>
<div className="flex flex-col items-center justify-center h-full w-full absolute left-0 top-0" style={{ minHeight: 'calc(8 * 56px)', background: 'rgba(255,255,255,0.7)', zIndex: 10 }}>
<div className="mx-auto h-16 w-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="h-8 w-8 text-gray-400" 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>
<h3 className="text-lg font-medium text-gray-900 mb-2">No hay pedimentos</h3>
<p className="text-gray-500">Aún no tienes pedimentos registrados.</p>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Botón de actualizar eliminado por solicitud */}
<SuccessModal open={showSuccessModal} onClose={() => setShowSuccessModal(false)} message={success || 'Descarga exitosa'} />
</div>
<div className="overflow-hidden">
{/* Paginación con botones numerados y elipsis */}
{totalDocuments > 0 && (
<div className="bg-white px-4 py-3 flex flex-col sm:flex-row items-center justify-between border-t border-gray-200">
{(() => {
const totalPages = Math.max(1, Math.ceil(totalDocuments / itemsPerPage));
const maxPagesToShow = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
let endPage = startPage + maxPagesToShow - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxPagesToShow + 1);
}
const pageNumbers = [];
for (let i = startPage; i <= endPage; i++) {
pageNumbers.push(i);
}
return (
<div className="flex flex-col sm:flex-row sm:items-center w-full gap-2 sm:gap-4 mt-2 sm:mt-0">
<div className="flex items-center gap-2">
<label htmlFor="itemsPerPage" className="text-xs text-gray-600 font-medium">Registros por página:</label>
<select
id="itemsPerPage"
value={itemsPerPage}
onChange={e => handleItemsPerPageChange(Number(e.target.value))}
className="border border-gray-300 rounded px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
{[5, 10, 20, 50, 100, 200, 400, 800, 1200, 2400, 10000].map(size => (
<option key={size} value={size}>{size}</option>
))}
</select>
</div>
<div className="flex items-center gap-1 flex-wrap">
<button
type="button"
onClick={e => handlePageChange(1, e)}
disabled={currentPage === 1}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${currentPage === 1 ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
>
«
</button>
<button
type="button"
onClick={e => handlePageChange(currentPage - 1, e)}
disabled={currentPage === 1}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${currentPage === 1 ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
>
</button>
{pageNumbers.map(num => (
<button
type="button"
key={num}
onClick={e => handlePageChange(num, e)}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${num === currentPage ? 'bg-blue-600 text-white border-blue-700 cursor-default' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
disabled={num === currentPage}
>
{num}
</button>
))}
<button
type="button"
onClick={e => handlePageChange(currentPage + 1, e)}
disabled={currentPage >= totalPages}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${(currentPage >= totalPages) ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
>
</button>
<button
type="button"
onClick={e => handlePageChange(totalPages, e)}
disabled={currentPage >= totalPages}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${(currentPage >= totalPages) ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
>
»
</button>
<span className="ml-3 text-xs text-gray-500">Página <span className="font-bold">{currentPage}</span> de <span className="font-bold">{totalPages}</span></span>
</div>
</div>
);
})()}
</div>
)}
</div>
</div>
</div>
</div>
);
}

578
src/pages/Expedientes.jsx Normal file
View File

@@ -0,0 +1,578 @@
import React, { useEffect, useState, useLayoutEffect, useRef } from 'react';
// Animación fade-in/slide-up para bloques
const fadeInSlideUp = `@keyframes fadein-slideup { 0% { opacity: 0; transform: translateY(40px); } 100% { opacity: 1; transform: translateY(0); } }`;
if (typeof document !== 'undefined' && !document.getElementById('fadein-slideup-documents')) {
const style = document.createElement('style');
style.id = 'fadein-slideup-documents';
style.innerHTML = fadeInSlideUp;
document.head.appendChild(style);
}
import { fetchDocuments } from '../api/expedientes.ts';
import { useNotification } from '../context/NotificationContext';
import { usePolling } from '../hooks/usePolling';
import { Link } from 'react-router-dom';
const API_URL = import.meta.env.VITE_EFC_API_URL;
const downloadFile = async (id, filename = 'archivo', setSuccess, setError, showMessage) => {
const token = localStorage.getItem('access');
const res = await fetch(`${API_URL}/record/documents/descargar/${id}/`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (res.status === 401) {
showMessage('Tu sesión ha expirado, por favor inicia sesión de nuevo.', 'error');
localStorage.removeItem('access');
localStorage.removeItem('refresh');
setTimeout(() => {
window.location.href = '/login';
}, 2000);
return;
}
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');useEffect
};
export default function Documents() {
const focusKeeperRef = useRef(null);
const [success, setSuccess] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [alertaFilter, setAlertaFilter] = useState('all'); // all, true, false
const [expedienteFilter, setExpedienteFilter] = useState('all'); // all, true, false
const [contribuyenteFilter, setContribuyenteFilter] = useState('');
const [contribuyenteInput, setContribuyenteInput] = useState('');
const [fechaPagoFilter, setFechaPagoFilter] = useState('');
const [pedimentoFilter, setPedimentoFilter] = useState('');
const [searchFilter, setSearchFilter] = useState('');
const [curpApoderadoFilter, setCurpApoderadoFilter] = useState('');
const [patenteFilter, setPatenteFilter] = useState('');
const [aduanaFilter, setAduanaFilter] = useState('');
const [tipoOperacionFilter, setTipoOperacionFilter] = useState('');
const [clavePedimentoFilter, setClavePedimentoFilter] = useState('');
const { showMessage } = useNotification();
// Estado para controlar la animación de entrada
const [showAnimation, setShowAnimation] = useState(false);
const [hasAnimated, setHasAnimated] = useState(false);
useLayoutEffect(() => {
// Forzar un render antes de activar la animación
setShowAnimation(true);
}, []);
useEffect(() => {
if (showAnimation && !hasAnimated) {
const timeout = setTimeout(() => {
setHasAnimated(true);
setShowAnimation(false);
}, 700); // Duración igual a la animación
return () => clearTimeout(timeout);
}
}, [showAnimation, hasAnimated]);
// Fetching usando la función tipada de TypeScript
const fetchPedimentosData = async (page = currentPage, pageSize = itemsPerPage) => {
const token = localStorage.getItem('access');
// Construir objeto de filtros
const filters = {
search: searchFilter || undefined,
pedimento: pedimentoFilter || undefined,
existe_expediente: expedienteFilter === 'all' ? undefined : expedienteFilter,
alerta: alertaFilter === 'all' ? undefined : alertaFilter,
contribuyente: contribuyenteFilter || undefined,
curp_apoderado: curpApoderadoFilter || undefined,
fecha_pago: fechaPagoFilter || undefined,
patente: patenteFilter || undefined,
aduana: aduanaFilter || undefined,
tipo_operacion: tipoOperacionFilter || undefined,
clave_pedimento: clavePedimentoFilter || undefined,
};
return await fetchDocuments(token, page, pageSize, filters);
};
// Hook de polling que se ejecuta cada 30 segundos
const { data: pedimentos, loading, error, refetch } = usePolling(
() => fetchPedimentosData(currentPage, itemsPerPage),
30000, // 30 segundos
[currentPage, itemsPerPage, searchFilter, pedimentoFilter, expedienteFilter, alertaFilter, contribuyenteFilter, curpApoderadoFilter, fechaPagoFilter, patenteFilter, aduanaFilter, tipoOperacionFilter, clavePedimentoFilter]
);
// Manejo de errores de sesión
useEffect(() => {
if (error && error.message === 'SESSION_EXPIRED') {
localStorage.removeItem('access');
localStorage.removeItem('refresh');
showMessage('Tu sesión ha expirado, por favor inicia sesión de nuevo.', 'error');
setTimeout(() => {
window.location.href = '/login';
}, 2000);
} else if (error) {
showMessage(error.message, 'error');
}
}, [error, showMessage]);
// Cálculos de paginación usando la estructura tipada
const documentsArray = pedimentos && pedimentos.results ? pedimentos.results : [];
const totalDocuments = pedimentos && typeof pedimentos.count === 'number' ? pedimentos.count : 0;
const totalPages = totalDocuments > 0 ? Math.ceil(totalDocuments / itemsPerPage) : 1;
const currentDocuments = documentsArray;
// Obtener lista única de contribuyentes para el combobox (de la página actual)
const contribuyentes = Array.from(new Set(currentDocuments.map(d => d.contribuyente).filter(Boolean)));
// Refuerza la paginación SPA: nunca recarga la página, solo cambia el estado local
const handlePageChange = (newPage, e) => {
if (e && typeof e.preventDefault === 'function') e.preventDefault();
if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
if (newPage < 1 || newPage > totalPages || newPage === currentPage) return;
setCurrentPage(newPage);
// Quitar el foco del botón activo para evitar salto de scroll
if (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
};
// Forzar foco al div invisible para evitar saltos por enfoque automático
useLayoutEffect(() => {
if (focusKeeperRef.current) {
focusKeeperRef.current.focus();
}
}, [currentPage]);
const handleItemsPerPageChange = (newItemsPerPage) => {
setItemsPerPage(newItemsPerPage);
setCurrentPage(1); // Reset a la primera página
};
// El layout principal y la tabla siempre se renderizan, loader/error/empty solo dentro del área de la tabla
return (
<div className="p-6 bg-gray-50">
<div ref={focusKeeperRef} tabIndex={-1} style={{position:'absolute',width:0,height:0,overflow:'hidden',outline:'none'}} aria-hidden="true"></div>
<div className="max-w-7xl mx-auto">
{/* Header mejorado y decorativo */}
<div className={
"mb-8 relative overflow-hidden rounded-2xl shadow bg-gradient-to-r from-blue-50 via-white to-indigo-50 border border-blue-100 p-8 flex items-center gap-6"+
(showAnimation && !hasAnimated ? ' animate-fadein-slideup opacity-0' : '')
}
style={showAnimation && !hasAnimated ? { animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.05s forwards' } : undefined}>
<div className="flex-shrink-0 bg-blue-100 rounded-full p-4 shadow-md animate-bounce-slow">
<svg className="h-10 w-10 text-blue-600" 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>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight mb-1 flex items-center gap-2">
Expedientes
<span className="inline-block bg-blue-200 text-blue-800 text-xs font-semibold px-2 py-0.5 rounded-full ml-2 animate-fade-in">{totalDocuments}</span>
</h1>
<p className="text-lg text-blue-700/80 font-medium">Gestiona y descarga los documentos de tus pedimentos.</p>
</div>
{/* Efecto decorativo de fondo */}
<div className="absolute -top-10 -right-10 opacity-30 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="url(#grad1)" />
<defs>
<linearGradient id="grad1" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse">
<stop stopColor="#3b82f6" stopOpacity="0.15" />
<stop offset="1" stopColor="#6366f1" stopOpacity="0.10" />
</linearGradient>
</defs>
</svg>
</div>
</div>
{/* Animación personalizada para el icono y contador */}
<style>{`
@keyframes bounce-slow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.animate-bounce-slow {
animation: bounce-slow 2.2s infinite;
}
@keyframes fade-in {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
.animate-fade-in {
animation: fade-in 0.7s ease;
}
`}</style>
<div className={
"bg-white shadow-lg rounded-xl border border-gray-200"+
(showAnimation && !hasAnimated ? ' animate-fadein-slideup opacity-0' : '')
}
style={showAnimation && !hasAnimated ? { animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.15s forwards' } : undefined}>
<div className="px-6 py-6 border-b border-gray-200">
{/* Filtros avanzados */}
<div className="mb-4 flex flex-wrap gap-4 items-end">
{/* Search global */}
<div className="flex flex-col">
<label className="text-xs font-semibold text-gray-700 mb-1">Buscar</label>
<input
type="text"
value={searchFilter}
onChange={e => setSearchFilter(e.target.value)}
placeholder="Buscar pedimento, contribuyente, agente aduanal..."
className="w-44 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
/>
</div>
{/* Pedimento */}
<div className="flex flex-col">
<label className="text-xs font-semibold text-gray-700 mb-1">Pedimento</label>
<input
type="text"
value={pedimentoFilter}
onChange={e => setPedimentoFilter(e.target.value)}
placeholder="Buscar pedimento..."
className="w-36 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
/>
</div>
{/* Alerta */}
<div className="flex flex-col">
<label className="text-xs font-semibold text-gray-700 mb-1">Alerta</label>
<select value={alertaFilter} onChange={e => setAlertaFilter(e.target.value)}
className="w-36 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50">
<option value="all">Todos</option>
<option value="true"></option>
<option value="false">No</option>
</select>
</div>
{/* Expediente */}
<div className="flex flex-col">
<label className="text-xs font-semibold text-gray-700 mb-1">Expediente</label>
<select value={expedienteFilter} onChange={e => setExpedienteFilter(e.target.value)}
className="w-36 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50">
<option value="all">Todos</option>
<option value="true"></option>
<option value="false">No</option>
</select>
</div>
{/* Contribuyente combobox */}
<div className="flex flex-col relative">
<label className="text-xs font-semibold text-gray-700 mb-1">Contribuyente</label>
<input
type="text"
value={contribuyenteInput}
onChange={e => {
setContribuyenteInput(e.target.value);
setContribuyenteFilter('');
}}
placeholder="Buscar o escribir..."
className="w-44 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
autoComplete="off"
/>
{/* Dropdown de sugerencias */}
{contribuyenteInput && (
<div className="absolute top-14 left-0 w-44 bg-white border border-gray-200 rounded-lg shadow-lg z-10 max-h-40 overflow-auto">
{contribuyentes.filter(c => c.toLowerCase().includes(contribuyenteInput.toLowerCase())).length === 0 ? (
<div className="px-3 py-2 text-sm text-gray-500">Sin coincidencias</div>
) : (
contribuyentes.filter(c => c.toLowerCase().includes(contribuyenteInput.toLowerCase())).map(c => (
<button
key={c}
type="button"
className="w-full text-left px-3 py-2 hover:bg-blue-50 text-sm"
onClick={() => {
setContribuyenteFilter(c);
setContribuyenteInput('');
}}
>
{c}
</button>
))
)}
</div>
)}
</div>
{/* CURP Apoderado */}
<div className="flex flex-col">
<label className="text-xs font-semibold text-gray-700 mb-1">CURP Apoderado</label>
<input
type="text"
value={curpApoderadoFilter}
onChange={e => setCurpApoderadoFilter(e.target.value)}
placeholder="CURP del apoderado..."
className="w-44 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
/>
</div>
{/* Fecha de pago */}
<div className="flex flex-col">
<label className="text-xs font-semibold text-gray-700 mb-1">Fecha de pago</label>
<input type="date" value={fechaPagoFilter} onChange={e => setFechaPagoFilter(e.target.value)}
className="w-44 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50" />
</div>
{/* Patente */}
<div className="flex flex-col">
<label className="text-xs font-semibold text-gray-700 mb-1">Patente</label>
<input
type="text"
value={patenteFilter}
onChange={e => setPatenteFilter(e.target.value)}
placeholder="Patente..."
className="w-36 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
/>
</div>
{/* Aduana */}
<div className="flex flex-col">
<label className="text-xs font-semibold text-gray-700 mb-1">Aduana</label>
<input
type="text"
value={aduanaFilter}
onChange={e => setAduanaFilter(e.target.value)}
placeholder="Aduana..."
className="w-36 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
/>
</div>
{/* Tipo de operación */}
<div className="flex flex-col">
<label className="text-xs font-semibold text-gray-700 mb-1">Tipo de operación</label>
<input
type="text"
value={tipoOperacionFilter}
onChange={e => setTipoOperacionFilter(e.target.value)}
placeholder="ID tipo operación..."
className="w-36 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
/>
</div>
{/* Clave pedimento */}
<div className="flex flex-col">
<label className="text-xs font-semibold text-gray-700 mb-1">Clave pedimento</label>
<input
type="text"
value={clavePedimentoFilter}
onChange={e => setClavePedimentoFilter(e.target.value)}
placeholder="Clave pedimento..."
className="w-36 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
/>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500 bg-gray-100 px-3 py-1 rounded-full">
🔄 Actualización automática cada 30 segundos
</span>
<button
onClick={refetch}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md 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 shadow-lg"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Actualizar Ahora
</button>
</div>
{success && (
<div className="mt-4 bg-green-50 border border-green-200 rounded-md p-4">
<div className="flex">
<svg className="h-5 w-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path>
</svg>
<p className="text-green-800">{success}</p>
</div>
</div>
)}
</div>
<div className="overflow-hidden">
<div className="overflow-x-auto" id="tabla-documentos">
<div style={{ minHeight: 'calc(7 * 56px)', maxHeight: 'calc(7 * 56px)', overflowY: currentDocuments.length > 8 ? 'auto' : 'hidden', position: 'relative' }}>
<table className="min-w-full divide-y divide-gray-200 rounded-lg overflow-hidden sticky text-xs">
<thead className="bg-gray-50 sticky top-0 z-20 shadow">
<tr>
<th className="px-2 py-2 text-left font-bold uppercase tracking-wider border-b border-gray-200 whitespace-nowrap">Pedimento</th>
<th className="px-2 py-2 text-left font-bold uppercase tracking-wider border-b border-gray-200 whitespace-nowrap">Fecha de pago</th>
<th className="px-2 py-2 text-left font-bold uppercase tracking-wider border-b border-gray-200 whitespace-nowrap">Contribuyente</th>
<th className="px-2 py-2 text-left font-bold uppercase tracking-wider border-b border-gray-200 whitespace-nowrap">Alerta</th>
<th className="px-2 py-2 text-left font-bold uppercase tracking-wider border-b border-gray-200 whitespace-nowrap">CURP Apoderado</th>
<th className="px-2 py-2 text-left font-bold uppercase tracking-wider border-b border-gray-200 whitespace-nowrap">Importe total</th>
<th className="px-2 py-2 text-left font-bold uppercase tracking-wider border-b border-gray-200 whitespace-nowrap">Saldo disponible</th>
<th className="px-2 py-2 text-left font-bold uppercase tracking-wider border-b border-gray-200 whitespace-nowrap">Importe pedimento</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Expediente</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200" style={{ position: 'relative', minHeight: 'calc(8 * 56px)' }}>
{/* Loader/Error/Empty state dentro del área de la tabla, sin cambiar el layout */}
{loading ? (
<tr>
<td colSpan={9} style={{ height: 'calc(8 * 56px)', padding: 0 }}>
<div className="flex items-center justify-center h-full w-full absolute left-0 top-0" style={{ minHeight: 'calc(8 * 56px)', background: 'rgba(255,255,255,0.7)', zIndex: 10 }}>
<span className="text-gray-500 text-lg">Cargando documentos...</span>
</div>
</td>
</tr>
) : error ? (
<tr>
<td colSpan={9} style={{ height: 'calc(8 * 56px)', padding: 0 }}>
<div className="flex items-center justify-center h-full w-full absolute left-0 top-0" style={{ minHeight: 'calc(8 * 56px)', background: 'rgba(255,255,255,0.7)', zIndex: 10 }}>
<span className="text-danger-600 text-lg">Error: {error.message || 'Error al cargar documentos'}</span>
</div>
</td>
</tr>
) : currentDocuments.length > 0 ? (
<>
{currentDocuments.map(ped => (
<tr key={ped.id} className="hover:bg-blue-50 transition-all duration-300 hover:scale-[1.02] hover:shadow-md">
<td className="px-6 py-4 whitespace-nowrap">
<Link
to={`/expedientes/pedimento/${ped.id}`}
className="text-blue-600 hover:text-blue-800 font-medium transition-colors duration-200"
>
{ped.pedimento}
</Link>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{ped.fechapago}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{ped.contribuyente}</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
ped.alerta
? 'bg-red-100 text-red-800'
: 'bg-green-100 text-green-800'
}`}>
{ped.alerta ? 'Sí' : 'No'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{ped.curp_apoderado}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-medium">${ped.importe_total}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-medium">${ped.saldo_disponible}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-medium">${ped.importe_pedimento}</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
ped.existe_expediente
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{ped.existe_expediente ? 'Sí' : 'No'}
</span>
</td>
</tr>
))}
{/* Rellenar con filas vacías si hay menos de 8 */}
{currentDocuments.length < 8 && !loading && !error && Array.from({length: 8 - currentDocuments.length}).map((_, idx) => (
<tr key={`empty-${idx}`} className="">
<td className="px-6 py-4 whitespace-nowrap" colSpan={9}>&nbsp;</td>
</tr>
))}
</>
) : (
<tr>
<td colSpan={9} style={{ height: 'calc(8 * 56px)', padding: 0 }}>
<div className="flex flex-col items-center justify-center h-full w-full absolute left-0 top-0" style={{ minHeight: 'calc(8 * 56px)', background: 'rgba(255,255,255,0.7)', zIndex: 10 }}>
<div className="mx-auto h-16 w-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="h-8 w-8 text-gray-400" 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>
<h3 className="text-lg font-medium text-gray-900 mb-2">No hay pedimentos</h3>
<p className="text-gray-500">Aún no tienes pedimentos registrados.</p>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Paginación con botones numerados y elipsis */}
{totalDocuments > 0 && (
<div className="bg-white px-4 py-3 flex flex-col sm:flex-row items-center justify-between border-t border-gray-200">
{(() => {
const totalPages = Math.max(1, Math.ceil(totalDocuments / itemsPerPage));
const maxPagesToShow = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
let endPage = startPage + maxPagesToShow - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxPagesToShow + 1);
}
const pageNumbers = [];
for (let i = startPage; i <= endPage; i++) {
pageNumbers.push(i);
}
return (
<div className="flex flex-col sm:flex-row sm:items-center w-full gap-2 sm:gap-4 mt-2 sm:mt-0">
<div className="flex items-center gap-2">
<label htmlFor="itemsPerPage" className="text-xs text-gray-600 font-medium">Registros por página:</label>
<select
id="itemsPerPage"
value={itemsPerPage}
onChange={e => handleItemsPerPageChange(Number(e.target.value))}
className="border border-gray-300 rounded px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
{[5, 10, 20, 50, 100, 200, 400, 800, 1200, 2400, 10000].map(size => (
<option key={size} value={size}>{size}</option>
))}
</select>
</div>
<div className="flex items-center gap-1 flex-wrap">
<button
type="button"
onClick={e => handlePageChange(1, e)}
disabled={currentPage === 1}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${currentPage === 1 ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
>
«
</button>
<button
type="button"
onClick={e => handlePageChange(currentPage - 1, e)}
disabled={currentPage === 1}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${currentPage === 1 ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
>
</button>
{pageNumbers.map(num => (
<button
type="button"
key={num}
onClick={e => handlePageChange(num, e)}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${num === currentPage ? 'bg-blue-600 text-white border-blue-700 cursor-default' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
disabled={num === currentPage}
>
{num}
</button>
))}
<button
type="button"
onClick={e => handlePageChange(currentPage + 1, e)}
disabled={currentPage >= totalPages}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${(currentPage >= totalPages) ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
>
</button>
<button
type="button"
onClick={e => handlePageChange(totalPages, e)}
disabled={currentPage >= totalPages}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${(currentPage >= totalPages) ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
>
»
</button>
<span className="ml-3 text-xs text-gray-500">Página <span className="font-bold">{currentPage}</span> de <span className="font-bold">{totalPages}</span></span>
</div>
</div>
);
})()}
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,195 @@
import React, { useState } from 'react';
const API_URL = import.meta.env.VITE_EFC_API_URL;
export default function ForgotPassword() {
const [email, setEmail] = useState('');
const [username, setUsername] = useState('');
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
setSuccess(false);
try {
const res = await fetch(`${API_URL}/user/password-reset/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.detail || 'No se pudo enviar el correo.');
} else {
setSuccess(true);
}
} catch (err) {
setError('Error de red. Intenta de nuevo.');
}
setLoading(false);
};
return (
<div className="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#F2F4F7' }}>
{/* Background pattern */}
<div className="absolute inset-0 opacity-20">
<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='%231B2A41' fillOpacity='0.1'%3E%3Ccircle cx='30' cy='30' r='2'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
}}></div>
</div>
<div className="relative max-w-md w-full">
{/* Main Card */}
<div className="bg-white backdrop-blur-md rounded-3xl shadow-2xl border border-gray-200 overflow-hidden">
{/* Header with navy background */}
<div className="px-8 py-10 text-center" style={{ backgroundColor: '#1B2A41' }}>
<div className="mb-4">
<a href="/" className="inline-block">
<h1 className="text-4xl font-bold text-white">
EFC
</h1>
</a>
</div>
<h2 className="text-2xl font-bold text-white mb-2">
Recuperar contraseña
</h2>
<p className="text-sm" style={{ color: 'rgba(255, 255, 255, 0.8)' }}>
Ingresa tu usuario y correo para recibir el enlace de recuperación
</p>
</div>
{/* Form */}
<div className="px-8 py-8">
{success ? (
<div className="text-green-700 bg-green-50 border border-green-200 rounded p-4 text-center mb-4">
Si el correo está registrado, recibirás un enlace para restablecer tu contraseña.
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#333333' }}>
Usuario
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5" style={{ color: '#7A7A7A' }} 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>
<input
type="text"
required
className="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:border-transparent transition duration-200"
style={{ color: '#333333', borderColor: '#d1d5db' }}
placeholder="Tu usuario"
value={username}
onChange={e => setUsername(e.target.value)}
onFocus={e => {
e.target.style.borderColor = 'transparent';
e.target.style.boxShadow = '0 0 0 2px #4DA6FF';
}}
onBlur={e => {
e.target.style.borderColor = '#d1d5db';
e.target.style.boxShadow = 'none';
}}
onMouseEnter={e => {
if (document.activeElement !== e.target) {
e.target.style.borderColor = '#4DA6FF';
}
}}
onMouseLeave={e => {
if (document.activeElement !== e.target) {
e.target.style.borderColor = '#d1d5db';
}
}}
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#333333' }}>
Correo electrónico
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5" style={{ color: '#7A7A7A' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 12H8m8 0a4 4 0 11-8 0 4 4 0 018 0zm-4 4v2m0-6V8" />
</svg>
</div>
<input
type="email"
required
className="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:border-transparent transition duration-200"
style={{ color: '#333333', borderColor: '#d1d5db' }}
placeholder="tucorreo@ejemplo.com"
value={email}
onChange={e => setEmail(e.target.value)}
onFocus={e => {
e.target.style.borderColor = 'transparent';
e.target.style.boxShadow = '0 0 0 2px #4DA6FF';
}}
onBlur={e => {
e.target.style.borderColor = '#d1d5db';
e.target.style.boxShadow = 'none';
}}
onMouseEnter={e => {
if (document.activeElement !== e.target) {
e.target.style.borderColor = '#4DA6FF';
}
}}
onMouseLeave={e => {
if (document.activeElement !== e.target) {
e.target.style.borderColor = '#d1d5db';
}
}}
/>
</div>
</div>
{error && <div className="rounded-xl bg-red-50 border p-4 animate-pulse text-danger-600 text-sm text-center" style={{ borderColor: 'rgba(198, 40, 40, 0.2)' }}>{error}</div>}
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
style={{ backgroundColor: '#1B2A41', '--tw-ring-color': '#1B2A41' }}
onMouseEnter={e => {
if (!loading) {
e.target.style.backgroundColor = '#162234';
}
}}
onMouseLeave={e => {
if (!loading) {
e.target.style.backgroundColor = '#1B2A41';
}
}}
>
{loading ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Enviando...
</>
) : (
<>
<span>Enviar enlace de recuperación</span>
<svg className="ml-2 w-4 h-4 group-hover:translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</>
)}
</button>
</form>
)}
<div className="mt-6 text-center">
<a href="/login" className="text-blue-600 hover:underline text-sm">Volver al inicio de sesión</a>
</div>
</div>
</div>
{/* Floating elements */}
<div className="absolute -top-4 -left-4 w-24 h-24 rounded-full blur-xl" style={{ backgroundColor: 'rgba(255, 255, 255, 0.1)' }}></div>
<div className="absolute -bottom-4 -right-4 w-32 h-32 rounded-full blur-xl" style={{ backgroundColor: 'rgba(27, 42, 65, 0.2)' }}></div>
</div>
</div>
);
}

427
src/pages/Importers.jsx Normal file
View File

@@ -0,0 +1,427 @@
import React, { useState, useEffect } from 'react';
export default function Importers() {
const [importers, setImporters] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
// Datos dummy para mostrar
const dummyImporters = [
{
id: 1,
name: 'Importadora ABC S.A.',
rfc: 'ABC123456789',
email: 'contacto@abc.com',
status: 'Activo',
lastActivity: '2024-01-15',
documentsCount: 45
},
{
id: 2,
name: 'Comercial XYZ Ltda.',
rfc: 'XYZ987654321',
email: 'info@xyz.com',
status: 'Activo',
lastActivity: '2024-01-14',
documentsCount: 23
},
{
id: 3,
name: 'Global Trade Corp.',
rfc: 'GTC555666777',
email: 'admin@globaltrade.com',
status: 'Inactivo',
lastActivity: '2024-01-10',
documentsCount: 12
}
];
useEffect(() => {
// Simular carga de datos
const timer = setTimeout(() => {
setImporters(dummyImporters);
setLoading(false);
}, 1000);
return () => clearTimeout(timer);
}, []);
const filteredImporters = importers.filter(importer =>
importer.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
importer.rfc.toLowerCase().includes(searchTerm.toLowerCase()) ||
importer.email.toLowerCase().includes(searchTerm.toLowerCase())
);
// Cálculos de paginación
const totalImporters = filteredImporters.length;
const totalPages = Math.ceil(totalImporters / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const currentImporters = filteredImporters.slice(startIndex, endIndex);
// Reset página cuando cambia el filtro
useEffect(() => {
setCurrentPage(1);
}, [searchTerm]);
const handlePageChange = (page) => {
setCurrentPage(page);
};
const handleItemsPerPageChange = (newItemsPerPage) => {
setItemsPerPage(newItemsPerPage);
setCurrentPage(1); // Reset a la primera página
};
const getStatusBadge = (status) => {
return status === 'Activo'
? 'bg-success-100 text-success-800 border border-success-200'
: 'bg-danger-100 text-danger-800 border border-danger-200';
};
if (loading) {
return (
<div className="h-full bg-gray-50 flex items-center justify-center">
<div className="text-center">
<svg className="animate-spin h-12 w-12 text-navy-600 mx-auto mb-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className="text-gray-600 text-lg">Cargando información de importadores...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-6">
<h1 className="text-3xl font-bold bg-gradient-to-r from-navy-900 to-navy-700 bg-clip-text text-transparent mb-2">
Importadores
</h1>
<p className="text-gray-600">Gestiona y supervisa las empresas importadoras registradas en el sistema.</p>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="bg-white overflow-hidden shadow-xl rounded-xl border border-gray-200">
<div className="p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-gradient-to-br from-navy-500 to-navy-600 rounded-lg flex items-center justify-center shadow-lg">
<svg className="h-6 w-6 text-white" 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>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total Importadores</dt>
<dd className="text-2xl font-bold text-navy-900">{importers.length}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow-xl rounded-xl border border-gray-200">
<div className="p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-gradient-to-br from-success-500 to-success-600 rounded-lg flex items-center justify-center shadow-lg">
<svg className="h-6 w-6 text-white" 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>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Activos</dt>
<dd className="text-2xl font-bold text-success-900">
{importers.filter(i => i.status === 'Activo').length}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow-xl rounded-xl border border-gray-200">
<div className="p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-gradient-to-br from-danger-500 to-danger-600 rounded-lg flex items-center justify-center shadow-lg">
<svg className="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Inactivos</dt>
<dd className="text-2xl font-bold text-danger-900">
{importers.filter(i => i.status === 'Inactivo').length}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow-xl rounded-xl border border-gray-200">
<div className="p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-lg flex items-center justify-center shadow-lg">
<svg className="h-6 w-6 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>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total Documentos</dt>
<dd className="text-2xl font-bold text-primary-900">
{importers.reduce((sum, i) => sum + i.documentsCount, 0)}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Search and Actions */}
<div className="bg-white shadow-xl rounded-xl border border-gray-200 mb-8">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 min-w-0">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
className="focus:ring-primary-500 focus:border-primary-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-lg shadow-sm transition-all duration-200"
placeholder="Buscar importadores..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<div className="mt-4 sm:mt-0 sm:ml-4">
<button
type="button"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-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>
Nuevo Importador
</button>
</div>
</div>
</div>
{/* Table */}
<div className="overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gradient-to-r from-gray-50 to-gray-100">
<tr>
<th scope="col" className="px-6 py-4 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">
Importador
</th>
<th scope="col" className="px-6 py-4 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">
RFC
</th>
<th scope="col" className="px-6 py-4 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">
Estado
</th>
<th scope="col" className="px-6 py-4 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">
Documentos
</th>
<th scope="col" className="px-6 py-4 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">
Última Actividad
</th>
<th scope="col" className="relative px-6 py-4">
<span className="sr-only">Acciones</span>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{currentImporters.map((importer, index) => (
<tr key={importer.id} className={`hover:bg-gray-50 transition-colors duration-200 ${index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}`}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-12 w-12">
<div className="h-12 w-12 rounded-full bg-gradient-to-br from-navy-500 to-navy-600 flex items-center justify-center shadow-lg">
<svg className="h-6 w-6 text-white" 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>
</div>
<div className="ml-4">
<div className="text-sm font-semibold text-gray-900">{importer.name}</div>
<div className="text-sm text-gray-500">{importer.email}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{importer.rfc}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-3 py-1 text-xs font-semibold rounded-full ${getStatusBadge(importer.status)}`}>
{importer.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<span className="text-sm font-semibold text-gray-900">{importer.documentsCount}</span>
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
docs
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(importer.lastActivity).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end space-x-2">
<button className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-white bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 transition-all duration-200 transform hover:scale-105">
Ver
</button>
<button className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-white bg-gradient-to-r from-warning-600 to-warning-700 hover:from-warning-700 hover:to-warning-800 transition-all duration-200 transform hover:scale-105">
Editar
</button>
<button className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-white bg-gradient-to-r from-danger-600 to-danger-700 hover:from-danger-700 hover:to-danger-800 transition-all duration-200 transform hover:scale-105">
Eliminar
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Paginación */}
{totalImporters > 0 && (
<div className="bg-white px-4 py-3 flex flex-col sm:flex-row items-center justify-between border-t border-gray-200">
<div className="flex items-center mb-4 sm:mb-0">
<span className="text-sm text-gray-700 mr-4">
Mostrando <span className="font-semibold">{startIndex + 1}</span> - <span className="font-semibold">{Math.min(endIndex, totalImporters)}</span> de <span className="font-semibold">{totalImporters}</span> importadores
</span>
<select
value={itemsPerPage}
onChange={(e) => handleItemsPerPageChange(Number(e.target.value))}
className="border border-gray-300 rounded-md px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-navy-500 focus:border-navy-500"
>
<option value={10}>10 por página</option>
<option value={15}>15 por página</option>
<option value={20}>20 por página</option>
</select>
</div>
{totalPages > 1 && (
<div className="flex items-center space-x-2">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="relative inline-flex items-center px-3 py-2 rounded-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 19l-7-7 7-7" />
</svg>
Anterior
</button>
<div className="hidden sm:flex space-x-1">
{[...Array(totalPages)].map((_, index) => {
const page = index + 1;
const isCurrentPage = page === currentPage;
const isNearCurrentPage = Math.abs(page - currentPage) <= 2;
const isFirstOrLast = page === 1 || page === totalPages;
if (totalPages <= 7 || isNearCurrentPage || isFirstOrLast) {
return (
<button
key={page}
onClick={() => handlePageChange(page)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium rounded-md transition-colors ${
isCurrentPage
? 'z-10 bg-navy-600 border-navy-600 text-white'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
}`}
>
{page}
</button>
);
} else if (page === currentPage - 3 || page === currentPage + 3) {
return (
<span key={page} className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
...
</span>
);
}
return null;
})}
</div>
<div className="sm:hidden flex items-center space-x-2">
<span className="text-sm text-gray-700">
Página {currentPage} de {totalPages}
</span>
</div>
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="relative inline-flex items-center px-3 py-2 rounded-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Siguiente
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
)}
</div>
)}
</div>
{/* Empty state */}
{currentImporters.length === 0 && !loading && (
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-12">
<div className="text-center">
<div className="mx-auto h-24 w-24 bg-gradient-to-br from-gray-100 to-gray-200 rounded-full flex items-center justify-center mb-6">
<svg className="h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">No se encontraron importadores</h3>
<p className="text-gray-500 mb-6">
{searchTerm ? 'Intenta con otros términos de búsqueda.' : 'Comienza agregando un nuevo importador.'}
</p>
{!searchTerm && (
<button className="inline-flex items-center px-4 py-2 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-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>
Agregar primer importador
</button>
)}
</div>
</div>
)}
</div>
</div>
);
}

785
src/pages/Landing.jsx Normal file
View File

@@ -0,0 +1,785 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
export default function Landing() {
const [isScrolled, setIsScrolled] = useState(false);
const [activeSection, setActiveSection] = useState('inicio');
const [contactForm, setContactForm] = useState({
name: '',
email: '',
company: '',
message: ''
});
// Efecto de scroll para navbar
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 50);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// Smooth scroll para navegación
const scrollToSection = (sectionId) => {
const element = document.getElementById(sectionId);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
setActiveSection(sectionId);
}
};
const handleContactSubmit = (e) => {
e.preventDefault();
alert('Gracias por tu mensaje. Nos pondremos en contacto contigo pronto.');
setContactForm({ name: '', email: '', company: '', message: '' });
};
// Estadísticas animadas
const stats = [
{ number: '500+', label: 'Agentes Aduanales', icon: '🏢' },
{ number: '15,000+', label: 'Pedimentos Procesados', icon: '📋' },
{ number: '99.9%', label: 'Uptime Garantizado', icon: '⚡' },
{ number: '24/7', label: 'Soporte Especializado', icon: '🛡️' }
];
return (
<div className="min-h-screen bg-white">
{/* Navbar flotante con efectos */}
<header className={`fixed top-0 w-full z-50 transition-all duration-300 ${
isScrolled
? '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="flex items-center">
<div className="flex-shrink-0">
<h1 className="text-2xl font-bold">
<span className="bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
EFC
</span>
</h1>
</div>
<nav className="hidden md:flex ml-10 space-x-8">
{[
{ id: 'inicio', label: 'Inicio' },
{ id: 'caracteristicas', label: 'Características' },
{ id: 'estadisticas', label: 'Confianza' },
{ id: 'testimonios', label: 'Testimonios' },
{ id: 'precios', label: 'Precios' },
{ id: 'contacto', label: 'Contacto' }
].map((item) => (
<button
key={item.id}
onClick={() => scrollToSection(item.id)}
className={`text-sm font-medium transition-all duration-200 hover:scale-105 ${
activeSection === item.id
? 'text-indigo-600 border-b-2 border-indigo-600'
: isScrolled
? 'text-gray-700 hover:text-indigo-600'
: 'text-white hover:text-indigo-200'
}`}
>
{item.label}
</button>
))}
</nav>
</div>
<div className="flex items-center space-x-4">
<Link
to="/login"
className="bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white px-6 py-2 rounded-full text-sm font-medium transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
Acceder
</Link>
</div>
</div>
</div>
</header>
{/* Hero Section con efectos */}
<section id="inicio" className="relative min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-900 via-blue-800 to-purple-900 overflow-hidden">
{/* Efectos de fondo animados */}
<div className="absolute inset-0">
<div className="absolute top-1/4 left-1/4 w-72 h-72 bg-indigo-500 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse"></div>
<div className="absolute top-1/3 right-1/4 w-96 h-96 bg-purple-500 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse animation-delay-2000"></div>
<div className="absolute bottom-1/4 left-1/3 w-80 h-80 bg-blue-500 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse animation-delay-4000"></div>
</div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 text-center">
<div className="mb-8">
<h1 className="text-5xl sm:text-6xl md:text-7xl font-extrabold text-white mb-6 leading-tight">
<span className="bg-gradient-to-r from-yellow-400 to-orange-500 bg-clip-text text-transparent animate-pulse">
EFC
</span>
<br />
<span className="text-3xl sm:text-4xl md:text-5xl bg-gradient-to-r from-blue-200 to-purple-200 bg-clip-text text-transparent">
Para Agentes Aduanales e Importadores
</span>
</h1>
<p className="text-xl sm:text-2xl text-blue-100 mb-12 max-w-4xl mx-auto leading-relaxed">
La plataforma líder para agentes aduanales e importadores que buscan
<span className="font-semibold text-yellow-300"> digitalizar y revolucionar</span>
{' '}sus procesos de comercio exterior. Gestiona pedimentos, documentación aduanal
y expedientes fiscales con total seguridad y eficiencia.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-6 justify-center mb-16">
<Link
to="/login"
className="group inline-flex items-center px-8 py-4 border border-transparent text-lg font-medium rounded-full text-indigo-900 bg-gradient-to-r from-yellow-400 to-orange-500 hover:from-yellow-500 hover:to-orange-600 transition-all duration-300 shadow-2xl hover:shadow-yellow-500/25 transform hover:-translate-y-1 hover:scale-105"
>
<span className="mr-2">🚀</span>
Acceder a la Plataforma
<svg className="ml-2 -mr-1 w-5 h-5 group-hover:translate-x-1 transition-transform" 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('contacto')}
className="group inline-flex items-center px-8 py-4 border-2 border-white/30 text-lg font-medium rounded-full text-white bg-white/10 backdrop-blur-md hover:bg-white/20 transition-all duration-300 shadow-xl hover:shadow-white/25"
>
<svg className="mr-2 w-5 h-5 group-hover:rotate-12 transition-transform" 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>
Solicitar Demo Gratuita
</button>
</div>
{/* Estadísticas animadas */}
<div id="estadisticas" className="grid grid-cols-2 md:grid-cols-4 gap-8 mt-20">
{stats.map((stat, index) => (
<div
key={index}
className="text-center p-6 bg-white/10 backdrop-blur-md rounded-2xl border border-white/20 hover:bg-white/20 transition-all duration-300 transform hover:-translate-y-2"
>
<div className="text-4xl mb-2">{stat.icon}</div>
<div className="text-3xl font-bold text-white mb-1">{stat.number}</div>
<div className="text-blue-200 text-sm">{stat.label}</div>
</div>
))}
</div>
</div>
{/* Scroll indicator */}
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce">
<button
onClick={() => scrollToSection('caracteristicas')}
className="text-white/70 hover:text-white transition-colors"
>
<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" />
</svg>
</button>
</div>
</section>
{/* Main content */}
<main className="relative bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
{/* Features Section */}
<div id="caracteristicas" className="mt-20">
<div className="text-center mb-12">
<h2 className="text-3xl font-extrabold text-gray-900 mb-4">
Soluciones Especializadas para Comercio Exterior
</h2>
<p className="text-lg text-gray-600">
Herramientas diseñadas específicamente para las necesidades de agentes aduanales e importadores
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Feature 1 */}
<div className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition duration-200">
<div className="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-indigo-600" 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>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
Gestión de Pedimentos
</h3>
<p className="text-gray-600">
Administra pedimentos de importación y exportación, documentos aduanales,
clasificaciones arancelarias y toda la documentación requerida por el SAT.
</p>
</div>
{/* Feature 2 */}
<div className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition duration-200">
<div className="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-indigo-600" 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>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
Control por Organización
</h3>
<p className="text-gray-600">
Gestiona múltiples clientes importadores con espacios de trabajo separados,
permisos granulares y control total sobre el acceso a la información.
</p>
</div>
{/* Feature 3 */}
<div className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition duration-200">
<div className="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
Reportes Aduanales
</h3>
<p className="text-gray-600">
Genera reportes especializados para auditorías, seguimiento de operaciones
aduanales, estadísticas de importación y cumplimiento normativo.
</p>
</div>
</div>
</div>
{/* Benefits Section */}
<div className="mt-20 bg-indigo-50 rounded-2xl p-8 md:p-12">
<div className="text-center mb-12">
<h2 className="text-3xl font-extrabold text-gray-900 mb-4">
¿Por qué elegir EFC?
</h2>
<p className="text-lg text-gray-600">
Diseñado por expertos en comercio exterior para profesionales del sector
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<div className="text-center">
<div className="w-16 h-16 bg-indigo-600 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Cumplimiento</h3>
<p className="text-gray-600 text-sm">Cumple con todas las regulaciones del SAT y normativas aduanales mexicanas</p>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-indigo-600 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Eficiencia</h3>
<p className="text-gray-600 text-sm">Reduce hasta 70% el tiempo en gestión documental y procesos administrativos</p>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-indigo-600 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 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>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Seguridad</h3>
<p className="text-gray-600 text-sm">Cifrado de extremo a extremo y controles de acceso empresariales</p>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-indigo-600 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192L5.636 18.364M12 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Soporte</h3>
<p className="text-gray-600 text-sm">Soporte especializado con conocimiento profundo en comercio exterior</p>
</div>
</div>
</div>
{/* Testimonios Section */}
<div id="testimonios" className="mt-20">
<div className="text-center mb-12">
<h2 className="text-3xl font-extrabold text-gray-900 mb-4">
Lo que dicen nuestros clientes
</h2>
<p className="text-lg text-gray-600">
Agentes aduanales e importadores que ya transformaron su operación con EFC
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center mb-4">
<div className="w-12 h-12 bg-indigo-100 rounded-full flex items-center justify-center">
<span className="text-indigo-600 font-semibold">JM</span>
</div>
<div className="ml-4">
<h4 className="font-semibold text-gray-900">José María González</h4>
<p className="text-gray-600 text-sm">Agente Aduanal Patente 1234</p>
</div>
</div>
<p className="text-gray-600 italic">
"EFC ha revolucionado nuestra operación. La gestión de pedimentos es ahora 60% más rápida
y tenemos control total sobre todos nuestros clientes importadores."
</p>
<div className="flex mt-4">
{[...Array(5)].map((_, i) => (
<svg key={i} className="w-5 h-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center mb-4">
<div className="w-12 h-12 bg-indigo-100 rounded-full flex items-center justify-center">
<span className="text-indigo-600 font-semibold">LR</span>
</div>
<div className="ml-4">
<h4 className="font-semibold text-gray-900">Laura Rodríguez</h4>
<p className="text-gray-600 text-sm">Directora de Comercio Exterior</p>
</div>
</div>
<p className="text-gray-600 italic">
"Como importador, necesitábamos una solución que nos diera visibilidad completa de nuestros procesos.
EFC nos permite colaborar eficientemente con nuestro agente aduanal."
</p>
<div className="flex mt-4">
{[...Array(5)].map((_, i) => (
<svg key={i} className="w-5 h-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center mb-4">
<div className="w-12 h-12 bg-indigo-100 rounded-full flex items-center justify-center">
<span className="text-indigo-600 font-semibold">CT</span>
</div>
<div className="ml-4">
<h4 className="font-semibold text-gray-900">Carlos Torres</h4>
<p className="text-gray-600 text-sm">Agente Aduanal Patente 5678</p>
</div>
</div>
<p className="text-gray-600 italic">
"La seguridad y el cumplimiento normativo de EFC nos dan tranquilidad total.
Nuestros clientes valoran mucho la transparencia que ofrecemos ahora."
</p>
<div className="flex mt-4">
{[...Array(5)].map((_, i) => (
<svg key={i} className="w-5 h-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
</div>
</div>
</div>
{/* Precios Section */}
<div id="precios" className="mt-20">
<div className="text-center mb-12">
<h2 className="text-3xl font-extrabold text-gray-900 mb-4">
Planes diseñados para tu crecimiento
</h2>
<p className="text-lg text-gray-600">
Desde agencias pequeñas hasta grandes corporaciones
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Plan Básico */}
<div className="bg-white rounded-lg shadow-md p-8 border border-gray-200">
<div className="text-center">
<h3 className="text-2xl font-semibold text-gray-900 mb-2">Básico</h3>
<p className="text-gray-600 mb-4">Para agencias pequeñas</p>
<div className="mb-6">
<span className="text-4xl font-bold text-gray-900">$2,999</span>
<span className="text-gray-600">/mes</span>
</div>
<ul className="text-left space-y-3 mb-8">
<li className="flex items-center">
<svg className="w-5 h-5 text-green-500 mr-2" 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>
Hasta 5 usuarios
</li>
<li className="flex items-center">
<svg className="w-5 h-5 text-green-500 mr-2" 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>
50GB almacenamiento
</li>
<li className="flex items-center">
<svg className="w-5 h-5 text-green-500 mr-2" 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>
Gestión de pedimentos
</li>
<li className="flex items-center">
<svg className="w-5 h-5 text-green-500 mr-2" 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>
Soporte por email
</li>
</ul>
<button className="w-full bg-indigo-600 text-white py-3 px-4 rounded-md hover:bg-indigo-700 transition duration-200">
Comenzar prueba gratuita
</button>
</div>
</div>
{/* Plan Profesional */}
<div className="bg-white rounded-lg shadow-xl p-8 border-2 border-indigo-500 relative">
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<span className="bg-indigo-500 text-white px-4 py-1 rounded-full text-sm font-medium">
Más Popular
</span>
</div>
<div className="text-center">
<h3 className="text-2xl font-semibold text-gray-900 mb-2">Profesional</h3>
<p className="text-gray-600 mb-4">Para agencias en crecimiento</p>
<div className="mb-6">
<span className="text-4xl font-bold text-gray-900">$5,999</span>
<span className="text-gray-600">/mes</span>
</div>
<ul className="text-left space-y-3 mb-8">
<li className="flex items-center">
<svg className="w-5 h-5 text-green-500 mr-2" 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>
Hasta 25 usuarios
</li>
<li className="flex items-center">
<svg className="w-5 h-5 text-green-500 mr-2" 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>
200GB almacenamiento
</li>
<li className="flex items-center">
<svg className="w-5 h-5 text-green-500 mr-2" 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>
Todas las funciones básicas
</li>
<li className="flex items-center">
<svg className="w-5 h-5 text-green-500 mr-2" 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>
Reportes avanzados
</li>
<li className="flex items-center">
<svg className="w-5 h-5 text-green-500 mr-2" 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>
Soporte prioritario
</li>
</ul>
<button className="w-full bg-indigo-600 text-white py-3 px-4 rounded-md hover:bg-indigo-700 transition duration-200">
Comenzar prueba gratuita
</button>
</div>
</div>
{/* Plan Empresarial */}
<div className="bg-white rounded-lg shadow-md p-8 border border-gray-200">
<div className="text-center">
<h3 className="text-2xl font-semibold text-gray-900 mb-2">Empresarial</h3>
<p className="text-gray-600 mb-4">Para grandes corporaciones</p>
<div className="mb-6">
<span className="text-4xl font-bold text-gray-900">$12,999</span>
<span className="text-gray-600">/mes</span>
</div>
<ul className="text-left space-y-3 mb-8">
<li className="flex items-center">
<svg className="w-5 h-5 text-green-500 mr-2" 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>
Usuarios ilimitados
</li>
<li className="flex items-center">
<svg className="w-5 h-5 text-green-500 mr-2" 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>
1TB almacenamiento
</li>
<li className="flex items-center">
<svg className="w-5 h-5 text-green-500 mr-2" 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>
Todas las funciones
</li>
<li className="flex items-center">
<svg className="w-5 h-5 text-green-500 mr-2" 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>
API personalizada
</li>
<li className="flex items-center">
<svg className="w-5 h-5 text-green-500 mr-2" 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>
Soporte 24/7
</li>
</ul>
<button className="w-full bg-indigo-600 text-white py-3 px-4 rounded-md hover:bg-indigo-700 transition duration-200">
Contactar ventas
</button>
</div>
</div>
</div>
</div>
{/* Contacto Section */}
<div id="contacto" className="mt-20 bg-white rounded-2xl shadow-lg p-8 md:p-12">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
<div>
<h2 className="text-3xl font-extrabold text-gray-900 mb-6">
¿Listo para transformar tu operación?
</h2>
<p className="text-lg text-gray-600 mb-8">
Nuestro equipo de expertos en comercio exterior está aquí para ayudarte.
Contáctanos y descubre cómo EFC puede optimizar tus procesos aduanales.
</p>
<div className="space-y-6">
<div className="flex items-center">
<div className="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
</div>
<div className="ml-4">
<h3 className="text-lg font-semibold text-gray-900">Teléfono</h3>
<p className="text-gray-600">+52 (55) 1234-5678</p>
</div>
</div>
<div className="flex items-center">
<div className="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div className="ml-4">
<h3 className="text-lg font-semibold text-gray-900">Email</h3>
<p className="text-gray-600">contacto@efc.com.mx</p>
</div>
</div>
<div className="flex items-center">
<div className="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div className="ml-4">
<h3 className="text-lg font-semibold text-gray-900">Oficina</h3>
<p className="text-gray-600">Ciudad de México, México</p>
</div>
</div>
</div>
</div>
<div>
<form onSubmit={handleContactSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Nombre completo *
</label>
<input
type="text"
id="name"
required
value={contactForm.name}
onChange={(e) => setContactForm({...contactForm, name: e.target.value})}
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="Tu nombre completo"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email corporativo *
</label>
<input
type="email"
id="email"
required
value={contactForm.email}
onChange={(e) => setContactForm({...contactForm, email: e.target.value})}
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="tu.email@empresa.com"
/>
</div>
<div>
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-1">
Empresa / Agencia Aduanal
</label>
<input
type="text"
id="company"
value={contactForm.company}
onChange={(e) => setContactForm({...contactForm, company: e.target.value})}
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="Nombre de tu empresa"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-1">
Mensaje *
</label>
<textarea
id="message"
rows={4}
required
value={contactForm.message}
onChange={(e) => setContactForm({...contactForm, message: e.target.value})}
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="Cuéntanos sobre tus necesidades..."
/>
</div>
<button
type="submit"
className="w-full bg-indigo-600 text-white py-3 px-6 rounded-md hover:bg-indigo-700 transition duration-200 font-medium"
>
Enviar mensaje
</button>
</form>
</div>
</div>
</div>
<div className="mt-20 bg-gradient-to-r from-indigo-600 to-purple-600 rounded-2xl p-8 md:p-12 text-center text-white">
<h2 className="text-3xl font-extrabold mb-4">
¿Listo para digitalizar tu operación aduanal?
</h2>
<p className="text-xl mb-8 opacity-90">
Únete a los agentes aduanales e importadores que ya confían en EFC
para gestionar sus procesos de comercio exterior de manera eficiente y segura.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
to="/login"
className="inline-flex items-center px-8 py-4 border border-white text-lg font-medium rounded-md text-indigo-600 bg-white hover:bg-gray-50 transition duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
Comenzar Ahora
<svg className="ml-2 -mr-1 w-5 h-5" 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 className="inline-flex items-center px-8 py-4 border-2 border-white text-lg font-medium rounded-md text-white bg-transparent hover:bg-white hover:text-indigo-600 transition duration-200">
<svg className="mr-2 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
Contactar Ventas
</button>
</div>
</div>
</div>
{/* Call to Action Section */}
<div className="mt-20 bg-gradient-to-r from-indigo-600 to-purple-600 rounded-2xl p-8 md:p-12 text-center text-white">
<h2 className="text-3xl font-extrabold mb-4">
¿Listo para digitalizar tu operación aduanal?
</h2>
<p className="text-xl mb-8 opacity-90">
Únete a los agentes aduanales e importadores que ya confían en EFC
para gestionar sus procesos de comercio exterior de manera eficiente y segura.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
to="/login"
className="inline-flex items-center px-8 py-4 border border-white text-lg font-medium rounded-md text-indigo-600 bg-white hover:bg-gray-50 transition duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
Comenzar Ahora
<svg className="ml-2 -mr-1 w-5 h-5" 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>
<a
href="#contacto"
className="inline-flex items-center px-8 py-4 border-2 border-white text-lg font-medium rounded-md text-white bg-transparent hover:bg-white hover:text-indigo-600 transition duration-200"
>
<svg className="mr-2 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
Contactar Ventas
</a>
</div>
</div>
</main>
{/* Footer */}
<footer className="bg-gray-900 text-white mt-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="col-span-1 md:col-span-2">
<h3 className="text-2xl font-bold mb-4">
<span className="text-indigo-400">EFC</span>
</h3>
<p className="text-gray-300 mb-4">
La plataforma líder para agentes aduanales e importadores.
Digitaliza y optimiza tus procesos de comercio exterior con total seguridad.
</p>
<p className="text-gray-400 text-sm">
Desarrollado con por <span className="text-indigo-400 font-semibold">@Aduanasoft</span>
</p>
</div>
<div>
<h4 className="text-lg font-semibold mb-4">Producto</h4>
<ul className="space-y-2">
<li><a href="#" className="text-gray-300 hover:text-white transition duration-200">Características</a></li>
<li><a href="#" className="text-gray-300 hover:text-white transition duration-200">Precios</a></li>
<li><a href="#" className="text-gray-300 hover:text-white transition duration-200">Seguridad</a></li>
<li><a href="#" className="text-gray-300 hover:text-white transition duration-200">API</a></li>
</ul>
</div>
<div>
<h4 className="text-lg font-semibold mb-4">Soporte</h4>
<ul className="space-y-2">
<li><a href="#contacto" className="text-gray-300 hover:text-white transition duration-200">Contacto</a></li>
<li><a href="#" className="text-gray-300 hover:text-white transition duration-200">Documentación</a></li>
<li><a href="#" className="text-gray-300 hover:text-white transition duration-200">Centro de ayuda</a></li>
<li><a href="#" className="text-gray-300 hover:text-white transition duration-200">Estado del servicio</a></li>
</ul>
</div>
</div>
<div className="border-t border-gray-800 mt-8 pt-8 flex flex-col md:flex-row justify-between items-center">
<p className="text-gray-400 text-sm">
&copy; 2025 EFC by @Aduanasoft. Todos los derechos reservados.
</p>
<div className="flex space-x-6 mt-4 md:mt-0">
<a href="#" className="text-gray-400 hover:text-white transition duration-200">
<span className="sr-only">Twitter</span>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84" />
</svg>
</a>
<a href="#" className="text-gray-400 hover:text-white transition duration-200">
<span className="sr-only">LinkedIn</span>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
</a>
<a href="#" className="text-gray-400 hover:text-white transition duration-200">
<span className="sr-only">GitHub</span>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
</a>
</div>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,998 @@
import React, { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { colors, tailwindClasses } from '../theme';
export default function LandingAnimated() {
const [isScrolled, setIsScrolled] = useState(false);
const [activeSection, setActiveSection] = useState('inicio');
const [visibleElements, setVisibleElements] = useState(new Set());
const [contactForm, setContactForm] = useState({
name: '',
email: '',
company: '',
message: ''
});
const observerRef = useRef(null);
const sectionsRef = useRef({});
// Configurar Intersection Observer para animaciones y navegación activa
useEffect(() => {
// Observer para animaciones de elementos
const animationObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const elementId = entry.target.dataset.animate;
if (elementId) {
setVisibleElements(prev => new Set([...prev, elementId]));
}
}
});
},
{
threshold: 0.1,
rootMargin: '0px 0px -100px 0px'
}
);
// Observer para navegación activa
const navigationObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveSection(entry.target.id);
}
});
},
{
threshold: 0.3,
rootMargin: '-20% 0px -70% 0px'
}
);
// Observar elementos para animaciones
const animatedElements = document.querySelectorAll('[data-animate]');
animatedElements.forEach(el => animationObserver.observe(el));
// Observar secciones para navegación
const sections = document.querySelectorAll('section[id]');
sections.forEach(section => navigationObserver.observe(section));
return () => {
animationObserver.disconnect();
navigationObserver.disconnect();
};
}, []);
// Efecto de scroll para navbar
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 50);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// Smooth scroll para navegación
const scrollToSection = (sectionId) => {
const element = document.getElementById(sectionId);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
};
const handleContactSubmit = (e) => {
e.preventDefault();
alert('Gracias por tu mensaje. Nos pondremos en contacto contigo pronto.');
setContactForm({ name: '', email: '', company: '', message: '' });
};
// Clase de animación condicional
const getAnimationClass = (elementId, baseClass = '') => {
return visibleElements.has(elementId)
? `${baseClass} animate-fade-in-up opacity-100 translate-y-0`
: `${baseClass} opacity-0 translate-y-8`;
};
// Estadísticas animadas
const stats = [
{ number: '500+', label: 'Agentes Aduanales', icon: '🏢' },
{ number: '15,000+', label: 'Pedimentos Procesados', icon: '📋' },
{ number: '99.9%', label: 'Uptime Garantizado', icon: '⚡' },
{ number: '24/7', label: 'Soporte Especializado', icon: '🛡️' }
];
return (
<div className="min-h-screen" style={{ backgroundColor: '#F2F4F7' }}>
{/* Navbar flotante con efectos */}
<header className={`fixed top-0 w-full z-50 transition-all duration-300 ${
isScrolled
? '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="flex items-center">
<div className="flex-shrink-0">
<h1 className="text-2xl font-bold">
<span
className="bg-clip-text text-transparent"
style={{
background: `linear-gradient(to right, #1B2A41, #4DA6FF)`,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}
>
EFC
</span>
</h1>
</div>
<nav className="hidden md:flex ml-10 space-x-8">
{[
{ id: 'inicio', label: 'Inicio' },
{ id: 'estadisticas', label: 'Confianza' },
{ id: 'caracteristicas', label: 'Características' },
{ id: 'testimonios', label: 'Testimonios' },
{ 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>
))}
</nav>
</div>
<div className="flex items-center space-x-4">
<Link
to="/login"
className="px-6 py-2 rounded-full text-sm font-medium transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 text-white"
style={{
background: 'linear-gradient(to right, #1B2A41, #4DA6FF)',
}}
onMouseEnter={(e) => {
e.target.style.background = 'linear-gradient(to right, #162234, #1976D2)';
}}
onMouseLeave={(e) => {
e.target.style.background = 'linear-gradient(to right, #1B2A41, #4DA6FF)';
}}
>
Acceder
</Link>
</div>
</div>
</div>
</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>
<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="space-y-8">
<h1
data-animate="hero-title"
className={`text-5xl sm:text-6xl md:text-7xl font-extrabold text-white transition-all duration-1000 ${
visibleElements.has('hero-title')
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-10'
}`}
>
<span className="block">
<span
className="bg-clip-text text-transparent"
style={{
background: 'linear-gradient(to right, white, #64B5F6)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}
>
EFC
</span>
</span>
<span
className="block text-3xl sm:text-4xl md:text-5xl mt-4 bg-clip-text text-transparent"
style={{
background: 'linear-gradient(to right, #64B5F6, white)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}
>
Para Agentes Aduanales
</span>
<span
className="block text-3xl sm:text-4xl md:text-5xl bg-clip-text text-transparent"
style={{
background: 'linear-gradient(to right, white, #64B5F6)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}
>
e Importadores
</span>
</h1>
<p
data-animate="hero-subtitle"
className={`text-xl sm:text-2xl max-w-4xl mx-auto leading-relaxed transition-all duration-1000 delay-300 ${
visibleElements.has('hero-subtitle')
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-10'
}`}
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>
{' '}todos tus procesos de comercio exterior con tecnología de vanguardia
</p>
<div
data-animate="hero-buttons"
className={`flex flex-col sm:flex-row gap-6 justify-center transition-all duration-1000 delay-500 ${
visibleElements.has('hero-buttons')
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-10'
}`}
>
<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"
style={{
color: '#1B2A41',
background: 'linear-gradient(to right, white, #F2F4F7)'
}}
onMouseEnter={(e) => {
e.target.style.background = 'linear-gradient(to right, #F2F4F7, white)';
}}
onMouseLeave={(e) => {
e.target.style.background = 'linear-gradient(to right, white, #F2F4F7)';
}}
>
<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">
<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"
>
<svg className="mr-2 w-5 h-5" 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>
</button>
</div>
{/* Floating cards con efectos */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
{[
{ icon: '🚀', title: 'Rápido', desc: 'Procesamiento instantáneo' },
{ icon: '🔒', title: 'Seguro', desc: 'Cifrado de nivel bancario' },
{ icon: '📊', title: 'Inteligente', desc: 'IA para optimización' }
].map((feature, index) => (
<div
key={index}
data-animate={`hero-card-${index}`}
className={`bg-white/10 backdrop-blur-md rounded-2xl p-6 border border-white/20 hover:bg-white/20 transition-all duration-500 hover:scale-105 hover:shadow-2xl ${
visibleElements.has(`hero-card-${index}`)
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-10'
}`}
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>
<p className="text-sm" style={{ color: '#64B5F6' }}>{feature.desc}</p>
</div>
))}
</div>
</div>
</div>
{/* Scroll indicator animado */}
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce">
<button
onClick={() => scrollToSection('estadisticas')}
className="text-white/70 hover:text-white transition-colors duration-200"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</button>
</div>
</section>
{/* 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
data-animate="stats-header"
className={`text-center mb-16 transition-all duration-1000 ${
visibleElements.has('stats-header')
? 'opacity-100 translate-y-0'
: '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>
<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>
</div>
{/* Stats con animaciones */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8 mb-16">
{stats.map((stat, index) => (
<div
key={index}
data-animate={`stat-${index}`}
className={`text-center p-6 bg-white rounded-2xl shadow-lg hover:shadow-xl transition-all duration-700 hover:scale-105 border border-gray-100 ${
visibleElements.has(`stat-${index}`)
? 'opacity-100 translate-y-0 scale-100'
: 'opacity-0 translate-y-10 scale-95'
}`}
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="font-medium" style={{ color: '#7A7A7A' }}>{stat.label}</div>
</div>
))}
</div>
{/* AduanaSoft Info */}
<div
data-animate="aduanasoft-info"
className={`rounded-3xl p-8 md:p-12 text-white transition-all duration-1000 ${
visibleElements.has('aduanasoft-info')
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-10'
}`}
style={{ background: 'linear-gradient(to right, #1B2A41, #263549)' }}
>
<div className="grid md:grid-cols-2 gap-8 items-center">
<div>
<h3 className="text-3xl font-bold mb-6">Acerca de AduanaSoft</h3>
<div className="space-y-4 text-indigo-100">
{[
"10+ 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">
<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>
</div>
<p><strong>{item.split(' ')[0]} {item.split(' ')[1]}</strong> {item.split(' ').slice(2).join(' ')}</p>
</div>
))}
</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>
<p className="text-indigo-100">
Reconocidos como la mejor solución tecnológica para agentes aduanales en México
</p>
</div>
</div>
</div>
</div>
</div>
</section>
{/* 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
data-animate="features-header"
className={`text-center mb-16 transition-all duration-1000 ${
visibleElements.has('features-header')
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-10'
}`}
>
<h2 className="text-4xl font-extrabold text-gray-900 mb-4">
Soluciones Especializadas para Comercio Exterior
</h2>
<p className="text-xl text-gray-600">
Herramientas diseñadas específicamente para las necesidades de agentes aduanales e importadores
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{[
{
icon: '📋',
title: 'Gestión de Pedimentos',
description: 'Administra pedimentos de importación y exportación, documentos aduanales, clasificaciones arancelarias y toda la documentación requerida por el SAT.',
features: ['Validación automática SAT', 'Clasificación arancelaria', 'Cálculo de impuestos', 'Trazabilidad completa']
},
{
icon: '🏢',
title: 'Control por Organización',
description: 'Gestiona múltiples clientes importadores con espacios de trabajo separados, permisos granulares y control total sobre el acceso a la información.',
features: ['Multi-tenancy', 'Roles y permisos', 'Auditoría completa', 'Segregación de datos']
},
{
icon: '📊',
title: 'Reportes Aduanales',
description: 'Genera reportes especializados para auditorías, seguimiento de operaciones aduanales, estadísticas de importación y cumplimiento normativo.',
features: ['Dashboards en tiempo real', 'Exportación múltiple', 'KPIs personalizados', 'Alertas automáticas']
}
].map((feature, index) => (
<div
key={index}
data-animate={`feature-${index}`}
className={`group bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-700 overflow-hidden border border-gray-100 hover:scale-105 ${
visibleElements.has(`feature-${index}`)
? 'opacity-100 translate-y-0 rotate-0'
: 'opacity-0 translate-y-10 rotate-3'
}`}
style={{
transitionDelay: `${index * 300}ms`,
borderColor: visibleElements.has(`feature-${index}`) ? '#4DA6FF' : '#e5e7eb'
}}
>
<div className="p-8">
<div className={`text-5xl mb-6 transition-all duration-500 ${
visibleElements.has(`feature-${index}`)
? 'transform scale-100 rotate-0'
: 'transform scale-75 rotate-12'
}`}>
{feature.icon}
</div>
<h3 className="text-2xl font-bold mb-4 transition-colors duration-300" style={{
color: visibleElements.has(`feature-${index}`) ? '#1B2A41' : '#333333'
}}>
{feature.title}
</h3>
<p className="mb-6 leading-relaxed" style={{ color: '#7A7A7A' }}>
{feature.description}
</p>
<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">
<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}
</li>
))}
</ul>
</div>
<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={{
color: '#1B2A41'
}}>
Conocer más
</button>
</div>
</div>
))}
</div>
</div>
</section>
{/* Testimonios */}
<section id="testimonios" className="py-20" style={{ background: 'linear-gradient(135deg, #F2F4F7, #FFFFFF)' }}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div
data-animate="testimonials-header"
className={`text-center mb-16 transition-all duration-1000 ${
visibleElements.has('testimonials-header')
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-10'
}`}
>
<h2 className="text-4xl font-extrabold mb-4" style={{ color: '#333333' }}>
Lo que dicen nuestros clientes
</h2>
<p className="text-xl" style={{ color: '#7A7A7A' }}>
Testimonios reales de agentes aduanales que han transformado su operación
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{[
{
name: 'Carlos Mendoza',
company: 'Agente Aduanal 1234',
image: '👨‍💼',
testimonial: 'EFC revolucionó nuestra operación. Reducimos 70% el tiempo en procesar pedimentos y eliminamos errores manuales.',
rating: 5
},
{
name: 'María González',
company: 'Importadora Global SA',
image: '👩‍💼',
testimonial: 'La plataforma más completa del mercado. El soporte de AduanaSoft es excepcional, entienden perfectamente nuestras necesidades.',
rating: 5
},
{
name: 'Roberto Silva',
company: 'Comercio Exterior RSC',
image: '👨‍💻',
testimonial: 'Migramos de sistemas obsoletos a EFC y fue la mejor decisión. Ahora somos más eficientes y competitivos.',
rating: 5
}
].map((testimonial, index) => (
<div
key={index}
data-animate={`testimonial-${index}`}
className={`bg-white rounded-2xl shadow-lg hover:shadow-xl transition-all duration-700 p-8 border border-gray-100 hover:scale-105 ${
visibleElements.has(`testimonial-${index}`)
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-10'
}`}
style={{ transitionDelay: `${index * 200}ms` }}
>
<div className="flex items-center mb-6">
<div className="text-4xl mr-4">{testimonial.image}</div>
<div>
<h4 className="font-bold" style={{ color: '#333333' }}>{testimonial.name}</h4>
<p className="text-sm" style={{ color: '#7A7A7A' }}>{testimonial.company}</p>
</div>
</div>
<p className="mb-4 italic" style={{ color: '#333333' }}>"{testimonial.testimonial}"</p>
<div className="flex" style={{ color: '#F57C00' }}>
{[...Array(testimonial.rating)].map((_, i) => (
<svg key={i} className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
</div>
))}
</div>
</div>
</section>
{/* 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
data-animate="pricing-header"
className={`text-center mb-16 transition-all duration-1000 ${
visibleElements.has('pricing-header')
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-10'
}`}
>
<h2 className="text-4xl font-extrabold text-gray-900 mb-4">
Planes diseñados para tu crecimiento
</h2>
<p className="text-xl text-gray-600">
Desde agentes independientes hasta grandes corporativos
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{[
{
name: 'Starter',
price: '$2,999',
period: '/mes',
description: 'Perfecto para agentes aduanales independientes',
features: [
'Hasta 50 pedimentos/mes',
'5 GB almacenamiento',
'Soporte por email',
'Reportes básicos',
'2 usuarios'
],
popular: false
},
{
name: 'Professional',
price: '$5,999',
period: '/mes',
description: 'Ideal para agencias medianas',
features: [
'Hasta 200 pedimentos/mes',
'20 GB almacenamiento',
'Soporte prioritario',
'Reportes avanzados',
'10 usuarios',
'API acceso',
'Integraciones SAT'
],
popular: true
},
{
name: 'Enterprise',
price: 'Personalizado',
period: '',
description: 'Para grandes corporativos',
features: [
'Pedimentos ilimitados',
'Almacenamiento ilimitado',
'Soporte 24/7 dedicado',
'Reportes personalizados',
'Usuarios ilimitados',
'API completa',
'Implementación dedicada',
'SLA garantizado'
],
popular: false
}
].map((plan, index) => (
<div
key={index}
data-animate={`plan-${index}`}
className={`relative rounded-2xl border-2 p-8 transition-all duration-700 hover:scale-105 ${
plan.popular
? 'shadow-xl'
: 'border-gray-200 bg-white shadow-lg hover:shadow-xl'
} ${
visibleElements.has(`plan-${index}`)
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-10'
}`}
style={{
transitionDelay: `${index * 200}ms`,
borderColor: plan.popular ? '#1B2A41' : '#e5e7eb',
background: plan.popular ? 'linear-gradient(to bottom, #F2F4F7, white)' : 'white'
}}
>
{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={{
background: 'linear-gradient(to right, #1B2A41, #263549)'
}}>
Más Popular
</span>
</div>
)}
<div className="text-center mb-8">
<h3 className="text-2xl font-bold mb-2" style={{ color: '#333333' }}>{plan.name}</h3>
<p className="mb-4" style={{ color: '#7A7A7A' }}>{plan.description}</p>
<div className="mb-4">
<span className="text-4xl font-extrabold" style={{ color: '#1B2A41' }}>{plan.price}</span>
<span style={{ color: '#7A7A7A' }}>{plan.period}</span>
</div>
</div>
<ul className="space-y-3 mb-8">
{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">
<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>
</li>
))}
</ul>
<button
onClick={() => scrollToSection('contacto')}
className={`w-full py-3 px-6 rounded-full font-semibold transition-all duration-200 ${
plan.popular
? 'bg-gradient-to-r from-indigo-600 to-purple-600 text-white hover:from-indigo-700 hover:to-purple-700 shadow-lg hover:shadow-xl'
: 'bg-gray-100 text-gray-900 hover:bg-gray-200'
}`}
>
{plan.name === 'Enterprise' ? 'Contactar Ventas' : 'Comenzar Prueba'}
</button>
</div>
))}
</div>
</div>
</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>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-start">
<div
data-animate="contact-info"
className={`transition-all duration-1000 ${
visibleElements.has('contact-info')
? 'opacity-100 translate-x-0'
: 'opacity-0 -translate-x-10'
}`}
>
{/* Card de información de contacto */}
<div className="bg-white/10 backdrop-blur-md rounded-3xl p-8 border border-white/20">
<div className="mb-8">
<h3 className="text-2xl font-bold text-white mb-4">
Hablemos de tu proyecto
</h3>
<p className="text-lg" style={{ color: '#64B5F6' }}>
Nuestros expertos en comercio exterior están listos para ayudarte
</p>
</div>
<div className="space-y-6">
{[
{
icon: 'M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z',
title: 'Teléfono',
info: '+52 (55) 1234-5678',
subtitle: 'Lun - Vie, 9:00 AM - 7:00 PM'
},
{
icon: 'M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
title: 'Email',
info: 'contacto@aduanasoft.com',
subtitle: 'Respuesta en menos de 2 horas'
},
{
icon: 'M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z',
title: 'Oficinas',
info: 'Ciudad de México, México',
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">
<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>
<p className="text-sm" style={{ color: '#64B5F6' }}>{contact.subtitle}</p>
</div>
</div>
))}
</div>
{/* Botón adicional para WhatsApp */}
<div className="mt-8 pt-6 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"
style={{
background: 'linear-gradient(45deg, #25D366, #128C7E)'
}}
>
<svg className="w-6 h-6 mr-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893A11.821 11.821 0 0020.885 3.488"/>
</svg>
Chatear por WhatsApp
</a>
</div>
</div>
</div>
<div
data-animate="contact-form"
className={`bg-white rounded-2xl shadow-2xl p-8 transition-all duration-1000 ${
visibleElements.has('contact-form')
? 'opacity-100 translate-x-0'
: 'opacity-0 translate-x-10'
}`}
>
<h3 className="text-2xl font-bold mb-6" 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' }}>
Nombre completo
</label>
<input
type="text"
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"
style={{
focusRingColor: '#4DA6FF',
outline: 'none'
}}
onFocus={(e) => e.target.style.borderColor = '#4DA6FF'}
onBlur={(e) => e.target.style.borderColor = '#d1d5db'}
placeholder="Tu nombre"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#333333' }}>
Email corporativo
</label>
<input
type="email"
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"
onFocus={(e) => e.target.style.borderColor = '#4DA6FF'}
onBlur={(e) => e.target.style.borderColor = '#d1d5db'}
placeholder="tu@empresa.com"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#333333' }}>
Empresa
</label>
<input
type="text"
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"
onFocus={(e) => e.target.style.borderColor = '#4DA6FF'}
onBlur={(e) => e.target.style.borderColor = '#d1d5db'}
placeholder="Nombre de tu empresa"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2" 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"
onFocus={(e) => e.target.style.borderColor = '#4DA6FF'}
onBlur={(e) => e.target.style.borderColor = '#d1d5db'}
placeholder="Cuéntanos sobre tu operación aduanal..."
></textarea>
</div>
<button
type="submit"
className="w-full text-white py-3 px-6 rounded-lg font-semibold transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
style={{
background: 'linear-gradient(to right, #1B2A41, #263549)'
}}
onMouseEnter={(e) => {
e.target.style.background = 'linear-gradient(to right, #263549, #1B2A41)';
}}
onMouseLeave={(e) => {
e.target.style.background = 'linear-gradient(to right, #1B2A41, #263549)';
}}
>
Enviar solicitud
</button>
</form>
</div>
</div>
</div>
</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">
<div className="col-span-1 md:col-span-2">
<h3 className="text-2xl font-bold mb-4">
<span style={{
background: 'linear-gradient(to right, #4DA6FF, #64B5F6)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}>
EFC
</span>
</h3>
<p className="mb-4 max-w-md" style={{ color: '#7A7A7A' }}>
La plataforma líder para agentes aduanales e importadores, desarrollada por
<span className="font-semibold" style={{ color: '#4DA6FF' }}> @AduanaSoft</span> con más de 10 años de experiencia.
</p>
<div className="flex space-x-4">
{['M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z',
'M22.46 6c-.77.35-1.6.58-2.46.69.88-.53 1.56-1.37 1.88-2.38-.83.5-1.75.85-2.72 1.05C18.37 4.5 17.26 4 16 4c-2.35 0-4.27 1.92-4.27 4.29 0 .34.04.67.11.98C8.28 9.09 5.11 7.38 3 4.79c-.37.63-.58 1.37-.58 2.15 0 1.49.75 2.81 1.91 3.56-.71 0-1.37-.2-1.95-.5v.03c0 2.08 1.48 3.82 3.44 4.21a4.22 4.22 0 0 1-1.93.07 4.28 4.28 0 0 0 4 2.98 8.521 8.521 0 0 1-5.33 1.84c-.34 0-.68-.02-1.02-.06C3.44 20.29 5.7 21 8.12 21 16 21 20.33 14.46 20.33 8.79c0-.19 0-.37-.01-.56.84-.6 1.56-1.36 2.14-2.23z',
'M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z'].map((path, idx) => (
<a key={idx} href="#" className="transition-colors duration-200" style={{ color: '#7A7A7A' }}
onMouseEnter={(e) => e.target.style.color = 'white'}
onMouseLeave={(e) => e.target.style.color = '#7A7A7A'}>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d={path}/>
</svg>
</a>
))}
</div>
</div>
<div>
<h4 className="font-semibold mb-4">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>
</ul>
</div>
<div>
<h4 className="font-semibold mb-4">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>
</ul>
</div>
</div>
<div className="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
<p>
&copy; 2025 EFC by <span className="font-semibold text-indigo-400">@AduanaSoft</span>.
Todos los derechos reservados. | Solución especializada para Agentes Aduanales e Importadores.
</p>
</div>
</div>
</footer>
</div>
);
}

756
src/pages/LandingNew.jsx Normal file
View File

@@ -0,0 +1,756 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
export default function Landing() {
const [isScrolled, setIsScrolled] = useState(false);
const [activeSection, setActiveSection] = useState('inicio');
const [visibleElements, setVisibleElements] = useState(new Set());
const [contactForm, setContactForm] = useState({
name: '',
email: '',
company: '',
message: ''
});
// Efecto de scroll para navbar y detección de secciones activas
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 50);
// Detectar sección activa
const sections = ['inicio', 'estadisticas', 'caracteristicas', 'testimonios', 'precios', 'contacto'];
const currentSection = sections.find(section => {
const element = document.getElementById(section);
if (element) {
const rect = element.getBoundingClientRect();
return rect.top <= 100 && rect.bottom >= 100;
}
return false;
});
if (currentSection && currentSection !== activeSection) {
setActiveSection(currentSection);
}
// Detectar elementos visibles para animaciones
const animatedElements = document.querySelectorAll('[data-animate]');
const newVisibleElements = new Set(visibleElements);
animatedElements.forEach((element) => {
const rect = element.getBoundingClientRect();
const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
if (isVisible && !visibleElements.has(element.dataset.animate)) {
newVisibleElements.add(element.dataset.animate);
}
});
if (newVisibleElements.size !== visibleElements.size) {
setVisibleElements(newVisibleElements);
}
};
window.addEventListener('scroll', handleScroll);
// Trigger inicial
handleScroll();
return () => window.removeEventListener('scroll', handleScroll);
}, [activeSection, visibleElements]);
// Smooth scroll para navegación
const scrollToSection = (sectionId) => {
const element = document.getElementById(sectionId);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
setActiveSection(sectionId);
}
};
const handleContactSubmit = (e) => {
e.preventDefault();
alert('Gracias por tu mensaje. Nos pondremos en contacto contigo pronto.');
setContactForm({ name: '', email: '', company: '', message: '' });
};
// Estadísticas animadas
const stats = [
{ number: '500+', label: 'Agentes Aduanales', icon: '🏢' },
{ number: '15,000+', label: 'Pedimentos Procesados', icon: '📋' },
{ number: '99.9%', label: 'Uptime Garantizado', icon: '⚡' },
{ number: '24/7', label: 'Soporte Especializado', icon: '🛡️' }
];
return (
<div className="min-h-screen bg-white">
{/* Navbar flotante con efectos */}
<header className={`fixed top-0 w-full z-50 transition-all duration-300 ${
isScrolled
? '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="flex items-center">
<div className="flex-shrink-0">
<h1 className="text-2xl font-bold">
<span className="bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
EFC
</span>
</h1>
</div>
<nav className="hidden md:flex ml-10 space-x-8">
{[
{ id: 'inicio', label: 'Inicio' },
{ id: 'caracteristicas', label: 'Características' },
{ id: 'estadisticas', label: 'Confianza' },
{ id: 'testimonios', label: 'Testimonios' },
{ id: 'precios', label: 'Precios' },
{ id: 'contacto', label: 'Contacto' }
].map((item) => (
<button
key={item.id}
onClick={() => scrollToSection(item.id)}
className={`text-sm font-medium transition-all duration-200 hover:scale-105 ${
activeSection === item.id
? 'text-indigo-600 border-b-2 border-indigo-600'
: isScrolled
? 'text-gray-700 hover:text-indigo-600'
: 'text-white hover:text-indigo-200'
}`}
>
{item.label}
</button>
))}
</nav>
</div>
<div className="flex items-center space-x-4">
<Link
to="/login"
className="bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white px-6 py-2 rounded-full text-sm font-medium transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
Acceder
</Link>
</div>
</div>
</div>
</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 bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800">
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent"></div>
<div className="absolute inset-0 bg-[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" fill-rule="evenodd"%3E%3Cg fill="%239C92AC" fill-opacity="0.1"%3E%3Ccircle cx="30" cy="30" r="2"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')] opacity-20"></div>
</div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-32 text-center">
<div className="animate-fade-in-up">
<h1 className="text-5xl sm:text-6xl md:text-7xl font-extrabold text-white mb-8">
<span className="block">
<span className="bg-gradient-to-r from-white to-indigo-200 bg-clip-text text-transparent">
EFC
</span>
</span>
<span className="block text-3xl sm:text-4xl md:text-5xl mt-4 bg-gradient-to-r from-indigo-200 to-purple-200 bg-clip-text text-transparent">
Para Agentes Aduanales
</span>
<span className="block text-3xl sm:text-4xl md:text-5xl bg-gradient-to-r from-purple-200 to-pink-200 bg-clip-text text-transparent">
e Importadores
</span>
</h1>
<p className="text-xl sm:text-2xl text-indigo-100 mb-12 max-w-4xl mx-auto leading-relaxed">
La plataforma líder desarrollada por
<span className="font-bold text-white"> @AduanaSoft</span> para
<span className="font-semibold text-yellow-300"> digitalizar y optimizar</span>
{' '}todos tus procesos de comercio exterior con tecnología de vanguardia
</p>
<div className="flex flex-col sm:flex-row gap-6 justify-center mb-16">
<Link
to="/login"
className="group inline-flex items-center px-8 py-4 text-lg font-semibold rounded-full text-indigo-900 bg-gradient-to-r from-white to-indigo-50 hover:from-indigo-50 hover:to-white transition-all duration-300 shadow-2xl hover:shadow-3xl transform hover:-translate-y-1 hover:scale-105"
>
<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">
<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"
>
<svg className="mr-2 w-5 h-5" 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>
</button>
</div>
{/* Floating cards con efectos */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
{[
{ icon: '🚀', title: 'Rápido', desc: 'Procesamiento instantáneo' },
{ icon: '🔒', title: 'Seguro', desc: 'Cifrado de nivel bancario' },
{ icon: '📊', title: 'Inteligente', desc: 'IA para optimización' }
].map((feature, index) => (
<div
key={index}
className="bg-white/10 backdrop-blur-md rounded-2xl p-6 border border-white/20 hover:bg-white/20 transition-all duration-300 hover:scale-105 hover:shadow-2xl"
style={{ animationDelay: `${index * 0.2}s` }}
>
<div className="text-4xl mb-3">{feature.icon}</div>
<h3 className="text-white font-semibold text-lg mb-2">{feature.title}</h3>
<p className="text-indigo-200 text-sm">{feature.desc}</p>
</div>
))}
</div>
</div>
</div>
{/* Scroll indicator animado */}
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce">
<button
onClick={() => scrollToSection('estadisticas')}
className="text-white/70 hover:text-white transition-colors duration-200"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</button>
</div>
</section>
{/* Sección de Estadísticas y Confianza */}
<section id="estadisticas" className="py-20 bg-gradient-to-r from-gray-50 to-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-4xl font-extrabold text-gray-900 mb-4">
Más de <span className="text-indigo-600">500 empresas</span> confían en nosotros
</h2>
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
Desarrollado por <span className="font-bold text-indigo-600">@AduanaSoft</span>,
líderes en tecnología aduanal con más de 10 años de experiencia
</p>
</div>
{/* Stats con animaciones */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8 mb-16">
{stats.map((stat, index) => (
<div
key={index}
className="text-center p-6 bg-white rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105 border border-gray-100"
>
<div className="text-4xl mb-4">{stat.icon}</div>
<div className="text-3xl font-bold text-indigo-600 mb-2">{stat.number}</div>
<div className="text-gray-600 font-medium">{stat.label}</div>
</div>
))}
</div>
{/* AduanaSoft Info */}
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 rounded-3xl p-8 md:p-12 text-white">
<div className="grid md:grid-cols-2 gap-8 items-center">
<div>
<h3 className="text-3xl font-bold mb-6">Acerca de AduanaSoft</h3>
<div className="space-y-4 text-indigo-100">
<div 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">
<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>
</div>
<p><strong>10+ años</strong> especializados en software aduanal</p>
</div>
<div 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">
<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>
</div>
<p><strong>Equipo experto</strong> en comercio exterior y tecnología</p>
</div>
<div 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">
<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>
</div>
<p><strong>Certificación SAT</strong> y cumplimiento normativo total</p>
</div>
<div 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">
<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>
</div>
<p><strong>Soporte 24/7</strong> con especialistas aduanales</p>
</div>
</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>
<p className="text-indigo-100">
Reconocidos como la mejor solución tecnológica para agentes aduanales en México
</p>
</div>
</div>
</div>
</div>
</div>
</section>
{/* 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="text-center mb-16">
<h2 className="text-4xl font-extrabold text-gray-900 mb-4">
Soluciones Especializadas para Comercio Exterior
</h2>
<p className="text-xl text-gray-600">
Herramientas diseñadas específicamente para las necesidades de agentes aduanales e importadores
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{[
{
icon: '📋',
title: 'Gestión de Pedimentos',
description: 'Administra pedimentos de importación y exportación, documentos aduanales, clasificaciones arancelarias y toda la documentación requerida por el SAT.',
features: ['Validación automática SAT', 'Clasificación arancelaria', 'Cálculo de impuestos', 'Trazabilidad completa']
},
{
icon: '🏢',
title: 'Control por Organización',
description: 'Gestiona múltiples clientes importadores con espacios de trabajo separados, permisos granulares y control total sobre el acceso a la información.',
features: ['Multi-tenancy', 'Roles y permisos', 'Auditoría completa', 'Segregación de datos']
},
{
icon: '📊',
title: 'Reportes Aduanales',
description: 'Genera reportes especializados para auditorías, seguimiento de operaciones aduanales, estadísticas de importación y cumplimiento normativo.',
features: ['Dashboards en tiempo real', 'Exportación múltiple', 'KPIs personalizados', 'Alertas automáticas']
}
].map((feature, index) => (
<div
key={index}
className="group bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden border border-gray-100 hover:border-indigo-200 hover:scale-105"
>
<div className="p-8">
<div className="text-5xl mb-6 group-hover:scale-110 transition-transform duration-300">
{feature.icon}
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-4 group-hover:text-indigo-600 transition-colors duration-300">
{feature.title}
</h3>
<p className="text-gray-600 mb-6 leading-relaxed">
{feature.description}
</p>
<ul className="space-y-2">
{feature.features.map((item, idx) => (
<li key={idx} className="flex items-center text-sm text-gray-500">
<svg className="w-4 h-4 text-indigo-500 mr-2 flex-shrink-0" 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}
</li>
))}
</ul>
</div>
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 p-4 group-hover:from-indigo-100 group-hover:to-purple-100 transition-colors duration-300">
<button className="text-indigo-600 font-semibold text-sm hover:text-indigo-700 transition-colors duration-200">
Conocer más
</button>
</div>
</div>
))}
</div>
</div>
</section>
{/* Testimonios */}
<section id="testimonios" className="py-20 bg-gradient-to-br from-gray-50 to-indigo-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-4xl font-extrabold text-gray-900 mb-4">
Lo que dicen nuestros clientes
</h2>
<p className="text-xl text-gray-600">
Testimonios reales de agentes aduanales que han transformado su operación
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{[
{
name: 'Carlos Mendoza',
company: 'Agente Aduanal 1234',
image: '👨‍💼',
testimonial: 'EFC revolucionó nuestra operación. Reducimos 70% el tiempo en procesar pedimentos y eliminamos errores manuales.',
rating: 5
},
{
name: 'María González',
company: 'Importadora Global SA',
image: '👩‍💼',
testimonial: 'La plataforma más completa del mercado. El soporte de AduanaSoft es excepcional, entienden perfectamente nuestras necesidades.',
rating: 5
},
{
name: 'Roberto Silva',
company: 'Comercio Exterior RSC',
image: '👨‍💻',
testimonial: 'Migramos de sistemas obsoletos a EFC y fue la mejor decisión. Ahora somos más eficientes y competitivos.',
rating: 5
}
].map((testimonial, index) => (
<div
key={index}
className="bg-white rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 p-8 border border-gray-100 hover:scale-105"
>
<div className="flex items-center mb-6">
<div className="text-4xl mr-4">{testimonial.image}</div>
<div>
<h4 className="font-bold text-gray-900">{testimonial.name}</h4>
<p className="text-gray-600 text-sm">{testimonial.company}</p>
</div>
</div>
<p className="text-gray-700 mb-4 italic">"{testimonial.testimonial}"</p>
<div className="flex text-yellow-400">
{[...Array(testimonial.rating)].map((_, i) => (
<svg key={i} className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
</div>
))}
</div>
</div>
</section>
{/* 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="text-center mb-16">
<h2 className="text-4xl font-extrabold text-gray-900 mb-4">
Planes diseñados para tu crecimiento
</h2>
<p className="text-xl text-gray-600">
Desde agentes independientes hasta grandes corporativos
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{[
{
name: 'Starter',
price: '$2,999',
period: '/mes',
description: 'Perfecto para agentes aduanales independientes',
features: [
'Hasta 50 pedimentos/mes',
'5 GB almacenamiento',
'Soporte por email',
'Reportes básicos',
'2 usuarios'
],
popular: false
},
{
name: 'Professional',
price: '$5,999',
period: '/mes',
description: 'Ideal para agencias medianas',
features: [
'Hasta 200 pedimentos/mes',
'20 GB almacenamiento',
'Soporte prioritario',
'Reportes avanzados',
'10 usuarios',
'API acceso',
'Integraciones SAT'
],
popular: true
},
{
name: 'Enterprise',
price: 'Personalizado',
period: '',
description: 'Para grandes corporativos',
features: [
'Pedimentos ilimitados',
'Almacenamiento ilimitado',
'Soporte 24/7 dedicado',
'Reportes personalizados',
'Usuarios ilimitados',
'API completa',
'Implementación dedicada',
'SLA garantizado'
],
popular: false
}
].map((plan, index) => (
<div
key={index}
className={`relative rounded-2xl border-2 p-8 hover:scale-105 transition-all duration-300 ${
plan.popular
? 'border-indigo-500 bg-gradient-to-b from-indigo-50 to-white shadow-xl'
: 'border-gray-200 bg-white hover:border-indigo-200 shadow-lg hover:shadow-xl'
}`}
>
{plan.popular && (
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
<span className="bg-gradient-to-r from-indigo-600 to-purple-600 text-white px-4 py-2 rounded-full text-sm font-semibold">
Más Popular
</span>
</div>
)}
<div className="text-center mb-8">
<h3 className="text-2xl font-bold text-gray-900 mb-2">{plan.name}</h3>
<p className="text-gray-600 mb-4">{plan.description}</p>
<div className="mb-4">
<span className="text-4xl font-extrabold text-gray-900">{plan.price}</span>
<span className="text-gray-600">{plan.period}</span>
</div>
</div>
<ul className="space-y-3 mb-8">
{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">
<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>
</li>
))}
</ul>
<button
onClick={() => scrollToSection('contacto')}
className={`w-full py-3 px-6 rounded-full font-semibold transition-all duration-200 ${
plan.popular
? 'bg-gradient-to-r from-indigo-600 to-purple-600 text-white hover:from-indigo-700 hover:to-purple-700 shadow-lg hover:shadow-xl'
: 'bg-gray-100 text-gray-900 hover:bg-gray-200'
}`}
>
{plan.name === 'Enterprise' ? 'Contactar Ventas' : 'Comenzar Prueba'}
</button>
</div>
))}
</div>
</div>
</section>
{/* Contacto */}
<section id="contacto" className="py-20 bg-gradient-to-br from-indigo-900 to-purple-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
<div className="text-white">
<h2 className="text-4xl font-extrabold mb-6">
¿Listo para transformar tu operación aduanal?
</h2>
<p className="text-xl text-indigo-200 mb-8">
Contáctanos y descubre cómo EFC puede optimizar tus procesos de comercio exterior
</p>
<div className="space-y-4">
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-white/10 rounded-full flex items-center justify-center">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
</div>
<div>
<h4 className="font-semibold">Teléfono</h4>
<p className="text-indigo-200">+52 (55) 1234-5678</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-white/10 rounded-full flex items-center justify-center">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<h4 className="font-semibold">Email</h4>
<p className="text-indigo-200">contacto@aduanasoft.com</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-white/10 rounded-full flex items-center justify-center">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<h4 className="font-semibold">Oficinas</h4>
<p className="text-indigo-200">Ciudad de México, México</p>
</div>
</div>
</div>
</div>
<div className="bg-white rounded-2xl shadow-2xl p-8">
<h3 className="text-2xl font-bold text-gray-900 mb-6">Solicita una demostración</h3>
<form onSubmit={handleContactSubmit} className="space-y-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Nombre completo
</label>
<input
type="text"
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:ring-indigo-500 focus:border-transparent transition-all duration-200"
placeholder="Tu nombre"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Email corporativo
</label>
<input
type="email"
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:ring-indigo-500 focus:border-transparent transition-all duration-200"
placeholder="tu@empresa.com"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Empresa
</label>
<input
type="text"
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:ring-indigo-500 focus:border-transparent transition-all duration-200"
placeholder="Nombre de tu empresa"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
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:ring-indigo-500 focus:border-transparent transition-all duration-200"
placeholder="Cuéntanos sobre tu operación aduanal..."
></textarea>
</div>
<button
type="submit"
className="w-full bg-gradient-to-r from-indigo-600 to-purple-600 text-white py-3 px-6 rounded-lg font-semibold hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
Enviar solicitud
</button>
</form>
</div>
</div>
</div>
</section>
{/* Footer */}
<footer className="bg-gray-900 text-white py-12">
<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">
<div className="col-span-1 md:col-span-2">
<h3 className="text-2xl font-bold mb-4">
<span className="bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">
EFC
</span>
</h3>
<p className="text-gray-400 mb-4 max-w-md">
La plataforma líder para agentes aduanales e importadores, desarrollada por
<span className="font-semibold text-indigo-400"> @AduanaSoft</span> con más de 10 años de experiencia.
</p>
<div className="flex space-x-4">
<a href="#" className="text-gray-400 hover:text-white transition-colors duration-200">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z"/>
</svg>
</a>
<a href="#" className="text-gray-400 hover:text-white transition-colors duration-200">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M22.46 6c-.77.35-1.6.58-2.46.69.88-.53 1.56-1.37 1.88-2.38-.83.5-1.75.85-2.72 1.05C18.37 4.5 17.26 4 16 4c-2.35 0-4.27 1.92-4.27 4.29 0 .34.04.67.11.98C8.28 9.09 5.11 7.38 3 4.79c-.37.63-.58 1.37-.58 2.15 0 1.49.75 2.81 1.91 3.56-.71 0-1.37-.2-1.95-.5v.03c0 2.08 1.48 3.82 3.44 4.21a4.22 4.22 0 0 1-1.93.07 4.28 4.28 0 0 0 4 2.98 8.521 8.521 0 0 1-5.33 1.84c-.34 0-.68-.02-1.02-.06C3.44 20.29 5.7 21 8.12 21 16 21 20.33 14.46 20.33 8.79c0-.19 0-.37-.01-.56.84-.6 1.56-1.36 2.14-2.23z"/>
</svg>
</a>
<a href="#" className="text-gray-400 hover:text-white transition-colors duration-200">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</a>
</div>
</div>
<div>
<h4 className="font-semibold mb-4">Producto</h4>
<ul className="space-y-2 text-gray-400">
<li><a href="#caracteristicas" className="hover:text-white transition-colors duration-200">Características</a></li>
<li><a href="#precios" className="hover:text-white transition-colors duration-200">Precios</a></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>
</ul>
</div>
<div>
<h4 className="font-semibold mb-4">Soporte</h4>
<ul className="space-y-2 text-gray-400">
<li><a href="#contacto" className="hover:text-white transition-colors duration-200">Contacto</a></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>
</ul>
</div>
</div>
<div className="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
<p>
&copy; 2025 EFC by <span className="font-semibold text-indigo-400">@AduanaSoft</span>.
Todos los derechos reservados. | Solución especializada para Agentes Aduanales e Importadores.
</p>
</div>
</div>
</footer>
{/* CSS personalizado para animaciones */}
<style jsx>{`
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.8s ease-out;
}
.hover\\:shadow-3xl:hover {
box-shadow: 0 35px 60px -12px rgba(0, 0, 0, 0.25);
}
`}</style>
</div>
);
}

313
src/pages/Login.jsx Normal file
View File

@@ -0,0 +1,313 @@
import React, { useState } from 'react';
import { login } from '../api/auth';
import { Link } from 'react-router-dom';
import { colors } from '../theme';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const data = await login(username, password);
localStorage.setItem('access', data.access);
localStorage.setItem('refresh', data.refresh);
// Obtener y guardar la información del usuario autenticado
const apiUrl = import.meta.env.VITE_EFC_API_URL || '';
const token = data.access;
try {
const res = await fetch(`${apiUrl}/user/users/me/`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const user = await res.json();
if (user && user.username) {
localStorage.setItem('username', user.username);
if (user.email) localStorage.setItem('user_email', user.email);
if (user.id) localStorage.setItem('user_id', String(user.id));
if (user.groups) localStorage.setItem('user_groups', JSON.stringify(user.groups));
if (user.first_name) localStorage.setItem('user_first_name', user.first_name);
if (user.last_name) localStorage.setItem('user_last_name', user.last_name);
if (typeof user.is_importador !== 'undefined') localStorage.setItem('user_is_importador', String(user.is_importador));
}
}
} catch (e) {
// Si falla, continuar igual
console.error('No se pudo guardar info de usuario en localStorage', e);
}
// Disparar evento personalizado para que el navbar se actualice
window.dispatchEvent(new CustomEvent('authStateChanged'));
// Redirigir al dashboard
window.location.href = '/admin';
} catch (err) {
setError('Usuario o contraseña incorrectos');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#F2F4F7' }}>
{/* Background pattern */}
<div className="absolute inset-0 opacity-20">
<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='%231B2A41' fillOpacity='0.1'%3E%3Ccircle cx='30' cy='30' r='2'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
}}></div>
</div>
<div className="relative max-w-md w-full">
{/* Main Card */}
<div className="bg-white backdrop-blur-md rounded-3xl shadow-2xl border border-gray-200 overflow-hidden">
{/* Header with navy background */}
<div className="px-8 py-10 text-center" style={{ backgroundColor: '#1B2A41' }}>
<div className="mb-4">
<Link to="/" className="inline-block">
<h1 className="text-4xl font-bold text-white">
EFC
</h1>
</Link>
</div>
<h2 className="text-2xl font-bold text-white mb-2">
Bienvenido de vuelta
</h2>
<p className="text-sm" style={{ color: 'rgba(255, 255, 255, 0.8)' }}>
Inicia sesión para acceder a tu plataforma aduanal
</p>
</div>
{/* Form */}
<div className="px-8 py-8">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Username Field */}
<div>
<label htmlFor="username" className="block text-sm font-semibold mb-2" style={{ color: '#333333' }}>
Usuario
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5" style={{ color: '#7A7A7A' }} 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>
<input
id="username"
name="username"
type="text"
required
className="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:border-transparent transition duration-200"
style={{
color: '#333333',
borderColor: '#d1d5db',
':focus': {
ringColor: '#4DA6FF',
borderColor: 'transparent'
},
':hover': {
borderColor: '#4DA6FF'
}
}}
placeholder="Ingresa tu usuario"
value={username}
onChange={e => setUsername(e.target.value)}
onFocus={(e) => {
e.target.style.borderColor = 'transparent';
e.target.style.boxShadow = '0 0 0 2px #4DA6FF';
}}
onBlur={(e) => {
e.target.style.borderColor = '#d1d5db';
e.target.style.boxShadow = 'none';
}}
onMouseEnter={(e) => {
if (document.activeElement !== e.target) {
e.target.style.borderColor = '#4DA6FF';
}
}}
onMouseLeave={(e) => {
if (document.activeElement !== e.target) {
e.target.style.borderColor = '#d1d5db';
}
}}
/>
</div>
</div>
{/* Password Field */}
<div>
<label htmlFor="password" className="block text-sm font-semibold mb-2" style={{ color: '#333333' }}>
Contraseña
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5" style={{ color: '#7A7A7A' }} 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>
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
required
className="block w-full pl-10 pr-12 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:border-transparent transition duration-200"
style={{
color: '#333333',
borderColor: '#d1d5db'
}}
placeholder="Ingresa tu contraseña"
value={password}
onChange={e => setPassword(e.target.value)}
onFocus={(e) => {
e.target.style.borderColor = 'transparent';
e.target.style.boxShadow = '0 0 0 2px #4DA6FF';
}}
onBlur={(e) => {
e.target.style.borderColor = '#d1d5db';
e.target.style.boxShadow = 'none';
}}
onMouseEnter={(e) => {
if (document.activeElement !== e.target) {
e.target.style.borderColor = '#4DA6FF';
}
}}
onMouseLeave={(e) => {
if (document.activeElement !== e.target) {
e.target.style.borderColor = '#d1d5db';
}
}}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center transition-colors duration-200"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<svg className="h-5 w-5 hover:opacity-70" style={{ color: '#7A7A7A' }} 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.414L12 12m0 0l2.122 2.122m0 0l1.414 1.414" />
</svg>
) : (
<svg className="h-5 w-5 hover:opacity-70" style={{ color: '#7A7A7A' }} 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>
</div>
{/* Error Message */}
{error && (
<div className="rounded-xl bg-red-50 border p-4 animate-pulse" style={{ borderColor: 'rgba(198, 40, 40, 0.2)' }}>
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5" style={{ color: '#C62828' }} viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium" style={{ color: '#C62828' }}>
{error}
</h3>
</div>
</div>
</div>
)}
{/* Login Button */}
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
style={{
backgroundColor: '#1B2A41',
'--tw-ring-color': '#1B2A41'
}}
onMouseEnter={(e) => {
if (!loading) {
e.target.style.backgroundColor = '#162234';
}
}}
onMouseLeave={(e) => {
if (!loading) {
e.target.style.backgroundColor = '#1B2A41';
}
}}
>
{loading ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Ingresando...
</>
) : (
<>
<span>Ingresar</span>
<svg className="ml-2 w-4 h-4 group-hover:translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</>
)}
</button>
</div>
{/* Additional Links */}
<div className="text-center space-y-3">
<div className="text-sm">
<Link
to="/forgot-password"
className="font-medium transition-colors duration-200"
style={{ color: '#4DA6FF' }}
onMouseEnter={(e) => e.target.style.color = '#1976D2'}
onMouseLeave={(e) => e.target.style.color = '#4DA6FF'}
>
¿Olvidaste tu contraseña?
</Link>
</div>
<div className="border-t border-gray-200 pt-4">
<Link
to="/"
className="inline-flex items-center text-sm font-medium group transition-colors duration-200"
style={{ color: '#4DA6FF' }}
onMouseEnter={(e) => e.target.style.color = '#1976D2'}
onMouseLeave={(e) => e.target.style.color = '#4DA6FF'}
>
<svg className="mr-2 w-4 h-4 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 17l-5-5m0 0l5-5m-5 5h12" />
</svg>
Volver al inicio
</Link>
</div>
</div>
</form>
</div>
{/* Footer */}
<div className="px-8 py-4 border-t border-gray-200" style={{ backgroundColor: '#F2F4F7' }}>
<div className="text-center">
<p className="text-xs" style={{ color: '#7A7A7A' }}>
Desarrollado por <span className="font-semibold" style={{ color: '#1B2A41' }}>@AduanaSoft</span>
</p>
<p className="text-xs mt-1" style={{ color: '#7A7A7A' }}>
Solución especializada para Agentes Aduanales
</p>
</div>
</div>
</div>
{/* Floating elements */}
<div className="absolute -top-4 -left-4 w-24 h-24 rounded-full blur-xl" style={{ backgroundColor: 'rgba(255, 255, 255, 0.1)' }}></div>
<div className="absolute -bottom-4 -right-4 w-32 h-32 rounded-full blur-xl" style={{ backgroundColor: 'rgba(27, 42, 65, 0.2)' }}></div>
</div>
</div>
);
}

391
src/pages/LoginBroken.jsx Normal file
View File

@@ -0,0 +1,391 @@
import React, { useState } from 'react';
import { login } from '../api/auth';
import { Link } from 'react-router-dom';
import { colors } from '../theme';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const data = await login(username, password);
localStorage.setItem('access', data.access);
localStorage.setItem('refresh', data.refresh);
// Disparar evento personalizado para que el navbar se actualice
window.dispatchEvent(new CustomEvent('authStateChanged'));
// Redirigir al dashboard
window.location.href = '/admin';
} catch (err) {
setError('Usuario o contraseña incorrectos');
} finally {
setLoading(false);
}
};
return (
<>
<div className="min-h-screen bg-light-gray flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
{/* Background pattern */}
<div className="absolute inset-0 opacity-20">
<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='%23${colors.primary.navy.substring(1)}' fillOpacity='0.1'%3E%3Ccircle cx='30' cy='30' r='2'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
}}></div>
</div>
<div className="relative max-w-md w-full">
{/* Main Card */}
<div className="bg-white backdrop-blur-md rounded-3xl shadow-2xl border border-gray-200 overflow-hidden">
{/* Header with navy background */}
<div className="bg-navy px-8 py-10 text-center">
<div className="mb-4">
<Link to="/" className="inline-block">
<h1 className="text-4xl font-bold text-white">
EFC
</h1>
</Link>
</div>
<h2 className="text-2xl font-bold text-white mb-2">
Bienvenido de vuelta
</h2>
<p className="text-white/80 text-sm">
Inicia sesión para acceder a tu plataforma aduanal
</p>
</div>
{/* Form */}
<div className="px-8 py-8">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Username Field */}
<div>
<label htmlFor="username" className="block text-sm font-semibold text-text-primary mb-2">
Usuario
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-text-secondary" 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>
<input
id="username"
name="username"
type="text"
required
className="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl text-text-primary placeholder-text-secondary focus:outline-none focus:ring-2 focus:ring-info focus:border-transparent transition duration-200 hover:border-info/50"
placeholder="Ingresa tu usuario"
value={username}
onChange={e => setUsername(e.target.value)}
/>
</div>
</div>
{/* Password Field */}
<div>
<label htmlFor="password" className="block text-sm font-semibold text-text-primary mb-2">
Contraseña
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-text-secondary" 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>
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
required
className="block w-full pl-10 pr-12 py-3 border border-gray-300 rounded-xl text-text-primary placeholder-text-secondary focus:outline-none focus:ring-2 focus:ring-info focus:border-transparent transition duration-200 hover:border-info/50"
placeholder="Ingresa tu contraseña"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<svg className="h-5 w-5 text-text-secondary hover:text-text-primary" 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.414L12 12m0 0l2.122 2.122m0 0l1.414 1.414" />
</svg>
) : (
<svg className="h-5 w-5 text-text-secondary hover:text-text-primary" 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>
</div>
{/* Error Message */}
{error && (
<div className="rounded-xl bg-red-50 border border-error/20 p-4 animate-pulse">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-error" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-error">
{error}
</h3>
</div>
</div>
</div>
)}
{/* Login Button */}
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-navy hover:bg-navy-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-navy disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
{loading ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Ingresando...
</>
) : (
<>
<span>Ingresar</span>
<svg className="ml-2 w-4 h-4 group-hover:translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</>
)}
</button>
</div>
{/* Additional Links */}
<div className="text-center space-y-3">
<div className="text-sm">
<a href="#" className="text-info hover:text-info-dark font-medium transition-colors duration-200">
¿Olvidaste tu contraseña?
</a>
</div>
<div className="border-t border-gray-200 pt-4">
<Link
to="/"
className="inline-flex items-center text-info hover:text-info-dark text-sm font-medium group transition-colors duration-200"
>
<svg className="mr-2 w-4 h-4 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 17l-5-5m0 0l5-5m-5 5h12" />
</svg>
Volver al inicio
</Link>
</div>
</div>
</form>
</div>
{/* Footer */}
<div className="bg-light-gray px-8 py-4 border-t border-gray-200">
<div className="text-center">
<p className="text-xs text-text-secondary">
Desarrollado por <span className="font-semibold text-navy">@AduanaSoft</span>
</p>
<p className="text-xs text-text-secondary mt-1">
Solución especializada para Agentes Aduanales
</p>
</div>
</div>
</div>
{/* Floating elements with new colors */}
<div className="absolute -top-4 -left-4 w-24 h-24 bg-navy/10 rounded-full blur-xl"></div>
<div className="absolute -bottom-4 -right-4 w-32 h-32 bg-info/20 rounded-full blur-xl"></div>
</div>
</div>
</>
);
}
<div className="relative max-w-md w-full">
{/* Main Card */}
<div className="bg-white/95 backdrop-blur-md rounded-3xl shadow-2xl border border-white/20 overflow-hidden">
{/* Header with gradient */}
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 px-8 py-10 text-center">
<div className="mb-4">
<Link to="/" className="inline-block">
<h1 className="text-4xl font-bold text-white">
EFC
</h1>
</Link>
</div>
<h2 className="text-2xl font-bold text-white mb-2">
Bienvenido de vuelta
</h2>
<p className="text-indigo-100 text-sm">
Inicia sesión para acceder a tu plataforma aduanal
</p>
</div>
{/* Form */}
<div className="px-8 py-8">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Username Field */}
<div>
<label htmlFor="username" className="block text-sm font-semibold text-gray-700 mb-2">
Usuario
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="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>
<input
id="username"
name="username"
type="text"
required
className="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition duration-200 hover:border-indigo-300"
placeholder="Ingresa tu usuario"
value={username}
onChange={e => setUsername(e.target.value)}
/>
</div>
</div>
{/* Password Field */}
<div>
<label htmlFor="password" className="block text-sm font-semibold text-gray-700 mb-2">
Contraseña
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="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>
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
required
className="block w-full pl-10 pr-12 py-3 border border-gray-300 rounded-xl text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition duration-200 hover:border-indigo-300"
placeholder="Ingresa tu contraseña"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<svg className="h-5 w-5 text-gray-400 hover:text-gray-600" 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.414L12 12m0 0l2.122 2.122m0 0l1.414 1.414" />
</svg>
) : (
<svg className="h-5 w-5 text-gray-400 hover:text-gray-600" 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>
</div>
{/* Error Message */}
{error && (
<div className="rounded-xl bg-red-50 border border-red-200 p-4 animate-pulse">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">
{error}
</h3>
</div>
</div>
</div>
)}
{/* Login Button */}
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
{loading ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Ingresando...
</>
) : (
<>
<span>Ingresar</span>
<svg className="ml-2 w-4 h-4 group-hover:translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</>
)}
</button>
</div>
{/* Additional Links */}
<div className="text-center space-y-3">
<div className="text-sm">
<a href="#" className="text-info hover:text-info-dark font-medium transition-colors duration-200">
¿Olvidaste tu contraseña?
</a>
</div>
<div className="border-t border-gray-200 pt-4">
<Link
to="/"
className="inline-flex items-center text-info hover:text-info-dark text-sm font-medium group transition-colors duration-200"
>
<svg className="mr-2 w-4 h-4 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 17l-5-5m0 0l5-5m-5 5h12" />
</svg>
Volver al inicio
</Link>
</div>
</div>
</form>
</div>
{/* Footer */}
<div className="bg-light-gray px-8 py-4 border-t border-gray-200">
<div className="text-center">
<p className="text-xs text-text-secondary">
Desarrollado por <span className="font-semibold text-navy">@AduanaSoft</span>
</p>
<p className="text-xs text-text-secondary mt-1">
Solución especializada para Agentes Aduanales
</p>
</div>
</div>
{/* Floating elements */}
<div className="absolute -top-4 -left-4 w-24 h-24 bg-white/10 rounded-full blur-xl"></div>
<div className="absolute -bottom-4 -right-4 w-32 h-32 bg-navy/20 rounded-full blur-xl"></div>
</div>
</div>
</>
);
}

286
src/pages/LoginFixed.jsx Normal file
View File

@@ -0,0 +1,286 @@
import React, { useState } from 'react';
import { login } from '../api/auth';
import { Link } from 'react-router-dom';
import { colors } from '../theme';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const data = await login(username, password);
localStorage.setItem('access', data.access);
localStorage.setItem('refresh', data.refresh);
// Disparar evento personalizado para que el navbar se actualice
window.dispatchEvent(new CustomEvent('authStateChanged'));
// Redirigir al dashboard
window.location.href = '/admin';
} catch (err) {
setError('Usuario o contraseña incorrectos');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#F2F4F7' }}>
{/* Background pattern */}
<div className="absolute inset-0 opacity-20">
<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='%231B2A41' fillOpacity='0.1'%3E%3Ccircle cx='30' cy='30' r='2'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
}}></div>
</div>
<div className="relative max-w-md w-full">
{/* Main Card */}
<div className="bg-white backdrop-blur-md rounded-3xl shadow-2xl border border-gray-200 overflow-hidden">
{/* Header with navy background */}
<div className="px-8 py-10 text-center" style={{ backgroundColor: '#1B2A41' }}>
<div className="mb-4">
<Link to="/" className="inline-block">
<h1 className="text-4xl font-bold text-white">
EFC
</h1>
</Link>
</div>
<h2 className="text-2xl font-bold text-white mb-2">
Bienvenido de vuelta
</h2>
<p className="text-sm" style={{ color: 'rgba(255, 255, 255, 0.8)' }}>
Inicia sesión para acceder a tu plataforma aduanal
</p>
</div>
{/* Form */}
<div className="px-8 py-8">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Username Field */}
<div>
<label htmlFor="username" className="block text-sm font-semibold mb-2" style={{ color: '#333333' }}>
Usuario
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5" style={{ color: '#7A7A7A' }} 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>
<input
id="username"
name="username"
type="text"
required
className="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:border-transparent transition duration-200"
style={{
color: '#333333',
borderColor: '#d1d5db',
':focus': {
ringColor: '#4DA6FF',
borderColor: 'transparent'
},
':hover': {
borderColor: '#4DA6FF'
}
}}
placeholder="Ingresa tu usuario"
value={username}
onChange={e => setUsername(e.target.value)}
onFocus={(e) => {
e.target.style.borderColor = 'transparent';
e.target.style.boxShadow = '0 0 0 2px #4DA6FF';
}}
onBlur={(e) => {
e.target.style.borderColor = '#d1d5db';
e.target.style.boxShadow = 'none';
}}
onMouseEnter={(e) => {
if (document.activeElement !== e.target) {
e.target.style.borderColor = '#4DA6FF';
}
}}
onMouseLeave={(e) => {
if (document.activeElement !== e.target) {
e.target.style.borderColor = '#d1d5db';
}
}}
/>
</div>
</div>
{/* Password Field */}
<div>
<label htmlFor="password" className="block text-sm font-semibold mb-2" style={{ color: '#333333' }}>
Contraseña
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5" style={{ color: '#7A7A7A' }} 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>
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
required
className="block w-full pl-10 pr-12 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:border-transparent transition duration-200"
style={{
color: '#333333',
borderColor: '#d1d5db'
}}
placeholder="Ingresa tu contraseña"
value={password}
onChange={e => setPassword(e.target.value)}
onFocus={(e) => {
e.target.style.borderColor = 'transparent';
e.target.style.boxShadow = '0 0 0 2px #4DA6FF';
}}
onBlur={(e) => {
e.target.style.borderColor = '#d1d5db';
e.target.style.boxShadow = 'none';
}}
onMouseEnter={(e) => {
if (document.activeElement !== e.target) {
e.target.style.borderColor = '#4DA6FF';
}
}}
onMouseLeave={(e) => {
if (document.activeElement !== e.target) {
e.target.style.borderColor = '#d1d5db';
}
}}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center transition-colors duration-200"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<svg className="h-5 w-5 hover:opacity-70" style={{ color: '#7A7A7A' }} 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.414L12 12m0 0l2.122 2.122m0 0l1.414 1.414" />
</svg>
) : (
<svg className="h-5 w-5 hover:opacity-70" style={{ color: '#7A7A7A' }} 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>
</div>
{/* Error Message */}
{error && (
<div className="rounded-xl bg-red-50 border p-4 animate-pulse" style={{ borderColor: 'rgba(198, 40, 40, 0.2)' }}>
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5" style={{ color: '#C62828' }} viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium" style={{ color: '#C62828' }}>
{error}
</h3>
</div>
</div>
</div>
)}
{/* Login Button */}
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
style={{
backgroundColor: '#1B2A41',
'--tw-ring-color': '#1B2A41'
}}
onMouseEnter={(e) => {
if (!loading) {
e.target.style.backgroundColor = '#162234';
}
}}
onMouseLeave={(e) => {
if (!loading) {
e.target.style.backgroundColor = '#1B2A41';
}
}}
>
{loading ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Ingresando...
</>
) : (
<>
<span>Ingresar</span>
<svg className="ml-2 w-4 h-4 group-hover:translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</>
)}
</button>
</div>
{/* Additional Links */}
<div className="text-center space-y-3">
<div className="text-sm">
<a href="#" className="font-medium transition-colors duration-200" style={{ color: '#4DA6FF' }}
onMouseEnter={(e) => e.target.style.color = '#1976D2'}
onMouseLeave={(e) => e.target.style.color = '#4DA6FF'}
>
¿Olvidaste tu contraseña?
</a>
</div>
<div className="border-t border-gray-200 pt-4">
<Link
to="/"
className="inline-flex items-center text-sm font-medium group transition-colors duration-200"
style={{ color: '#4DA6FF' }}
onMouseEnter={(e) => e.target.style.color = '#1976D2'}
onMouseLeave={(e) => e.target.style.color = '#4DA6FF'}
>
<svg className="mr-2 w-4 h-4 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 17l-5-5m0 0l5-5m-5 5h12" />
</svg>
Volver al inicio
</Link>
</div>
</div>
</form>
</div>
{/* Footer */}
<div className="px-8 py-4 border-t border-gray-200" style={{ backgroundColor: '#F2F4F7' }}>
<div className="text-center">
<p className="text-xs" style={{ color: '#7A7A7A' }}>
Desarrollado por <span className="font-semibold" style={{ color: '#1B2A41' }}>@AduanaSoft</span>
</p>
<p className="text-xs mt-1" style={{ color: '#7A7A7A' }}>
Solución especializada para Agentes Aduanales
</p>
</div>
</div>
</div>
{/* Floating elements */}
<div className="absolute -top-4 -left-4 w-24 h-24 rounded-full blur-xl" style={{ backgroundColor: 'rgba(255, 255, 255, 0.1)' }}></div>
<div className="absolute -bottom-4 -right-4 w-32 h-32 rounded-full blur-xl" style={{ backgroundColor: 'rgba(27, 42, 65, 0.2)' }}></div>
</div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
import React, { useEffect, useState } from 'react';
import { fetchNotificaciones, fetchAllNotifications, marcarNotificacionComoVista } from '../api/notificaciones';
export default function Notificaciones() {
const [notificaciones, setNotificaciones] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
const pageSize = 15;
const [count, setCount] = useState(0);
const [filtroVisto, setFiltroVisto] = useState('todos');
const fetchData = async () => {
setLoading(true);
setError(null);
try {
let data;
if (filtroVisto === 'todos') {
data = await fetchAllNotifications({ page, pageSize });
} else {
const params = { page, pageSize };
if (filtroVisto === 'visto') params.visto = true;
else if (filtroVisto === 'novisto') params.visto = false;
data = await fetchNotificaciones(params);
}
setNotificaciones(data.results);
setCount(data.count);
} catch (e) {
setError('Error al cargar notificaciones');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
// eslint-disable-next-line
}, [page, filtroVisto]);
const handleActualizarTodas = async () => {
setLoading(true);
try {
await Promise.all(
notificaciones.filter(n => !n.visto).map(n => marcarNotificacionComoVista(n.id))
);
fetchData();
} catch (e) {
setError('Error al actualizar notificaciones');
} finally {
setLoading(false);
}
};
const handleFiltroChange = (e) => {
setFiltroVisto(e.target.value);
setPage(1);
};
return (
<div className="p-6 max-w-5xl mx-auto">
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-4 gap-2">
<h1 className="text-2xl font-bold">Notificaciones</h1>
<div className="flex gap-2 items-center">
<select
className="border rounded px-2 py-1"
value={filtroVisto}
onChange={handleFiltroChange}
>
<option value="todos">Todas</option>
<option value="visto">Vistas</option>
<option value="novisto">No vistas</option>
</select>
<button
onClick={handleActualizarTodas}
className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded shadow"
disabled={loading}
>
Actualizar todas como leídas
</button>
</div>
</div>
{loading ? (
<div className="text-blue-500 py-8 text-center">Cargando...</div>
) : error ? (
<div className="text-red-500 py-8 text-center">{error}</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 rounded-lg overflow-hidden">
<thead className="bg-gradient-to-r from-gray-50 sticky top-0 z-20">
<tr>
<th className="px-6 py-3 text-center text-xs font-bold text-blue-700 uppercase tracking-wider border-b border-gray-200">ID</th>
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider border-b border-gray-200">Tipo</th>
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider border-b border-gray-200">Mensaje</th>
<th className="px-6 py-3 text-left text-xs font-bold uppercase tracking-wider border-b border-gray-200">Fecha</th>
<th className="px-6 py-3 text-center text-xs font-bold text-blue-700 uppercase tracking-wider border-b border-gray-200">Visto</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100" style={{ position: 'relative', minHeight: 'calc(8 * 56px)' }}>
{Array.from({ length: pageSize }).map((_, idx) => {
const n = notificaciones[idx];
if (n) {
return (
<tr key={n.id} className={`transition-all duration-200 hover:bg-blue-100 hover:shadow-lg ${n.visto ? '' : 'bg-blue-50'}`}>
<td className="px-6 py-4 text-center align-middle">{n.id}</td>
<td className="px-6 py-4 whitespace-nowrap align-middle font-medium text-blue-900">{n.tipo?.descripcion || n.tipo?.tipo}</td>
<td className="px-6 py-4 whitespace-nowrap align-middle text-gray-800">{n.mensaje}</td>
<td className="px-6 py-4 whitespace-nowrap align-middle text-gray-700">{new Date(n.fecha_envio || n.created_at).toLocaleString()}</td>
<td className="px-6 py-4 whitespace-nowrap text-center align-middle">
{n.visto ? (
<span className="text-green-600 font-semibold"></span>
) : (
<span className="text-red-600 font-semibold">No</span>
)}
</td>
</tr>
);
} else {
return (
<tr key={"empty-" + idx}>
<td className="px-6 py-4 text-center text-gray-300">-</td>
<td className="px-6 py-4 text-gray-300">-</td>
<td className="px-6 py-4 text-gray-300">-</td>
<td className="px-6 py-4 text-gray-300">-</td>
<td className="px-6 py-4 text-center text-gray-300">-</td>
</tr>
);
}
})}
</tbody>
</table>
</div>
)}
{/* Paginación */}
<div className="flex justify-between items-center mt-4">
<div>
Página {page} de {Math.ceil(count / pageSize) || 1}
</div>
<div className="flex gap-2 items-center">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1 rounded bg-gray-200 hover:bg-gray-300 disabled:opacity-50"
>
Anterior
</button>
<button
onClick={() => setPage((p) => (p * pageSize < count ? p + 1 : p))}
disabled={page * pageSize >= count}
className="px-3 py-1 rounded bg-gray-200 hover:bg-gray-300 disabled:opacity-50"
>
Siguiente
</button>
<span className="ml-2 text-sm text-gray-500">15 por página</span>
</div>
</div>
</div>
);
}

300
src/pages/Organization.jsx Normal file
View File

@@ -0,0 +1,300 @@
import React, { useState, useEffect } from 'react';
import { fetchOrganizationUsage } from '../api/organization';
import { useNotification } from '../context/NotificationContext';
import '../assets/organization-animations.css';
export default function Organization() {
const [info, setInfo] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const { showMessage } = useNotification();
// Estado para animar el progress bar
const [animatedPercent, setAnimatedPercent] = useState(0);
useEffect(() => {
const token = localStorage.getItem('access');
if (!token) {
setError('No se encontró el token de acceso.');
setLoading(false);
return;
}
fetchOrganizationUsage(token)
.then(data => {
setInfo(data);
setLoading(false);
})
.catch(err => {
if (err.message === 'SESSION_EXPIRED') {
localStorage.removeItem('access');
localStorage.removeItem('refresh');
showMessage('Tu sesión ha expirado, por favor inicia sesión de nuevo.', 'error');
setTimeout(() => {
window.location.href = '/login';
}, 2000);
} else {
setError(err.message);
}
setLoading(false);
});
}, [showMessage]);
// Animación del progress bar
useEffect(() => {
if (!info) return;
const used = info.espacio_utilizado_gb || 0;
const limit = info.limite_almacenamiento_gb || 1;
const percent = Math.min(100, (100 * used / limit));
let start = 0;
// Si ya está en el valor correcto, no animar
if (animatedPercent === percent) return;
// Animar de 0 a percent
const step = () => {
setAnimatedPercent(prev => {
if (prev < percent) {
const next = Math.min(prev + 2, percent); // velocidad de animación
if (next < percent) {
setTimeout(step, 10);
}
return next;
} else {
return percent;
}
});
};
setAnimatedPercent(0);
setTimeout(step, 200); // pequeño delay para que se note la animación
// eslint-disable-next-line
}, [info]);
if (loading) return (
<div className="h-full bg-gray-50 flex items-center justify-center">
<div className="text-center">
<svg className="animate-spin h-12 w-12 text-navy-600 mx-auto mb-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className="text-gray-600 text-lg">Cargando información de la organización...</p>
</div>
</div>
);
if (error) return (
<div className="h-full bg-gray-50 flex items-center justify-center">
<div className="bg-danger-50 border border-danger-200 rounded-xl p-6 max-w-md shadow-lg">
<div className="flex items-center">
<svg className="h-6 w-6 text-danger-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p className="text-danger-800 font-medium">{error}</p>
</div>
</div>
</div>
);
return (
<div className="bg-gray-50 p-6">
<div className="max-w-6xl mx-auto">
{/* Header mejorado y decorativo */}
<div className="mb-8 relative overflow-hidden rounded-2xl shadow bg-gradient-to-r from-blue-50 via-white to-indigo-50 border border-blue-100 p-8 flex items-center gap-6 animate-fadein-slideup opacity-0" style={{ animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.05s forwards' }}>
<div className="flex-shrink-0 bg-blue-100 rounded-full p-4 shadow-md animate-bounce-slow">
<svg className="h-10 w-10 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M3 7c0 2.21 3.582 4 8 4s8-1.79 8-4M3 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
</div>
<div>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight mb-1 flex items-center gap-2">
Mi Organización
{info && (
<span className="inline-block bg-blue-200 text-blue-800 text-xs font-semibold px-2 py-0.5 rounded-full ml-2 animate-fade-in">
{info.total_usuarios} usuarios
</span>
)}
</h1>
<p className="text-lg text-blue-700/80 font-medium">Información y métricas de uso de tu organización</p>
</div>
{/* Efecto decorativo de fondo */}
<div className="absolute -top-10 -right-10 opacity-30 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="url(#grad1)" />
<defs>
<linearGradient id="grad1" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse">
<stop stopColor="#3b82f6" stopOpacity="0.15" />
<stop offset="1" stopColor="#6366f1" stopOpacity="0.10" />
</linearGradient>
</defs>
</svg>
</div>
{/* Animación personalizada para el icono y contador */}
<style>{`
@keyframes bounce-slow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.animate-bounce-slow {
animation: bounce-slow 2.2s infinite;
}
@keyframes fade-in {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
.animate-fade-in {
animation: fade-in 0.7s ease;
}
`}</style>
</div>
{/* Barra de almacenamiento con color y progress bar */}
<div className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center">
<svg className="w-6 h-6 text-success-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
Uso de Almacenamiento
</h2>
<div className="relative w-full h-8 bg-gray-200 rounded-full overflow-hidden shadow-inner">
{/* Progress bar de color dinámico según porcentaje */}
{(() => {
const used = info?.espacio_utilizado_gb || 0;
const limit = info?.limite_almacenamiento_gb || 1;
const percent = Math.min(100, (100 * used / limit));
let barColor = 'linear-gradient(90deg, #22c55e 0%, #16a34a 100%)'; // verde
if (animatedPercent >= 80) {
barColor = 'linear-gradient(90deg, #ef4444 0%, #b91c1c 100%)'; // rojo
} else if (animatedPercent >= 50) {
barColor = 'linear-gradient(90deg, #f59e42 0%, #d97706 100%)'; // naranja
}
return (
<div
className="absolute left-0 top-0 h-8 rounded-full shadow-lg transition-all duration-700"
style={{ width: `${animatedPercent}%`, background: barColor }}
></div>
);
})()}
{/* Etiquetas sobre la barra */}
<div className="absolute left-0 top-0 w-full h-8 flex items-center justify-between px-4 text-sm font-semibold z-10">
<span className="text-success-700 flex items-center">
<span className="inline-block w-3 h-3 rounded-full bg-gradient-to-br from-green-400 to-green-600 mr-2"></span>
{info?.espacio_utilizado_gb?.toFixed(2)} GB usados
</span>
<span className="text-gray-700">{info?.espacio_disponible_bytes ? (info.espacio_disponible_bytes / (1024 * 1024 * 1024)).toFixed(2) : 0} GB libres</span>
<span className="text-gray-500">{info?.limite_almacenamiento_gb} GB límite</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
{/* Tarjeta Organización */}
<div className="bg-white rounded-lg shadow-md border border-navy-200 p-4 flex flex-col items-center min-w-0 opacity-0 translate-y-6 animate-fadein-slideup transition-all duration-500 hover:scale-110 hover:shadow-2xl" style={{ animationDelay: '0ms', animationFillMode: 'forwards', transitionTimingFunction: 'cubic-bezier(0.22,1,0.36,1)' }}>
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-700 rounded-lg flex items-center justify-center shadow-md mb-2">
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M3 7c0 2.21 3.582 4 8 4s8-1.79 8-4M3 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
</div>
<span className="text-navy-700 font-semibold mb-1">Organización</span>
<span className="text-2xl font-bold text-navy-900">{info?.organizacion}</span>
</div>
{/* Tarjeta Usuarios */}
<div className="bg-white rounded-lg shadow-md border border-primary-200 p-4 flex flex-col items-center min-w-0 opacity-0 translate-y-6 animate-fadein-slideup transition-all duration-500 hover:scale-110 hover:shadow-2xl" style={{ animationDelay: '50ms', animationFillMode: 'forwards', transitionTimingFunction: 'cubic-bezier(0.22,1,0.36,1)' }}>
<div className="w-12 h-12 bg-gradient-to-br from-green-500 to-green-700 rounded-lg flex items-center justify-center shadow-md mb-2">
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m9-4a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<span className="text-primary-700 font-semibold mb-1">Usuarios</span>
<span className="text-2xl font-bold text-primary-900">{info?.total_usuarios}</span>
</div>
{/* Tarjeta Pedimentos */}
<div className="bg-white rounded-lg shadow-md border border-success-200 p-4 flex flex-col items-center min-w-0 opacity-0 translate-y-6 animate-fadein-slideup transition-all duration-500 hover:scale-110 hover:shadow-2xl" style={{ animationDelay: '100ms', animationFillMode: 'forwards', transitionTimingFunction: 'cubic-bezier(0.22,1,0.36,1)' }}>
<div className="w-12 h-12 bg-gradient-to-br from-orange-400 to-orange-600 rounded-lg flex items-center justify-center shadow-md mb-2">
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 17v-6a2 2 0 012-2h2a2 2 0 012 2v6m-4 0h4" />
</svg>
</div>
<span className="text-success-700 font-semibold mb-1">Pedimentos</span>
<span className="text-2xl font-bold text-success-900">{info?.total_pedimentos}</span>
</div>
{/* Tarjeta Documentos */}
<div className="bg-white rounded-lg shadow-md border border-warning-200 p-4 flex flex-col items-center min-w-0 opacity-0 translate-y-6 animate-fadein-slideup transition-all duration-500 hover:scale-110 hover:shadow-2xl" style={{ animationDelay: '150ms', animationFillMode: 'forwards', transitionTimingFunction: 'cubic-bezier(0.22,1,0.36,1)' }}>
<div className="w-12 h-12 bg-gradient-to-br from-yellow-400 to-yellow-600 rounded-lg flex items-center justify-center shadow-md mb-2">
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h10M7 11h10M7 15h6M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<span className="text-warning-700 font-semibold mb-1">Documentos</span>
<span className="text-2xl font-bold text-warning-900">{info?.total_documentos}</span>
</div>
{/* Tarjeta Límite de Almacenamiento */}
<div className="bg-white rounded-lg shadow-md border border-light-gray-200 p-4 flex flex-col items-center min-w-0 opacity-0 translate-y-6 animate-fadein-slideup transition-all duration-500 hover:scale-110 hover:shadow-2xl" style={{ animationDelay: '200ms', animationFillMode: 'forwards', transitionTimingFunction: 'cubic-bezier(0.22,1,0.36,1)' }}>
<div className="w-12 h-12 bg-gradient-to-br from-gray-400 to-gray-600 rounded-lg flex items-center justify-center shadow-md mb-2">
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
</svg>
</div>
<span className="text-light-gray-700 font-semibold mb-1">Límite de Almacenamiento</span>
<span className="text-2xl font-bold text-light-gray-900">{info?.limite_almacenamiento_gb} GB</span>
</div>
{/* Tarjeta Espacio Utilizado */}
<div className="bg-white rounded-lg shadow-md border border-warning-200 p-4 flex flex-col items-center min-w-0 opacity-0 translate-y-6 animate-fadein-slideup transition-all duration-500 hover:scale-110 hover:shadow-2xl" style={{ animationDelay: '250ms', animationFillMode: 'forwards', transitionTimingFunction: 'cubic-bezier(0.22,1,0.36,1)' }}>
<div className="w-12 h-12 bg-gradient-to-br from-pink-400 to-pink-600 rounded-lg flex items-center justify-center shadow-md mb-2">
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v8m0 0a4 4 0 100-8 4 4 0 000 8z" />
</svg>
</div>
<span className="text-warning-700 font-semibold mb-1">Espacio Utilizado</span>
<span className="text-2xl font-bold text-warning-900">{info?.espacio_utilizado_gb?.toFixed(2)} GB</span>
</div>
{/* Tarjeta Espacio Disponible */}
<div className="bg-white rounded-lg shadow-md border border-success-200 p-4 flex flex-col items-center min-w-0 opacity-0 translate-y-6 animate-fadein-slideup transition-all duration-500 hover:scale-110 hover:shadow-2xl" style={{ animationDelay: '300ms', animationFillMode: 'forwards', transitionTimingFunction: 'cubic-bezier(0.22,1,0.36,1)' }}>
<div className="w-12 h-12 bg-gradient-to-br from-green-400 to-green-600 rounded-lg flex items-center justify-center shadow-md mb-2">
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 20V4m0 0a8 8 0 110 16 8 8 0 010-16z" />
</svg>
</div>
<span className="text-success-700 font-semibold mb-1">Espacio Disponible</span>
<span className="text-2xl font-bold text-success-900">{info?.espacio_disponible_bytes ? (info.espacio_disponible_bytes / (1024 * 1024 * 1024)).toFixed(2) : 0} GB</span>
</div>
{/* Tarjeta Porcentaje Utilizado */}
<div className="bg-white rounded-lg shadow-md border border-accent-200 p-4 flex flex-col items-center min-w-0 opacity-0 translate-y-6 animate-fadein-slideup transition-all duration-500 hover:scale-110 hover:shadow-2xl" style={{ animationDelay: '350ms', animationFillMode: 'forwards', transitionTimingFunction: 'cubic-bezier(0.22,1,0.36,1)' }}>
<div className="w-12 h-12 bg-gradient-to-br from-purple-400 to-purple-700 rounded-lg flex items-center justify-center shadow-md mb-2">
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" strokeWidth="2" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6l4 2" />
</svg>
</div>
<span className="text-accent-700 font-semibold mb-1">Porcentaje Utilizado</span>
<span className="text-2xl font-bold text-accent-900">{info?.porcentaje_utilizado}%</span>
</div>
</div>
{/* Acciones */}
<div className="mt-8 bg-white rounded-xl shadow-lg border border-gray-200 p-6">
<div className="flex items-center mb-6">
<div className="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-lg flex items-center justify-center shadow-lg mr-4">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h2 className="text-xl font-semibold text-gray-900">Acciones</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button className="inline-flex items-center justify-center px-6 py-3 border border-transparent text-sm font-medium rounded-lg text-white bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200 transform hover:scale-105 shadow-lg">
<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="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 Organización
</button>
<button className="inline-flex items-center justify-center px-6 py-3 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200 transform hover:scale-105 shadow-lg">
<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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Ver Reportes
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,212 @@
import React, { useState } from 'react';
import logo from '../assets/react.svg';
import { useParams, useNavigate } from 'react-router-dom';
export default function PasswordResetConfirm() {
const { uid, token } = useParams();
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (newPassword !== confirmPassword) {
setError('Las contraseñas no coinciden.');
return;
}
setLoading(true);
try {
// POST a la ruta real del backend (URL absoluta)
const apiUrl = `http://192.168.1.195:8000/api/v1/user/password-reset-confirm/${uid}/${token}/`;
const res = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: newPassword }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.detail || 'No se pudo cambiar la contraseña.');
} else {
setSuccess(true);
setTimeout(() => navigate('/login'), 2500);
}
} catch (err) {
setError('Error de red. Intenta de nuevo.');
}
setLoading(false);
};
return (
<div className="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8" style={{ backgroundColor: '#F2F4F7' }}>
{/* Background pattern */}
<div className="absolute inset-0 opacity-20 pointer-events-none select-none">
<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='%231B2A41' fillOpacity='0.1'%3E%3Ccircle cx='30' cy='30' r='2'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
}}></div>
</div>
<div className="relative max-w-md w-full">
<div className="bg-white backdrop-blur-md rounded-3xl shadow-2xl border border-gray-200 overflow-hidden">
{/* Header navy */}
<div className="px-8 py-10 text-center" style={{ backgroundColor: '#1B2A41' }}>
{/* Logo eliminado para diseño limpio */}
<h2 className="text-2xl font-bold text-white mb-2">Nueva contraseña</h2>
<p className="text-sm" style={{ color: 'rgba(255, 255, 255, 0.8)' }}>
Ingresa tu nueva contraseña y confírmala
</p>
</div>
<div className="px-8 py-8">
{success ? (
<div className="text-green-700 bg-green-50 border border-green-200 rounded p-4 text-center mb-4">
Contraseña cambiada correctamente. Redirigiendo al login...
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Nueva contraseña */}
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#333333' }}>
Nueva contraseña
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5" style={{ color: '#7A7A7A' }} 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>
<input
type="password"
required
className="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:border-transparent transition duration-200"
style={{ color: '#333333', borderColor: '#d1d5db' }}
placeholder="Nueva contraseña"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
onFocus={e => { e.target.style.borderColor = 'transparent'; e.target.style.boxShadow = '0 0 0 2px #4DA6FF'; }}
onBlur={e => { e.target.style.borderColor = '#d1d5db'; e.target.style.boxShadow = 'none'; }}
onMouseEnter={e => { if (document.activeElement !== e.target) e.target.style.borderColor = '#4DA6FF'; }}
onMouseLeave={e => { if (document.activeElement !== e.target) e.target.style.borderColor = '#d1d5db'; }}
/>
</div>
</div>
{/* Confirmar contraseña */}
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#333333' }}>
Confirmar contraseña
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5" style={{ color: '#7A7A7A' }} 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>
<input
type="password"
required
className="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:border-transparent transition duration-200"
style={{ color: '#333333', borderColor: '#d1d5db' }}
placeholder="Confirmar contraseña"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
onFocus={e => { e.target.style.borderColor = 'transparent'; e.target.style.boxShadow = '0 0 0 2px #4DA6FF'; }}
onBlur={e => { e.target.style.borderColor = '#d1d5db'; e.target.style.boxShadow = 'none'; }}
onMouseEnter={e => { if (document.activeElement !== e.target) e.target.style.borderColor = '#4DA6FF'; }}
onMouseLeave={e => { if (document.activeElement !== e.target) e.target.style.borderColor = '#d1d5db'; }}
/>
</div>
</div>
{/* Error */}
{error && (
<div className="rounded-xl bg-red-50 border p-4 animate-pulse" style={{ borderColor: 'rgba(198, 40, 40, 0.2)' }}>
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5" style={{ color: '#C62828' }} viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium" style={{ color: '#C62828' }}>{error}</h3>
</div>
</div>
</div>
)}
{/* Botón */}
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
style={{ backgroundColor: '#1B2A41', '--tw-ring-color': '#1B2A41' }}
onMouseEnter={e => { if (!loading) e.target.style.backgroundColor = '#162234'; }}
onMouseLeave={e => { if (!loading) e.target.style.backgroundColor = '#1B2A41'; }}
>
{loading ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Cambiando...
</>
) : (
<>
<span>Cambiar contraseña</span>
<svg className="ml-2 w-4 h-4 group-hover:translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</>
)}
</button>
</div>
{/* Link volver al login */}
<div className="text-center space-y-3 mt-4">
<div className="text-sm">
<a
href="/login"
className="font-medium transition-colors duration-200"
style={{ color: '#4DA6FF' }}
onMouseEnter={e => e.target.style.color = '#1976D2'}
onMouseLeave={e => e.target.style.color = '#4DA6FF'}
>
Volver al login
</a>
</div>
<div className="border-t border-gray-200 pt-4">
<a
href="/"
className="inline-flex items-center text-sm font-medium group transition-colors duration-200"
style={{ color: '#4DA6FF' }}
onMouseEnter={e => e.target.style.color = '#1976D2'}
onMouseLeave={e => e.target.style.color = '#4DA6FF'}
>
<svg className="mr-2 w-4 h-4 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 17l-5-5m0 0l5-5m-5 5h12" />
</svg>
Volver al inicio
</a>
</div>
</div>
</form>
)}
</div>
{/* Footer */}
<div className="px-8 py-4 border-t border-gray-200" style={{ backgroundColor: '#F2F4F7' }}>
<div className="text-center">
<p className="text-xs" style={{ color: '#7A7A7A' }}>
Desarrollado por <span className="font-semibold" style={{ color: '#1B2A41' }}>@AduanaSoft</span>
</p>
<p className="text-xs mt-1" style={{ color: '#7A7A7A' }}>
Solución especializada para Agentes Aduanales
</p>
</div>
</div>
</div>
{/* Floating elements */}
<div className="absolute -top-4 -left-4 w-24 h-24 rounded-full blur-xl" style={{ backgroundColor: 'rgba(255, 255, 255, 0.1)' }}></div>
<div className="absolute -bottom-4 -right-4 w-32 h-32 rounded-full blur-xl" style={{ backgroundColor: 'rgba(27, 42, 65, 0.2)' }}></div>
</div>
</div>
);
}

View File

@@ -0,0 +1,930 @@
import React, { useEffect, useState } from 'react';
// Animación fade-in/slide-up para bloques
const fadeInSlideUp = `@keyframes fadein-slideup {
0% { opacity: 0; transform: translateY(40px); }
100% { opacity: 1; transform: translateY(0); }
}`;
if (typeof document !== 'undefined' && !document.getElementById('fadein-slideup-pedimento')) {
const style = document.createElement('style');
style.id = 'fadein-slideup-pedimento';
style.innerHTML = fadeInSlideUp;
document.head.appendChild(style);
}
import hljs from 'highlight.js/lib/core';
import xml from 'highlight.js/lib/languages/xml';
import 'highlight.js/styles/github.css';
hljs.registerLanguage('xml', xml);
// import type removed for JSX compatibility
import { fetchPedimentoDocuments } from '../api/pedimentoDocuments';
import { useParams, Link } from 'react-router-dom';
import { useNotification } from '../context/NotificationContext';
const API_URL = import.meta.env.VITE_EFC_API_URL;
const downloadFile = async (id, filename = 'archivo', showMessage) => {
const token = localStorage.getItem('access');
const res = await fetch(`${API_URL}/record/documents/descargar/${id}/`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (res.status === 401) {
showMessage('Tu sesión ha expirado, por favor inicia sesión de nuevo.', 'error');
localStorage.removeItem('access');
localStorage.removeItem('refresh');
setTimeout(() => {
window.location.href = '/login';
}, 2000);
return;
}
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);
};
const downloadBulkZip = async (ids, showMessage, pedimentoNombre) => {
if (!ids.length) {
showMessage('Selecciona al menos un documento.', 'error');
return;
}
const token = localStorage.getItem('access');
const res = await fetch(`${API_URL}/record/documents/bulk-download/`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ document_ids: ids, pedimento_nombre: pedimentoNombre }),
});
if (res.status === 401) {
showMessage('Tu sesión ha expirado, por favor inicia sesión de nuevo.', 'error');
localStorage.removeItem('access');
localStorage.removeItem('refresh');
setTimeout(() => {
window.location.href = '/login';
}, 2000);
return;
}
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 = `${pedimentoNombre || 'documentos'}.zip`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
};
import { useRef, useLayoutEffect } from 'react';
export default function PedimentoDetail() {
// Función para formatear XML (pretty print)
function formatXml(xml) {
const PADDING = ' ';
const reg = /(>)(<)(\/*)/g;
let formatted = '';
let pad = 0;
xml = xml.replace(reg, '$1\r\n$2$3');
xml.split(/\r?\n/).forEach((node) => {
let indent = 0;
if (node.match(/.+<\/\w[^>]*>$/)) {
indent = 0;
} else if (node.match(/^<\/\w/)) {
if (pad !== 0) pad -= 1;
} else if (node.match(/^<\w[^>]*[^\/]>/)) {
indent = 1;
}
formatted += PADDING.repeat(pad) + node + '\r\n';
pad += indent;
});
return formatted.trim();
}
// Helper para obtener el nombre legible del tipo de documento
const getDocumentTypeName = (type) => {
const found = documentTypeOptions.find(opt => String(opt.value) === String(type));
return found ? found.label : 'Documento';
};
// Estado para modal de preview
const [previewOpen, setPreviewOpen] = useState(false);
const [previewUrl, setPreviewUrl] = useState('');
const [previewType, setPreviewType] = useState('');
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState('');
const [previewXml, setPreviewXml] = useState('');
const [previewXmlHtml, setPreviewXmlHtml] = useState('');
// Filtros y ordenamiento
const [fileNameFilter, setFileNameFilter] = useState('');
const [extensionFilter, setExtensionFilter] = useState('');
const [dateFilter, setDateFilter] = useState('');
const [orderBy, setOrderBy] = useState('');
const [orderDir, setOrderDir] = useState('asc');
const { id } = useParams();
const [pedimento, setPedimento] = useState(null);
const [docsLoading, setDocsLoading] = useState(true);
const [docsError, setDocsError] = useState('');
const [documents, setDocuments] = useState([]);
const [docsCount, setDocsCount] = useState(0);
const [docsNext, setDocsNext] = useState(null);
const [docsPrev, setDocsPrev] = useState(null);
// Refuerza la paginación SPA: nunca recarga la página, solo cambia el estado local
const [page, setPage] = useState(1);
// Ref para foco oculto (accesibilidad, opcional)
const focusKeeperRef = useRef(null);
// Handler SPA para paginación
const handlePageChange = (newPage, e) => {
if (e && typeof e.preventDefault === 'function') e.preventDefault();
if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
if (newPage < 1 || newPage > Math.max(1, Math.ceil(docsCount / pageSize)) || newPage === page) return;
setPage(newPage);
// Quitar el foco del botón activo para evitar salto de scroll
if (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
};
// Eliminado manejo manual de scroll para evitar saltos
const [pageSize, setPageSize] = useState(10);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [selected, setSelected] = useState([]);
const [downloading, setDownloading] = useState(false);
const [documentTypeFilter, setDocumentTypeFilter] = useState('');
const documentTypeOptions = [
{ value: '', label: 'Todos' },
{ value: 1, label: 'Pedimento Partida' },
{ value: 2, label: 'Pedimento Completo' },
{ value: 3, label: 'Pedimento Remesas' },
{ value: 4, label: 'Pedimento Acuse' },
{ value: 5, label: 'Pedimento EDocument' },
{ value: 6, label: 'Estado Pedimento' },
];
const { showMessage } = useNotification();
useEffect(() => {
const token = localStorage.getItem('access');
fetch(`${API_URL}/customs/pedimentos/${id}/`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
.then(res => {
if (res.status === 401) {
localStorage.removeItem('access');
localStorage.removeItem('refresh');
showMessage('Tu sesión ha expirado, por favor inicia sesión de nuevo.', 'error');
setTimeout(() => {
window.location.href = '/login';
}, 2000);
return null;
}
if (!res.ok) throw new Error('No autorizado o error en la petición');
return res.json();
})
.then(data => {
setPedimento(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, [id, showMessage]);
// Fetch paginated documents
useEffect(() => {
if (!id) return;
const token = localStorage.getItem('access');
setDocsLoading(true);
setDocsError('');
fetchPedimentoDocuments(token, id, page, pageSize)
.then((data) => {
setDocuments(data.results);
setDocsCount(data.count);
setDocsNext(data.next);
setDocsPrev(data.previous);
setDocsLoading(false);
})
.catch(err => {
if (err.message === 'SESSION_EXPIRED') {
localStorage.removeItem('access');
localStorage.removeItem('refresh');
showMessage('Tu sesión ha expirado, por favor inicia sesión de nuevo.', 'error');
setTimeout(() => {
window.location.href = '/login';
}, 2000);
} else {
setDocsError(err.message);
}
setDocsLoading(false);
});
}, [id, page, pageSize, showMessage]);
if (loading) return (
<div className="h-full bg-gray-50 flex items-center justify-center">
<div className="text-center">
<svg className="animate-spin h-12 w-12 text-navy-600 mx-auto mb-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className="text-gray-600 text-lg">Cargando detalle de pedimento...</p>
</div>
</div>
);
if (error) return (
<div className="h-full bg-gray-50 flex items-center justify-center">
<div className="bg-danger-50 border border-danger-200 rounded-xl p-6 max-w-md shadow-lg">
<div className="flex items-center">
<svg className="h-6 w-6 text-danger-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p className="text-danger-800 font-medium">{error}</p>
</div>
</div>
</div>
);
if (!pedimento) return null;
const allDocIds = documents.map(doc => doc.id);
const allSelected = selected.length === allDocIds.length && allDocIds.length > 0;
const handleSelect = (id) => {
setSelected(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
};
const handleSelectAll = () => {
if (allSelected) setSelected([]);
else setSelected(allDocIds);
};
const handleBulkDownload = async (ids) => {
setDownloading(true);
await downloadBulkZip(ids, showMessage, pedimento?.pedimento);
setDownloading(false);
};
// Vista previa de documento
const handlePreview = async (doc) => {
setPreviewLoading(true);
setPreviewError('');
setPreviewUrl('');
setPreviewType('');
setPreviewXml('');
setPreviewOpen(true);
try {
const token = localStorage.getItem('access');
const res = await fetch(`${API_URL}/record/documents/descargar/${doc.id}/`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (res.status === 401) {
setPreviewError('Tu sesión ha expirado, por favor inicia sesión de nuevo.');
setPreviewLoading(false);
return;
}
if (!res.ok) {
setPreviewError('No autorizado o error en la descarga');
setPreviewLoading(false);
return;
}
// Detectar tipo de archivo
let type = '';
if (doc.extension) {
if (doc.extension.toLowerCase() === 'pdf') type = 'pdf';
else if (["jpg","jpeg","png","gif","bmp","webp"].includes(doc.extension.toLowerCase())) type = 'img';
else if (doc.extension.toLowerCase() === 'xml') type = 'xml';
else type = 'other';
}
setPreviewType(type);
if (type === 'xml') {
const text = await res.text();
const prettyText = formatXml(text);
setPreviewXml(prettyText);
// Formatear y resaltar XML
try {
const highlighted = hljs.highlight(prettyText, { language: 'xml' }).value;
setPreviewXmlHtml(highlighted);
} catch (e) {
setPreviewXmlHtml(prettyText);
}
setPreviewLoading(false);
} else {
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
setPreviewUrl(url);
setPreviewLoading(false);
}
} catch (err) {
setPreviewError('Error al obtener el archivo');
setPreviewLoading(false);
}
};
// Cerrar modal y limpiar blob
const handleClosePreview = () => {
setPreviewOpen(false);
if (previewUrl) window.URL.revokeObjectURL(previewUrl);
setPreviewUrl('');
setPreviewType('');
setPreviewError('');
setPreviewXml('');
setPreviewXmlHtml('');
};
return (
<div className="p-6 bg-gray-50 h-full flex flex-col">
{/* Modal de vista previa resizable */}
{previewOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div
className="bg-white rounded-xl shadow-2xl resize overflow-auto relative flex flex-col border border-blue-200"
style={{ minWidth: '350px', minHeight: '300px', maxWidth: '600px', maxHeight: '90vh', width: '500px', height: '80vh', display: 'flex', flexDirection: 'column' }}
>
{/* Header mejorado del modal */}
<div className="flex items-center justify-between px-6 py-4 bg-gradient-to-r from-blue-50 to-blue-100 border-b border-blue-200 rounded-t-xl sticky top-0">
<div className="flex items-center gap-3 ">
<div className="bg-blue-200 rounded-full p-2 flex items-center justify-center">
<svg className="h-6 w-6 text-blue-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<h3 className="text-xl font-extrabold text-blue-900 tracking-tight">Vista previa de documento</h3>
</div>
<button
onClick={handleClosePreview}
className="ml-2 text-blue-600 hover:text-blue-800 bg-blue-100 hover:bg-blue-200 rounded-full p-2 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-400"
title="Cerrar"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Contenido del modal */}
<div className="flex-1 flex flex-col">
{previewLoading ? (
<div className="text-center py-8 text-gray-500 flex-1 flex items-center justify-center">Cargando documento...</div>
) : previewError ? (
<div className="text-center py-8 text-danger-600 flex-1 flex items-center justify-center">{previewError}</div>
) : previewType === 'pdf' ? (
<iframe src={previewUrl} title="PDF Preview" className="border rounded flex-1" style={{ width: '100%', height: '100%' }} />
) : previewType === 'img' ? (
<img src={previewUrl} alt="Vista previa" className="max-w-full max-h-full mx-auto flex-1" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
) : previewType === 'xml' ? (
<div className="bg-white border rounded p-0 overflow-auto flex-1" style={{ fontFamily: 'Fira Mono, monospace', fontSize: '13px', width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
<div className="bg-gray-100 px-3 py-2 text-xs text-gray-800 border-b border-gray-200 flex items-center justify-between" style={{ flexShrink: 0 }}>
<span>Vista XML</span>
<button
className="text-xs text-blue-600 hover:text-blue-800 px-2 py-1 rounded border border-blue-300 bg-blue-100"
onClick={() => {
navigator.clipboard.writeText(previewXml);
}}
>Copiar</button>
</div>
<pre
className="hljs language-xml p-4 text-xs text-gray-900 flex-1"
style={{
background: 'white',
margin: 0,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
width: '100%',
height: '100%'
}}
dangerouslySetInnerHTML={{ __html: previewXmlHtml }}
/>
</div>
) : previewUrl ? (
<a href={previewUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 underline">Descargar archivo</a>
) : null}
</div>
</div>
</div>
)}
{/* Header mejorado */}
<div className="mb-8 animate-fadein-slideup opacity-0" style={{ animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.05s forwards' }}>
<div className="max-w-7xl mx-auto relative overflow-hidden rounded-2xl shadow bg-gradient-to-r from-blue-50 via-white to-indigo-50 border border-blue-100 p-8 flex items-center gap-6">
<div className="flex-shrink-0 bg-blue-100 rounded-full p-4 shadow-md animate-bounce-slow">
<svg className="h-10 w-10 text-blue-600" 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 className="flex-1 min-w-0">
<Link
to="/expedientes"
className="inline-flex items-center text-blue-600 hover:text-blue-800 transition-colors duration-200 mb-4"
>
<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="M15 19l-7-7 7-7"></path>
</svg>
<span className="font-semibold text-base">Volver a la lista</span>
</Link>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight mb-1 flex items-center gap-2">
Detalle de Pedimento
{docsCount !== undefined && (
<span className="inline-block bg-blue-200 text-blue-800 text-xs font-semibold px-2 py-0.5 rounded-full ml-2 animate-fade-in">
{docsCount} documentos
</span>
)}
</h1>
<p className="text-lg text-blue-700/80 font-medium">Información completa del pedimento y documentos asociados</p>
</div>
{/* Efecto decorativo de fondo */}
<div className="absolute -top-10 -right-10 opacity-30 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="url(#grad1)" />
<defs>
<linearGradient id="grad1" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse">
<stop stopColor="#3b82f6" stopOpacity="0.15" />
<stop offset="1" stopColor="#6366f1" stopOpacity="0.10" />
</linearGradient>
</defs>
</svg>
</div>
{/* Animación personalizada para el icono y contador */}
<style>{`
@keyframes bounce-slow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.animate-bounce-slow {
animation: bounce-slow 2.2s infinite;
}
@keyframes fade-in {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
.animate-fade-in {
animation: fade-in 0.7s ease;
}
`}</style>
</div>
</div>
{/* Contenido scrolleable */}
<div className="flex-1 ">
<div className="max-w-7xl mx-auto">
{/* Información del Pedimento */}
<div className="bg-white shadow-lg rounded-xl border border-gray-200 mb-8 animate-fadein-slideup opacity-0"
style={{ animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.15s forwards' }}>
<div className="px-8 py-6 border-b border-gray-200 flex items-center gap-4">
<h2 className="text-2xl font-extrabold text-blue-800 tracking-tight">Información General</h2>
<div className="h-1 w-10 bg-blue-400 rounded"></div>
</div>
<div className="p-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="bg-gray-50 p-6 rounded-xl border border-gray-200 shadow-sm transition-all duration-400 hover:scale-105 hover:shadow-lg">
<dt className="text-sm font-semibold text-gray-700 mb-2">Pedimento</dt>
<dd className="text-2xl font-bold text-gray-900">{pedimento.pedimento}</dd>
</div>
<div className="bg-gray-50 p-6 rounded-xl border border-gray-200 shadow-sm transition-all duration-400 hover:scale-105 hover:shadow-lg">
<dt className="text-sm font-semibold text-gray-700 mb-2">Contribuyente</dt>
<dd className="text-2xl font-bold text-gray-900">{pedimento.contribuyente}</dd>
</div>
<div className="bg-gray-50 p-6 rounded-xl border border-gray-200 shadow-sm transition-all duration-400 hover:scale-105 hover:shadow-lg">
<dt className="text-sm font-semibold text-gray-700 mb-2">Fecha de Pago</dt>
<dd className="text-2xl font-bold text-gray-900">{pedimento.fechapago}</dd>
</div>
<div className="bg-gray-50 p-6 rounded-xl border border-gray-200 shadow-sm transition-all duration-400 hover:scale-105 hover:shadow-lg">
<dt className="text-sm font-semibold text-gray-700 mb-2">Importe Total</dt>
<dd className="text-2xl font-bold text-gray-900">${pedimento.importe_total || 'N/A'}</dd>
</div>
<div className="bg-gray-50 p-6 rounded-xl border border-gray-200 shadow-sm transition-all duration-400 hover:scale-105 hover:shadow-lg">
<dt className="text-sm font-semibold text-gray-700 mb-2">Saldo Disponible</dt>
<dd className="text-2xl font-bold text-gray-900">${pedimento.saldo_disponible || 'N/A'}</dd>
</div>
<div className={`p-6 rounded-xl border shadow-sm transition-all duration-400 hover:scale-105 hover:shadow-lg ${pedimento.existe_expediente
? 'bg-green-50 border-green-200'
: 'bg-red-50 border-red-200'
}`}>
<dt className={`text-sm font-semibold mb-2 ${pedimento.existe_expediente ? 'text-green-700' : 'text-red-700'}`}>
Expediente
</dt>
<dd className={`text-2xl font-bold flex items-center ${pedimento.existe_expediente ? 'text-green-900' : 'text-red-900'}`}>
<svg className={`w-6 h-6 mr-2 ${pedimento.existe_expediente ? 'text-green-600' : 'text-red-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
{pedimento.existe_expediente ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
)}
</svg>
{pedimento.existe_expediente ? 'Disponible' : 'No disponible'}
</dd>
</div>
</div>
</div>
</div>
{/* Sección de Documentos */}
<div ref={focusKeeperRef} tabIndex={-1} style={{position:'absolute',width:0,height:0,overflow:'hidden',outline:'none'}} aria-hidden="true"></div>
<div className="bg-white shadow-lg rounded-xl border border-gray-200 animate-fadein-slideup opacity-0"
style={{ animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.25s forwards' }}>
<div className="px-8 py-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-extrabold text-blue-800 tracking-tight mb-1">
Documentos Relacionados
</h2>
<div className="h-1 w-10 bg-blue-400 rounded mb-2"></div>
<div className="flex items-center mt-1">
<span className="text-sm text-gray-600 bg-blue-50 px-3 py-1 rounded-full font-semibold">
📄 {docsCount} documentos
</span>
</div>
{/* Filtro de tipo de documento */}
<div className="mt-4">
{/* Filtros avanzados */}
<div className="mb-4 flex flex-wrap gap-4 items-end">
{/* Archivo */}
<div className="flex flex-col">
<label className="text-xs font-semibold text-gray-700 mb-1">Archivo</label>
<input
type="text"
value={fileNameFilter}
onChange={e => setFileNameFilter(e.target.value)}
placeholder="Buscar archivo..."
className="w-44 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
/>
</div>
{/* Extensión */}
<div className="flex flex-col">
<label className="text-xs font-semibold text-gray-700 mb-1">Extensión</label>
<select
value={extensionFilter}
onChange={e => setExtensionFilter(e.target.value)}
className="w-32 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
>
<option value="">Todas</option>
{[...new Set(documents.map(d => d.extension).filter(Boolean))].map(ext => (
<option key={ext} value={ext}>{ext}</option>
))}
</select>
</div>
{/* Fecha */}
<div className="flex flex-col">
<label className="text-xs font-semibold text-gray-700 mb-1">Fecha</label>
<input
type="date"
value={dateFilter}
onChange={e => setDateFilter(e.target.value)}
className="w-36 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
/>
</div>
</div>
<label className="block text-xs font-semibold text-gray-700 mb-1">Tipo de documento</label>
<select
value={documentTypeFilter}
onChange={e => setDocumentTypeFilter(e.target.value)}
className="w-64 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
>
{documentTypeOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
{allDocIds.length > 0 && (
<div className="flex space-x-3">
<button
onClick={() => handleBulkDownload(allDocIds)}
disabled={downloading}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md 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 shadow-lg"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
{downloading ? 'Descargando...' : 'Descargar todos'}
</button>
<button
onClick={() => handleBulkDownload(selected)}
disabled={selected.length === 0 || downloading}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200 transform hover:scale-105 shadow-lg"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Descargar seleccionados ({selected.length})
</button>
</div>
)}
</div>
</div>
{/* ...existing code... */}
<div className="overflow-hidden">
<div className="overflow-x-auto" id="tabla-documentos">
<div style={{ minHeight: 'calc(8 * 56px)', maxHeight: 'calc(8 * 56px)', overflowY: documents.length > 8 ? 'auto' : 'hidden', position: 'relative' }}>
<table className="min-w-full divide-y divide-gray-200 rounded-lg overflow-hidden text-xs font-normal">
<thead className="bg-gray-50 sticky top-0 z-20">
<tr>
<th className="px-2 py-2 text-left font-bold uppercase tracking-wider border-b border-gray-200 whitespace-nowrap align-middle" style={{ minWidth: '36px', width: '36px', maxWidth: '36px' }}>
<input
type="checkbox"
checked={allSelected}
onChange={handleSelectAll}
className="h-3.5 w-3.5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded align-middle"
style={{ minWidth: '0.85rem', minHeight: '0.85rem' }}
/>
</th>
<th className="px-3 py-2 text-left text-[11px] font-bold text-gray-600 uppercase tracking-wider border-b border-gray-200 whitespace-nowrap cursor-pointer select-none align-middle max-w-[180px]" style={{ minWidth: '120px' }} onClick={() => {
setOrderBy('archivo');
setOrderDir(orderBy === 'archivo' && orderDir === 'asc' ? 'desc' : 'asc');
}}>
Archivo {orderBy === 'archivo' && (<span className="ml-1">{orderDir === 'asc' ? '▲' : '▼'}</span>)}
</th>
<th className="px-2 py-2 text-left text-[11px] font-bold text-gray-600 uppercase tracking-wider border-b border-gray-200 whitespace-nowrap cursor-pointer select-none align-middle" style={{ minWidth: '90px' }} onClick={() => {
setOrderBy('document_type');
setOrderDir(orderBy === 'document_type' && orderDir === 'asc' ? 'desc' : 'asc');
}}>
Tipo {orderBy === 'document_type' && (<span className="ml-1">{orderDir === 'asc' ? '▲' : '▼'}</span>)}
</th>
<th className="px-2 py-2 text-left text-[11px] font-bold text-gray-600 uppercase tracking-wider border-b border-gray-200 whitespace-nowrap cursor-pointer select-none align-middle" style={{ minWidth: '70px' }} onClick={() => {
setOrderBy('extension');
setOrderDir(orderBy === 'extension' && orderDir === 'asc' ? 'desc' : 'asc');
}}>
Extensión {orderBy === 'extension' && (<span className="ml-1">{orderDir === 'asc' ? '▲' : '▼'}</span>)}
</th>
<th className="px-2 py-2 text-left text-[11px] font-bold text-gray-600 uppercase tracking-wider border-b border-gray-200 whitespace-nowrap cursor-pointer select-none align-middle" style={{ minWidth: '70px' }} onClick={() => {
setOrderBy('size');
setOrderDir(orderBy === 'size' && orderDir === 'asc' ? 'desc' : 'asc');
}}>
Tamaño {orderBy === 'size' && (<span className="ml-1">{orderDir === 'asc' ? '▲' : '▼'}</span>)}
</th>
<th className="px-2 py-2 text-left text-[11px] font-bold text-gray-600 uppercase tracking-wider border-b border-gray-200 whitespace-nowrap cursor-pointer select-none align-middle" style={{ minWidth: '90px' }} onClick={() => {
setOrderBy('created_at');
setOrderDir(orderBy === 'created_at' && orderDir === 'asc' ? 'desc' : 'asc');
}}>
Fecha {orderBy === 'created_at' && (<span className="ml-1">{orderDir === 'asc' ? '▲' : '▼'}</span>)}
</th>
<th className="px-2 py-2 text-center text-[11px] font-bold text-gray-600 uppercase tracking-wider border-b border-gray-200 whitespace-nowrap align-middle" style={{ minWidth: '80px' }}>
Acción
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200 text-[13px]" style={{ position: 'relative', minHeight: 'calc(8 * 40px)' }}>
{docsLoading ? (
<tr>
<td colSpan={7} style={{ height: 'calc(8 * 56px)', padding: 0 }}>
<div className="flex items-center justify-center h-full w-full absolute left-0 top-0" style={{ minHeight: 'calc(8 * 56px)', background: 'rgba(255,255,255,0.7)', zIndex: 10 }}>
<span className="text-gray-500 text-lg">Cargando documentos...</span>
</div>
</td>
</tr>
) : docsError ? (
<tr>
<td colSpan={7} style={{ height: 'calc(8 * 56px)', padding: 0 }}>
<div className="flex items-center justify-center h-full w-full absolute left-0 top-0" style={{ minHeight: 'calc(8 * 56px)', background: 'rgba(255,255,255,0.7)', zIndex: 10 }}>
<span className="text-danger-600 text-lg">Error: {docsError}</span>
</div>
</td>
</tr>
) : documents.length > 0 ? (
<>
{documents
// Filtro por tipo de documento
.filter(doc => {
if (!documentTypeFilter) return true;
return String(doc.document_type) === String(documentTypeFilter);
})
// Filtro por nombre de archivo
.filter(doc => {
if (!fileNameFilter) return true;
const fileName = doc.archivo ? doc.archivo.split('/').pop().toLowerCase() : '';
return fileName.includes(fileNameFilter.toLowerCase());
})
// Filtro por extensión
.filter(doc => {
if (!extensionFilter) return true;
return doc.extension === extensionFilter;
})
// Filtro por fecha
.filter(doc => {
if (!dateFilter) return true;
if (!doc.created_at) return false;
const docDate = new Date(doc.created_at).toISOString().slice(0, 10);
return docDate === dateFilter;
})
// Ordenamiento
.sort((a, b) => {
if (!orderBy) return 0;
let aVal = a[orderBy];
let bVal = b[orderBy];
// Para archivo, usar solo el nombre
if (orderBy === 'archivo') {
aVal = a.archivo ? a.archivo.split('/').pop().toLowerCase() : '';
bVal = b.archivo ? b.archivo.split('/').pop().toLowerCase() : '';
}
// Para fecha, convertir a Date
if (orderBy === 'created_at') {
aVal = a.created_at ? new Date(a.created_at) : new Date(0);
bVal = b.created_at ? new Date(b.created_at) : new Date(0);
}
// Para tamaño, convertir a número
if (orderBy === 'size') {
aVal = Number(a.size) || 0;
bVal = Number(b.size) || 0;
}
// Para document_type, convertir a número
if (orderBy === 'document_type') {
aVal = Number(a.document_type) || 0;
bVal = Number(b.document_type) || 0;
}
if (aVal < bVal) return orderDir === 'asc' ? -1 : 1;
if (aVal > bVal) return orderDir === 'asc' ? 1 : -1;
return 0;
})
.map((doc, index) => (
<tr key={doc.id} className="hover:bg-blue-50 transition-all duration-200">
<td className="px-2 py-2 whitespace-nowrap align-middle text-center" style={{ minWidth: '36px', width: '36px', maxWidth: '36px' }}>
<input
type="checkbox"
checked={selected.includes(doc.id)}
onChange={() => handleSelect(doc.id)}
className="h-3.5 w-3.5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded align-middle"
style={{ minWidth: '0.85rem', minHeight: '0.85rem' }}
/>
</td>
<td className="px-3 py-2 whitespace-nowrap text-[13px] text-gray-900 max-w-[180px] truncate align-middle" style={{ minWidth: '120px' }}>
<span className="truncate font-medium" title={doc.archivo || 'Sin nombre'}>
{doc.archivo ? doc.archivo.split('/').pop() : 'Sin nombre'}
</span>
</td>
<td className="px-2 py-2 whitespace-nowrap align-middle">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-gray-100 text-gray-800">
{getDocumentTypeName(doc.document_type)}
</span>
</td>
<td className="px-2 py-2 whitespace-nowrap align-middle">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-blue-100 text-blue-800">
{doc.extension || 'N/A'}
</span>
</td>
<td className="px-2 py-2 whitespace-nowrap text-[13px] text-gray-700 align-middle">
{doc.size || 'N/A'}
</td>
<td className="px-2 py-2 whitespace-nowrap text-[13px] text-gray-700 align-middle">
{doc.created_at ? new Date(doc.created_at).toLocaleDateString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric'
}) : 'N/A'}
</td>
<td className="px-2 py-2 whitespace-nowrap text-center align-middle">
<div className="flex justify-center space-x-2">
<button
onClick={() => handlePreview(doc)}
className="inline-flex items-center px-2 py-1 border border-gray-300 shadow-sm text-[11px] font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-200"
title="Vista previa"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
<button
onClick={() => downloadFile(doc.id, doc.archivo ? doc.archivo.split('/').pop() : `documento_${doc.id}`, showMessage)}
className="inline-flex items-center px-2 py-1 border border-blue-300 shadow-sm text-[11px] font-medium rounded-md text-blue-700 bg-white hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-200"
title="Descargar"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
</button>
</div>
</td>
</tr>
))}
{/* Rellenar con filas vacías si hay menos de 8 */}
{documents.length < 8 && !docsLoading && !docsError && Array.from({length: 8 - documents.length}).map((_, idx) => (
<tr key={`empty-${idx}`}>
<td className="px-6 py-4 whitespace-nowrap" colSpan={7}>&nbsp;</td>
</tr>
))}
</>
) : (
<tr>
<td colSpan={7} style={{ height: 'calc(8 * 56px)', padding: 0 }}>
<div className="flex flex-col items-center justify-center h-full w-full absolute left-0 top-0" style={{ minHeight: 'calc(8 * 56px)', background: 'rgba(255,255,255,0.7)', zIndex: 10 }}>
<div className="mx-auto h-16 w-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="h-8 w-8 text-gray-400" 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>
<h3 className="text-lg font-medium text-gray-900 mb-2">Sin documentos</h3>
<p className="text-gray-500">No hay documentos relacionados con este pedimento.</p>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination block below the table, always visible at the bottom */}
<div className="px-6 py-4 flex flex-col sm:flex-row items-center justify-between border-t border-gray-200 bg-gray-50 rounded-b-xl">
{/* Selector de número de registros y paginación numerada */}
{(() => {
const totalPages = Math.max(1, Math.ceil(docsCount / pageSize));
const maxPagesToShow = 5;
let startPage = Math.max(1, page - Math.floor(maxPagesToShow / 2));
let endPage = startPage + maxPagesToShow - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxPagesToShow + 1);
}
const pageNumbers = [];
for (let i = startPage; i <= endPage; i++) {
pageNumbers.push(i);
}
return (
<div className="flex flex-col sm:flex-row sm:items-center w-full gap-2 sm:gap-4 mt-2 sm:mt-0">
<div className="flex items-center gap-2">
<label htmlFor="pageSize" className="text-xs text-gray-600 font-medium">Registros por página:</label>
<select
id="pageSize"
value={pageSize}
onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}
className="border border-gray-300 rounded px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
{[5, 10, 20, 50, 100, 200, 400,600, 1200, 2400, 10000].map(size => (
<option key={size} value={size}>{size}</option>
))}
</select>
</div>
<div className="flex items-center gap-1 flex-wrap">
<button
onClick={e => handlePageChange(1, e)}
disabled={page === 1}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${page === 1 ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
>
«
</button>
<button
onClick={e => handlePageChange(page - 1, e)}
disabled={page === 1}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${page === 1 ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
>
</button>
{pageNumbers.map(num => (
<button
key={num}
onClick={e => handlePageChange(num, e)}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${num === page ? 'bg-blue-600 text-white border-blue-700 cursor-default' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
disabled={num === page}
>
{num}
</button>
))}
<button
onClick={e => handlePageChange(page + 1, e)}
disabled={page >= totalPages}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${(page >= totalPages) ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
>
</button>
<button
onClick={e => handlePageChange(totalPages, e)}
disabled={page >= totalPages}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${(page >= totalPages) ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
>
»
</button>
<span className="ml-3 text-xs text-gray-500">Página <span className="font-bold">{page}</span> de <span className="font-bold">{totalPages}</span></span>
</div>
</div>
);
})()}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

385
src/pages/Procesos.jsx Normal file
View File

@@ -0,0 +1,385 @@
import React, { useEffect, useState } from 'react';
import { fetchProcesamientoPedimentos } from '../api/procesos.ts';
const API_URL = import.meta.env.VITE_EFC_API_URL;
const MICROSERVICE_URL = import.meta.env.VITE_EFC_MICROSERVICE_URL;
// Estado para loading de ejecución de servicio
// y función para ejecutar el servicio según el tipo de proceso
export default function Procesos() {
const [procesos, setProcesos] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [page, setPage] = useState(1);
const [count, setCount] = useState(0);
const [itemsPerPage, setItemsPerPage] = useState(12);
// Filtros
const [pedimentoPedimentoFilter, setPedimentoPedimentoFilter] = useState('');
const [estadoFilter, setEstadoFilter] = useState('');
const [servicioFilter, setServicioFilter] = useState('');
// Estado para loading de ejecución de servicio
const [executingId, setExecutingId] = useState(null);
// Dropdown state: id del proceso abierto o null
const [openDropdownId, setOpenDropdownId] = useState(null);
// Función para ejecutar el servicio según el tipo de proceso
const handleEjecutarServicio = async (proc) => {
setExecutingId(proc.id);
let endpoint = '';
// Determinar endpoint según el tipo de servicio
switch (proc.servicio) {
case 4: // Partidas
endpoint = '/services/partidas';
break;
case 5: // Remesas
endpoint = '/services/remesas';
break;
case 6: // Acuse
endpoint = '/services/acuse';
break;
case 8: // Acuse Cove
endpoint = '/services/acuseCove';
break;
default:
alert('Servicio no soportado para ejecución directa.');
setExecutingId(null);
return;
}
try {
const token = localStorage.getItem('access');
const headers = {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
};
const body = JSON.stringify({
pedimento: typeof proc.pedimento === 'object' && proc.pedimento !== null ? proc.pedimento.id : proc.pedimento,
organizacion: proc.organizacion_id || proc.organizacion || proc.organizacionId,
});
const res = await fetch(`${MICROSERVICE_URL}${endpoint}`, {
method: 'POST',
headers,
body,
});
if (!res.ok) throw new Error('Error al ejecutar el servicio');
alert('Servicio ejecutado correctamente');
setOpenDropdownId(null);
} catch (err) {
alert('Error al ejecutar el servicio: ' + (err instanceof Error ? err.message : String(err)));
} finally {
setExecutingId(null);
}
};
// Cierra el dropdown si se hace click fuera
useEffect(() => {
if (openDropdownId === null) return;
function handleClick(e) {
const el = document.getElementById(`dropdown-acciones-${openDropdownId}`);
if (el && !el.contains(e.target)) {
setOpenDropdownId(null);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [openDropdownId]);
useEffect(() => {
async function fetchProcesos() {
setLoading(true);
setError('');
try {
const token = localStorage.getItem('access');
// Construir query params
const params = new URLSearchParams();
params.append('page', String(page));
params.append('page_size', String(itemsPerPage));
if (pedimentoPedimentoFilter) params.append('pedimento__pedimento', pedimentoPedimentoFilter);
if (estadoFilter) params.append('estado', estadoFilter);
if (servicioFilter) params.append('servicio', servicioFilter);
// ...existing code...
const API_URL = import.meta.env.VITE_EFC_API_URL;
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const res = await fetch(`${API_URL}/customs/procesamientopedimentos/?${params.toString()}`, { headers });
if (!res.ok) throw new Error('Error al obtener procesamiento de pedimentos');
const data = await res.json();
setProcesos(data.results || []);
setCount(data.count || 0);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
fetchProcesos();
}, [page, itemsPerPage, pedimentoPedimentoFilter, estadoFilter, servicioFilter]);
return (
<div className="p-6 bg-gray-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<div className="mb-8 relative overflow-hidden rounded-2xl shadow bg-gradient-to-r from-blue-50 via-white to-indigo-50 border border-blue-100 p-8 flex items-center gap-6 animate-fadein-slideup opacity-0" style={{ animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.05s forwards' }}>
<div className="flex-shrink-0 bg-blue-100 rounded-full p-4 shadow-md animate-bounce-slow">
<svg className="h-10 w-10 text-blue-600" 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>
<h1 className="text-3xl font-extrabold text-blue-900 tracking-tight mb-1 flex items-center gap-2">
Procesos del Sistema
</h1>
<p className="text-lg text-blue-700/80 font-medium">Estado actual de los procesos de la agencia aduanal</p>
</div>
<div className="absolute -top-10 -right-10 opacity-30 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="url(#grad1)" />
<defs>
<linearGradient id="grad1" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse">
<stop stopColor="#3b82f6" stopOpacity="0.15" />
<stop offset="1" stopColor="#6366f1" stopOpacity="0.10" />
</linearGradient>
</defs>
</svg>
</div>
<style>{`
@keyframes bounce-slow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.animate-bounce-slow {
animation: bounce-slow 2.2s infinite;
}
@keyframes fadein-slideup {
0% { opacity: 0; transform: translateY(40px); }
100% { opacity: 1; transform: translateY(0); }
}
.animate-fadein-slideup {
animation: fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.05s forwards;
}
`}</style>
</div>
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-8 animate-fadein-slideup opacity-0" style={{ animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.15s forwards' }}>
<h2 className="text-2xl font-bold text-blue-800 mb-6">Procesamiento de Pedimentos</h2>
{/* Filtros */}
<div className="mb-4 flex flex-wrap gap-4 items-end justify-between">
<div className="flex flex-col flex-1 min-w-[150px]">
<label className="text-xs font-semibold text-gray-700 mb-1">Pedimento</label>
<input
type="text"
value={pedimentoPedimentoFilter}
onChange={e => setPedimentoPedimentoFilter(e.target.value)}
placeholder="Buscar por pedimento..."
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
/>
</div>
<div className="flex flex-col flex-1 min-w-[150px]">
<label className="text-xs font-semibold text-gray-700 mb-1">Estado</label>
<select
value={estadoFilter}
onChange={e => setEstadoFilter(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
>
<option value="">Todos</option>
<option value="1">En Espera</option>
<option value="2">Procesando</option>
<option value="3">Finalizado</option>
<option value="4">Error</option>
</select>
</div>
<div className="flex flex-col flex-1 min-w-[150px]">
<label className="text-xs font-semibold text-gray-700 mb-1">Servicio</label>
<select
value={servicioFilter}
onChange={e => setServicioFilter(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
>
<option value="">Todos</option>
<option value="1">Estado de pedimento</option>
<option value="2">Listado de pedimentos</option>
<option value="3">Pedimento Completo</option>
<option value="4">Pedimento Partidas</option>
<option value="5">Pedimento Remesas</option>
<option value="6">Acuse</option>
<option value="7">EDocument</option>
<option value="8">Cove</option>
</select>
</div>
{/* ...filtros anteriores... */}
</div>
{loading ? (
<div className="text-center text-gray-500 py-8">Cargando procesos...</div>
) : error ? (
<div className="text-center text-danger-600 py-8">{error}</div>
) : (
<div className="overflow-x-auto">
<div style={{ minHeight: 'calc(6 * 56px)', maxHeight: 'calc(6 * 56px)', overflowY: 'auto', position: 'relative' }}>
<table className="min-w-full divide-y divide-gray-200 rounded-lg overflow-hidden sticky text-xs">
<thead className="bg-gradient-to-r from-gray-50 sticky top-0 z-20">
<tr>
<th className="px-2 py-2 text-center font-bold text-blue-700 uppercase tracking-wider border-b border-gray-200 whitespace-nowrap">ID</th>
<th className="px-2 py-2 text-left font-bold uppercase tracking-wider border-b border-gray-200 whitespace-nowrap">Organización</th>
<th className="px-2 py-2 text-left font-bold uppercase tracking-wider border-b border-gray-200 whitespace-nowrap">Estado</th>
<th className="px-2 py-2 text-left font-bold uppercase tracking-wider border-b border-gray-200 whitespace-nowrap">Pedimento</th>
<th className="px-2 py-2 text-left font-bold uppercase tracking-wider border-b border-gray-200 whitespace-nowrap">Servicio</th>
<th className="px-2 py-2 text-center font-bold text-blue-700 uppercase tracking-wider border-b border-gray-200 whitespace-nowrap">Acciones</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100" style={{ position: 'relative', minHeight: 'calc(12 * 40px)' }}>
{procesos.length === 0 ? (
<tr>
<td colSpan={8} className="text-center py-8 text-gray-500">No hay registros</td>
</tr>
) : (
procesos.map((proc) => (
<tr key={proc.id} className="transition-all duration-200 hover:bg-blue-100 hover:shadow-lg">
<td className="px-2 py-2 text-center align-middle whitespace-nowrap">{proc.id}</td>
<td className="px-2 py-2 whitespace-nowrap align-middle">{proc.organizacion_name || '-'}</td>
<td className="px-2 py-2 whitespace-nowrap align-middle">{
proc.estado === 1 ? 'En Espera'
: proc.estado === 2 ? 'Procesando'
: proc.estado === 3 ? 'Finalizado'
: proc.estado === 4 ? 'Error'
: String(proc.estado)
}</td>
<td className="px-2 py-2 whitespace-nowrap align-middle">{
typeof proc.pedimento === 'object' && proc.pedimento !== null
? proc.pedimento.pedimento || JSON.stringify(proc.pedimento)
: proc.pedimento
}</td>
<td className="px-2 py-2 whitespace-nowrap align-middle">{
proc.servicio === 1 ? 'Estado de pedimento'
: proc.servicio === 2 ? 'Listado de pedimentos'
: proc.servicio === 3 ? 'Pedimento Completo'
: proc.servicio === 4 ? 'Pedimento Partidas'
: proc.servicio === 5 ? 'Pedimento Remesas'
: proc.servicio === 6 ? 'Acuse'
: proc.servicio === 7 ? 'EDocument'
: proc.servicio === 8 ? 'Cove'
: String(proc.servicio)
}</td>
<td className="px-2 py-2 text-center align-middle whitespace-nowrap">
<div className="relative inline-block text-left" id={`dropdown-acciones-${proc.id}`}>
<button
className="inline-flex justify-center w-full rounded-md border border-gray-300 shadow-sm px-3 py-1 bg-white text-xs font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-200 transform hover:scale-105"
type="button"
onClick={() => setOpenDropdownId(openDropdownId === proc.id ? null : proc.id)}
>
Acciones
<svg className="ml-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" /></svg>
</button>
{openDropdownId === proc.id && (
<div className="absolute right-0 mt-2 w-32 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-20">
<div className="py-1">
<button
className="block w-full text-left px-4 py-2 text-xs text-blue-700 hover:bg-blue-100 disabled:opacity-60"
onClick={() => handleEjecutarServicio(proc)}
disabled={executingId === proc.id}
>
{executingId === proc.id ? 'Ejecutando...' : 'Ejecutar Servicio'}
</button>
<button className="block w-full text-left px-4 py-2 text-xs text-gray-700 hover:bg-blue-100">Pasar a espera</button>
<button className="block w-full text-left px-4 py-2 text-xs text-gray-700 hover:bg-blue-100">Editar</button>
</div>
</div>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Paginación igual a Documents.jsx */}
{count > 0 && (
<div className="bg-white px-4 py-3 flex flex-col sm:flex-row items-center justify-between border-t border-gray-200">
{(() => {
const totalPages = Math.max(1, Math.ceil(count / itemsPerPage));
const maxPagesToShow = 5;
let startPage = Math.max(1, page - Math.floor(maxPagesToShow / 2));
let endPage = startPage + maxPagesToShow - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxPagesToShow + 1);
}
const pageNumbers = [];
for (let i = startPage; i <= endPage; i++) {
pageNumbers.push(i);
}
return (
<div className="flex flex-col sm:flex-row sm:items-center w-full gap-2 sm:gap-4 mt-2 sm:mt-0">
<div className="flex items-center gap-2">
<label htmlFor="itemsPerPage" className="text-xs text-gray-600 font-medium">Registros por página:</label>
<select
id="itemsPerPage"
value={itemsPerPage}
onChange={e => { setItemsPerPage(Number(e.target.value)); setPage(1); }}
className="border border-gray-300 rounded px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
{[5, 8, 12, 20, 50, 100].map(size => (
<option key={size} value={size}>{size}</option>
))}
</select>
</div>
<div className="flex items-center gap-1 flex-wrap">
<button
type="button"
onClick={e => { e.preventDefault(); setPage(1); }}
disabled={page === 1}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${page === 1 ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
>
«
</button>
<button
type="button"
onClick={e => { e.preventDefault(); setPage(p => Math.max(1, p - 1)); }}
disabled={page === 1}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${page === 1 ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
>
</button>
{pageNumbers.map(num => (
<button
type="button"
key={num}
onClick={e => { e.preventDefault(); setPage(num); }}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${num === page ? 'bg-blue-600 text-white border-blue-700 cursor-default' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
disabled={num === page}
>
{num}
</button>
))}
<button
type="button"
onClick={e => { e.preventDefault(); setPage(p => p + 1); }}
disabled={page >= totalPages}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${(page >= totalPages) ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
>
</button>
<button
type="button"
onClick={e => { e.preventDefault(); setPage(totalPages); }}
disabled={page >= totalPages}
className={`px-2 py-1 rounded border text-xs font-semibold transition-colors duration-150 ${(page >= totalPages) ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900'}`}
>
»
</button>
<span className="ml-3 text-xs text-gray-500">Página <span className="font-bold">{page}</span> de <span className="font-bold">{totalPages}</span></span>
</div>
</div>
);
})()}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}

80
src/pages/Reportes.jsx Normal file
View File

@@ -0,0 +1,80 @@
import React from "react";
export default function Reportes() {
return (
<div className="p-6 bg-gradient-to-br from-blue-50 to-blue-100 min-h-screen">
<div className="max-w-5xl mx-auto">
<h1 className="text-3xl font-extrabold mb-2 text-blue-900 tracking-tight">Reportes</h1>
<p className="mb-6 text-gray-600">Consulta y descarga reportes relacionados con el sistema.</p>
{/* Filtros de ejemplo */}
<div className="flex flex-wrap gap-4 mb-6 bg-white p-4 rounded-lg shadow-sm">
<div>
<label className="block text-xs font-semibold text-gray-500 mb-1">Tipo de reporte</label>
<select className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-300">
<option>General</option>
<option>Usuarios</option>
<option>Documentos</option>
<option>Procesos</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 mb-1">Fecha inicio</label>
<input type="date" className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-300" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 mb-1">Fecha fin</label>
<input type="date" className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-300" />
</div>
<div className="flex items-end">
<button className="bg-blue-600 text-white px-4 py-2 rounded shadow hover:bg-blue-700 transition">Filtrar</button>
</div>
</div>
{/* Tabla de reportes de ejemplo */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-bold text-blue-800">Resultados</h2>
<button className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 transition flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /></svg>
Descargar Excel
</button>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm text-left">
<thead>
<tr className="bg-blue-100 text-blue-900">
<th className="py-2 px-4 font-semibold">ID</th>
<th className="py-2 px-4 font-semibold">Nombre</th>
<th className="py-2 px-4 font-semibold">Tipo</th>
<th className="py-2 px-4 font-semibold">Fecha</th>
<th className="py-2 px-4 font-semibold">Acciones</th>
</tr>
</thead>
<tbody>
<tr className="border-b hover:bg-blue-50">
<td className="py-2 px-4">1</td>
<td className="py-2 px-4">Reporte de usuarios</td>
<td className="py-2 px-4">Usuarios</td>
<td className="py-2 px-4">2025-07-22</td>
<td className="py-2 px-4">
<button className="text-blue-600 hover:underline">Ver</button>
</td>
</tr>
<tr className="border-b hover:bg-blue-50">
<td className="py-2 px-4">2</td>
<td className="py-2 px-4">Reporte de documentos</td>
<td className="py-2 px-4">Documentos</td>
<td className="py-2 px-4">2025-07-21</td>
<td className="py-2 px-4">
<button className="text-blue-600 hover:underline">Ver</button>
</td>
</tr>
{/* Más filas de ejemplo aquí */}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

316
src/pages/Reports.jsx Normal file
View File

@@ -0,0 +1,316 @@
import React, { useState, useEffect } from 'react';
export default function Reports() {
const tabs = [
{ key: 'pedimentos', label: 'Generar Reporte de Pedimentos' },
{ key: 'datastage', label: 'Generar Reporte de Datastage' },
{ key: 'minimos', label: 'Generar Reporte de Mínimos' },
{ key: 'coves', label: 'Generar Reporte de COVES' },
];
// Columnas por tipo de reporte y tipo de registro para datastage
const columnasPorTab = {
pedimentos: [
{ key: 'id', label: 'ID' },
{ key: 'nombre', label: 'Nombre' },
{ key: 'pedimento', label: 'Pedimento' },
{ key: 'fecha', label: 'Fecha' },
{ key: 'estado', label: 'Estado' },
],
datastage: {
entrada: [
{ key: 'id', label: 'ID' },
{ key: 'nombre', label: 'Nombre' },
{ key: 'fecha', label: 'Fecha' },
{ key: 'usuario', label: 'Usuario' },
{ key: 'entrada', label: 'Entrada' },
],
salida: [
{ key: 'id', label: 'ID' },
{ key: 'nombre', label: 'Nombre' },
{ key: 'fecha', label: 'Fecha' },
{ key: 'usuario', label: 'Usuario' },
{ key: 'salida', label: 'Salida' },
],
proceso: [
{ key: 'id', label: 'ID' },
{ key: 'nombre', label: 'Nombre' },
{ key: 'fecha', label: 'Fecha' },
{ key: 'usuario', label: 'Usuario' },
{ key: 'proceso', label: 'Proceso' },
],
default: [
{ key: 'id', label: 'ID' },
{ key: 'nombre', label: 'Nombre' },
{ key: 'fecha', label: 'Fecha' },
{ key: 'usuario', label: 'Usuario' },
],
},
minimos: [
{ key: 'id', label: 'ID' },
{ key: 'nombre', label: 'Nombre' },
{ key: 'minimo', label: 'Mínimo' },
{ key: 'fecha', label: 'Fecha' },
],
coves: [
{ key: 'id', label: 'ID' },
{ key: 'nombre', label: 'Nombre' },
{ key: 'cove', label: 'COVE' },
{ key: 'fecha', label: 'Fecha' },
],
};
const [activeTab, setActiveTab] = useState('pedimentos');
const [nombreReporte, setNombreReporte] = useState('');
const [columnas, setColumnas] = useState(['id', 'nombre']);
const [fechaInicio, setFechaInicio] = useState('');
const [fechaFin, setFechaFin] = useState('');
const [pedimento, setPedimento] = useState('');
const [tipoRegistro, setTipoRegistro] = useState('');
const handleColumnaChange = (col) => {
setColumnas((prev) =>
prev.includes(col)
? prev.filter((c) => c !== col)
: [...prev, col]
);
};
const handleGenerarReporte = (e) => {
e.preventDefault();
alert(`Generando reporte: ${nombreReporte}\nTipo: ${activeTab}\nColumnas: ${columnas.join(', ')}\nPedimento: ${pedimento}\nFecha: ${fechaInicio} a ${fechaFin}`);
};
// Reset columnas al cambiar de tab o tipo de registro en datastage
useEffect(() => {
if (activeTab === 'datastage') {
if (tipoRegistro && columnasPorTab.datastage[tipoRegistro]) {
setColumnas(['id', 'nombre']);
} else {
setColumnas(['id', 'nombre']);
}
} else {
setColumnas(['id', 'nombre']);
}
}, [activeTab, tipoRegistro]);
return (
<div className="p-6 bg-gray-50 min-h-screen">
<div className="max-w-7xl mx-auto">
{/* Header decorativo */}
<div className="mb-8 relative overflow-hidden rounded-2xl shadow bg-gradient-to-r from-blue-50 via-white to-indigo-50 border border-blue-100 p-8 flex items-center gap-6">
<div className="flex-shrink-0 bg-blue-100 rounded-full p-4 shadow-md animate-bounce-slow">
<svg className="h-10 w-10 text-blue-600" 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>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight mb-1 flex items-center gap-2">
Reportes
</h1>
<p className="text-lg text-blue-700/80 font-medium">Consulta y descarga reportes relacionados con el sistema.</p>
</div>
{/* Efecto decorativo de fondo */}
<div className="absolute -top-10 -right-10 opacity-30 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="url(#grad1)" />
<defs>
<linearGradient id="grad1" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse">
<stop stopColor="#3b82f6" stopOpacity="0.15" />
<stop offset="1" stopColor="#6366f1" stopOpacity="0.10" />
</linearGradient>
</defs>
</svg>
</div>
</div>
{/* Animación personalizada para el icono */}
<style>{`
@keyframes bounce-slow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.animate-bounce-slow {
animation: bounce-slow 2.2s infinite;
}
`}</style>
{/* Tabs y formulario en tarjeta */}
<div className="mb-8 bg-white shadow-lg rounded-xl border border-gray-200">
<div className="flex gap-2 mb-4 px-6 pt-6">
{tabs.map(tab => (
<button
key={tab.key}
className={`px-4 py-2 rounded-t font-semibold border-b-2 transition-all ${activeTab === tab.key ? 'bg-white border-blue-700 text-blue-800' : 'bg-blue-100 border-transparent text-blue-500 hover:bg-blue-200'}`}
onClick={() => setActiveTab(tab.key)}
type="button"
>
{tab.label}
</button>
))}
</div>
<div className="px-6 pb-6 min-h-[340px] flex flex-col justify-between">
<h2 className="text-xl font-bold text-blue-800 mb-2">{tabs.find(t => t.key === activeTab)?.label}</h2>
<p className="text-gray-600 mb-4">Selecciona los campos y filtros que deseas incluir en tu reporte personalizado.</p>
<form className="grid grid-cols-1 md:grid-cols-2 gap-4" onSubmit={handleGenerarReporte}>
<div>
<label className="block text-xs font-semibold text-gray-500 mb-1">Nombre del reporte</label>
<input type="text" value={nombreReporte} onChange={e => setNombreReporte(e.target.value)} placeholder="Ej: Reporte personalizado" className="border rounded px-3 py-2 w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-300" />
</div>
{/* Pedimento y fechas para cada tab según requerimiento */}
{activeTab === 'pedimentos' && (
<div>
<label className="block text-xs font-semibold text-gray-500 mb-1">Pedimento específico</label>
<input type="text" value={pedimento} onChange={e => setPedimento(e.target.value)} placeholder="Ej: 12345678" className="border rounded px-3 py-2 w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-300" />
</div>
)}
{activeTab === 'datastage' && (
<>
<div>
<label className="block text-xs font-semibold text-gray-500 mb-1">Pedimento</label>
<input type="text" value={pedimento} onChange={e => setPedimento(e.target.value)} placeholder="Ej: 12345678" className="border rounded px-3 py-2 w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-300" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 mb-1">Fecha inicial</label>
<input type="date" value={fechaInicio} onChange={e => setFechaInicio(e.target.value)} className="border rounded px-3 py-2 w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-300" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 mb-1">Fecha final</label>
<input type="date" value={fechaFin} onChange={e => setFechaFin(e.target.value)} className="border rounded px-3 py-2 w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-300" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 mb-1">Tipo de registro</label>
<select
className="border rounded px-3 py-2 w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-300"
value={tipoRegistro}
onChange={e => setTipoRegistro(e.target.value)}
>
<option value="">Selecciona...</option>
<option value="entrada">Entrada</option>
<option value="salida">Salida</option>
<option value="proceso">Proceso</option>
{/* Agrega más opciones según los registros disponibles */}
</select>
</div>
</>
)}
{activeTab === 'coves' && (
<>
<div>
<label className="block text-xs font-semibold text-gray-500 mb-1">Pedimento</label>
<input type="text" value={pedimento} onChange={e => setPedimento(e.target.value)} placeholder="Ej: 12345678" className="border rounded px-3 py-2 w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-300" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 mb-1">Fecha inicial</label>
<input type="date" value={fechaInicio} onChange={e => setFechaInicio(e.target.value)} className="border rounded px-3 py-2 w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-300" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 mb-1">Fecha final</label>
<input type="date" value={fechaFin} onChange={e => setFechaFin(e.target.value)} className="border rounded px-3 py-2 w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-300" />
</div>
</>
)}
{activeTab === 'minimos' && (
<>
<div>
<label className="block text-xs font-semibold text-gray-500 mb-1">Pedimento</label>
<input type="text" value={pedimento} onChange={e => setPedimento(e.target.value)} placeholder="Ej: 12345678" className="border rounded px-3 py-2 w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-300" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 mb-1">Fecha inicial</label>
<input type="date" value={fechaInicio} onChange={e => setFechaInicio(e.target.value)} className="border rounded px-3 py-2 w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-300" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 mb-1">Fecha final</label>
<input type="date" value={fechaFin} onChange={e => setFechaFin(e.target.value)} className="border rounded px-3 py-2 w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-300" />
</div>
</>
)}
{/* Columnas a incluir solo si no es minimos */}
{activeTab !== 'minimos' && (
<div className="md:col-span-2">
<label className="block text-xs font-semibold text-gray-500 mb-1">Columnas a incluir</label>
<div className="flex flex-wrap gap-4">
{activeTab === 'datastage'
? (columnasPorTab.datastage[tipoRegistro] || columnasPorTab.datastage.default).map(col => (
<label key={col.key} className="inline-flex items-center">
<input
type="checkbox"
className="mr-2"
checked={columnas.includes(col.key)}
onChange={() => handleColumnaChange(col.key)}
/>
{col.label}
</label>
))
: columnasPorTab[activeTab].map(col => (
<label key={col.key} className="inline-flex items-center">
<input
type="checkbox"
className="mr-2"
checked={columnas.includes(col.key)}
onChange={() => handleColumnaChange(col.key)}
/>
{col.label}
</label>
))}
</div>
</div>
)}
{/* Fechas para los demás tabs - ya incluidas arriba */}
<div className="md:col-span-2 flex justify-end mt-2">
<button type="submit" className="bg-blue-700 text-white px-6 py-2 rounded shadow hover:bg-blue-800 transition">Generar reporte</button>
</div>
</form>
</div>
</div>
{/* Tabla de reportes de ejemplo */}
<div className="bg-white shadow-lg rounded-xl border border-gray-200 mt-8">
<div className="flex justify-between items-center mb-4 px-6 pt-6">
<h2 className="text-lg font-bold text-blue-800">Resultados</h2>
<button className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 transition flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /></svg>
Descargar Excel
</button>
</div>
<div className="overflow-x-auto px-6 pb-6">
<table className="min-w-full text-sm text-left">
<thead>
<tr className="bg-blue-100 text-blue-900">
<th className="py-2 px-4 font-semibold">ID</th>
<th className="py-2 px-4 font-semibold">Nombre</th>
<th className="py-2 px-4 font-semibold">Tipo</th>
<th className="py-2 px-4 font-semibold">Pedimento</th>
<th className="py-2 px-4 font-semibold">Fecha</th>
<th className="py-2 px-4 font-semibold">Acciones</th>
</tr>
</thead>
<tbody>
<tr className="border-b hover:bg-blue-50">
<td className="py-2 px-4">1</td>
<td className="py-2 px-4">Reporte de usuarios</td>
<td className="py-2 px-4">Usuarios</td>
<td className="py-2 px-4">12345678</td>
<td className="py-2 px-4">2025-07-22</td>
<td className="py-2 px-4">
<button className="text-blue-600 hover:underline">Ver</button>
</td>
</tr>
<tr className="border-b hover:bg-blue-50">
<td className="py-2 px-4">2</td>
<td className="py-2 px-4">Reporte de documentos</td>
<td className="py-2 px-4">Documentos</td>
<td className="py-2 px-4">87654321</td>
<td className="py-2 px-4">2025-07-21</td>
<td className="py-2 px-4">
<button className="text-blue-600 hover:underline">Ver</button>
</td>
</tr>
{/* Más filas de ejemplo aquí */}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

431
src/pages/Settings.jsx Normal file
View File

@@ -0,0 +1,431 @@
import React, { useState, useEffect } from 'react';
import { getCurrentUser } from '../api/users.ts';
const Settings = () => {
const [activeTab, setActiveTab] = useState('profile');
const [currentUser, setCurrentUser] = useState(null);
const [loading, setLoading] = useState(true);
// Cargar información del usuario al montar el componente
useEffect(() => {
const loadUserData = async () => {
try {
const token = localStorage.getItem('access');
if (token) {
const userData = await getCurrentUser(token);
setCurrentUser(userData);
}
} catch (error) {
console.error('Error al cargar datos del usuario:', error);
} finally {
setLoading(false);
}
};
loadUserData();
}, []);
// Solo mostrar tabs permitidas si es importador
const isImportador = typeof window !== 'undefined' && localStorage.getItem('user_is_importador') === 'true';
const tabs = [
{ id: 'profile', name: 'Perfil', icon: 'user' },
{ id: 'organization', name: 'Organización', icon: 'building' },
{ id: 'security', name: 'Seguridad', icon: 'shield' },
{ id: 'notifications', name: 'Notificaciones', icon: 'bell' }
].filter(tab =>
isImportador
? tab.id === 'profile' || tab.id === 'security' || tab.id === 'notifications'
: true
);
const getTabIcon = (iconType) => {
const icons = {
user: (
<svg className="w-5 h-5" 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>
),
building: (
<svg className="w-5 h-5" 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>
),
shield: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
bell: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 17h5l-5-5v5zM4 4h5l-5 5v-5z" />
</svg>
)
};
return icons[iconType];
};
const renderProfileTab = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Información Personal</h3>
{loading ? (
<div className="animate-pulse space-y-4">
<div className="flex items-center space-x-4">
<div className="w-20 h-20 bg-gray-200 rounded-full"></div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded w-32"></div>
<div className="h-3 bg-gray-200 rounded w-24"></div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="h-10 bg-gray-200 rounded"></div>
<div className="h-10 bg-gray-200 rounded"></div>
<div className="h-10 bg-gray-200 rounded"></div>
<div className="h-10 bg-gray-200 rounded"></div>
</div>
</div>
) : (
<>
{/* Avatar y información básica */}
<div className="flex items-center space-x-6 mb-6">
<div className="flex-shrink-0">
{currentUser?.profile_picture ? (
<img
className="w-20 h-20 rounded-full object-cover"
src={currentUser.profile_picture}
alt="Avatar del usuario"
/>
) : (
<div className="w-20 h-20 bg-gray-300 rounded-full flex items-center justify-center">
<svg className="w-10 h-10 text-gray-600" 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>
<div>
<h4 className="text-lg font-semibold text-gray-900">
{currentUser ? `${currentUser.first_name} ${currentUser.last_name}` : 'Usuario'}
</h4>
<p className="text-sm text-gray-500">
{currentUser?.username || 'Sin username'}
</p>
<p className="text-sm text-gray-500">
ID: {currentUser?.id || 'Sin ID'}
</p>
<button className="mt-2 text-sm text-indigo-600 hover:text-indigo-500">
Cambiar foto
</button>
</div>
</div>
{/* Formulario de información */}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nombre
</label>
<input
type="text"
defaultValue={currentUser?.first_name || ''}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="Ingresa tu nombre"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Apellido
</label>
<input
type="text"
defaultValue={currentUser?.last_name || ''}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="Ingresa tu apellido"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email
</label>
<input
type="email"
defaultValue={currentUser?.email || ''}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="correo@ejemplo.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Username
</label>
<input
type="text"
defaultValue={currentUser?.username || ''}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="nombre_usuario"
/>
</div>
{currentUser?.rfc && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
RFC
</label>
<input
type="text"
defaultValue={currentUser.rfc}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="XXXX000000XXX"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Organización ID
</label>
<input
type="text"
defaultValue={currentUser?.organizacion || ''}
disabled
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-50 text-gray-500 cursor-not-allowed text-sm"
placeholder="ID de organización"
/>
</div>
</div>
<div className="flex justify-end space-x-3 pt-6 border-t border-gray-200">
<button
type="button"
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
>
Guardar cambios
</button>
</div>
</>
)}
</div>
</div>
);
const renderOrganizationTab = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Configuración de Organización</h3>
<p className="text-sm text-gray-600 mb-6">
Gestiona la configuración relacionada con tu organización.
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">Próximamente</h3>
<div className="mt-2 text-sm text-yellow-700">
<p>Las configuraciones de organización estarán disponibles pronto.</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
const renderSecurityTab = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Seguridad</h3>
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium text-gray-900 mb-2">Cambiar contraseña</h4>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Contraseña actual
</label>
<input
type="password"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="Ingresa tu contraseña actual"
/>
</div>
<div></div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nueva contraseña
</label>
<input
type="password"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="Ingresa una nueva contraseña"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Confirmar nueva contraseña
</label>
<input
type="password"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="Confirma tu nueva contraseña"
/>
</div>
</div>
<div className="mt-6">
<button
type="button"
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
>
Actualizar contraseña
</button>
</div>
</div>
</div>
</div>
</div>
);
const renderNotificationsTab = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Notificaciones</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-900">
Notificaciones por email
</label>
<p className="text-sm text-gray-500">
Recibir notificaciones importantes por correo electrónico
</p>
</div>
<button
type="button"
className="bg-indigo-600 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span className="translate-x-5 pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"></span>
</button>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-900">
Notificaciones de documentos
</label>
<p className="text-sm text-gray-500">
Notificar cuando se suban o actualicen documentos
</p>
</div>
<button
type="button"
className="bg-indigo-600 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span className="translate-x-5 pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"></span>
</button>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-900">
Notificaciones del sistema
</label>
<p className="text-sm text-gray-500">
Recibir actualizaciones del sistema y mantenimiento
</p>
</div>
<button
type="button"
className="bg-gray-200 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span className="translate-x-0 pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"></span>
</button>
</div>
</div>
</div>
</div>
);
const renderTabContent = () => {
switch (activeTab) {
case 'profile':
return renderProfileTab();
case 'organization':
return renderOrganizationTab();
case 'security':
return renderSecurityTab();
case 'notifications':
return renderNotificationsTab();
default:
return renderProfileTab();
}
};
return (
<div className="max-w-6xl mx-auto p-6">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Configuración</h1>
<p className="mt-2 text-gray-600">
Gestiona tu perfil, configuración de cuenta y preferencias.
</p>
</div>
<div className="lg:grid lg:grid-cols-12 lg:gap-x-5">
{/* Sidebar de navegación */}
<aside className="py-6 px-2 sm:px-6 lg:py-0 lg:px-0 lg:col-span-3">
<nav className="space-y-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`${
activeTab === tab.id
? 'bg-indigo-50 border-indigo-500 text-indigo-700'
: 'border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900'
} group border-l-4 px-3 py-2 flex items-center text-sm font-medium w-full text-left`}
>
<span
className={`${
activeTab === tab.id
? 'text-indigo-500'
: 'text-gray-400 group-hover:text-gray-500'
} flex-shrink-0 -ml-1 mr-3 h-6 w-6`}
>
{getTabIcon(tab.icon)}
</span>
<span className="truncate">{tab.name}</span>
</button>
))}
</nav>
</aside>
{/* Contenido principal */}
<div className="space-y-6 sm:px-6 lg:px-0 lg:col-span-9">
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
{renderTabContent()}
</div>
</div>
</div>
</div>
</div>
);
};
export default Settings;

425
src/pages/SettingsNew.jsx Normal file
View File

@@ -0,0 +1,425 @@
import React, { useState, useEffect } from 'react';
import { getCurrentUser } from '../api/users.js';
const Settings = () => {
const [activeTab, setActiveTab] = useState('profile');
const [currentUser, setCurrentUser] = useState(null);
const [loading, setLoading] = useState(true);
// Cargar información del usuario al montar el componente
useEffect(() => {
const loadUserData = async () => {
try {
const token = localStorage.getItem('access');
if (token) {
const userData = await getCurrentUser(token);
setCurrentUser(userData);
}
} catch (error) {
console.error('Error al cargar datos del usuario:', error);
} finally {
setLoading(false);
}
};
loadUserData();
}, []);
const tabs = [
{ id: 'profile', name: 'Perfil', icon: 'user' },
{ id: 'organization', name: 'Organización', icon: 'building' },
{ id: 'security', name: 'Seguridad', icon: 'shield' },
{ id: 'notifications', name: 'Notificaciones', icon: 'bell' }
];
const getTabIcon = (iconType) => {
const icons = {
user: (
<svg className="w-5 h-5" 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>
),
building: (
<svg className="w-5 h-5" 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>
),
shield: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
bell: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 17h5l-5-5v5zM4 4h5l-5 5v-5z" />
</svg>
)
};
return icons[iconType];
};
const renderProfileTab = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Información Personal</h3>
{loading ? (
<div className="animate-pulse space-y-4">
<div className="flex items-center space-x-4">
<div className="w-20 h-20 bg-gray-200 rounded-full"></div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded w-32"></div>
<div className="h-3 bg-gray-200 rounded w-24"></div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="h-10 bg-gray-200 rounded"></div>
<div className="h-10 bg-gray-200 rounded"></div>
<div className="h-10 bg-gray-200 rounded"></div>
<div className="h-10 bg-gray-200 rounded"></div>
</div>
</div>
) : (
<>
{/* Avatar y información básica */}
<div className="flex items-center space-x-6 mb-6">
<div className="flex-shrink-0">
{currentUser?.profile_picture ? (
<img
className="w-20 h-20 rounded-full object-cover"
src={currentUser.profile_picture}
alt="Avatar del usuario"
/>
) : (
<div className="w-20 h-20 bg-gray-300 rounded-full flex items-center justify-center">
<svg className="w-10 h-10 text-gray-600" 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>
<div>
<h4 className="text-lg font-semibold text-gray-900">
{currentUser ? `${currentUser.first_name} ${currentUser.last_name}` : 'Usuario'}
</h4>
<p className="text-sm text-gray-500">
{currentUser?.username || 'Sin username'}
</p>
<p className="text-sm text-gray-500">
ID: {currentUser?.id || 'Sin ID'}
</p>
<button className="mt-2 text-sm text-indigo-600 hover:text-indigo-500">
Cambiar foto
</button>
</div>
</div>
{/* Formulario de información */}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nombre
</label>
<input
type="text"
defaultValue={currentUser?.first_name || ''}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="Ingresa tu nombre"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Apellido
</label>
<input
type="text"
defaultValue={currentUser?.last_name || ''}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="Ingresa tu apellido"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email
</label>
<input
type="email"
defaultValue={currentUser?.email || ''}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="correo@ejemplo.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Username
</label>
<input
type="text"
defaultValue={currentUser?.username || ''}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="nombre_usuario"
/>
</div>
{currentUser?.rfc && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
RFC
</label>
<input
type="text"
defaultValue={currentUser.rfc}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="XXXX000000XXX"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Organización ID
</label>
<input
type="text"
defaultValue={currentUser?.organizacion || ''}
disabled
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-50 text-gray-500 cursor-not-allowed text-sm"
placeholder="ID de organización"
/>
</div>
</div>
<div className="flex justify-end space-x-3 pt-6 border-t border-gray-200">
<button
type="button"
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
>
Guardar cambios
</button>
</div>
</>
)}
</div>
</div>
);
const renderOrganizationTab = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Configuración de Organización</h3>
<p className="text-sm text-gray-600 mb-6">
Gestiona la configuración relacionada con tu organización.
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">Próximamente</h3>
<div className="mt-2 text-sm text-yellow-700">
<p>Las configuraciones de organización estarán disponibles pronto.</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
const renderSecurityTab = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Seguridad</h3>
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium text-gray-900 mb-2">Cambiar contraseña</h4>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Contraseña actual
</label>
<input
type="password"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="Ingresa tu contraseña actual"
/>
</div>
<div></div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nueva contraseña
</label>
<input
type="password"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="Ingresa una nueva contraseña"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Confirmar nueva contraseña
</label>
<input
type="password"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="Confirma tu nueva contraseña"
/>
</div>
</div>
<div className="mt-6">
<button
type="button"
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
>
Actualizar contraseña
</button>
</div>
</div>
</div>
</div>
</div>
);
const renderNotificationsTab = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Notificaciones</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-900">
Notificaciones por email
</label>
<p className="text-sm text-gray-500">
Recibir notificaciones importantes por correo electrónico
</p>
</div>
<button
type="button"
className="bg-indigo-600 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span className="translate-x-5 pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"></span>
</button>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-900">
Notificaciones de documentos
</label>
<p className="text-sm text-gray-500">
Notificar cuando se suban o actualicen documentos
</p>
</div>
<button
type="button"
className="bg-indigo-600 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span className="translate-x-5 pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"></span>
</button>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-900">
Notificaciones del sistema
</label>
<p className="text-sm text-gray-500">
Recibir actualizaciones del sistema y mantenimiento
</p>
</div>
<button
type="button"
className="bg-gray-200 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span className="translate-x-0 pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"></span>
</button>
</div>
</div>
</div>
</div>
);
const renderTabContent = () => {
switch (activeTab) {
case 'profile':
return renderProfileTab();
case 'organization':
return renderOrganizationTab();
case 'security':
return renderSecurityTab();
case 'notifications':
return renderNotificationsTab();
default:
return renderProfileTab();
}
};
return (
<div className="max-w-6xl mx-auto p-6">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Configuración</h1>
<p className="mt-2 text-gray-600">
Gestiona tu perfil, configuración de cuenta y preferencias.
</p>
</div>
<div className="lg:grid lg:grid-cols-12 lg:gap-x-5">
{/* Sidebar de navegación */}
<aside className="py-6 px-2 sm:px-6 lg:py-0 lg:px-0 lg:col-span-3">
<nav className="space-y-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`${
activeTab === tab.id
? 'bg-indigo-50 border-indigo-500 text-indigo-700'
: 'border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900'
} group border-l-4 px-3 py-2 flex items-center text-sm font-medium w-full text-left`}
>
<span
className={`${
activeTab === tab.id
? 'text-indigo-500'
: 'text-gray-400 group-hover:text-gray-500'
} flex-shrink-0 -ml-1 mr-3 h-6 w-6`}
>
{getTabIcon(tab.icon)}
</span>
<span className="truncate">{tab.name}</span>
</button>
))}
</nav>
</aside>
{/* Contenido principal */}
<div className="space-y-6 sm:px-6 lg:px-0 lg:col-span-9">
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
{renderTabContent()}
</div>
</div>
</div>
</div>
</div>
);
};
export default Settings;

View File

@@ -0,0 +1,326 @@
import React, { useState } from 'react';
import { Line, Pie, Doughnut, Bar } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend
} from 'chart.js';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, BarElement, ArcElement, Title, Tooltip, Legend);
export default function TableroAlmacenamiento() {
// Estado para la tabla de documentos y la opción seleccionada
const [selectedMetric, setSelectedMetric] = useState('');
const [documentos, setDocumentos] = useState([
{ nombre: 'Factura_123.pdf', tipo: 'Factura', ext: 'PDF' },
{ nombre: 'Pedimento_456.xml', tipo: 'Pedimento', ext: 'XML' },
{ nombre: 'Manifiesto_789.docx', tipo: 'Manifiesto', ext: 'DOCX' },
]);
// Por ahora solo cambia el estado seleccionado, no fetch
const handleMetricClick = (metric) => {
setSelectedMetric(metric);
};
// Datos simulados para las nuevas gráficas y KPIs
const tiposArchivos = [
{ tipo: 'PDF', espacio: 220 },
{ tipo: 'XML', espacio: 120 },
{ tipo: 'DOCX', espacio: 80 },
{ tipo: 'JPG', espacio: 60 },
{ tipo: 'Otros', espacio: 32 },
];
const topArchivos = [
{ nombre: 'Factura_123.pdf', size: 2.5 },
{ nombre: 'Reporte_2024.pdf', size: 2.1 },
{ nombre: 'Pedimento_456.xml', size: 1.8 },
{ nombre: 'Manifiesto_789.docx', size: 1.2 },
{ nombre: 'Imagen_001.jpg', size: 1.0 },
];
const espacioTotal = 1024; // GB
const espacioOcupado = 512; // GB
const espacioLibre = espacioTotal - espacioOcupado;
const usuarios = [
{ nombre: 'Juan', docs: 120 },
{ nombre: 'Ana', docs: 90 },
{ nombre: 'Luis', docs: 70 },
{ nombre: 'Sofía', docs: 60 },
{ nombre: 'Carlos', docs: 40 },
];
const docsEsteMes = 45;
const docsEliminados = 7;
const usuariosActivos = 4;
const porcentajeUsado = Math.round((espacioOcupado / espacioTotal) * 100);
return (
<div className="p-6 bg-gray-50 min-h-screen flex flex-col">
{/* Header animado */}
<div className="mb-8 animate-fadein-slideup opacity-0" style={{ animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.05s forwards' }}>
<div className="max-w-7xl mx-auto relative overflow-hidden rounded-2xl shadow bg-gradient-to-r from-blue-50 via-white to-indigo-50 border border-blue-100 p-8 flex items-center gap-6">
<div className="flex-shrink-0 bg-blue-100 rounded-full p-4 shadow-md animate-bounce-slow">
<svg className="h-10 w-10 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
</div>
<div className="flex-1 min-w-0">
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight mb-1 flex items-center gap-2">
Uso de Almacenamiento
</h1>
<p className="text-lg text-blue-700/80 font-medium">Visualiza y analiza el uso de almacenamiento de la plataforma</p>
</div>
{/* Efecto decorativo de fondo */}
<div className="absolute -top-10 -right-10 opacity-30 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="url(#grad1)" />
<defs>
<linearGradient id="grad1" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse">
<stop stopColor="#3b82f6" stopOpacity="0.15" />
<stop offset="1" stopColor="#6366f1" stopOpacity="0.10" />
</linearGradient>
</defs>
</svg>
</div>
<style>{`
@keyframes bounce-slow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.animate-bounce-slow {
animation: bounce-slow 2.2s infinite;
}
@keyframes fadein-slideup {
0% { opacity: 0; transform: translateY(40px); }
100% { opacity: 1; transform: translateY(0); }
}
.animate-fadein-slideup {
animation: fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.05s forwards;
}
`}</style>
</div>
</div>
{/* Filtros */}
<div className="max-w-7xl mx-auto w-full mb-8 flex flex-col md:flex-row gap-4 animate-fadein-slideup opacity-0" style={{ animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.12s forwards' }}>
<select className="border border-gray-300 rounded-lg px-4 py-2 text-sm bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 flex-1" defaultValue="">
<option value="">Organización</option>
<option value="org1">Organización 1</option>
<option value="org2">Organización 2</option>
</select>
<select className="border border-gray-300 rounded-lg px-4 py-2 text-sm bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 flex-1" defaultValue="">
<option value="">Importador</option>
<option value="imp1">Importador 1</option>
<option value="imp2">Importador 2</option>
</select>
<input type="date" className="border border-gray-300 rounded-lg px-4 py-2 text-sm bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 flex-1" />
<select className="border border-gray-300 rounded-lg px-4 py-2 text-sm bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 flex-1" defaultValue="">
<option value="">Año</option>
<option value="2022">2022</option>
<option value="2023">2023</option>
<option value="2024">2024</option>
<option value="2025">2025</option>
</select>
<select className="border border-gray-300 rounded-lg px-4 py-2 text-sm bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 flex-1" defaultValue="">
<option value="">Mes</option>
<option value="01">Enero</option>
<option value="02">Febrero</option>
<option value="03">Marzo</option>
<option value="04">Abril</option>
<option value="05">Mayo</option>
<option value="06">Junio</option>
<option value="07">Julio</option>
<option value="08">Agosto</option>
<option value="09">Septiembre</option>
<option value="10">Octubre</option>
<option value="11">Noviembre</option>
<option value="12">Diciembre</option>
</select>
</div>
{/* Cards y KPIs */}
<div className="max-w-7xl mx-auto w-full grid grid-cols-1 md:grid-cols-6 gap-6 mb-8 animate-fadein-slideup opacity-0" style={{ animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.18s forwards' }}>
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6 flex flex-col items-center shadow hover:scale-105 transition-transform duration-200">
<span className="text-3xl font-bold text-blue-800 mb-2">1,234</span>
<span className="text-sm font-semibold text-blue-700">Total de Pedimentos</span>
</div>
<div className="bg-indigo-50 border border-indigo-200 rounded-xl p-6 flex flex-col items-center shadow hover:scale-105 transition-transform duration-200">
<span className="text-3xl font-bold text-indigo-800 mb-2">8,765</span>
<span className="text-sm font-semibold text-indigo-700">Total de Documentos</span>
</div>
<div className="bg-green-50 border border-green-200 rounded-xl p-6 flex flex-col items-center shadow hover:scale-105 transition-transform duration-200">
<span className="text-3xl font-bold text-green-800 mb-2">{espacioOcupado} GB</span>
<span className="text-sm font-semibold text-green-700">Espacio Utilizado</span>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-6 flex flex-col items-center shadow hover:scale-105 transition-transform duration-200">
<span className="text-3xl font-bold text-yellow-800 mb-2">2.5 GB</span>
<span className="text-sm font-semibold text-yellow-700">Archivo más grande</span>
</div>
<div className="bg-pink-50 border border-pink-200 rounded-xl p-6 flex flex-col items-center shadow hover:scale-105 transition-transform duration-200">
<span className="text-3xl font-bold text-pink-800 mb-2">120 MB</span>
<span className="text-sm font-semibold text-pink-700">Tamaño promedio</span>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-xl p-6 flex flex-col items-center shadow hover:scale-105 transition-transform duration-200">
<span className="text-3xl font-bold text-gray-800 mb-2">{espacioLibre} GB</span>
<span className="text-sm font-semibold text-gray-700">Espacio Libre</span>
</div>
</div>
<div className="max-w-7xl mx-auto w-full grid grid-cols-1 md:grid-cols-4 gap-6 mb-8 animate-fadein-slideup opacity-0">
<div className="bg-white border border-gray-200 rounded-xl p-6 flex flex-col items-center shadow">
<span className="text-2xl font-bold text-blue-700 mb-1">{porcentajeUsado}%</span>
<span className="text-xs text-gray-600">% Espacio Usado</span>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 flex flex-col items-center shadow">
<span className="text-2xl font-bold text-green-700 mb-1">{docsEsteMes}</span>
<span className="text-xs text-gray-600">Docs subidos este mes</span>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 flex flex-col items-center shadow">
<span className="text-2xl font-bold text-red-700 mb-1">{docsEliminados}</span>
<span className="text-xs text-gray-600">Docs eliminados este mes</span>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 flex flex-col items-center shadow">
<span className="text-2xl font-bold text-indigo-700 mb-1">{usuariosActivos}</span>
<span className="text-xs text-gray-600">Usuarios activos este mes</span>
</div>
</div>
{/* Gráficas */}
<div className="max-w-7xl mx-auto w-full grid grid-cols-1 md:grid-cols-3 gap-8 animate-fadein-slideup opacity-0" style={{ animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.22s forwards' }}>
{/* Gráfica 1: Espacio utilizado a lo largo del tiempo */}
<div className="bg-white border border-gray-200 rounded-2xl p-6 shadow flex flex-col">
<h2 className="text-lg font-bold text-blue-800 mb-4">Espacio utilizado a lo largo del tiempo</h2>
<Line
data={{
labels: ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul'],
datasets: [
{
label: 'Espacio Utilizado (GB)',
data: [100, 150, 200, 250, 300, 400, 512],
borderColor: '#3b82f6',
backgroundColor: 'rgba(59,130,246,0.1)',
tension: 0.4,
fill: true,
},
],
}}
options={{
responsive: true,
plugins: {
legend: { display: true },
title: { display: false },
},
scales: {
x: { title: { display: true, text: 'Mes' } },
y: { title: { display: true, text: 'GB' } },
},
}}
/>
</div>
{/* Gráfica 2: Distribución de tipos de archivo */}
<div className="bg-white border border-gray-200 rounded-2xl p-6 shadow flex flex-col">
<h2 className="text-lg font-bold text-purple-800 mb-4">Distribución por tipo de archivo</h2>
<Pie
data={{
labels: tiposArchivos.map(t => t.tipo),
datasets: [
{
data: tiposArchivos.map(t => t.espacio),
backgroundColor: ['#3b82f6', '#6366f1', '#f59e42', '#10b981', '#f472b6'],
},
],
}}
options={{
plugins: {
legend: { display: true, position: 'bottom' },
},
}}
/>
</div>
{/* Gráfica 3: Espacio ocupado vs libre (donut) */}
<div className="bg-white border border-gray-200 rounded-2xl p-6 shadow flex flex-col">
<h2 className="text-lg font-bold text-green-800 mb-4">Espacio ocupado vs libre</h2>
<Doughnut
data={{
labels: ['Ocupado', 'Libre'],
datasets: [
{
data: [espacioOcupado, espacioLibre],
backgroundColor: ['#3b82f6', '#d1fae5'],
},
],
}}
options={{
cutout: '70%',
plugins: {
legend: { display: true, position: 'bottom' },
},
}}
/>
</div>
</div>
{/* Gráficas adicionales */}
<div className="max-w-7xl mx-auto w-full grid grid-cols-1 md:grid-cols-2 gap-8 mt-8 animate-fadein-slideup opacity-0">
{/* Top archivos más grandes */}
<div className="bg-white border border-gray-200 rounded-2xl p-6 shadow flex flex-col">
<h2 className="text-lg font-bold text-yellow-800 mb-4">Top 5 archivos más grandes</h2>
<Bar
data={{
labels: topArchivos.map(a => a.nombre),
datasets: [
{
label: 'Tamaño (GB)',
data: topArchivos.map(a => a.size),
backgroundColor: '#f59e42',
},
],
}}
options={{
indexAxis: 'y',
plugins: {
legend: { display: false },
},
scales: {
x: { title: { display: true, text: 'Tamaño (GB)' } },
y: { title: { display: false } },
},
}}
/>
</div>
{/* Documentos subidos por usuario */}
<div className="bg-white border border-gray-200 rounded-2xl p-6 shadow flex flex-col">
<h2 className="text-lg font-bold text-indigo-800 mb-4">Documentos subidos por usuario</h2>
<Bar
data={{
labels: usuarios.map(u => u.nombre),
datasets: [
{
label: 'Documentos',
data: usuarios.map(u => u.docs),
backgroundColor: '#6366f1',
},
],
}}
options={{
plugins: {
legend: { display: false },
},
scales: {
x: { title: { display: false } },
y: { title: { display: true, text: 'Documentos' } },
},
}}
/>
</div>
</div>
</div>
);
}

20
src/pages/Test.jsx Normal file
View File

@@ -0,0 +1,20 @@
import React from 'react';
export default function Test() {
console.log('🟢 Test component loaded');
return (
<div style={{
padding: '50px',
backgroundColor: '#ff0000',
color: 'white',
fontSize: '30px',
textAlign: 'center',
minHeight: '100vh'
}}>
<h1>🚨 TEST PAGE 🚨</h1>
<p>Si ves esto, React funciona!</p>
<p>Hora: {new Date().toLocaleTimeString()}</p>
</div>
);
}

1243
src/pages/Users.jsx Normal file

File diff suppressed because it is too large Load Diff

723
src/pages/UsersNew.jsx Normal file
View File

@@ -0,0 +1,723 @@
import React, { useEffect, useState } from 'react';
import { fetchUsers, createUser, updateUser, deleteUser } from '../api/users.js';
import { useNotification } from '../context/NotificationContext';
const initialForm = {
username: '',
email: '',
first_name: '',
last_name: '',
password: '',
};
export default function Users() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [form, setForm] = useState(initialForm);
const [editingId, setEditingId] = useState(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [userToDelete, setUserToDelete] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const { showMessage } = useNotification();
const token = localStorage.getItem('access');
const loadUsers = () => {
setLoading(true);
fetchUsers(token)
.then(data => {
setUsers(data);
setLoading(false);
})
.catch(err => {
if (err.message === 'SESSION_EXPIRED') {
localStorage.removeItem('access');
localStorage.removeItem('refresh');
showMessage('Tu sesión ha expirado, por favor inicia sesión de nuevo.', 'error');
setTimeout(() => {
window.location.href = '/login';
}, 2000);
} else {
setError(err.message);
}
setLoading(false);
});
};
useEffect(() => {
loadUsers();
// eslint-disable-next-line
}, [showMessage]);
const handleChange = e => {
setForm({ ...form, [e.target.name]: e.target.value });
};
const handleSubmit = async e => {
e.preventDefault();
setSubmitting(true);
try {
if (editingId) {
await updateUser(token, editingId, form);
showMessage('Usuario actualizado exitosamente', 'success');
setShowEditModal(false);
} else {
await createUser(token, form);
showMessage('Usuario creado exitosamente', 'success');
setShowCreateModal(false);
}
setForm(initialForm);
setEditingId(null);
loadUsers();
} catch (err) {
showMessage(err.message, 'error');
} finally {
setSubmitting(false);
}
};
const handleEdit = user => {
setForm({
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
password: '',
});
setEditingId(user.id);
setShowEditModal(true);
};
const handleDeleteClick = (user) => {
setUserToDelete(user);
setShowDeleteModal(true);
};
const handleDeleteConfirm = async () => {
if (!userToDelete) return;
setSubmitting(true);
try {
await deleteUser(token, userToDelete.id);
showMessage('Usuario eliminado exitosamente', 'success');
setShowDeleteModal(false);
setUserToDelete(null);
loadUsers();
} catch (err) {
showMessage(err.message, 'error');
} finally {
setSubmitting(false);
}
};
const handleCancel = () => {
setForm(initialForm);
setEditingId(null);
setShowCreateModal(false);
setShowEditModal(false);
setShowDeleteModal(false);
setUserToDelete(null);
};
// Función para filtrar usuarios
const filteredUsers = users.filter(user =>
user.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
(user.first_name && user.first_name.toLowerCase().includes(searchTerm.toLowerCase())) ||
(user.last_name && user.last_name.toLowerCase().includes(searchTerm.toLowerCase()))
);
// Función para obtener el badge de estado
const getStatusBadge = () => {
return 'bg-green-100 text-green-800';
};
if (loading) {
return (
<div className="p-6">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-6"></div>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-20 bg-gray-200 rounded"></div>
))}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Error al cargar usuarios</h3>
<div className="mt-2 text-sm text-red-700">
<p>{error}</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="p-6 max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Usuarios</h1>
<p className="text-gray-600">Gestiona y supervisa los usuarios registrados en el sistema.</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-blue-600" 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 className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total Usuarios</dt>
<dd className="text-lg font-medium text-gray-900">{users.length}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-green-600" 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>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Activos</dt>
<dd className="text-lg font-medium text-gray-900">{users.length}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-purple-600" 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="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Con Perfil Completo</dt>
<dd className="text-lg font-medium text-gray-900">
{users.filter(u => u.first_name && u.last_name).length}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Última Semana</dt>
<dd className="text-lg font-medium text-gray-900">
{users.filter(u => u.id % 3 === 0).length}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Search and Actions */}
<div className="bg-white shadow rounded-lg mb-6">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 min-w-0">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
className="focus:ring-indigo-500 focus:border-indigo-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md"
placeholder="Buscar usuarios..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<div className="mt-4 sm:mt-0 sm:ml-4">
<button
onClick={() => setShowCreateModal(true)}
type="button"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<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>
Nuevo Usuario
</button>
</div>
</div>
</div>
{/* Table */}
<div className="overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Usuario
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Estado
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Nombre Completo
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
ID
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Acciones</span>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredUsers.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
{user.profile_picture ? (
<img
className="h-10 w-10 rounded-full object-cover"
src={user.profile_picture}
alt="Avatar"
/>
) : (
<div className="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center">
<svg className="h-6 w-6 text-gray-600" 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>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{user.username}</div>
<div className="text-sm text-gray-500">ID: {user.id}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{user.email}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadge()}`}>
Activo
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{user.first_name || user.last_name ?
`${user.first_name} ${user.last_name}`.trim() :
<span className="text-gray-400 italic">Sin nombre</span>
}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
#{user.id}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEdit(user)}
className="text-indigo-600 hover:text-indigo-900 mr-3"
>
Editar
</button>
<button
onClick={() => handleDeleteClick(user)}
className="text-red-600 hover:text-red-900"
>
Eliminar
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Empty state */}
{filteredUsers.length === 0 && !loading && (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" 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>
<h3 className="mt-2 text-sm font-medium text-gray-900">No se encontraron usuarios</h3>
<p className="mt-1 text-sm text-gray-500">
{searchTerm ? 'Intenta con otros términos de búsqueda.' : 'Comienza agregando un nuevo usuario.'}
</p>
{!searchTerm && (
<div className="mt-6">
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Nuevo Usuario
</button>
</div>
)}
</div>
)}
{/* Modales */}
{/* Modal Crear Usuario */}
{showCreateModal && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white">
<div className="mt-3">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">Crear Nuevo Usuario</h3>
<button
onClick={handleCancel}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nombre de usuario *
</label>
<input
type="text"
name="username"
value={form.username}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="nombre_usuario"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<input
type="email"
name="email"
value={form.email}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="usuario@ejemplo.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nombre
</label>
<input
type="text"
name="first_name"
value={form.first_name}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="Nombre"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Apellido
</label>
<input
type="text"
name="last_name"
value={form.last_name}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="Apellido"
/>
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Contraseña *
</label>
<input
type="password"
name="password"
value={form.password}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="Contraseña del usuario"
/>
</div>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={handleCancel}
disabled={submitting}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors disabled:opacity-50"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors disabled:opacity-50 flex items-center"
>
{submitting && (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
)}
{submitting ? 'Creando...' : 'Crear Usuario'}
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Modal Editar Usuario */}
{showEditModal && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white">
<div className="mt-3">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">Editar Usuario</h3>
<button
onClick={handleCancel}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nombre de usuario *
</label>
<input
type="text"
name="username"
value={form.username}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="nombre_usuario"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<input
type="email"
name="email"
value={form.email}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="usuario@ejemplo.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nombre
</label>
<input
type="text"
name="first_name"
value={form.first_name}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="Nombre"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Apellido
</label>
<input
type="text"
name="last_name"
value={form.last_name}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="Apellido"
/>
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Nueva contraseña
</label>
<input
type="password"
name="password"
value={form.password}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm transition-colors"
placeholder="Dejar vacío para mantener actual"
/>
<p className="mt-1 text-xs text-gray-500">
Deja este campo vacío si no deseas cambiar la contraseña
</p>
</div>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={handleCancel}
disabled={submitting}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors disabled:opacity-50"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors disabled:opacity-50 flex items-center"
>
{submitting && (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
)}
{submitting ? 'Actualizando...' : 'Actualizar Usuario'}
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Modal Eliminar Usuario */}
{showDeleteModal && userToDelete && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-11/12 md:w-96 shadow-lg rounded-md bg-white">
<div className="mt-3 text-center">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
<svg className="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Eliminar Usuario</h3>
<div className="mt-2 px-7 py-3">
<p className="text-sm text-gray-500 mb-4">
¿Estás seguro que deseas eliminar al usuario <strong>{userToDelete.username}</strong>?
</p>
<div className="bg-gray-50 rounded-md p-3 mb-4">
<div className="flex items-center">
<div className="flex-shrink-0">
{userToDelete.profile_picture ? (
<img
className="h-10 w-10 rounded-full object-cover"
src={userToDelete.profile_picture}
alt="Avatar"
/>
) : (
<div className="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center">
<svg className="h-6 w-6 text-gray-600" 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>
<div className="ml-3 text-left">
<p className="text-sm font-medium text-gray-900">{userToDelete.username}</p>
<p className="text-sm text-gray-500">{userToDelete.email}</p>
{(userToDelete.first_name || userToDelete.last_name) && (
<p className="text-xs text-gray-400">
{`${userToDelete.first_name} ${userToDelete.last_name}`.trim()}
</p>
)}
</div>
</div>
</div>
<p className="text-sm text-red-600">
Esta acción no se puede deshacer.
</p>
</div>
<div className="flex justify-center space-x-3 pt-4">
<button
onClick={handleCancel}
disabled={submitting}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors disabled:opacity-50"
>
Cancelar
</button>
<button
onClick={handleDeleteConfirm}
disabled={submitting}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors disabled:opacity-50 flex items-center"
>
{submitting && (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
)}
{submitting ? 'Eliminando...' : 'Eliminar Usuario'}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

19
src/pages/Vucem.jsx Normal file
View File

@@ -0,0 +1,19 @@
import React from 'react';
export default function Vucem() {
return (
<div className="p-8 min-h-screen bg-gradient-to-br from-blue-50 to-blue-100 flex flex-col items-center justify-center">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-xl w-full text-center">
<h1 className="text-3xl font-extrabold text-blue-800 mb-4">Vucem</h1>
<p className="text-gray-600 mb-6">Esta es la vista de integración con VUCEM. Aquí podrás consultar, gestionar o integrar funcionalidades relacionadas con la Ventanilla Única de Comercio Exterior Mexicana.</p>
<div className="flex flex-col gap-4 items-center">
<svg className="w-16 h-16 text-blue-400 mx-auto" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 12h8M12 8v8" />
</svg>
<span className="text-blue-700 font-semibold">Próximamente podrás ver información y acciones de VUCEM aquí.</span>
</div>
</div>
</div>
);
}

93
src/theme.js Normal file
View File

@@ -0,0 +1,93 @@
// Paleta de colores profesional para agencias aduanales
export const colors = {
// Colores principales
primary: {
navy: '#1B2A41',
navyDark: '#162234',
navyLight: '#263549',
lightGray: '#F2F4F7',
white: '#FFFFFF'
},
// Colores de acento
accent: {
success: '#2E7D32',
successLight: '#4CAF50',
successDark: '#1B5E20',
warning: '#F57C00',
warningLight: '#FF9800',
warningDark: '#E65100',
error: '#C62828',
errorLight: '#E53935',
errorDark: '#B71C1C',
info: '#4DA6FF',
infoLight: '#64B5F6',
infoDark: '#1976D2'
},
// Colores neutros para texto
text: {
primary: '#333333',
secondary: '#7A7A7A',
inverse: '#FFFFFF'
},
// Fondos
background: {
primary: '#F2F4F7',
card: '#FFFFFF',
overlay: 'rgba(27, 42, 65, 0.8)'
}
};
// Clases de Tailwind CSS pre-definidas para uso común
export const tailwindClasses = {
// Botones
button: {
primary: 'bg-navy hover:bg-navy-dark text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200',
secondary: 'bg-info hover:bg-info-dark text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200',
success: 'bg-success hover:bg-success-dark text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200',
warning: 'bg-warning hover:bg-warning-dark text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200',
error: 'bg-error hover:bg-error-dark text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200',
outline: 'border-2 border-navy text-navy hover:bg-navy hover:text-white font-semibold py-2 px-4 rounded-lg transition-all duration-200'
},
// Tarjetas
card: {
default: 'bg-card rounded-lg shadow-md border border-gray-200 p-6',
elevated: 'bg-card rounded-lg shadow-lg border border-gray-200 p-6 hover:shadow-xl transition-shadow duration-200'
},
// Inputs
input: {
default: 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-info focus:border-transparent transition-all duration-200',
error: 'w-full px-3 py-2 border border-error rounded-lg focus:outline-none focus:ring-2 focus:ring-error focus:border-transparent transition-all duration-200'
},
// Texto
text: {
heading: 'text-text-primary font-bold',
subheading: 'text-text-primary font-semibold',
body: 'text-text-primary',
caption: 'text-text-secondary text-sm',
inverse: 'text-text-inverse'
},
// Estados
status: {
success: 'bg-success-light text-white px-3 py-1 rounded-full text-sm font-medium',
warning: 'bg-warning text-white px-3 py-1 rounded-full text-sm font-medium',
error: 'bg-error text-white px-3 py-1 rounded-full text-sm font-medium',
info: 'bg-info text-white px-3 py-1 rounded-full text-sm font-medium'
}
};
// Gradientes personalizados
export const gradients = {
primaryHero: 'bg-gradient-to-br from-navy via-navy-light to-info-dark',
card: 'bg-gradient-to-r from-white to-light-gray-50',
button: 'bg-gradient-to-r from-navy to-navy-light',
accent: 'bg-gradient-to-r from-info to-info-dark'
};
export default { colors, tailwindClasses, gradients };

4
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
/// <reference types="vite/client" />
// Este archivo permite que TypeScript reconozca import.meta.env con las variables de Vite.
// No requiere más contenido, solo debe existir en el proyecto.

11
tailwind.config.cjs Normal file
View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

111
tailwind.config.js Normal file
View File

@@ -0,0 +1,111 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
// Azul marino (principal)
'navy': {
50: '#F0F4F8',
100: '#D9E2EC',
200: '#BCCCDC',
300: '#9FB3C8',
400: '#829AB1',
500: '#627D98',
600: '#486581',
700: '#334E68',
800: '#243B53',
900: '#1B2A41',
950: '#102A43'
},
// Azul empresarial (primary)
'primary': {
50: '#EBF5FF',
100: '#DBEAFE',
200: '#BFDBFE',
300: '#93C5FD',
400: '#60A5FA',
500: '#3B82F6',
600: '#2563EB',
700: '#1D4ED8',
800: '#1E40AF',
900: '#1E3A8A',
950: '#172554'
},
// Verde (success)
'success': {
50: '#F0FDF4',
100: '#DCFCE7',
200: '#BBF7D0',
300: '#86EFAC',
400: '#4ADE80',
500: '#22C55E',
600: '#16A34A',
700: '#15803D',
800: '#166534',
900: '#14532D',
950: '#052E16'
},
// Naranja (warning)
'warning': {
50: '#FFF7ED',
100: '#FFEDD5',
200: '#FED7AA',
300: '#FDBA74',
400: '#FB923C',
500: '#F97316',
600: '#EA580C',
700: '#C2410C',
800: '#9A3412',
900: '#7C2D12',
950: '#431407'
},
// Rojo (danger)
'danger': {
50: '#FEF2F2',
100: '#FEE2E2',
200: '#FECACA',
300: '#FCA5A5',
400: '#F87171',
500: '#EF4444',
600: '#DC2626',
700: '#B91C1C',
800: '#991B1B',
900: '#7F1D1D',
950: '#450A0A'
},
// Azul claro (accent)
'accent': {
50: '#F0F9FF',
100: '#E0F2FE',
200: '#BAE6FD',
300: '#7DD3FC',
400: '#38BDF8',
500: '#0EA5E9',
600: '#0284C7',
700: '#0369A1',
800: '#075985',
900: '#0C4A6E',
950: '#082F49'
},
// Gris claro actualizado
'light-gray': {
50: '#FAFBFC',
100: '#F2F4F7',
200: '#E8ECEF',
300: '#D1D9E0',
400: '#B0BCC9',
500: '#8DA2B5',
600: '#6B8AA3',
700: '#5A7590',
800: '#4A6374',
900: '#3D5161'
}
}
},
},
plugins: [],
}

18
vite.config.js Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from 'tailwindcss'
import autoprefixer from 'autoprefixer'
// https://vite.dev/config/
export default defineConfig({
base: '/',
plugins: [react()],
css: {
postcss: {
plugins: [
tailwindcss('./tailwind.config.cjs'),
autoprefixer,
],
},
},
})