Primera version de frontend
This commit is contained in:
4
.env
Normal file
4
.env
Normal 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
24
.gitignore
vendored
Normal 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
14
Dockerfile
Normal 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
22
Dockerfile.prod
Normal 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
15
docker-compose.prod.yml
Normal 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
33
eslint.config.js
Normal 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
13
index.html
Normal 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
29
nginx.conf
Normal 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
4418
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
package.json
Normal file
41
package.json
Normal 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
6
postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
0
public/debug.html
Normal file
0
public/debug.html
Normal file
1
public/vite.svg
Normal file
1
public/vite.svg
Normal 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
131
src/App.jsx
Normal 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
25
src/api/auth.js
Normal 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
60
src/api/documentos.ts
Normal 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
102
src/api/documents.js
Normal 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
0
src/api/documents.ts
Normal file
115
src/api/expedientes.ts
Normal file
115
src/api/expedientes.ts
Normal 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
68
src/api/notificaciones.ts
Normal 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
36
src/api/organizacion.js
Normal 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
33
src/api/organization.ts
Normal 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();
|
||||||
|
}
|
||||||
44
src/api/pedimentoDocuments.ts
Normal file
44
src/api/pedimentoDocuments.ts
Normal 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
33
src/api/procesos.ts
Normal 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
83
src/api/users.js
Normal 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
172
src/api/users.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/assets/organization-animations.css
Normal file
19
src/assets/organization-animations.css
Normal 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
1
src/assets/react.svg
Normal 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
17
src/components/Layout.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/components/LogoutButton.jsx
Normal file
28
src/components/LogoutButton.jsx
Normal 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
186
src/components/Navbar.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
src/components/NotificationBell.jsx
Normal file
222
src/components/NotificationBell.jsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/components/RequireAuth.jsx
Normal file
22
src/components/RequireAuth.jsx
Normal 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
370
src/components/Sidebar.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
src/components/Sidebar.jsx.bak
Normal file
211
src/components/Sidebar.jsx.bak
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
src/components/SidebarNew.jsx
Normal file
207
src/components/SidebarNew.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/SuccessModal.jsx
Normal file
25
src/components/SuccessModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/components/TestTailwind.jsx
Normal file
10
src/components/TestTailwind.jsx
Normal 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
0
src/components/card.jsx
Normal file
94
src/context/NotificationContext.jsx
Normal file
94
src/context/NotificationContext.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/context/UserContext.jsx
Normal file
90
src/context/UserContext.jsx
Normal 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
0
src/fetchWithAuth.js
Normal file
73
src/hooks/usePolling.js
Normal file
73
src/hooks/usePolling.js
Normal 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
49
src/hooks/useWebSocket.js
Normal 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
21
src/index.css
Normal 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
19
src/main.jsx
Normal 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
373
src/pages/Admin.jsx
Normal 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
13
src/pages/Debug.jsx
Normal 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
645
src/pages/Documents.jsx
Normal 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}> </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
578
src/pages/Expedientes.jsx
Normal 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">Sí</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">Sí</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}> </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>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
src/pages/ForgotPassword.jsx
Normal file
195
src/pages/ForgotPassword.jsx
Normal 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
427
src/pages/Importers.jsx
Normal 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
785
src/pages/Landing.jsx
Normal 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">
|
||||||
|
© 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
998
src/pages/LandingAnimated.jsx
Normal file
998
src/pages/LandingAnimated.jsx
Normal 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>
|
||||||
|
© 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
756
src/pages/LandingNew.jsx
Normal 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>
|
||||||
|
© 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
313
src/pages/Login.jsx
Normal 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
391
src/pages/LoginBroken.jsx
Normal 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
286
src/pages/LoginFixed.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
src/pages/Notificaciones.jsx
Normal file
158
src/pages/Notificaciones.jsx
Normal 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">Sí</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
300
src/pages/Organization.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
src/pages/PasswordResetConfirm.jsx
Normal file
212
src/pages/PasswordResetConfirm.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
930
src/pages/PedimentoDetail.jsx
Normal file
930
src/pages/PedimentoDetail.jsx
Normal 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}> </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
385
src/pages/Procesos.jsx
Normal 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
80
src/pages/Reportes.jsx
Normal 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
316
src/pages/Reports.jsx
Normal 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
431
src/pages/Settings.jsx
Normal 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
425
src/pages/SettingsNew.jsx
Normal 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;
|
||||||
326
src/pages/TableroAlmacenamiento.jsx
Normal file
326
src/pages/TableroAlmacenamiento.jsx
Normal 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
20
src/pages/Test.jsx
Normal 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
1243
src/pages/Users.jsx
Normal file
File diff suppressed because it is too large
Load Diff
723
src/pages/UsersNew.jsx
Normal file
723
src/pages/UsersNew.jsx
Normal 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
19
src/pages/Vucem.jsx
Normal 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
93
src/theme.js
Normal 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
4
src/vite-env.d.ts
vendored
Normal 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
11
tailwind.config.cjs
Normal 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
111
tailwind.config.js
Normal 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
18
vite.config.js
Normal 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,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user