FlotillasGPS - Sistema completo de monitoreo de flotillas GPS
Sistema completo para monitoreo y gestion de flotas de vehiculos con: - Backend FastAPI con PostgreSQL/TimescaleDB - Frontend React con TypeScript y TailwindCSS - App movil React Native con Expo - Soporte para dispositivos GPS, Meshtastic y celulares - Video streaming en vivo con MediaMTX - Geocercas, alertas, viajes y reportes - Autenticacion JWT y WebSockets en tiempo real Documentacion completa y guias de usuario incluidas.
This commit is contained in:
45
frontend/.dockerignore
Normal file
45
frontend/.dockerignore
Normal file
@@ -0,0 +1,45 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Build output
|
||||
dist
|
||||
build
|
||||
|
||||
# Development files
|
||||
.git
|
||||
.gitignore
|
||||
.env.local
|
||||
.env.development
|
||||
.env.test
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
*.test.ts
|
||||
*.test.tsx
|
||||
*.spec.ts
|
||||
*.spec.tsx
|
||||
__tests__
|
||||
jest.config.*
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
docs
|
||||
*.md
|
||||
|
||||
# Docker
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
.dockerignore
|
||||
36
frontend/Dockerfile
Normal file
36
frontend/Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine AS production
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built files from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Add healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80/health || exit 1
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
19
frontend/index.html
Normal file
19
frontend/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Sistema de Monitoreo de Flotillas GPS" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<title>Flotillas GPS - Sistema de Monitoreo</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
||||
</head>
|
||||
<body class="bg-background-900 text-white antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
104
frontend/nginx.conf
Normal file
104
frontend/nginx.conf
Normal file
@@ -0,0 +1,104 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
|
||||
gzip_disable "MSIE [1-6]\.";
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Static assets - long cache
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Favicon and static files
|
||||
location ~* \.(ico|svg|png|jpg|jpeg|gif|webp|woff|woff2|ttf|eot)$ {
|
||||
expires 1M;
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# JavaScript and CSS with content hash
|
||||
location ~* \.(js|css)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# API proxy (if needed - configure backend URL)
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
# WebSocket proxy
|
||||
location /ws/ {
|
||||
proxy_pass http://backend:8000/ws/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
# SPA fallback - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# No cache for index.html
|
||||
location = /index.html {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
}
|
||||
|
||||
# Deny access to hidden files
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 404 /index.html;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
44
frontend/package.json
Normal file
44
frontend/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "flotillas-gps-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.18",
|
||||
"@heroicons/react": "^2.1.1",
|
||||
"@tanstack/react-query": "^5.17.19",
|
||||
"axios": "^1.6.5",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.3.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.21.3",
|
||||
"recharts": "^2.10.4",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/node": "^20.11.5",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.12"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
14
frontend/public/favicon.svg
Normal file
14
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3b82f6"/>
|
||||
<stop offset="100%" style="stop-color:#1d4ed8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="45" fill="url(#grad)"/>
|
||||
<path d="M50 20 L50 55 M50 55 L35 40 M50 55 L65 40" stroke="white" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<circle cx="50" cy="65" r="8" fill="white"/>
|
||||
<circle cx="50" cy="50" r="35" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="2" stroke-dasharray="5,5">
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 50 50" to="360 50 50" dur="20s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 791 B |
95
frontend/src/App.tsx
Normal file
95
frontend/src/App.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import MainLayout from './components/layout/MainLayout'
|
||||
|
||||
// Pages
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Mapa from './pages/Mapa'
|
||||
import Vehiculos from './pages/Vehiculos'
|
||||
import VehiculoDetalle from './pages/VehiculoDetalle'
|
||||
import Conductores from './pages/Conductores'
|
||||
import Alertas from './pages/Alertas'
|
||||
import Viajes from './pages/Viajes'
|
||||
import ViajeReplay from './pages/ViajeReplay'
|
||||
import VideoLive from './pages/VideoLive'
|
||||
import Grabaciones from './pages/Grabaciones'
|
||||
import Geocercas from './pages/Geocercas'
|
||||
import POIs from './pages/POIs'
|
||||
import Combustible from './pages/Combustible'
|
||||
import Mantenimiento from './pages/Mantenimiento'
|
||||
import Reportes from './pages/Reportes'
|
||||
import Configuracion from './pages/Configuracion'
|
||||
import NotFound from './pages/NotFound'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: ProtectedRouteProps) {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<Login />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="mapa" element={<Mapa />} />
|
||||
<Route path="vehiculos" element={<Vehiculos />} />
|
||||
<Route path="vehiculos/:id" element={<VehiculoDetalle />} />
|
||||
<Route path="conductores" element={<Conductores />} />
|
||||
<Route path="alertas" element={<Alertas />} />
|
||||
<Route path="viajes" element={<Viajes />} />
|
||||
<Route path="viajes/:id/replay" element={<ViajeReplay />} />
|
||||
<Route path="video" element={<VideoLive />} />
|
||||
<Route path="grabaciones" element={<Grabaciones />} />
|
||||
<Route path="geocercas" element={<Geocercas />} />
|
||||
<Route path="pois" element={<POIs />} />
|
||||
<Route path="combustible" element={<Combustible />} />
|
||||
<Route path="mantenimiento" element={<Mantenimiento />} />
|
||||
<Route path="reportes" element={<Reportes />} />
|
||||
<Route path="configuracion" element={<Configuracion />} />
|
||||
</Route>
|
||||
|
||||
{/* 404 */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
79
frontend/src/api/alertas.ts
Normal file
79
frontend/src/api/alertas.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { api } from './client'
|
||||
import {
|
||||
Alerta,
|
||||
AlertaCreate,
|
||||
AlertaConfiguracion,
|
||||
FiltrosAlertas,
|
||||
PaginatedResponse,
|
||||
} from '@/types'
|
||||
|
||||
export const alertasApi = {
|
||||
// List alertas with pagination and filters
|
||||
list: (params?: Partial<FiltrosAlertas> & {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<PaginatedResponse<Alerta>> => {
|
||||
return api.get<PaginatedResponse<Alerta>>('/alertas', params)
|
||||
},
|
||||
|
||||
// Get active alerts
|
||||
getActivas: (): Promise<Alerta[]> => {
|
||||
return api.get<Alerta[]>('/alertas/activas')
|
||||
},
|
||||
|
||||
// Get single alerta
|
||||
get: (id: string): Promise<Alerta> => {
|
||||
return api.get<Alerta>(`/alertas/${id}`)
|
||||
},
|
||||
|
||||
// Create manual alerta
|
||||
create: (data: AlertaCreate): Promise<Alerta> => {
|
||||
return api.post<Alerta>('/alertas', data)
|
||||
},
|
||||
|
||||
// Acknowledge alerta
|
||||
reconocer: (id: string, notas?: string): Promise<Alerta> => {
|
||||
return api.post<Alerta>(`/alertas/${id}/reconocer`, { notas })
|
||||
},
|
||||
|
||||
// Resolve alerta
|
||||
resolver: (id: string, notas?: string): Promise<Alerta> => {
|
||||
return api.post<Alerta>(`/alertas/${id}/resolver`, { notas })
|
||||
},
|
||||
|
||||
// Ignore alerta
|
||||
ignorar: (id: string, notas?: string): Promise<Alerta> => {
|
||||
return api.post<Alerta>(`/alertas/${id}/ignorar`, { notas })
|
||||
},
|
||||
|
||||
// Get alert count by type
|
||||
getConteo: (): Promise<Record<string, number>> => {
|
||||
return api.get('/alertas/conteo')
|
||||
},
|
||||
|
||||
// Get alert statistics
|
||||
getStats: (params?: { desde?: string; hasta?: string }): Promise<{
|
||||
total: number
|
||||
porTipo: Record<string, number>
|
||||
porPrioridad: Record<string, number>
|
||||
porEstado: Record<string, number>
|
||||
tendencia: Array<{ fecha: string; cantidad: number }>
|
||||
}> => {
|
||||
return api.get('/alertas/stats', params)
|
||||
},
|
||||
|
||||
// Get alert configuration
|
||||
getConfiguracion: (): Promise<AlertaConfiguracion[]> => {
|
||||
return api.get<AlertaConfiguracion[]>('/alertas/configuracion')
|
||||
},
|
||||
|
||||
// Update alert configuration
|
||||
updateConfiguracion: (config: AlertaConfiguracion[]): Promise<AlertaConfiguracion[]> => {
|
||||
return api.put<AlertaConfiguracion[]>('/alertas/configuracion', { configuracion: config })
|
||||
},
|
||||
|
||||
// Mark all as read
|
||||
marcarTodasLeidas: (): Promise<void> => {
|
||||
return api.post('/alertas/marcar-leidas')
|
||||
},
|
||||
}
|
||||
42
frontend/src/api/auth.ts
Normal file
42
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { api, setTokens, clearTokens } from './client'
|
||||
import { AuthResponse, LoginCredentials, User } from '@/types'
|
||||
|
||||
export const authApi = {
|
||||
login: async (credentials: LoginCredentials): Promise<AuthResponse> => {
|
||||
const response = await api.post<AuthResponse>('/auth/login', credentials)
|
||||
setTokens(response.accessToken, response.refreshToken)
|
||||
return response
|
||||
},
|
||||
|
||||
logout: async (): Promise<void> => {
|
||||
try {
|
||||
await api.post('/auth/logout')
|
||||
} finally {
|
||||
clearTokens()
|
||||
}
|
||||
},
|
||||
|
||||
getProfile: (): Promise<User> => {
|
||||
return api.get<User>('/auth/me')
|
||||
},
|
||||
|
||||
updateProfile: (data: Partial<User>): Promise<User> => {
|
||||
return api.patch<User>('/auth/me', data)
|
||||
},
|
||||
|
||||
changePassword: (data: { currentPassword: string; newPassword: string }): Promise<void> => {
|
||||
return api.post('/auth/change-password', data)
|
||||
},
|
||||
|
||||
requestPasswordReset: (email: string): Promise<void> => {
|
||||
return api.post('/auth/forgot-password', { email })
|
||||
},
|
||||
|
||||
resetPassword: (data: { token: string; password: string }): Promise<void> => {
|
||||
return api.post('/auth/reset-password', data)
|
||||
},
|
||||
|
||||
refreshToken: (refreshToken: string): Promise<AuthResponse> => {
|
||||
return api.post<AuthResponse>('/auth/refresh', { refreshToken })
|
||||
},
|
||||
}
|
||||
152
frontend/src/api/client.ts
Normal file
152
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
|
||||
// Create axios instance
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// Token storage
|
||||
const TOKEN_KEY = 'flotillas_access_token'
|
||||
const REFRESH_TOKEN_KEY = 'flotillas_refresh_token'
|
||||
|
||||
export const getAccessToken = (): string | null => {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export const getRefreshToken = (): string | null => {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY)
|
||||
}
|
||||
|
||||
export const setTokens = (accessToken: string, refreshToken: string): void => {
|
||||
localStorage.setItem(TOKEN_KEY, accessToken)
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken)
|
||||
}
|
||||
|
||||
export const clearTokens = (): void => {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
}
|
||||
|
||||
// Request interceptor - Add auth token
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = getAccessToken()
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor - Handle token refresh and errors
|
||||
let isRefreshing = false
|
||||
let failedQueue: Array<{
|
||||
resolve: (value?: unknown) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}> = []
|
||||
|
||||
const processQueue = (error: Error | null, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error)
|
||||
} else {
|
||||
prom.resolve(token)
|
||||
}
|
||||
})
|
||||
failedQueue = []
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
// Handle 401 Unauthorized
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject })
|
||||
})
|
||||
.then((token) => {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return apiClient(originalRequest)
|
||||
})
|
||||
.catch((err) => Promise.reject(err))
|
||||
}
|
||||
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
const refreshToken = getRefreshToken()
|
||||
|
||||
if (!refreshToken) {
|
||||
clearTokens()
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
||||
refreshToken,
|
||||
})
|
||||
|
||||
const { accessToken, refreshToken: newRefreshToken } = response.data
|
||||
setTokens(accessToken, newRefreshToken)
|
||||
|
||||
processQueue(null, accessToken)
|
||||
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`
|
||||
}
|
||||
|
||||
return apiClient(originalRequest)
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError as Error, null)
|
||||
clearTokens()
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
const errorMessage =
|
||||
(error.response?.data as { message?: string })?.message ||
|
||||
error.message ||
|
||||
'Error de conexion'
|
||||
|
||||
return Promise.reject(new Error(errorMessage))
|
||||
}
|
||||
)
|
||||
|
||||
export default apiClient
|
||||
|
||||
// Helper functions for common HTTP methods
|
||||
export const api = {
|
||||
get: <T>(url: string, params?: object) =>
|
||||
apiClient.get<T>(url, { params }).then((res) => res.data),
|
||||
|
||||
post: <T>(url: string, data?: object) =>
|
||||
apiClient.post<T>(url, data).then((res) => res.data),
|
||||
|
||||
put: <T>(url: string, data?: object) =>
|
||||
apiClient.put<T>(url, data).then((res) => res.data),
|
||||
|
||||
patch: <T>(url: string, data?: object) =>
|
||||
apiClient.patch<T>(url, data).then((res) => res.data),
|
||||
|
||||
delete: <T>(url: string) =>
|
||||
apiClient.delete<T>(url).then((res) => res.data),
|
||||
}
|
||||
68
frontend/src/api/conductores.ts
Normal file
68
frontend/src/api/conductores.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { api } from './client'
|
||||
import {
|
||||
Conductor,
|
||||
ConductorCreate,
|
||||
ConductorUpdate,
|
||||
PaginatedResponse,
|
||||
} from '@/types'
|
||||
|
||||
export const conductoresApi = {
|
||||
// List conductores with pagination
|
||||
list: (params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
busqueda?: string
|
||||
estado?: string
|
||||
activo?: boolean
|
||||
}): Promise<PaginatedResponse<Conductor>> => {
|
||||
return api.get<PaginatedResponse<Conductor>>('/conductores', params)
|
||||
},
|
||||
|
||||
// Get all conductores
|
||||
listAll: (): Promise<Conductor[]> => {
|
||||
return api.get<Conductor[]>('/conductores/all')
|
||||
},
|
||||
|
||||
// Get single conductor
|
||||
get: (id: string): Promise<Conductor> => {
|
||||
return api.get<Conductor>(`/conductores/${id}`)
|
||||
},
|
||||
|
||||
// Create conductor
|
||||
create: (data: ConductorCreate): Promise<Conductor> => {
|
||||
return api.post<Conductor>('/conductores', data)
|
||||
},
|
||||
|
||||
// Update conductor
|
||||
update: (id: string, data: ConductorUpdate): Promise<Conductor> => {
|
||||
return api.patch<Conductor>(`/conductores/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete conductor
|
||||
delete: (id: string): Promise<void> => {
|
||||
return api.delete(`/conductores/${id}`)
|
||||
},
|
||||
|
||||
// Get conductor stats
|
||||
getStats: (id: string, periodo?: 'dia' | 'semana' | 'mes'): Promise<{
|
||||
viajes: number
|
||||
kilometros: number
|
||||
horasConduccion: number
|
||||
alertas: number
|
||||
calificacion: number
|
||||
}> => {
|
||||
return api.get(`/conductores/${id}/stats`, { periodo })
|
||||
},
|
||||
|
||||
// Get available conductores (not assigned to a vehicle)
|
||||
getDisponibles: (): Promise<Conductor[]> => {
|
||||
return api.get<Conductor[]>('/conductores/disponibles')
|
||||
},
|
||||
|
||||
// Update conductor photo
|
||||
updateFoto: (id: string, file: File): Promise<Conductor> => {
|
||||
const formData = new FormData()
|
||||
formData.append('foto', file)
|
||||
return api.post<Conductor>(`/conductores/${id}/foto`, formData)
|
||||
},
|
||||
}
|
||||
77
frontend/src/api/geocercas.ts
Normal file
77
frontend/src/api/geocercas.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { api } from './client'
|
||||
import {
|
||||
Geocerca,
|
||||
GeocercaCreate,
|
||||
PaginatedResponse,
|
||||
} from '@/types'
|
||||
|
||||
export const geocercasApi = {
|
||||
// List geocercas
|
||||
list: (params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
busqueda?: string
|
||||
tipo?: string
|
||||
activa?: boolean
|
||||
}): Promise<PaginatedResponse<Geocerca>> => {
|
||||
return api.get<PaginatedResponse<Geocerca>>('/geocercas', params)
|
||||
},
|
||||
|
||||
// Get all geocercas (for map display)
|
||||
listAll: (): Promise<Geocerca[]> => {
|
||||
return api.get<Geocerca[]>('/geocercas/all')
|
||||
},
|
||||
|
||||
// Get single geocerca
|
||||
get: (id: string): Promise<Geocerca> => {
|
||||
return api.get<Geocerca>(`/geocercas/${id}`)
|
||||
},
|
||||
|
||||
// Create geocerca
|
||||
create: (data: GeocercaCreate): Promise<Geocerca> => {
|
||||
return api.post<Geocerca>('/geocercas', data)
|
||||
},
|
||||
|
||||
// Update geocerca
|
||||
update: (id: string, data: Partial<GeocercaCreate>): Promise<Geocerca> => {
|
||||
return api.patch<Geocerca>(`/geocercas/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete geocerca
|
||||
delete: (id: string): Promise<void> => {
|
||||
return api.delete(`/geocercas/${id}`)
|
||||
},
|
||||
|
||||
// Toggle geocerca active status
|
||||
toggleActiva: (id: string): Promise<Geocerca> => {
|
||||
return api.post<Geocerca>(`/geocercas/${id}/toggle`)
|
||||
},
|
||||
|
||||
// Assign vehiculos to geocerca
|
||||
assignVehiculos: (id: string, vehiculoIds: string[]): Promise<Geocerca> => {
|
||||
return api.post<Geocerca>(`/geocercas/${id}/vehiculos`, { vehiculoIds })
|
||||
},
|
||||
|
||||
// Check if point is inside geocerca
|
||||
checkPunto: (id: string, lat: number, lng: number): Promise<boolean> => {
|
||||
return api.post<boolean>(`/geocercas/${id}/check`, { lat, lng })
|
||||
},
|
||||
|
||||
// Get vehiculos currently inside geocerca
|
||||
getVehiculosDentro: (id: string): Promise<string[]> => {
|
||||
return api.get<string[]>(`/geocercas/${id}/vehiculos-dentro`)
|
||||
},
|
||||
|
||||
// Get geocerca events/history
|
||||
getEventos: (id: string, params?: {
|
||||
desde?: string
|
||||
hasta?: string
|
||||
tipo?: 'entrada' | 'salida'
|
||||
}): Promise<Array<{
|
||||
vehiculoId: string
|
||||
tipo: 'entrada' | 'salida'
|
||||
timestamp: string
|
||||
}>> => {
|
||||
return api.get(`/geocercas/${id}/eventos`, params)
|
||||
},
|
||||
}
|
||||
162
frontend/src/api/index.ts
Normal file
162
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
export { default as apiClient, api } from './client'
|
||||
export { wsClient } from './websocket'
|
||||
export { authApi } from './auth'
|
||||
export { vehiculosApi } from './vehiculos'
|
||||
export { conductoresApi } from './conductores'
|
||||
export { alertasApi } from './alertas'
|
||||
export { viajesApi } from './viajes'
|
||||
export { geocercasApi } from './geocercas'
|
||||
export { videoApi } from './video'
|
||||
export { reportesApi } from './reportes'
|
||||
|
||||
// POIs API
|
||||
import { api } from './client'
|
||||
import { POI, POICreate, PaginatedResponse } from '@/types'
|
||||
|
||||
export const poisApi = {
|
||||
list: (params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
categoria?: string
|
||||
busqueda?: string
|
||||
}): Promise<PaginatedResponse<POI>> => {
|
||||
return api.get<PaginatedResponse<POI>>('/pois', params)
|
||||
},
|
||||
|
||||
listAll: (): Promise<POI[]> => {
|
||||
return api.get<POI[]>('/pois/all')
|
||||
},
|
||||
|
||||
get: (id: string): Promise<POI> => {
|
||||
return api.get<POI>(`/pois/${id}`)
|
||||
},
|
||||
|
||||
create: (data: POICreate): Promise<POI> => {
|
||||
return api.post<POI>('/pois', data)
|
||||
},
|
||||
|
||||
update: (id: string, data: Partial<POICreate>): Promise<POI> => {
|
||||
return api.patch<POI>(`/pois/${id}`, data)
|
||||
},
|
||||
|
||||
delete: (id: string): Promise<void> => {
|
||||
return api.delete(`/pois/${id}`)
|
||||
},
|
||||
}
|
||||
|
||||
// Combustible API
|
||||
import { CargaCombustible, CargaCombustibleCreate } from '@/types'
|
||||
|
||||
export const combustibleApi = {
|
||||
list: (params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
vehiculoId?: string
|
||||
desde?: string
|
||||
hasta?: string
|
||||
}): Promise<PaginatedResponse<CargaCombustible>> => {
|
||||
return api.get<PaginatedResponse<CargaCombustible>>('/combustible', params)
|
||||
},
|
||||
|
||||
get: (id: string): Promise<CargaCombustible> => {
|
||||
return api.get<CargaCombustible>(`/combustible/${id}`)
|
||||
},
|
||||
|
||||
create: (data: CargaCombustibleCreate): Promise<CargaCombustible> => {
|
||||
return api.post<CargaCombustible>('/combustible', data)
|
||||
},
|
||||
|
||||
update: (id: string, data: Partial<CargaCombustibleCreate>): Promise<CargaCombustible> => {
|
||||
return api.patch<CargaCombustible>(`/combustible/${id}`, data)
|
||||
},
|
||||
|
||||
delete: (id: string): Promise<void> => {
|
||||
return api.delete(`/combustible/${id}`)
|
||||
},
|
||||
|
||||
getStats: (params?: {
|
||||
vehiculoId?: string
|
||||
desde?: string
|
||||
hasta?: string
|
||||
}): Promise<{
|
||||
totalLitros: number
|
||||
totalCosto: number
|
||||
rendimientoPromedio: number
|
||||
porVehiculo: Array<{
|
||||
vehiculoId: string
|
||||
litros: number
|
||||
costo: number
|
||||
rendimiento: number
|
||||
}>
|
||||
}> => {
|
||||
return api.get('/combustible/stats', params)
|
||||
},
|
||||
}
|
||||
|
||||
// Mantenimiento API
|
||||
import { Mantenimiento, MantenimientoCreate } from '@/types'
|
||||
|
||||
export const mantenimientoApi = {
|
||||
list: (params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
vehiculoId?: string
|
||||
tipo?: string
|
||||
estado?: string
|
||||
desde?: string
|
||||
hasta?: string
|
||||
}): Promise<PaginatedResponse<Mantenimiento>> => {
|
||||
return api.get<PaginatedResponse<Mantenimiento>>('/mantenimiento', params)
|
||||
},
|
||||
|
||||
get: (id: string): Promise<Mantenimiento> => {
|
||||
return api.get<Mantenimiento>(`/mantenimiento/${id}`)
|
||||
},
|
||||
|
||||
create: (data: MantenimientoCreate): Promise<Mantenimiento> => {
|
||||
return api.post<Mantenimiento>('/mantenimiento', data)
|
||||
},
|
||||
|
||||
update: (id: string, data: Partial<MantenimientoCreate>): Promise<Mantenimiento> => {
|
||||
return api.patch<Mantenimiento>(`/mantenimiento/${id}`, data)
|
||||
},
|
||||
|
||||
delete: (id: string): Promise<void> => {
|
||||
return api.delete(`/mantenimiento/${id}`)
|
||||
},
|
||||
|
||||
completar: (id: string, data: {
|
||||
fechaRealizada: string
|
||||
costo?: number
|
||||
notas?: string
|
||||
}): Promise<Mantenimiento> => {
|
||||
return api.post<Mantenimiento>(`/mantenimiento/${id}/completar`, data)
|
||||
},
|
||||
|
||||
getProximos: (dias?: number): Promise<Mantenimiento[]> => {
|
||||
return api.get<Mantenimiento[]>('/mantenimiento/proximos', { dias })
|
||||
},
|
||||
|
||||
getVencidos: (): Promise<Mantenimiento[]> => {
|
||||
return api.get<Mantenimiento[]>('/mantenimiento/vencidos')
|
||||
},
|
||||
}
|
||||
|
||||
// Configuracion API
|
||||
import { ConfiguracionSistema } from '@/types'
|
||||
|
||||
export const configuracionApi = {
|
||||
get: (): Promise<ConfiguracionSistema> => {
|
||||
return api.get<ConfiguracionSistema>('/configuracion')
|
||||
},
|
||||
|
||||
update: (data: Partial<ConfiguracionSistema>): Promise<ConfiguracionSistema> => {
|
||||
return api.patch<ConfiguracionSistema>('/configuracion', data)
|
||||
},
|
||||
|
||||
updateLogo: (file: File): Promise<{ url: string }> => {
|
||||
const formData = new FormData()
|
||||
formData.append('logo', file)
|
||||
return api.post('/configuracion/logo', formData)
|
||||
},
|
||||
}
|
||||
89
frontend/src/api/reportes.ts
Normal file
89
frontend/src/api/reportes.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { api } from './client'
|
||||
import {
|
||||
Reporte,
|
||||
ReporteConfiguracion,
|
||||
ReporteTipo,
|
||||
PaginatedResponse,
|
||||
} from '@/types'
|
||||
|
||||
export const reportesApi = {
|
||||
// List generated reportes
|
||||
list: (params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
tipo?: ReporteTipo
|
||||
estado?: string
|
||||
desde?: string
|
||||
hasta?: string
|
||||
}): Promise<PaginatedResponse<Reporte>> => {
|
||||
return api.get<PaginatedResponse<Reporte>>('/reportes', params)
|
||||
},
|
||||
|
||||
// Get single reporte
|
||||
get: (id: string): Promise<Reporte> => {
|
||||
return api.get<Reporte>(`/reportes/${id}`)
|
||||
},
|
||||
|
||||
// Generate new reporte
|
||||
generar: (config: ReporteConfiguracion): Promise<Reporte> => {
|
||||
return api.post<Reporte>('/reportes/generar', config)
|
||||
},
|
||||
|
||||
// Download reporte
|
||||
download: (id: string): Promise<Blob> => {
|
||||
return api.get(`/reportes/${id}/download`)
|
||||
},
|
||||
|
||||
// Delete reporte
|
||||
delete: (id: string): Promise<void> => {
|
||||
return api.delete(`/reportes/${id}`)
|
||||
},
|
||||
|
||||
// Get reporte templates
|
||||
getTemplates: (): Promise<Array<{
|
||||
tipo: ReporteTipo
|
||||
nombre: string
|
||||
descripcion: string
|
||||
campos: string[]
|
||||
}>> => {
|
||||
return api.get('/reportes/templates')
|
||||
},
|
||||
|
||||
// Get scheduled reportes
|
||||
getScheduled: (): Promise<ReporteConfiguracion[]> => {
|
||||
return api.get<ReporteConfiguracion[]>('/reportes/programados')
|
||||
},
|
||||
|
||||
// Schedule a reporte
|
||||
schedule: (config: ReporteConfiguracion): Promise<ReporteConfiguracion> => {
|
||||
return api.post<ReporteConfiguracion>('/reportes/programar', config)
|
||||
},
|
||||
|
||||
// Update scheduled reporte
|
||||
updateScheduled: (id: string, config: Partial<ReporteConfiguracion>): Promise<ReporteConfiguracion> => {
|
||||
return api.patch<ReporteConfiguracion>(`/reportes/programados/${id}`, config)
|
||||
},
|
||||
|
||||
// Delete scheduled reporte
|
||||
deleteScheduled: (id: string): Promise<void> => {
|
||||
return api.delete(`/reportes/programados/${id}`)
|
||||
},
|
||||
|
||||
// Preview reporte data (without generating file)
|
||||
preview: (config: ReporteConfiguracion): Promise<{
|
||||
columnas: string[]
|
||||
filas: Array<Record<string, unknown>>
|
||||
resumen: Record<string, unknown>
|
||||
}> => {
|
||||
return api.post('/reportes/preview', config)
|
||||
},
|
||||
|
||||
// Get reporte statistics
|
||||
getStats: (): Promise<{
|
||||
totalGenerados: number
|
||||
porTipo: Record<ReporteTipo, number>
|
||||
ultimosGenerados: Reporte[]
|
||||
}> => {
|
||||
return api.get('/reportes/stats')
|
||||
},
|
||||
}
|
||||
84
frontend/src/api/vehiculos.ts
Normal file
84
frontend/src/api/vehiculos.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { api } from './client'
|
||||
import {
|
||||
Vehiculo,
|
||||
VehiculoCreate,
|
||||
VehiculoUpdate,
|
||||
VehiculoStats,
|
||||
PaginatedResponse,
|
||||
Ubicacion,
|
||||
FiltrosVehiculos,
|
||||
} from '@/types'
|
||||
|
||||
export const vehiculosApi = {
|
||||
// List vehiculos with pagination and filters
|
||||
list: (params?: Partial<FiltrosVehiculos> & { page?: number; pageSize?: number }): Promise<PaginatedResponse<Vehiculo>> => {
|
||||
return api.get<PaginatedResponse<Vehiculo>>('/vehiculos', params)
|
||||
},
|
||||
|
||||
// Get all vehiculos (for map/dashboard)
|
||||
listAll: (): Promise<Vehiculo[]> => {
|
||||
return api.get<Vehiculo[]>('/vehiculos/all')
|
||||
},
|
||||
|
||||
// Get single vehiculo
|
||||
get: (id: string): Promise<Vehiculo> => {
|
||||
return api.get<Vehiculo>(`/vehiculos/${id}`)
|
||||
},
|
||||
|
||||
// Create vehiculo
|
||||
create: (data: VehiculoCreate): Promise<Vehiculo> => {
|
||||
return api.post<Vehiculo>('/vehiculos', data)
|
||||
},
|
||||
|
||||
// Update vehiculo
|
||||
update: (id: string, data: VehiculoUpdate): Promise<Vehiculo> => {
|
||||
return api.patch<Vehiculo>(`/vehiculos/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete vehiculo
|
||||
delete: (id: string): Promise<void> => {
|
||||
return api.delete(`/vehiculos/${id}`)
|
||||
},
|
||||
|
||||
// Get vehiculo stats
|
||||
getStats: (id: string, periodo?: 'dia' | 'semana' | 'mes'): Promise<VehiculoStats> => {
|
||||
return api.get<VehiculoStats>(`/vehiculos/${id}/stats`, { periodo })
|
||||
},
|
||||
|
||||
// Get vehiculo ubicacion history
|
||||
getUbicaciones: (
|
||||
id: string,
|
||||
params: { desde: string; hasta: string }
|
||||
): Promise<Ubicacion[]> => {
|
||||
return api.get<Ubicacion[]>(`/vehiculos/${id}/ubicaciones`, params)
|
||||
},
|
||||
|
||||
// Get current ubicacion for all vehiculos
|
||||
getUbicacionesActuales: (): Promise<Ubicacion[]> => {
|
||||
return api.get<Ubicacion[]>('/vehiculos/ubicaciones/actuales')
|
||||
},
|
||||
|
||||
// Assign conductor to vehiculo
|
||||
assignConductor: (vehiculoId: string, conductorId: string): Promise<Vehiculo> => {
|
||||
return api.post<Vehiculo>(`/vehiculos/${vehiculoId}/conductor`, { conductorId })
|
||||
},
|
||||
|
||||
// Remove conductor from vehiculo
|
||||
removeConductor: (vehiculoId: string): Promise<Vehiculo> => {
|
||||
return api.delete<Vehiculo>(`/vehiculos/${vehiculoId}/conductor`)
|
||||
},
|
||||
|
||||
// Get fleet summary stats
|
||||
getFleetStats: (): Promise<{
|
||||
total: number
|
||||
activos: number
|
||||
inactivos: number
|
||||
mantenimiento: number
|
||||
enMovimiento: number
|
||||
detenidos: number
|
||||
sinSenal: number
|
||||
alertasActivas: number
|
||||
}> => {
|
||||
return api.get('/vehiculos/fleet/stats')
|
||||
},
|
||||
}
|
||||
95
frontend/src/api/viajes.ts
Normal file
95
frontend/src/api/viajes.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { api } from './client'
|
||||
import {
|
||||
Viaje,
|
||||
Parada,
|
||||
EventoViaje,
|
||||
ViajeReplayData,
|
||||
PaginatedResponse,
|
||||
} from '@/types'
|
||||
|
||||
export const viajesApi = {
|
||||
// List viajes with pagination
|
||||
list: (params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
vehiculoId?: string
|
||||
conductorId?: string
|
||||
estado?: string
|
||||
desde?: string
|
||||
hasta?: string
|
||||
}): Promise<PaginatedResponse<Viaje>> => {
|
||||
return api.get<PaginatedResponse<Viaje>>('/viajes', params)
|
||||
},
|
||||
|
||||
// Get single viaje
|
||||
get: (id: string): Promise<Viaje> => {
|
||||
return api.get<Viaje>(`/viajes/${id}`)
|
||||
},
|
||||
|
||||
// Get viaje replay data (includes route points, events, recordings)
|
||||
getReplayData: (id: string): Promise<ViajeReplayData> => {
|
||||
return api.get<ViajeReplayData>(`/viajes/${id}/replay`)
|
||||
},
|
||||
|
||||
// Get viaje route
|
||||
getRuta: (id: string): Promise<Array<{
|
||||
lat: number
|
||||
lng: number
|
||||
timestamp: string
|
||||
velocidad: number
|
||||
}>> => {
|
||||
return api.get(`/viajes/${id}/ruta`)
|
||||
},
|
||||
|
||||
// Get viaje events
|
||||
getEventos: (id: string): Promise<EventoViaje[]> => {
|
||||
return api.get<EventoViaje[]>(`/viajes/${id}/eventos`)
|
||||
},
|
||||
|
||||
// Get viaje paradas
|
||||
getParadas: (id: string): Promise<Parada[]> => {
|
||||
return api.get<Parada[]>(`/viajes/${id}/paradas`)
|
||||
},
|
||||
|
||||
// Get current/active viajes
|
||||
getActivos: (): Promise<Viaje[]> => {
|
||||
return api.get<Viaje[]>('/viajes/activos')
|
||||
},
|
||||
|
||||
// Start a new viaje
|
||||
iniciar: (vehiculoId: string, conductorId?: string): Promise<Viaje> => {
|
||||
return api.post<Viaje>('/viajes/iniciar', { vehiculoId, conductorId })
|
||||
},
|
||||
|
||||
// End a viaje
|
||||
finalizar: (id: string): Promise<Viaje> => {
|
||||
return api.post<Viaje>(`/viajes/${id}/finalizar`)
|
||||
},
|
||||
|
||||
// Add a parada to viaje
|
||||
addParada: (id: string, parada: Partial<Parada>): Promise<Parada> => {
|
||||
return api.post<Parada>(`/viajes/${id}/paradas`, parada)
|
||||
},
|
||||
|
||||
// Get viaje statistics
|
||||
getStats: (params?: {
|
||||
vehiculoId?: string
|
||||
conductorId?: string
|
||||
desde?: string
|
||||
hasta?: string
|
||||
}): Promise<{
|
||||
totalViajes: number
|
||||
distanciaTotal: number
|
||||
tiempoTotal: number
|
||||
velocidadPromedio: number
|
||||
paradasTotal: number
|
||||
combustibleTotal: number
|
||||
}> => {
|
||||
return api.get('/viajes/stats', params)
|
||||
},
|
||||
|
||||
// Export viaje
|
||||
exportar: (id: string, formato: 'pdf' | 'excel'): Promise<Blob> => {
|
||||
return api.get(`/viajes/${id}/exportar`, { formato })
|
||||
},
|
||||
}
|
||||
137
frontend/src/api/video.ts
Normal file
137
frontend/src/api/video.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { api } from './client'
|
||||
import {
|
||||
Camara,
|
||||
Grabacion,
|
||||
EventoVideo,
|
||||
PaginatedResponse,
|
||||
} from '@/types'
|
||||
|
||||
export const videoApi = {
|
||||
// ==========================================
|
||||
// CAMARAS
|
||||
// ==========================================
|
||||
|
||||
// List camaras
|
||||
listCamaras: (params?: {
|
||||
vehiculoId?: string
|
||||
estado?: string
|
||||
activa?: boolean
|
||||
}): Promise<Camara[]> => {
|
||||
return api.get<Camara[]>('/video/camaras', params)
|
||||
},
|
||||
|
||||
// Get single camara
|
||||
getCamara: (id: string): Promise<Camara> => {
|
||||
return api.get<Camara>(`/video/camaras/${id}`)
|
||||
},
|
||||
|
||||
// Get camara stream URL
|
||||
getStreamUrl: (id: string, tipo?: 'webrtc' | 'hls' | 'rtsp'): Promise<{
|
||||
url: string
|
||||
tipo: string
|
||||
expires: string
|
||||
}> => {
|
||||
return api.get(`/video/camaras/${id}/stream`, { tipo })
|
||||
},
|
||||
|
||||
// Start recording
|
||||
startRecording: (id: string): Promise<Grabacion> => {
|
||||
return api.post<Grabacion>(`/video/camaras/${id}/grabar/iniciar`)
|
||||
},
|
||||
|
||||
// Stop recording
|
||||
stopRecording: (id: string): Promise<Grabacion> => {
|
||||
return api.post<Grabacion>(`/video/camaras/${id}/grabar/detener`)
|
||||
},
|
||||
|
||||
// Take snapshot
|
||||
takeSnapshot: (id: string): Promise<{ url: string }> => {
|
||||
return api.post(`/video/camaras/${id}/snapshot`)
|
||||
},
|
||||
|
||||
// ==========================================
|
||||
// GRABACIONES
|
||||
// ==========================================
|
||||
|
||||
// List grabaciones
|
||||
listGrabaciones: (params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
camaraId?: string
|
||||
vehiculoId?: string
|
||||
viajeId?: string
|
||||
tipo?: string
|
||||
desde?: string
|
||||
hasta?: string
|
||||
}): Promise<PaginatedResponse<Grabacion>> => {
|
||||
return api.get<PaginatedResponse<Grabacion>>('/video/grabaciones', params)
|
||||
},
|
||||
|
||||
// Get single grabacion
|
||||
getGrabacion: (id: string): Promise<Grabacion> => {
|
||||
return api.get<Grabacion>(`/video/grabaciones/${id}`)
|
||||
},
|
||||
|
||||
// Get grabacion playback URL
|
||||
getPlaybackUrl: (id: string): Promise<{ url: string; expires: string }> => {
|
||||
return api.get(`/video/grabaciones/${id}/playback`)
|
||||
},
|
||||
|
||||
// Download grabacion
|
||||
downloadGrabacion: (id: string): Promise<Blob> => {
|
||||
return api.get(`/video/grabaciones/${id}/download`)
|
||||
},
|
||||
|
||||
// Delete grabacion
|
||||
deleteGrabacion: (id: string): Promise<void> => {
|
||||
return api.delete(`/video/grabaciones/${id}`)
|
||||
},
|
||||
|
||||
// ==========================================
|
||||
// EVENTOS VIDEO
|
||||
// ==========================================
|
||||
|
||||
// List eventos video
|
||||
listEventos: (params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
camaraId?: string
|
||||
vehiculoId?: string
|
||||
tipo?: string
|
||||
desde?: string
|
||||
hasta?: string
|
||||
}): Promise<PaginatedResponse<EventoVideo>> => {
|
||||
return api.get<PaginatedResponse<EventoVideo>>('/video/eventos', params)
|
||||
},
|
||||
|
||||
// Get single evento
|
||||
getEvento: (id: string): Promise<EventoVideo> => {
|
||||
return api.get<EventoVideo>(`/video/eventos/${id}`)
|
||||
},
|
||||
|
||||
// ==========================================
|
||||
// WEBRTC SIGNALING
|
||||
// ==========================================
|
||||
|
||||
// Get WebRTC offer
|
||||
getWebRTCOffer: (camaraId: string): Promise<{
|
||||
sdp: string
|
||||
type: 'offer'
|
||||
iceServers: Array<{ urls: string[] }>
|
||||
}> => {
|
||||
return api.get(`/video/camaras/${camaraId}/webrtc/offer`)
|
||||
},
|
||||
|
||||
// Send WebRTC answer
|
||||
sendWebRTCAnswer: (camaraId: string, answer: {
|
||||
sdp: string
|
||||
type: 'answer'
|
||||
}): Promise<void> => {
|
||||
return api.post(`/video/camaras/${camaraId}/webrtc/answer`, answer)
|
||||
},
|
||||
|
||||
// Send ICE candidate
|
||||
sendICECandidate: (camaraId: string, candidate: RTCIceCandidate): Promise<void> => {
|
||||
return api.post(`/video/camaras/${camaraId}/webrtc/ice`, candidate)
|
||||
},
|
||||
}
|
||||
205
frontend/src/api/websocket.ts
Normal file
205
frontend/src/api/websocket.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { WSMessage, WSMessageType } from '@/types'
|
||||
import { getAccessToken } from './client'
|
||||
|
||||
type MessageHandler = (message: WSMessage) => void
|
||||
type ConnectionHandler = () => void
|
||||
|
||||
interface WebSocketConfig {
|
||||
url?: string
|
||||
reconnectInterval?: number
|
||||
maxReconnectAttempts?: number
|
||||
heartbeatInterval?: number
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: Required<WebSocketConfig> = {
|
||||
url: import.meta.env.VITE_WS_URL || `ws://${window.location.host}/ws/v1`,
|
||||
reconnectInterval: 3000,
|
||||
maxReconnectAttempts: 10,
|
||||
heartbeatInterval: 30000,
|
||||
}
|
||||
|
||||
class WebSocketClient {
|
||||
private socket: WebSocket | null = null
|
||||
private config: Required<WebSocketConfig>
|
||||
private reconnectAttempts = 0
|
||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
private heartbeatInterval: ReturnType<typeof setInterval> | null = null
|
||||
private messageHandlers: Map<WSMessageType | 'all', Set<MessageHandler>> = new Map()
|
||||
private onConnectHandlers: Set<ConnectionHandler> = new Set()
|
||||
private onDisconnectHandlers: Set<ConnectionHandler> = new Set()
|
||||
private isManualClose = false
|
||||
|
||||
constructor(config?: WebSocketConfig) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config }
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
const token = getAccessToken()
|
||||
if (!token) {
|
||||
console.warn('No auth token available for WebSocket connection')
|
||||
return
|
||||
}
|
||||
|
||||
this.isManualClose = false
|
||||
const url = `${this.config.url}?token=${token}`
|
||||
|
||||
try {
|
||||
this.socket = new WebSocket(url)
|
||||
|
||||
this.socket.onopen = () => {
|
||||
console.log('WebSocket connected')
|
||||
this.reconnectAttempts = 0
|
||||
this.startHeartbeat()
|
||||
this.onConnectHandlers.forEach((handler) => handler())
|
||||
}
|
||||
|
||||
this.socket.onclose = (event) => {
|
||||
console.log('WebSocket disconnected', event.code, event.reason)
|
||||
this.stopHeartbeat()
|
||||
this.onDisconnectHandlers.forEach((handler) => handler())
|
||||
|
||||
if (!this.isManualClose && this.reconnectAttempts < this.config.maxReconnectAttempts) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
this.socket.onerror = (error) => {
|
||||
console.error('WebSocket error', error)
|
||||
}
|
||||
|
||||
this.socket.onmessage = (event) => {
|
||||
try {
|
||||
const message: WSMessage = JSON.parse(event.data)
|
||||
this.handleMessage(message)
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message', error)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating WebSocket connection', error)
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.isManualClose = true
|
||||
this.stopHeartbeat()
|
||||
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout)
|
||||
this.reconnectTimeout = null
|
||||
}
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.close(1000, 'Client disconnect')
|
||||
this.socket = null
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimeout) {
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++
|
||||
const delay = this.config.reconnectInterval * Math.min(this.reconnectAttempts, 5)
|
||||
|
||||
console.log(`Scheduling WebSocket reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`)
|
||||
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
this.reconnectTimeout = null
|
||||
this.connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat()
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
this.send({ type: 'ping', payload: {}, timestamp: new Date().toISOString() })
|
||||
}, this.config.heartbeatInterval)
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval)
|
||||
this.heartbeatInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(message: WSMessage): void {
|
||||
// Handle pong messages
|
||||
if (message.type === 'pong') {
|
||||
return
|
||||
}
|
||||
|
||||
// Call type-specific handlers
|
||||
const typeHandlers = this.messageHandlers.get(message.type)
|
||||
if (typeHandlers) {
|
||||
typeHandlers.forEach((handler) => handler(message))
|
||||
}
|
||||
|
||||
// Call 'all' handlers
|
||||
const allHandlers = this.messageHandlers.get('all')
|
||||
if (allHandlers) {
|
||||
allHandlers.forEach((handler) => handler(message))
|
||||
}
|
||||
}
|
||||
|
||||
send(message: WSMessage): void {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify(message))
|
||||
} else {
|
||||
console.warn('WebSocket not connected, message not sent')
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(type: WSMessageType | 'all', handler: MessageHandler): () => void {
|
||||
if (!this.messageHandlers.has(type)) {
|
||||
this.messageHandlers.set(type, new Set())
|
||||
}
|
||||
this.messageHandlers.get(type)!.add(handler)
|
||||
|
||||
return () => {
|
||||
this.messageHandlers.get(type)?.delete(handler)
|
||||
}
|
||||
}
|
||||
|
||||
onConnect(handler: ConnectionHandler): () => void {
|
||||
this.onConnectHandlers.add(handler)
|
||||
return () => {
|
||||
this.onConnectHandlers.delete(handler)
|
||||
}
|
||||
}
|
||||
|
||||
onDisconnect(handler: ConnectionHandler): () => void {
|
||||
this.onDisconnectHandlers.add(handler)
|
||||
return () => {
|
||||
this.onDisconnectHandlers.delete(handler)
|
||||
}
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.socket?.readyState === WebSocket.OPEN
|
||||
}
|
||||
|
||||
get connectionState(): 'connecting' | 'connected' | 'disconnected' {
|
||||
if (!this.socket) return 'disconnected'
|
||||
switch (this.socket.readyState) {
|
||||
case WebSocket.CONNECTING:
|
||||
return 'connecting'
|
||||
case WebSocket.OPEN:
|
||||
return 'connected'
|
||||
default:
|
||||
return 'disconnected'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const wsClient = new WebSocketClient()
|
||||
|
||||
export default WebSocketClient
|
||||
248
frontend/src/components/alertas/AlertaCard.tsx
Normal file
248
frontend/src/components/alertas/AlertaCard.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import {
|
||||
ExclamationTriangleIcon,
|
||||
BellAlertIcon,
|
||||
MapPinIcon,
|
||||
TruckIcon,
|
||||
CheckIcon,
|
||||
XMarkIcon,
|
||||
EyeIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { Alerta, AlertaTipo, AlertaPrioridad } from '@/types'
|
||||
import { PriorityBadge } from '@/components/ui/Badge'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
|
||||
interface AlertaCardProps {
|
||||
alerta: Alerta
|
||||
onReconocer?: () => void
|
||||
onResolver?: () => void
|
||||
onIgnorar?: () => void
|
||||
onVerDetalles?: () => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
// Icon mapping for alert types
|
||||
const alertaIconMap: Partial<Record<AlertaTipo, typeof ExclamationTriangleIcon>> = {
|
||||
exceso_velocidad: BellAlertIcon,
|
||||
frenado_brusco: ExclamationTriangleIcon,
|
||||
aceleracion_brusca: ExclamationTriangleIcon,
|
||||
entrada_geocerca: MapPinIcon,
|
||||
salida_geocerca: MapPinIcon,
|
||||
sos: ExclamationTriangleIcon,
|
||||
impacto: ExclamationTriangleIcon,
|
||||
desconexion: ExclamationTriangleIcon,
|
||||
}
|
||||
|
||||
// Color mapping for priorities
|
||||
const priorityColorMap: Record<AlertaPrioridad, string> = {
|
||||
baja: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
media: 'bg-warning-500/20 text-warning-400 border-warning-500/30',
|
||||
alta: 'bg-error-500/20 text-error-400 border-error-500/30',
|
||||
critica: 'bg-error-500/30 text-error-300 border-error-500/50',
|
||||
}
|
||||
|
||||
export default function AlertaCard({
|
||||
alerta,
|
||||
onReconocer,
|
||||
onResolver,
|
||||
onIgnorar,
|
||||
onVerDetalles,
|
||||
compact = false,
|
||||
}: AlertaCardProps) {
|
||||
const Icon = alertaIconMap[alerta.tipo] || BellAlertIcon
|
||||
const timeAgo = formatDistanceToNow(new Date(alerta.timestamp), {
|
||||
addSuffix: true,
|
||||
locale: es,
|
||||
})
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-start gap-3 p-3 rounded-lg border-l-4 cursor-pointer',
|
||||
'hover:bg-slate-800/50 transition-colors',
|
||||
alerta.prioridad === 'critica' && 'border-l-error-500 bg-error-500/5',
|
||||
alerta.prioridad === 'alta' && 'border-l-error-400',
|
||||
alerta.prioridad === 'media' && 'border-l-warning-500',
|
||||
alerta.prioridad === 'baja' && 'border-l-blue-500'
|
||||
)}
|
||||
onClick={onVerDetalles}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0',
|
||||
priorityColorMap[alerta.prioridad]
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-white truncate">{alerta.titulo}</h4>
|
||||
<PriorityBadge priority={alerta.prioridad} size="xs" />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-0.5 line-clamp-1">{alerta.mensaje}</p>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-slate-500">
|
||||
{alerta.vehiculo && (
|
||||
<>
|
||||
<TruckIcon className="w-3 h-3" />
|
||||
<span>{alerta.vehiculo.placa}</span>
|
||||
<span className="text-slate-600">|</span>
|
||||
</>
|
||||
)}
|
||||
<span>{timeAgo}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{alerta.estado === 'activa' && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReconocer?.()
|
||||
}}
|
||||
className="p-1.5 text-slate-400 hover:text-success-400 hover:bg-success-500/10 rounded transition-colors"
|
||||
title="Reconocer"
|
||||
>
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onIgnorar?.()
|
||||
}}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-300 hover:bg-slate-700 rounded transition-colors"
|
||||
title="Ignorar"
|
||||
>
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="md"
|
||||
className={clsx(
|
||||
'border-l-4',
|
||||
alerta.prioridad === 'critica' && 'border-l-error-500',
|
||||
alerta.prioridad === 'alta' && 'border-l-error-400',
|
||||
alerta.prioridad === 'media' && 'border-l-warning-500',
|
||||
alerta.prioridad === 'baja' && 'border-l-blue-500'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={clsx(
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center border',
|
||||
priorityColorMap[alerta.prioridad]
|
||||
)}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white">{alerta.titulo}</h3>
|
||||
<p className="text-sm text-slate-500">{timeAgo}</p>
|
||||
</div>
|
||||
</div>
|
||||
<PriorityBadge priority={alerta.prioridad} />
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<p className="text-sm text-slate-300 mb-3">{alerta.mensaje}</p>
|
||||
|
||||
{/* Details */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-3 text-sm">
|
||||
{alerta.vehiculo && (
|
||||
<div className="flex items-center gap-2">
|
||||
<TruckIcon className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-slate-400">
|
||||
{alerta.vehiculo.nombre} ({alerta.vehiculo.placa})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{alerta.lat && alerta.lng && (
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPinIcon className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-slate-400">
|
||||
{alerta.lat.toFixed(4)}, {alerta.lng.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{alerta.valor !== undefined && alerta.umbral !== undefined && (
|
||||
<div className="col-span-2 flex items-center gap-2">
|
||||
<span className="text-slate-500">Valor:</span>
|
||||
<span className="text-white font-medium">{alerta.valor}</span>
|
||||
<span className="text-slate-500">/ Umbral:</span>
|
||||
<span className="text-slate-400">{alerta.umbral}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{alerta.estado === 'activa' && (
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-slate-700/50">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="success"
|
||||
leftIcon={<CheckIcon className="w-4 h-4" />}
|
||||
onClick={onReconocer}
|
||||
>
|
||||
Reconocer
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
leftIcon={<CheckIcon className="w-4 h-4" />}
|
||||
onClick={onResolver}
|
||||
>
|
||||
Resolver
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<XMarkIcon className="w-4 h-4" />}
|
||||
onClick={onIgnorar}
|
||||
>
|
||||
Ignorar
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
leftIcon={<EyeIcon className="w-4 h-4" />}
|
||||
onClick={onVerDetalles}
|
||||
>
|
||||
Detalles
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{alerta.estado !== 'activa' && (
|
||||
<div className="pt-3 border-t border-slate-700/50 text-sm text-slate-500">
|
||||
{alerta.estado === 'reconocida' && (
|
||||
<span>
|
||||
Reconocida por {alerta.reconocidaPor} -{' '}
|
||||
{alerta.reconocidaAt && format(new Date(alerta.reconocidaAt), 'dd/MM HH:mm')}
|
||||
</span>
|
||||
)}
|
||||
{alerta.estado === 'resuelta' && (
|
||||
<span>
|
||||
Resuelta por {alerta.resueltaPor} -{' '}
|
||||
{alerta.resueltaAt && format(new Date(alerta.resueltaAt), 'dd/MM HH:mm')}
|
||||
</span>
|
||||
)}
|
||||
{alerta.estado === 'ignorada' && <span>Ignorada</span>}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
201
frontend/src/components/alertas/AlertaList.tsx
Normal file
201
frontend/src/components/alertas/AlertaList.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
FunnelIcon,
|
||||
MagnifyingGlassIcon,
|
||||
BellSlashIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { Alerta, AlertaPrioridad, AlertaEstado, AlertaTipo } from '@/types'
|
||||
import AlertaCard from './AlertaCard'
|
||||
import { SkeletonListItem } from '@/components/ui/Skeleton'
|
||||
|
||||
interface AlertaListProps {
|
||||
alertas: Alerta[]
|
||||
isLoading?: boolean
|
||||
onReconocer?: (id: string) => void
|
||||
onResolver?: (id: string) => void
|
||||
onIgnorar?: (id: string) => void
|
||||
onVerDetalles?: (id: string) => void
|
||||
compact?: boolean
|
||||
showFilters?: boolean
|
||||
}
|
||||
|
||||
export default function AlertaList({
|
||||
alertas,
|
||||
isLoading = false,
|
||||
onReconocer,
|
||||
onResolver,
|
||||
onIgnorar,
|
||||
onVerDetalles,
|
||||
compact = false,
|
||||
showFilters = true,
|
||||
}: AlertaListProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [prioridadFilter, setPrioridadFilter] = useState<AlertaPrioridad[]>([])
|
||||
const [estadoFilter, setEstadoFilter] = useState<AlertaEstado[]>(['activa'])
|
||||
|
||||
// Filter alertas
|
||||
const filteredAlertas = alertas.filter((a) => {
|
||||
// Search
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase()
|
||||
if (
|
||||
!a.titulo.toLowerCase().includes(searchLower) &&
|
||||
!a.mensaje.toLowerCase().includes(searchLower)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Prioridad
|
||||
if (prioridadFilter.length > 0 && !prioridadFilter.includes(a.prioridad)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Estado
|
||||
if (estadoFilter.length > 0 && !estadoFilter.includes(a.estado)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
total: alertas.length,
|
||||
activas: alertas.filter((a) => a.estado === 'activa').length,
|
||||
criticas: alertas.filter((a) => a.prioridad === 'critica' && a.estado === 'activa').length,
|
||||
altas: alertas.filter((a) => a.prioridad === 'alta' && a.estado === 'activa').length,
|
||||
}
|
||||
|
||||
const togglePrioridad = (p: AlertaPrioridad) => {
|
||||
setPrioridadFilter((prev) =>
|
||||
prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p]
|
||||
)
|
||||
}
|
||||
|
||||
const toggleEstado = (e: AlertaEstado) => {
|
||||
setEstadoFilter((prev) =>
|
||||
prev.includes(e) ? prev.filter((x) => x !== e) : [...prev, e]
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="mb-4 space-y-3">
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-slate-400">
|
||||
<span className="text-white font-medium">{stats.activas}</span> alertas activas
|
||||
</span>
|
||||
{stats.criticas > 0 && (
|
||||
<>
|
||||
<span className="text-slate-600">|</span>
|
||||
<span className="text-error-400">
|
||||
<span className="font-medium">{stats.criticas}</span> criticas
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{stats.altas > 0 && (
|
||||
<>
|
||||
<span className="text-slate-600">|</span>
|
||||
<span className="text-error-300">
|
||||
<span className="font-medium">{stats.altas}</span> altas
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search and filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar alertas..."
|
||||
className={clsx(
|
||||
'w-full pl-10 pr-4 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Priority filters */}
|
||||
<div className="flex items-center gap-1">
|
||||
{(['critica', 'alta', 'media', 'baja'] as AlertaPrioridad[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => togglePrioridad(p)}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 text-xs font-medium rounded-lg transition-colors capitalize',
|
||||
prioridadFilter.includes(p)
|
||||
? p === 'critica' || p === 'alta'
|
||||
? 'bg-error-500/20 text-error-400 border border-error-500/30'
|
||||
: p === 'media'
|
||||
? 'bg-warning-500/20 text-warning-400 border border-warning-500/30'
|
||||
: 'bg-blue-500/20 text-blue-400 border border-blue-500/30'
|
||||
: 'bg-slate-800 text-slate-400 hover:text-white'
|
||||
)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Estado filters */}
|
||||
<div className="flex items-center gap-1">
|
||||
{(['activa', 'reconocida', 'resuelta'] as AlertaEstado[]).map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
onClick={() => toggleEstado(e)}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 text-xs font-medium rounded-lg transition-colors capitalize',
|
||||
estadoFilter.includes(e)
|
||||
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30'
|
||||
: 'bg-slate-800 text-slate-400 hover:text-white'
|
||||
)}
|
||||
>
|
||||
{e === 'activa' ? 'Activas' : e === 'reconocida' ? 'Reconocidas' : 'Resueltas'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alertas list */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<SkeletonListItem key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : filteredAlertas.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<BellSlashIcon className="w-12 h-12 text-slate-600 mx-auto mb-3" />
|
||||
<p className="text-slate-500">No hay alertas que mostrar</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredAlertas.map((alerta) => (
|
||||
<AlertaCard
|
||||
key={alerta.id}
|
||||
alerta={alerta}
|
||||
compact={compact}
|
||||
onReconocer={() => onReconocer?.(alerta.id)}
|
||||
onResolver={() => onResolver?.(alerta.id)}
|
||||
onIgnorar={() => onIgnorar?.(alerta.id)}
|
||||
onVerDetalles={() => onVerDetalles?.(alerta.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2
frontend/src/components/alertas/index.ts
Normal file
2
frontend/src/components/alertas/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as AlertaCard } from './AlertaCard'
|
||||
export { default as AlertaList } from './AlertaList'
|
||||
158
frontend/src/components/charts/BarChart.tsx
Normal file
158
frontend/src/components/charts/BarChart.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
BarChart as RechartsBarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
Cell,
|
||||
} from 'recharts'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface BarChartProps {
|
||||
data: Array<Record<string, unknown>>
|
||||
bars: Array<{
|
||||
dataKey: string
|
||||
name: string
|
||||
color: string
|
||||
radius?: number
|
||||
}>
|
||||
xAxisKey: string
|
||||
height?: number
|
||||
showGrid?: boolean
|
||||
showLegend?: boolean
|
||||
layout?: 'vertical' | 'horizontal'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function BarChart({
|
||||
data,
|
||||
bars,
|
||||
xAxisKey,
|
||||
height = 300,
|
||||
showGrid = true,
|
||||
showLegend = false,
|
||||
layout = 'horizontal',
|
||||
className,
|
||||
}: BarChartProps) {
|
||||
const isVertical = layout === 'vertical'
|
||||
|
||||
return (
|
||||
<div className={clsx('w-full', className)} style={{ height }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsBarChart
|
||||
data={data}
|
||||
layout={layout}
|
||||
margin={{ top: 5, right: 20, left: isVertical ? 80 : 0, bottom: 5 }}
|
||||
>
|
||||
{showGrid && (
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="#334155"
|
||||
horizontal={!isVertical}
|
||||
vertical={isVertical}
|
||||
/>
|
||||
)}
|
||||
{isVertical ? (
|
||||
<>
|
||||
<XAxis type="number" stroke="#64748b" fontSize={12} tickLine={false} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey={xAxisKey}
|
||||
stroke="#64748b"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XAxis
|
||||
dataKey={xAxisKey}
|
||||
stroke="#64748b"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#334155' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#64748b"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid #334155',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
labelStyle={{ color: '#fff', fontWeight: 600 }}
|
||||
itemStyle={{ color: '#94a3b8' }}
|
||||
cursor={{ fill: 'rgba(59, 130, 246, 0.1)' }}
|
||||
/>
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ paddingTop: 20 }}
|
||||
formatter={(value) => (
|
||||
<span className="text-slate-400 text-sm">{value}</span>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{bars.map((bar) => (
|
||||
<Bar
|
||||
key={bar.dataKey}
|
||||
dataKey={bar.dataKey}
|
||||
name={bar.name}
|
||||
fill={bar.color}
|
||||
radius={bar.radius || [4, 4, 0, 0]}
|
||||
/>
|
||||
))}
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Simple horizontal bar with colors
|
||||
interface SimpleBarProps {
|
||||
data: Array<{
|
||||
name: string
|
||||
value: number
|
||||
color: string
|
||||
}>
|
||||
height?: number
|
||||
showValues?: boolean
|
||||
}
|
||||
|
||||
export function SimpleBar({ data, height = 200, showValues = true }: SimpleBarProps) {
|
||||
const maxValue = Math.max(...data.map((d) => d.value))
|
||||
|
||||
return (
|
||||
<div className="space-y-3" style={{ height }}>
|
||||
{data.map((item) => (
|
||||
<div key={item.name}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-slate-400">{item.name}</span>
|
||||
{showValues && (
|
||||
<span className="text-sm font-medium text-white">{item.value}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${(item.value / maxValue) * 100}%`,
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
155
frontend/src/components/charts/FuelGauge.tsx
Normal file
155
frontend/src/components/charts/FuelGauge.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface FuelGaugeProps {
|
||||
value: number // 0-100
|
||||
maxValue?: number
|
||||
label?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
showPercentage?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function FuelGauge({
|
||||
value,
|
||||
maxValue = 100,
|
||||
label = 'Combustible',
|
||||
size = 'md',
|
||||
showPercentage = true,
|
||||
className,
|
||||
}: FuelGaugeProps) {
|
||||
const percentage = Math.min(100, Math.max(0, (value / maxValue) * 100))
|
||||
|
||||
// Color based on level
|
||||
const getColor = () => {
|
||||
if (percentage <= 20) return { bg: 'bg-error-500', text: 'text-error-400' }
|
||||
if (percentage <= 40) return { bg: 'bg-warning-500', text: 'text-warning-400' }
|
||||
return { bg: 'bg-success-500', text: 'text-success-400' }
|
||||
}
|
||||
|
||||
const colors = getColor()
|
||||
|
||||
const sizeStyles = {
|
||||
sm: { height: 'h-2', text: 'text-xs', icon: 'w-4 h-4' },
|
||||
md: { height: 'h-3', text: 'text-sm', icon: 'w-5 h-5' },
|
||||
lg: { height: 'h-4', text: 'text-base', icon: 'w-6 h-6' },
|
||||
}
|
||||
|
||||
const styles = sizeStyles[size]
|
||||
|
||||
return (
|
||||
<div className={clsx('space-y-2', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className={clsx(styles.icon, colors.text)}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5"
|
||||
/>
|
||||
</svg>
|
||||
<span className={clsx(styles.text, 'text-slate-400')}>{label}</span>
|
||||
</div>
|
||||
{showPercentage && (
|
||||
<span className={clsx(styles.text, 'font-bold', colors.text)}>
|
||||
{percentage.toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gauge bar */}
|
||||
<div className={clsx('w-full bg-slate-700 rounded-full overflow-hidden', styles.height)}>
|
||||
<div
|
||||
className={clsx(
|
||||
'h-full rounded-full transition-all duration-500',
|
||||
colors.bg
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Markers */}
|
||||
<div className="flex justify-between text-xs text-slate-600">
|
||||
<span>E</span>
|
||||
<span>1/4</span>
|
||||
<span>1/2</span>
|
||||
<span>3/4</span>
|
||||
<span>F</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Circular gauge variant
|
||||
interface CircularGaugeProps {
|
||||
value: number
|
||||
maxValue?: number
|
||||
label?: string
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CircularGauge({
|
||||
value,
|
||||
maxValue = 100,
|
||||
label,
|
||||
size = 120,
|
||||
strokeWidth = 8,
|
||||
className,
|
||||
}: CircularGaugeProps) {
|
||||
const percentage = Math.min(100, Math.max(0, (value / maxValue) * 100))
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const strokeDashoffset = circumference - (percentage / 100) * circumference
|
||||
|
||||
// Color based on level
|
||||
const getColor = () => {
|
||||
if (percentage <= 20) return '#ef4444'
|
||||
if (percentage <= 40) return '#eab308'
|
||||
return '#22c55e'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('relative inline-flex', className)}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#334155"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={getColor()}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Center content */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-white">{percentage.toFixed(0)}%</p>
|
||||
{label && <p className="text-xs text-slate-500">{label}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
161
frontend/src/components/charts/KPICard.tsx
Normal file
161
frontend/src/components/charts/KPICard.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { ArrowUpIcon, ArrowDownIcon } from '@heroicons/react/24/solid'
|
||||
import clsx from 'clsx'
|
||||
import Card from '@/components/ui/Card'
|
||||
|
||||
interface KPICardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
subtitle?: string
|
||||
icon?: ReactNode
|
||||
trend?: {
|
||||
value: number
|
||||
label?: string
|
||||
isPositive?: boolean
|
||||
}
|
||||
color?: 'default' | 'blue' | 'green' | 'yellow' | 'red'
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function KPICard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon,
|
||||
trend,
|
||||
color = 'default',
|
||||
loading = false,
|
||||
}: KPICardProps) {
|
||||
const colorStyles = {
|
||||
default: {
|
||||
icon: 'bg-slate-700 text-slate-300',
|
||||
value: 'text-white',
|
||||
},
|
||||
blue: {
|
||||
icon: 'bg-accent-500/20 text-accent-400',
|
||||
value: 'text-accent-400',
|
||||
},
|
||||
green: {
|
||||
icon: 'bg-success-500/20 text-success-400',
|
||||
value: 'text-success-400',
|
||||
},
|
||||
yellow: {
|
||||
icon: 'bg-warning-500/20 text-warning-400',
|
||||
value: 'text-warning-400',
|
||||
},
|
||||
red: {
|
||||
icon: 'bg-error-500/20 text-error-400',
|
||||
value: 'text-error-400',
|
||||
},
|
||||
}
|
||||
|
||||
const styles = colorStyles[color]
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card padding="md">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-slate-700 rounded w-24 mb-3" />
|
||||
<div className="h-8 bg-slate-700 rounded w-16 mb-2" />
|
||||
<div className="h-3 bg-slate-700 rounded w-20" />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding="md" className="relative overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
{color !== 'default' && (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute -right-4 -top-4 w-24 h-24 rounded-full opacity-10',
|
||||
color === 'blue' && 'bg-accent-500',
|
||||
color === 'green' && 'bg-success-500',
|
||||
color === 'yellow' && 'bg-warning-500',
|
||||
color === 'red' && 'bg-error-500'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm font-medium text-slate-400">{title}</p>
|
||||
{icon && (
|
||||
<div
|
||||
className={clsx(
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center',
|
||||
styles.icon
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<p className={clsx('text-3xl font-bold', styles.value)}>{value}</p>
|
||||
|
||||
{/* Trend and subtitle */}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{trend && (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 text-sm font-medium',
|
||||
trend.isPositive !== false ? 'text-success-400' : 'text-error-400'
|
||||
)}
|
||||
>
|
||||
{trend.isPositive !== false ? (
|
||||
<ArrowUpIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<ArrowDownIcon className="w-4 h-4" />
|
||||
)}
|
||||
{trend.value}%
|
||||
</span>
|
||||
)}
|
||||
{subtitle && (
|
||||
<span className="text-sm text-slate-500">{subtitle}</span>
|
||||
)}
|
||||
{trend?.label && (
|
||||
<span className="text-sm text-slate-500">{trend.label}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Mini KPI for dashboards
|
||||
interface MiniKPIProps {
|
||||
label: string
|
||||
value: string | number
|
||||
icon?: ReactNode
|
||||
color?: 'default' | 'blue' | 'green' | 'yellow' | 'red'
|
||||
}
|
||||
|
||||
export function MiniKPI({ label, value, icon, color = 'default' }: MiniKPIProps) {
|
||||
const dotColors = {
|
||||
default: 'bg-slate-500',
|
||||
blue: 'bg-accent-500',
|
||||
green: 'bg-success-500',
|
||||
yellow: 'bg-warning-500',
|
||||
red: 'bg-error-500',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{icon ? (
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-800 flex items-center justify-center text-slate-400">
|
||||
{icon}
|
||||
</div>
|
||||
) : (
|
||||
<span className={clsx('w-2 h-2 rounded-full', dotColors[color])} />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-lg font-bold text-white">{value}</p>
|
||||
<p className="text-xs text-slate-500">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
99
frontend/src/components/charts/LineChart.tsx
Normal file
99
frontend/src/components/charts/LineChart.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
LineChart as RechartsLineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface LineChartProps {
|
||||
data: Array<Record<string, unknown>>
|
||||
lines: Array<{
|
||||
dataKey: string
|
||||
name: string
|
||||
color: string
|
||||
strokeWidth?: number
|
||||
dot?: boolean
|
||||
}>
|
||||
xAxisKey: string
|
||||
height?: number
|
||||
showGrid?: boolean
|
||||
showLegend?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function LineChart({
|
||||
data,
|
||||
lines,
|
||||
xAxisKey,
|
||||
height = 300,
|
||||
showGrid = true,
|
||||
showLegend = true,
|
||||
className,
|
||||
}: LineChartProps) {
|
||||
return (
|
||||
<div className={clsx('w-full', className)} style={{ height }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsLineChart
|
||||
data={data}
|
||||
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
|
||||
>
|
||||
{showGrid && (
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="#334155"
|
||||
vertical={false}
|
||||
/>
|
||||
)}
|
||||
<XAxis
|
||||
dataKey={xAxisKey}
|
||||
stroke="#64748b"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#334155' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#64748b"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid #334155',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
labelStyle={{ color: '#fff', fontWeight: 600 }}
|
||||
itemStyle={{ color: '#94a3b8' }}
|
||||
/>
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ paddingTop: 20 }}
|
||||
formatter={(value) => (
|
||||
<span className="text-slate-400 text-sm">{value}</span>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{lines.map((line) => (
|
||||
<Line
|
||||
key={line.dataKey}
|
||||
type="monotone"
|
||||
dataKey={line.dataKey}
|
||||
name={line.name}
|
||||
stroke={line.color}
|
||||
strokeWidth={line.strokeWidth || 2}
|
||||
dot={line.dot !== false ? { fill: line.color, r: 4 } : false}
|
||||
activeDot={{ r: 6, fill: line.color }}
|
||||
/>
|
||||
))}
|
||||
</RechartsLineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
156
frontend/src/components/charts/PieChart.tsx
Normal file
156
frontend/src/components/charts/PieChart.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
PieChart as RechartsPieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
Tooltip,
|
||||
} from 'recharts'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface PieChartProps {
|
||||
data: Array<{
|
||||
name: string
|
||||
value: number
|
||||
color: string
|
||||
}>
|
||||
height?: number
|
||||
innerRadius?: number
|
||||
outerRadius?: number
|
||||
showLegend?: boolean
|
||||
showLabels?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function PieChart({
|
||||
data,
|
||||
height = 300,
|
||||
innerRadius = 60,
|
||||
outerRadius = 100,
|
||||
showLegend = true,
|
||||
showLabels = false,
|
||||
className,
|
||||
}: PieChartProps) {
|
||||
const total = data.reduce((sum, item) => sum + item.value, 0)
|
||||
|
||||
return (
|
||||
<div className={clsx('w-full', className)} style={{ height }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsPieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
label={
|
||||
showLabels
|
||||
? ({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`
|
||||
: false
|
||||
}
|
||||
labelLine={showLabels}
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} strokeWidth={0} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid #334155',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
formatter={(value: number, name: string) => [
|
||||
`${value} (${((value / total) * 100).toFixed(1)}%)`,
|
||||
name,
|
||||
]}
|
||||
/>
|
||||
{showLegend && (
|
||||
<Legend
|
||||
layout="vertical"
|
||||
verticalAlign="middle"
|
||||
align="right"
|
||||
formatter={(value) => (
|
||||
<span className="text-slate-400 text-sm">{value}</span>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Donut chart with center label
|
||||
interface DonutChartProps extends Omit<PieChartProps, 'innerRadius'> {
|
||||
centerLabel?: string
|
||||
centerValue?: string | number
|
||||
}
|
||||
|
||||
export function DonutChart({
|
||||
data,
|
||||
height = 200,
|
||||
outerRadius = 80,
|
||||
centerLabel,
|
||||
centerValue,
|
||||
showLegend = false,
|
||||
className,
|
||||
}: DonutChartProps) {
|
||||
const innerRadius = outerRadius * 0.65
|
||||
|
||||
return (
|
||||
<div className={clsx('relative', className)} style={{ height }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsPieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} strokeWidth={0} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid #334155',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
{showLegend && (
|
||||
<Legend
|
||||
layout="horizontal"
|
||||
verticalAlign="bottom"
|
||||
align="center"
|
||||
formatter={(value) => (
|
||||
<span className="text-slate-400 text-xs">{value}</span>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Center label */}
|
||||
{(centerLabel || centerValue) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center">
|
||||
{centerValue && (
|
||||
<p className="text-2xl font-bold text-white">{centerValue}</p>
|
||||
)}
|
||||
{centerLabel && (
|
||||
<p className="text-xs text-slate-500">{centerLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
frontend/src/components/charts/index.ts
Normal file
5
frontend/src/components/charts/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as KPICard, MiniKPI } from './KPICard'
|
||||
export { default as LineChart } from './LineChart'
|
||||
export { default as BarChart, SimpleBar } from './BarChart'
|
||||
export { default as PieChart, DonutChart } from './PieChart'
|
||||
export { default as FuelGauge, CircularGauge } from './FuelGauge'
|
||||
402
frontend/src/components/layout/Header.tsx
Normal file
402
frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import { Fragment, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Menu, Transition, Combobox } from '@headlessui/react'
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
BellIcon,
|
||||
UserCircleIcon,
|
||||
Cog6ToothIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
SunIcon,
|
||||
MoonIcon,
|
||||
ChevronDownIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { useAlertasStore } from '@/store/alertasStore'
|
||||
import { useVehiculosStore } from '@/store/vehiculosStore'
|
||||
import { CounterBadge } from '@/components/ui/Badge'
|
||||
|
||||
export default function Header() {
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const { user, logout } = useAuthStore()
|
||||
const alertasActivas = useAlertasStore((state) => state.alertasActivas)
|
||||
const vehiculos = useVehiculosStore((state) => state.getVehiculosArray())
|
||||
|
||||
// Search results
|
||||
const searchResults = searchQuery
|
||||
? vehiculos
|
||||
.filter(
|
||||
(v) =>
|
||||
v.nombre.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
v.placa.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
.slice(0, 5)
|
||||
: []
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 h-16 bg-background-900/95 backdrop-blur border-b border-slate-800">
|
||||
<div className="h-full px-4 flex items-center justify-between gap-4">
|
||||
{/* Left side - Search */}
|
||||
<div className="flex-1 max-w-xl">
|
||||
{/* Desktop search */}
|
||||
<div className="relative hidden md:block">
|
||||
<Combobox value={null} onChange={() => {}}>
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<Combobox.Input
|
||||
className={clsx(
|
||||
'w-full pl-10 pr-4 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent',
|
||||
'transition-all duration-200'
|
||||
)}
|
||||
placeholder="Buscar vehiculos, conductores..."
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
afterLeave={() => setSearchQuery('')}
|
||||
>
|
||||
<Combobox.Options
|
||||
className={clsx(
|
||||
'absolute z-20 mt-2 w-full overflow-hidden rounded-lg',
|
||||
'bg-card border border-slate-700 shadow-xl'
|
||||
)}
|
||||
>
|
||||
{searchResults.length === 0 && searchQuery !== '' ? (
|
||||
<div className="px-4 py-3 text-sm text-slate-500">
|
||||
No se encontraron resultados
|
||||
</div>
|
||||
) : (
|
||||
searchResults.map((vehiculo) => (
|
||||
<Combobox.Option
|
||||
key={vehiculo.id}
|
||||
value={vehiculo}
|
||||
className={({ active }) =>
|
||||
clsx(
|
||||
'cursor-pointer select-none px-4 py-3',
|
||||
active && 'bg-slate-700/50'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Link
|
||||
to={`/vehiculos/${vehiculo.id}`}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-slate-700 flex items-center justify-center">
|
||||
<span className="text-xs font-medium text-white">
|
||||
{vehiculo.placa.slice(0, 2)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{vehiculo.nombre}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">{vehiculo.placa}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</Combobox.Option>
|
||||
))
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
{/* Mobile search button */}
|
||||
<button
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="md:hidden p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
<MagnifyingGlassIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right side - Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
|
||||
title="Cambiar tema"
|
||||
>
|
||||
<MoonIcon className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Notifications */}
|
||||
<NotificationsMenu alertas={alertasActivas} />
|
||||
|
||||
{/* User menu */}
|
||||
<UserMenu user={user} onLogout={logout} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile search overlay */}
|
||||
<MobileSearch
|
||||
isOpen={searchOpen}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
query={searchQuery}
|
||||
setQuery={setSearchQuery}
|
||||
results={searchResults}
|
||||
/>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
// Notifications dropdown
|
||||
function NotificationsMenu({ alertas }: { alertas: Array<{ id: string; titulo: string; mensaje: string; prioridad: string }> }) {
|
||||
return (
|
||||
<Menu as="div" className="relative">
|
||||
<Menu.Button className="relative p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors">
|
||||
<BellIcon className="w-5 h-5" />
|
||||
{alertas.length > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5">
|
||||
<CounterBadge count={alertas.length} />
|
||||
</span>
|
||||
)}
|
||||
</Menu.Button>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className={clsx(
|
||||
'absolute right-0 z-20 mt-2 w-80 origin-top-right rounded-lg',
|
||||
'bg-card border border-slate-700 shadow-xl',
|
||||
'focus:outline-none'
|
||||
)}
|
||||
>
|
||||
<div className="p-4 border-b border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-white">Notificaciones</h3>
|
||||
<p className="text-xs text-slate-500">{alertas.length} alertas activas</p>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{alertas.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-slate-500">
|
||||
No hay alertas pendientes
|
||||
</div>
|
||||
) : (
|
||||
alertas.slice(0, 5).map((alerta) => (
|
||||
<Menu.Item key={alerta.id}>
|
||||
{({ active }) => (
|
||||
<Link
|
||||
to={`/alertas?id=${alerta.id}`}
|
||||
className={clsx(
|
||||
'block px-4 py-3',
|
||||
active && 'bg-slate-700/50'
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium text-white line-clamp-1">
|
||||
{alerta.titulo}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 line-clamp-1">
|
||||
{alerta.mensaje}
|
||||
</p>
|
||||
</Link>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{alertas.length > 0 && (
|
||||
<div className="p-2 border-t border-slate-700">
|
||||
<Menu.Item>
|
||||
<Link
|
||||
to="/alertas"
|
||||
className="block w-full px-3 py-2 text-center text-sm text-accent-400 hover:bg-slate-700/50 rounded-lg transition-colors"
|
||||
>
|
||||
Ver todas las alertas
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
)}
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
// User menu dropdown
|
||||
function UserMenu({ user, onLogout }: { user: { nombre: string; email: string; rol: string } | null; onLogout: () => void }) {
|
||||
return (
|
||||
<Menu as="div" className="relative">
|
||||
<Menu.Button
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-lg',
|
||||
'text-slate-300 hover:text-white hover:bg-slate-800',
|
||||
'transition-colors duration-200'
|
||||
)}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-accent-500 to-accent-700 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{user?.nombre?.charAt(0) || 'U'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="hidden sm:block text-left">
|
||||
<p className="text-sm font-medium text-white">{user?.nombre || 'Usuario'}</p>
|
||||
<p className="text-xs text-slate-500 capitalize">{user?.rol || 'admin'}</p>
|
||||
</div>
|
||||
<ChevronDownIcon className="w-4 h-4 text-slate-500 hidden sm:block" />
|
||||
</Menu.Button>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className={clsx(
|
||||
'absolute right-0 z-20 mt-2 w-56 origin-top-right rounded-lg',
|
||||
'bg-card border border-slate-700 shadow-xl',
|
||||
'divide-y divide-slate-700/50',
|
||||
'focus:outline-none'
|
||||
)}
|
||||
>
|
||||
{/* User info */}
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-sm font-medium text-white">{user?.nombre}</p>
|
||||
<p className="text-xs text-slate-500 truncate">{user?.email}</p>
|
||||
</div>
|
||||
|
||||
{/* Menu items */}
|
||||
<div className="py-1">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<Link
|
||||
to="/configuracion"
|
||||
className={clsx(
|
||||
'flex items-center gap-3 px-4 py-2 text-sm',
|
||||
active ? 'bg-slate-700/50 text-white' : 'text-slate-300'
|
||||
)}
|
||||
>
|
||||
<UserCircleIcon className="w-5 h-5" />
|
||||
Mi perfil
|
||||
</Link>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<Link
|
||||
to="/configuracion"
|
||||
className={clsx(
|
||||
'flex items-center gap-3 px-4 py-2 text-sm',
|
||||
active ? 'bg-slate-700/50 text-white' : 'text-slate-300'
|
||||
)}
|
||||
>
|
||||
<Cog6ToothIcon className="w-5 h-5" />
|
||||
Configuracion
|
||||
</Link>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="py-1">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-4 py-2 text-sm',
|
||||
active ? 'bg-slate-700/50 text-error-400' : 'text-error-400'
|
||||
)}
|
||||
>
|
||||
<ArrowRightOnRectangleIcon className="w-5 h-5" />
|
||||
Cerrar sesion
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
// Mobile search modal
|
||||
function MobileSearch({
|
||||
isOpen,
|
||||
onClose,
|
||||
query,
|
||||
setQuery,
|
||||
results,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
query: string
|
||||
setQuery: (q: string) => void
|
||||
results: Array<{ id: string; nombre: string; placa: string }>
|
||||
}) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-background-900/95 backdrop-blur md:hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Buscar..."
|
||||
className={clsx(
|
||||
'w-full pl-10 pr-4 py-3 bg-slate-800 border border-slate-700 rounded-lg',
|
||||
'text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-3 text-slate-400 hover:text-white"
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="mt-4">
|
||||
{results.map((vehiculo) => (
|
||||
<Link
|
||||
key={vehiculo.id}
|
||||
to={`/vehiculos/${vehiculo.id}`}
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-800"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{vehiculo.placa.slice(0, 2)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{vehiculo.nombre}</p>
|
||||
<p className="text-xs text-slate-500">{vehiculo.placa}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
frontend/src/components/layout/MainLayout.tsx
Normal file
81
frontend/src/components/layout/MainLayout.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import clsx from 'clsx'
|
||||
import Sidebar from './Sidebar'
|
||||
import Header from './Header'
|
||||
import { useConfigStore } from '@/store/configStore'
|
||||
import { useVehiculosRealtime, useVehiculos } from '@/hooks/useVehiculos'
|
||||
import { useAlertasRealtime, useAlertasActivas } from '@/hooks/useAlertas'
|
||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||
|
||||
export default function MainLayout() {
|
||||
const { config } = useConfigStore()
|
||||
const sidebarCollapsed = config.sidebarCollapsed
|
||||
|
||||
// Initialize real-time connections
|
||||
useWebSocket({ autoConnect: true })
|
||||
|
||||
// Load initial data
|
||||
useVehiculos()
|
||||
useAlertasActivas()
|
||||
|
||||
// Subscribe to real-time updates
|
||||
useVehiculosRealtime()
|
||||
useAlertasRealtime()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background-900">
|
||||
{/* Sidebar */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Main content area */}
|
||||
<div
|
||||
className={clsx(
|
||||
'transition-all duration-300',
|
||||
sidebarCollapsed ? 'ml-20' : 'ml-64'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<Header />
|
||||
|
||||
{/* Page content */}
|
||||
<main className="p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Layout variant without sidebar (for fullscreen pages like map)
|
||||
export function FullscreenLayout() {
|
||||
const { config } = useConfigStore()
|
||||
const sidebarCollapsed = config.sidebarCollapsed
|
||||
|
||||
// Initialize real-time connections
|
||||
useWebSocket({ autoConnect: true })
|
||||
|
||||
// Load initial data
|
||||
useVehiculos()
|
||||
useAlertasActivas()
|
||||
|
||||
// Subscribe to real-time updates
|
||||
useVehiculosRealtime()
|
||||
useAlertasRealtime()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background-900">
|
||||
{/* Sidebar */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Main content area - no padding */}
|
||||
<div
|
||||
className={clsx(
|
||||
'transition-all duration-300 h-screen',
|
||||
sidebarCollapsed ? 'ml-20' : 'ml-64'
|
||||
)}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
245
frontend/src/components/layout/Sidebar.tsx
Normal file
245
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
HomeIcon,
|
||||
MapIcon,
|
||||
TruckIcon,
|
||||
UserGroupIcon,
|
||||
BellAlertIcon,
|
||||
MapPinIcon,
|
||||
VideoCameraIcon,
|
||||
FolderIcon,
|
||||
RectangleGroupIcon,
|
||||
ChartBarIcon,
|
||||
Cog6ToothIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ArrowPathIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
BeakerIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { useConfigStore } from '@/store/configStore'
|
||||
import { CounterBadge } from '@/components/ui/Badge'
|
||||
import { useAlertasStore } from '@/store/alertasStore'
|
||||
|
||||
interface NavItem {
|
||||
name: string
|
||||
href: string
|
||||
icon: typeof HomeIcon
|
||||
badge?: number
|
||||
children?: Omit<NavItem, 'children'>[]
|
||||
}
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{ name: 'Dashboard', href: '/', icon: HomeIcon },
|
||||
{ name: 'Mapa', href: '/mapa', icon: MapIcon },
|
||||
{ name: 'Vehiculos', href: '/vehiculos', icon: TruckIcon },
|
||||
{ name: 'Conductores', href: '/conductores', icon: UserGroupIcon },
|
||||
{ name: 'Alertas', href: '/alertas', icon: BellAlertIcon },
|
||||
{ name: 'Viajes', href: '/viajes', icon: ArrowPathIcon },
|
||||
{
|
||||
name: 'Video',
|
||||
href: '/video',
|
||||
icon: VideoCameraIcon,
|
||||
children: [
|
||||
{ name: 'En vivo', href: '/video', icon: VideoCameraIcon },
|
||||
{ name: 'Grabaciones', href: '/grabaciones', icon: FolderIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Zonas',
|
||||
href: '/geocercas',
|
||||
icon: RectangleGroupIcon,
|
||||
children: [
|
||||
{ name: 'Geocercas', href: '/geocercas', icon: RectangleGroupIcon },
|
||||
{ name: 'POIs', href: '/pois', icon: MapPinIcon },
|
||||
],
|
||||
},
|
||||
{ name: 'Combustible', href: '/combustible', icon: BeakerIcon },
|
||||
{ name: 'Mantenimiento', href: '/mantenimiento', icon: WrenchScrewdriverIcon },
|
||||
{ name: 'Reportes', href: '/reportes', icon: ChartBarIcon },
|
||||
]
|
||||
|
||||
const bottomNavigation: NavItem[] = [
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Cog6ToothIcon },
|
||||
]
|
||||
|
||||
export default function Sidebar() {
|
||||
const location = useLocation()
|
||||
const { config, toggleSidebar } = useConfigStore()
|
||||
const { collapsed } = config.sidebarCollapsed ? { collapsed: true } : { collapsed: false }
|
||||
const alertasActivas = useAlertasStore((state) => state.alertasActivas.length)
|
||||
|
||||
// Add badge to alertas
|
||||
const navWithBadges = navigation.map((item) => {
|
||||
if (item.href === '/alertas') {
|
||||
return { ...item, badge: alertasActivas }
|
||||
}
|
||||
return item
|
||||
})
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
'fixed left-0 top-0 z-40 h-screen',
|
||||
'bg-background-900 border-r border-slate-800',
|
||||
'transition-all duration-300 ease-in-out',
|
||||
collapsed ? 'w-20' : 'w-64'
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="h-16 flex items-center justify-between px-4 border-b border-slate-800">
|
||||
{!collapsed && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-accent-500 to-accent-700 flex items-center justify-center">
|
||||
<MapPinIcon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">Flotillas</h1>
|
||||
<p className="text-xs text-slate-500">GPS Monitor</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="w-10 h-10 mx-auto rounded-lg bg-gradient-to-br from-accent-500 to-accent-700 flex items-center justify-center">
|
||||
<MapPinIcon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex flex-col h-[calc(100vh-4rem)] py-4 px-3">
|
||||
<div className="flex-1 space-y-1 overflow-y-auto scrollbar-hide">
|
||||
{navWithBadges.map((item) => (
|
||||
<NavItem key={item.href} item={item} collapsed={collapsed} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<div className="pt-4 border-t border-slate-800 space-y-1">
|
||||
{bottomNavigation.map((item) => (
|
||||
<NavItem key={item.href} item={item} collapsed={collapsed} />
|
||||
))}
|
||||
|
||||
{/* Collapse button */}
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg',
|
||||
'text-slate-400 hover:text-white hover:bg-slate-800',
|
||||
'transition-colors duration-200',
|
||||
collapsed && 'justify-center'
|
||||
)}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRightIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">Colapsar</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
// Nav item component
|
||||
interface NavItemProps {
|
||||
item: NavItem
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
function NavItem({ item, collapsed }: NavItemProps) {
|
||||
const location = useLocation()
|
||||
const isActive = location.pathname === item.href
|
||||
const hasChildren = item.children && item.children.length > 0
|
||||
const isChildActive = hasChildren && item.children?.some((child) => location.pathname === child.href)
|
||||
|
||||
// If collapsed, show only icon
|
||||
if (collapsed) {
|
||||
return (
|
||||
<NavLink
|
||||
to={hasChildren ? item.children![0].href : item.href}
|
||||
className={({ isActive: linkActive }) =>
|
||||
clsx(
|
||||
'relative flex items-center justify-center w-full h-12 rounded-lg',
|
||||
'transition-all duration-200 group',
|
||||
(linkActive || isChildActive)
|
||||
? 'bg-accent-500/20 text-accent-400'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||
)
|
||||
}
|
||||
title={item.name}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
{item.badge && item.badge > 0 && (
|
||||
<span className="absolute -top-1 -right-1">
|
||||
<CounterBadge count={item.badge} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute left-full ml-2 px-3 py-2 rounded-lg',
|
||||
'bg-slate-800 text-white text-sm font-medium',
|
||||
'opacity-0 invisible group-hover:opacity-100 group-hover:visible',
|
||||
'transition-all duration-200 whitespace-nowrap z-50',
|
||||
'shadow-xl border border-slate-700'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
// Expanded state
|
||||
return (
|
||||
<div>
|
||||
<NavLink
|
||||
to={hasChildren ? item.children![0].href : item.href}
|
||||
className={({ isActive: linkActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg',
|
||||
'transition-all duration-200',
|
||||
(linkActive || isChildActive)
|
||||
? 'bg-accent-500/20 text-accent-400'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="text-sm font-medium flex-1">{item.name}</span>
|
||||
{item.badge && item.badge > 0 && <CounterBadge count={item.badge} />}
|
||||
</NavLink>
|
||||
|
||||
{/* Children */}
|
||||
{hasChildren && (
|
||||
<div className="ml-8 mt-1 space-y-1">
|
||||
{item.children!.map((child) => (
|
||||
<NavLink
|
||||
key={child.href}
|
||||
to={child.href}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm',
|
||||
'transition-all duration-200',
|
||||
isActive
|
||||
? 'text-accent-400 bg-accent-500/10'
|
||||
: 'text-slate-500 hover:text-slate-300 hover:bg-slate-800/50'
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current" />
|
||||
{child.name}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
frontend/src/components/layout/index.ts
Normal file
3
frontend/src/components/layout/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as Sidebar } from './Sidebar'
|
||||
export { default as Header } from './Header'
|
||||
export { default as MainLayout, FullscreenLayout } from './MainLayout'
|
||||
209
frontend/src/components/mapa/DrawingTools.tsx
Normal file
209
frontend/src/components/mapa/DrawingTools.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useMap, useMapEvents, Circle, Polygon, Marker } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import clsx from 'clsx'
|
||||
import { useMapaStore } from '@/store/mapaStore'
|
||||
import { Coordenadas } from '@/types'
|
||||
|
||||
interface DrawingToolsProps {
|
||||
onComplete?: (type: 'circulo' | 'poligono', data: {
|
||||
centro?: Coordenadas
|
||||
radio?: number
|
||||
vertices?: Coordenadas[]
|
||||
}) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export default function DrawingTools({ onComplete, onCancel }: DrawingToolsProps) {
|
||||
const map = useMap()
|
||||
const {
|
||||
herramienta,
|
||||
dibujando,
|
||||
puntosDibujo,
|
||||
startDibujo,
|
||||
addPuntoDibujo,
|
||||
finishDibujo,
|
||||
cancelDibujo,
|
||||
} = useMapaStore()
|
||||
|
||||
const [mousePosition, setMousePosition] = useState<Coordenadas | null>(null)
|
||||
const [circleRadius, setCircleRadius] = useState(0)
|
||||
|
||||
// Handle map clicks for drawing
|
||||
useMapEvents({
|
||||
click: (e) => {
|
||||
if (!herramienta) return
|
||||
|
||||
const point = { lat: e.latlng.lat, lng: e.latlng.lng }
|
||||
|
||||
if (herramienta === 'dibujar_circulo') {
|
||||
if (puntosDibujo.length === 0) {
|
||||
startDibujo()
|
||||
addPuntoDibujo(point)
|
||||
} else {
|
||||
// Complete circle
|
||||
const center = puntosDibujo[0]
|
||||
const radius = calculateDistance(center, point)
|
||||
onComplete?.('circulo', { centro: center, radio: radius })
|
||||
finishDibujo()
|
||||
}
|
||||
} else if (herramienta === 'dibujar_poligono') {
|
||||
if (!dibujando) {
|
||||
startDibujo()
|
||||
}
|
||||
addPuntoDibujo(point)
|
||||
}
|
||||
},
|
||||
dblclick: (e) => {
|
||||
if (herramienta === 'dibujar_poligono' && puntosDibujo.length >= 3) {
|
||||
e.originalEvent.preventDefault()
|
||||
onComplete?.('poligono', { vertices: puntosDibujo })
|
||||
finishDibujo()
|
||||
}
|
||||
},
|
||||
mousemove: (e) => {
|
||||
setMousePosition({ lat: e.latlng.lat, lng: e.latlng.lng })
|
||||
|
||||
// Update circle radius preview
|
||||
if (herramienta === 'dibujar_circulo' && puntosDibujo.length === 1) {
|
||||
const radius = calculateDistance(puntosDibujo[0], {
|
||||
lat: e.latlng.lat,
|
||||
lng: e.latlng.lng,
|
||||
})
|
||||
setCircleRadius(radius)
|
||||
}
|
||||
},
|
||||
contextmenu: (e) => {
|
||||
e.originalEvent.preventDefault()
|
||||
if (dibujando) {
|
||||
cancelDibujo()
|
||||
onCancel?.()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Escape key to cancel
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && dibujando) {
|
||||
cancelDibujo()
|
||||
onCancel?.()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [dibujando, cancelDibujo, onCancel])
|
||||
|
||||
// Calculate distance between two points in meters
|
||||
const calculateDistance = useCallback(
|
||||
(p1: Coordenadas, p2: Coordenadas) => {
|
||||
const latlng1 = L.latLng(p1.lat, p1.lng)
|
||||
const latlng2 = L.latLng(p2.lat, p2.lng)
|
||||
return latlng1.distanceTo(latlng2)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Point marker icon
|
||||
const pointIcon = L.divIcon({
|
||||
className: 'drawing-point',
|
||||
html: `
|
||||
<div style="
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #3b82f6;
|
||||
border: 2px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
"></div>
|
||||
`,
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6],
|
||||
})
|
||||
|
||||
if (!herramienta || (!dibujando && puntosDibujo.length === 0)) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Circle preview */}
|
||||
{herramienta === 'dibujar_circulo' && puntosDibujo.length === 1 && (
|
||||
<Circle
|
||||
center={[puntosDibujo[0].lat, puntosDibujo[0].lng]}
|
||||
radius={circleRadius}
|
||||
pathOptions={{
|
||||
color: '#3b82f6',
|
||||
fillColor: '#3b82f6',
|
||||
fillOpacity: 0.2,
|
||||
weight: 2,
|
||||
dashArray: '5, 5',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Polygon preview */}
|
||||
{herramienta === 'dibujar_poligono' && puntosDibujo.length >= 2 && (
|
||||
<Polygon
|
||||
positions={[
|
||||
...puntosDibujo.map((p) => [p.lat, p.lng] as [number, number]),
|
||||
...(mousePosition ? [[mousePosition.lat, mousePosition.lng] as [number, number]] : []),
|
||||
]}
|
||||
pathOptions={{
|
||||
color: '#3b82f6',
|
||||
fillColor: '#3b82f6',
|
||||
fillOpacity: 0.2,
|
||||
weight: 2,
|
||||
dashArray: '5, 5',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drawing points */}
|
||||
{puntosDibujo.map((punto, index) => (
|
||||
<Marker
|
||||
key={index}
|
||||
position={[punto.lat, punto.lng]}
|
||||
icon={pointIcon}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Instructions overlay */}
|
||||
<DrawingInstructions herramienta={herramienta} puntosDibujo={puntosDibujo} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Instructions panel
|
||||
function DrawingInstructions({
|
||||
herramienta,
|
||||
puntosDibujo,
|
||||
}: {
|
||||
herramienta: string
|
||||
puntosDibujo: Coordenadas[]
|
||||
}) {
|
||||
let instruction = ''
|
||||
|
||||
if (herramienta === 'dibujar_circulo') {
|
||||
if (puntosDibujo.length === 0) {
|
||||
instruction = 'Haz clic para definir el centro del circulo'
|
||||
} else {
|
||||
instruction = 'Haz clic para definir el radio del circulo'
|
||||
}
|
||||
} else if (herramienta === 'dibujar_poligono') {
|
||||
if (puntosDibujo.length < 3) {
|
||||
instruction = `Haz clic para agregar puntos (${puntosDibujo.length}/3 minimo)`
|
||||
} else {
|
||||
instruction = 'Doble clic para completar el poligono'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-[1000]">
|
||||
<div className="bg-card border border-slate-700 rounded-lg shadow-xl px-4 py-2 flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-accent-500 animate-pulse" />
|
||||
<span className="text-sm text-slate-300">{instruction}</span>
|
||||
<span className="text-xs text-slate-500">(Esc para cancelar)</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
112
frontend/src/components/mapa/GeocercaLayer.tsx
Normal file
112
frontend/src/components/mapa/GeocercaLayer.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Circle, Polygon, Polyline, Popup, Tooltip } from 'react-leaflet'
|
||||
import { Geocerca } from '@/types'
|
||||
|
||||
interface GeocercaLayerProps {
|
||||
geocerca: Geocerca
|
||||
editable?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export default function GeocercaLayer({
|
||||
geocerca,
|
||||
editable = false,
|
||||
onClick,
|
||||
}: GeocercaLayerProps) {
|
||||
const pathOptions = {
|
||||
color: geocerca.color,
|
||||
fillColor: geocerca.color,
|
||||
fillOpacity: 0.2,
|
||||
weight: 2,
|
||||
}
|
||||
|
||||
const eventHandlers = {
|
||||
click: () => onClick?.(),
|
||||
}
|
||||
|
||||
// Circle geocerca
|
||||
if (geocerca.tipo === 'circulo' && geocerca.centroLat && geocerca.centroLng && geocerca.radio) {
|
||||
return (
|
||||
<Circle
|
||||
center={[geocerca.centroLat, geocerca.centroLng]}
|
||||
radius={geocerca.radio}
|
||||
pathOptions={pathOptions}
|
||||
eventHandlers={eventHandlers}
|
||||
>
|
||||
<Tooltip permanent={false} direction="top">
|
||||
<span className="text-sm font-medium">{geocerca.nombre}</span>
|
||||
</Tooltip>
|
||||
<Popup>
|
||||
<GeocercaPopup geocerca={geocerca} />
|
||||
</Popup>
|
||||
</Circle>
|
||||
)
|
||||
}
|
||||
|
||||
// Polygon geocerca
|
||||
if (geocerca.tipo === 'poligono' && geocerca.vertices && geocerca.vertices.length > 2) {
|
||||
const positions = geocerca.vertices.map((v) => [v.lat, v.lng] as [number, number])
|
||||
|
||||
return (
|
||||
<Polygon positions={positions} pathOptions={pathOptions} eventHandlers={eventHandlers}>
|
||||
<Tooltip permanent={false} direction="top">
|
||||
<span className="text-sm font-medium">{geocerca.nombre}</span>
|
||||
</Tooltip>
|
||||
<Popup>
|
||||
<GeocercaPopup geocerca={geocerca} />
|
||||
</Popup>
|
||||
</Polygon>
|
||||
)
|
||||
}
|
||||
|
||||
// Route geocerca
|
||||
if (geocerca.tipo === 'ruta' && geocerca.vertices && geocerca.vertices.length > 1) {
|
||||
const positions = geocerca.vertices.map((v) => [v.lat, v.lng] as [number, number])
|
||||
|
||||
return (
|
||||
<Polyline positions={positions} pathOptions={{ ...pathOptions, fillOpacity: 0 }} eventHandlers={eventHandlers}>
|
||||
<Tooltip permanent={false} direction="top">
|
||||
<span className="text-sm font-medium">{geocerca.nombre}</span>
|
||||
</Tooltip>
|
||||
<Popup>
|
||||
<GeocercaPopup geocerca={geocerca} />
|
||||
</Popup>
|
||||
</Polyline>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Popup content
|
||||
function GeocercaPopup({ geocerca }: { geocerca: Geocerca }) {
|
||||
return (
|
||||
<div className="p-1">
|
||||
<h3 className="text-sm font-semibold text-white mb-1">{geocerca.nombre}</h3>
|
||||
{geocerca.descripcion && (
|
||||
<p className="text-xs text-slate-400 mb-2">{geocerca.descripcion}</p>
|
||||
)}
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Tipo:</span>
|
||||
<span className="text-slate-300 capitalize">{geocerca.tipo}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Accion:</span>
|
||||
<span className="text-slate-300 capitalize">{geocerca.accion}</span>
|
||||
</div>
|
||||
{geocerca.velocidadMaxima && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Vel. max:</span>
|
||||
<span className="text-slate-300">{geocerca.velocidadMaxima} km/h</span>
|
||||
</div>
|
||||
)}
|
||||
{geocerca.tipo === 'circulo' && geocerca.radio && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Radio:</span>
|
||||
<span className="text-slate-300">{geocerca.radio} m</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
223
frontend/src/components/mapa/MapContainer.tsx
Normal file
223
frontend/src/components/mapa/MapContainer.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { MapContainer as LeafletMapContainer, TileLayer, useMap, useMapEvents } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import clsx from 'clsx'
|
||||
import { useMapaStore } from '@/store/mapaStore'
|
||||
import VehiculoMarker from './VehiculoMarker'
|
||||
import GeocercaLayer from './GeocercaLayer'
|
||||
import POILayer from './POILayer'
|
||||
import { useVehiculosStore } from '@/store/vehiculosStore'
|
||||
|
||||
// Fix Leaflet default marker icon issue
|
||||
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
|
||||
})
|
||||
|
||||
// Map styles
|
||||
const mapStyles = {
|
||||
dark: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
light: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
satellite: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
}
|
||||
|
||||
interface MapContainerProps {
|
||||
className?: string
|
||||
showControls?: boolean
|
||||
onVehiculoSelect?: (id: string) => void
|
||||
selectedVehiculoId?: string | null
|
||||
}
|
||||
|
||||
export default function MapContainer({
|
||||
className,
|
||||
showControls = true,
|
||||
onVehiculoSelect,
|
||||
selectedVehiculoId,
|
||||
}: MapContainerProps) {
|
||||
const {
|
||||
centro,
|
||||
zoom,
|
||||
estilo,
|
||||
capas,
|
||||
geocercas,
|
||||
pois,
|
||||
setCentro,
|
||||
setZoom,
|
||||
} = useMapaStore()
|
||||
|
||||
const vehiculos = useVehiculosStore((state) => state.getVehiculosArray())
|
||||
const vehiculosConUbicacion = vehiculos.filter((v) => v.ubicacion)
|
||||
|
||||
return (
|
||||
<LeafletMapContainer
|
||||
center={[centro.lat, centro.lng]}
|
||||
zoom={zoom}
|
||||
className={clsx('w-full h-full', className)}
|
||||
zoomControl={false}
|
||||
>
|
||||
{/* Map sync component */}
|
||||
<MapSync onCenterChange={setCentro} onZoomChange={setZoom} />
|
||||
|
||||
{/* Base tile layer */}
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url={mapStyles[estilo]}
|
||||
/>
|
||||
|
||||
{/* Vehicle markers */}
|
||||
{capas.vehiculos &&
|
||||
vehiculosConUbicacion.map((vehiculo) => (
|
||||
<VehiculoMarker
|
||||
key={vehiculo.id}
|
||||
vehiculo={vehiculo}
|
||||
isSelected={selectedVehiculoId === vehiculo.id}
|
||||
onClick={() => onVehiculoSelect?.(vehiculo.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Geocercas */}
|
||||
{capas.geocercas &&
|
||||
geocercas
|
||||
.filter((g) => g.activa)
|
||||
.map((geocerca) => (
|
||||
<GeocercaLayer key={geocerca.id} geocerca={geocerca} />
|
||||
))}
|
||||
|
||||
{/* POIs */}
|
||||
{capas.pois &&
|
||||
pois
|
||||
.filter((p) => p.activo)
|
||||
.map((poi) => <POILayer key={poi.id} poi={poi} />)}
|
||||
|
||||
{/* Custom controls */}
|
||||
{showControls && <MapControls />}
|
||||
</LeafletMapContainer>
|
||||
)
|
||||
}
|
||||
|
||||
// Component to sync map state with store
|
||||
function MapSync({
|
||||
onCenterChange,
|
||||
onZoomChange,
|
||||
}: {
|
||||
onCenterChange: (coords: { lat: number; lng: number }) => void
|
||||
onZoomChange: (zoom: number) => void
|
||||
}) {
|
||||
const map = useMapEvents({
|
||||
moveend: () => {
|
||||
const center = map.getCenter()
|
||||
onCenterChange({ lat: center.lat, lng: center.lng })
|
||||
},
|
||||
zoomend: () => {
|
||||
onZoomChange(map.getZoom())
|
||||
},
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Custom map controls
|
||||
function MapControls() {
|
||||
const map = useMap()
|
||||
const { estilo, setEstilo, capas, toggleCapa } = useMapaStore()
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
map.zoomIn()
|
||||
}, [map])
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
map.zoomOut()
|
||||
}, [map])
|
||||
|
||||
const handleResetView = useCallback(() => {
|
||||
map.setView([19.4326, -99.1332], 12)
|
||||
}, [map])
|
||||
|
||||
return (
|
||||
<div className="absolute right-4 top-4 z-[1000] flex flex-col gap-2">
|
||||
{/* Zoom controls */}
|
||||
<div className="flex flex-col bg-card border border-slate-700 rounded-lg overflow-hidden shadow-lg">
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="p-2 text-slate-300 hover:text-white hover:bg-slate-700 transition-colors"
|
||||
title="Acercar"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v12M6 12h12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="border-t border-slate-700" />
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="p-2 text-slate-300 hover:text-white hover:bg-slate-700 transition-colors"
|
||||
title="Alejar"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 12h12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Layer controls */}
|
||||
<div className="bg-card border border-slate-700 rounded-lg overflow-hidden shadow-lg">
|
||||
<button
|
||||
onClick={() => toggleCapa('vehiculos')}
|
||||
className={clsx(
|
||||
'p-2 transition-colors',
|
||||
capas.vehiculos ? 'text-accent-400' : 'text-slate-500 hover:text-slate-300'
|
||||
)}
|
||||
title="Mostrar vehiculos"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="border-t border-slate-700" />
|
||||
<button
|
||||
onClick={() => toggleCapa('geocercas')}
|
||||
className={clsx(
|
||||
'p-2 transition-colors',
|
||||
capas.geocercas ? 'text-accent-400' : 'text-slate-500 hover:text-slate-300'
|
||||
)}
|
||||
title="Mostrar geocercas"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="border-t border-slate-700" />
|
||||
<button
|
||||
onClick={() => toggleCapa('pois')}
|
||||
className={clsx(
|
||||
'p-2 transition-colors',
|
||||
capas.pois ? 'text-accent-400' : 'text-slate-500 hover:text-slate-300'
|
||||
)}
|
||||
title="Mostrar POIs"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Style selector */}
|
||||
<div className="bg-card border border-slate-700 rounded-lg overflow-hidden shadow-lg">
|
||||
<button
|
||||
onClick={() =>
|
||||
setEstilo(estilo === 'dark' ? 'light' : estilo === 'light' ? 'satellite' : 'dark')
|
||||
}
|
||||
className="p-2 text-slate-300 hover:text-white hover:bg-slate-700 transition-colors"
|
||||
title="Cambiar estilo de mapa"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
119
frontend/src/components/mapa/POILayer.tsx
Normal file
119
frontend/src/components/mapa/POILayer.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Marker, Popup, Tooltip } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { POI, POICategoria } from '@/types'
|
||||
|
||||
interface POILayerProps {
|
||||
poi: POI
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
// POI category icons and colors
|
||||
const categoryConfig: Record<POICategoria, { icon: string; color: string }> = {
|
||||
oficina: { icon: 'building', color: '#3b82f6' },
|
||||
cliente: { icon: 'user', color: '#22c55e' },
|
||||
gasolinera: { icon: 'gas', color: '#ef4444' },
|
||||
taller: { icon: 'wrench', color: '#f97316' },
|
||||
estacionamiento: { icon: 'parking', color: '#8b5cf6' },
|
||||
restaurante: { icon: 'food', color: '#eab308' },
|
||||
hotel: { icon: 'bed', color: '#ec4899' },
|
||||
otro: { icon: 'pin', color: '#64748b' },
|
||||
}
|
||||
|
||||
function createPOIIcon(poi: POI): L.DivIcon {
|
||||
const config = categoryConfig[poi.categoria] || categoryConfig.otro
|
||||
const color = poi.color || config.color
|
||||
|
||||
const iconSvg = getIconSvg(config.icon)
|
||||
|
||||
const html = `
|
||||
<div style="
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: ${color};
|
||||
border: 2px solid white;
|
||||
border-radius: 50% 50% 50% 0;
|
||||
transform: rotate(-45deg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||||
">
|
||||
<div style="transform: rotate(45deg); color: white; width: 16px; height: 16px;">
|
||||
${iconSvg}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
return L.divIcon({
|
||||
className: 'custom-poi-marker',
|
||||
html,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [8, 32],
|
||||
popupAnchor: [8, -32],
|
||||
})
|
||||
}
|
||||
|
||||
function getIconSvg(icon: string): string {
|
||||
const svgMap: Record<string, string> = {
|
||||
building: '<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" 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>',
|
||||
user: '<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" 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>',
|
||||
gas: '<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z"/></svg>',
|
||||
wrench: '<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" 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 stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>',
|
||||
parking: '<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>',
|
||||
food: '<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
|
||||
bed: '<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" 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>',
|
||||
pin: '<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" 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 stroke-linecap="round" stroke-linejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>',
|
||||
}
|
||||
|
||||
return svgMap[icon] || svgMap.pin
|
||||
}
|
||||
|
||||
export default function POILayer({ poi, onClick }: POILayerProps) {
|
||||
const icon = createPOIIcon(poi)
|
||||
|
||||
return (
|
||||
<Marker
|
||||
position={[poi.lat, poi.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onClick?.(),
|
||||
}}
|
||||
>
|
||||
<Tooltip permanent={false} direction="top">
|
||||
<span className="text-sm font-medium">{poi.nombre}</span>
|
||||
</Tooltip>
|
||||
<Popup>
|
||||
<div className="p-1">
|
||||
<h3 className="text-sm font-semibold text-white mb-1">{poi.nombre}</h3>
|
||||
{poi.descripcion && (
|
||||
<p className="text-xs text-slate-400 mb-2">{poi.descripcion}</p>
|
||||
)}
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Categoria:</span>
|
||||
<span className="text-slate-300 capitalize">{poi.categoria}</span>
|
||||
</div>
|
||||
{poi.direccion && (
|
||||
<div>
|
||||
<span className="text-slate-500">Direccion:</span>
|
||||
<p className="text-slate-300 mt-0.5">{poi.direccion}</p>
|
||||
</div>
|
||||
)}
|
||||
{poi.telefono && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Telefono:</span>
|
||||
<span className="text-slate-300">{poi.telefono}</span>
|
||||
</div>
|
||||
)}
|
||||
{poi.horario && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Horario:</span>
|
||||
<span className="text-slate-300">{poi.horario}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
)
|
||||
}
|
||||
189
frontend/src/components/mapa/RutaLayer.tsx
Normal file
189
frontend/src/components/mapa/RutaLayer.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { Polyline, Marker, Popup } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { format } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import { Coordenadas } from '@/types'
|
||||
|
||||
interface RutaPoint extends Coordenadas {
|
||||
timestamp?: string
|
||||
velocidad?: number
|
||||
}
|
||||
|
||||
interface RutaLayerProps {
|
||||
puntos: RutaPoint[]
|
||||
color?: string
|
||||
showMarkers?: boolean
|
||||
animated?: boolean
|
||||
showStartEnd?: boolean
|
||||
}
|
||||
|
||||
export default function RutaLayer({
|
||||
puntos,
|
||||
color = '#3b82f6',
|
||||
showMarkers = false,
|
||||
animated = false,
|
||||
showStartEnd = true,
|
||||
}: RutaLayerProps) {
|
||||
if (puntos.length < 2) return null
|
||||
|
||||
const positions = puntos.map((p) => [p.lat, p.lng] as [number, number])
|
||||
|
||||
// Create start/end markers
|
||||
const startIcon = L.divIcon({
|
||||
className: 'custom-marker',
|
||||
html: `
|
||||
<div style="
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #22c55e;
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||||
">
|
||||
<span style="color: white; font-size: 10px; font-weight: bold;">A</span>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
})
|
||||
|
||||
const endIcon = L.divIcon({
|
||||
className: 'custom-marker',
|
||||
html: `
|
||||
<div style="
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #ef4444;
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||||
">
|
||||
<span style="color: white; font-size: 10px; font-weight: bold;">B</span>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Route line */}
|
||||
<Polyline
|
||||
positions={positions}
|
||||
pathOptions={{
|
||||
color,
|
||||
weight: 4,
|
||||
opacity: 0.8,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Animated dash effect */}
|
||||
{animated && (
|
||||
<Polyline
|
||||
positions={positions}
|
||||
pathOptions={{
|
||||
color: '#ffffff',
|
||||
weight: 2,
|
||||
opacity: 0.5,
|
||||
dashArray: '10, 20',
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Start marker */}
|
||||
{showStartEnd && puntos.length > 0 && (
|
||||
<Marker position={[puntos[0].lat, puntos[0].lng]} icon={startIcon}>
|
||||
<Popup>
|
||||
<div className="p-1">
|
||||
<p className="text-sm font-semibold text-white mb-1">Inicio</p>
|
||||
{puntos[0].timestamp && (
|
||||
<p className="text-xs text-slate-400">
|
||||
{format(new Date(puntos[0].timestamp), "d MMM yyyy, HH:mm", {
|
||||
locale: es,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
{puntos[0].lat.toFixed(6)}, {puntos[0].lng.toFixed(6)}
|
||||
</p>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
)}
|
||||
|
||||
{/* End marker */}
|
||||
{showStartEnd && puntos.length > 1 && (
|
||||
<Marker
|
||||
position={[puntos[puntos.length - 1].lat, puntos[puntos.length - 1].lng]}
|
||||
icon={endIcon}
|
||||
>
|
||||
<Popup>
|
||||
<div className="p-1">
|
||||
<p className="text-sm font-semibold text-white mb-1">Fin</p>
|
||||
{puntos[puntos.length - 1].timestamp && (
|
||||
<p className="text-xs text-slate-400">
|
||||
{format(new Date(puntos[puntos.length - 1].timestamp), "d MMM yyyy, HH:mm", {
|
||||
locale: es,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
{puntos[puntos.length - 1].lat.toFixed(6)},{' '}
|
||||
{puntos[puntos.length - 1].lng.toFixed(6)}
|
||||
</p>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
)}
|
||||
|
||||
{/* Intermediate markers */}
|
||||
{showMarkers &&
|
||||
puntos.slice(1, -1).map((punto, index) => (
|
||||
<Marker
|
||||
key={index}
|
||||
position={[punto.lat, punto.lng]}
|
||||
icon={L.divIcon({
|
||||
className: 'custom-marker',
|
||||
html: `
|
||||
<div style="
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: ${color};
|
||||
border: 2px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
"></div>
|
||||
`,
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6],
|
||||
})}
|
||||
>
|
||||
<Popup>
|
||||
<div className="p-1">
|
||||
{punto.timestamp && (
|
||||
<p className="text-xs text-slate-400">
|
||||
{format(new Date(punto.timestamp), 'HH:mm:ss', { locale: es })}
|
||||
</p>
|
||||
)}
|
||||
{punto.velocidad !== undefined && (
|
||||
<p className="text-xs text-slate-300">
|
||||
{punto.velocidad.toFixed(0)} km/h
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
110
frontend/src/components/mapa/VehiculoMarker.tsx
Normal file
110
frontend/src/components/mapa/VehiculoMarker.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Marker, Popup } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { Vehiculo } from '@/types'
|
||||
import VehiculoPopup from './VehiculoPopup'
|
||||
|
||||
interface VehiculoMarkerProps {
|
||||
vehiculo: Vehiculo
|
||||
isSelected?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
// Create custom icon based on vehicle state
|
||||
function createVehicleIcon(vehiculo: Vehiculo, isSelected: boolean): L.DivIcon {
|
||||
const { movimiento } = vehiculo
|
||||
const rotation = vehiculo.rumbo || 0
|
||||
|
||||
// Color based on movement state
|
||||
let color = '#64748b' // gray - offline/unknown
|
||||
let pulseClass = ''
|
||||
|
||||
switch (movimiento) {
|
||||
case 'movimiento':
|
||||
color = '#22c55e' // green
|
||||
pulseClass = 'marker-pulse-green'
|
||||
break
|
||||
case 'detenido':
|
||||
color = '#eab308' // yellow
|
||||
break
|
||||
case 'ralenti':
|
||||
color = '#f97316' // orange
|
||||
break
|
||||
case 'sin_senal':
|
||||
color = '#64748b' // gray
|
||||
break
|
||||
}
|
||||
|
||||
// Selected state
|
||||
const borderColor = isSelected ? '#3b82f6' : '#ffffff'
|
||||
const size = isSelected ? 44 : 40
|
||||
|
||||
const html = `
|
||||
<div class="vehicle-marker" style="transform: rotate(${rotation}deg);">
|
||||
<div style="
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
background: ${color};
|
||||
border: 3px solid ${borderColor};
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
${isSelected ? 'box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.3), 0 2px 8px rgba(0,0,0,0.3);' : ''}
|
||||
">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
|
||||
<path d="M12 2L12 14M12 14L8 10M12 14L16 10" />
|
||||
</svg>
|
||||
</div>
|
||||
${
|
||||
movimiento === 'movimiento'
|
||||
? `<div style="
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
background: ${color};
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0.4;
|
||||
animation: pulse 2s ease-out infinite;
|
||||
"></div>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
`
|
||||
|
||||
return L.divIcon({
|
||||
className: 'custom-vehicle-marker',
|
||||
html,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
popupAnchor: [0, -size / 2],
|
||||
})
|
||||
}
|
||||
|
||||
export default function VehiculoMarker({
|
||||
vehiculo,
|
||||
isSelected = false,
|
||||
onClick,
|
||||
}: VehiculoMarkerProps) {
|
||||
if (!vehiculo.ubicacion) return null
|
||||
|
||||
const position: [number, number] = [vehiculo.ubicacion.lat, vehiculo.ubicacion.lng]
|
||||
const icon = createVehicleIcon(vehiculo, isSelected)
|
||||
|
||||
return (
|
||||
<Marker
|
||||
position={position}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onClick?.(),
|
||||
}}
|
||||
>
|
||||
<Popup className="vehicle-popup" maxWidth={320} minWidth={280}>
|
||||
<VehiculoPopup vehiculo={vehiculo} />
|
||||
</Popup>
|
||||
</Marker>
|
||||
)
|
||||
}
|
||||
136
frontend/src/components/mapa/VehiculoPopup.tsx
Normal file
136
frontend/src/components/mapa/VehiculoPopup.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { format } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import {
|
||||
TruckIcon,
|
||||
MapPinIcon,
|
||||
ClockIcon,
|
||||
BoltIcon,
|
||||
ArrowTrendingUpIcon,
|
||||
UserIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { Vehiculo } from '@/types'
|
||||
import { StatusBadge } from '@/components/ui/Badge'
|
||||
|
||||
interface VehiculoPopupProps {
|
||||
vehiculo: Vehiculo
|
||||
}
|
||||
|
||||
export default function VehiculoPopup({ vehiculo }: VehiculoPopupProps) {
|
||||
const { ubicacion, conductor } = vehiculo
|
||||
|
||||
// Format timestamp
|
||||
const lastUpdate = ubicacion?.timestamp
|
||||
? format(new Date(ubicacion.timestamp), "d MMM, HH:mm", { locale: es })
|
||||
: 'Sin datos'
|
||||
|
||||
// Movement status
|
||||
const getMovimientoStatus = () => {
|
||||
switch (vehiculo.movimiento) {
|
||||
case 'movimiento':
|
||||
return { label: 'En movimiento', status: 'online' as const }
|
||||
case 'detenido':
|
||||
return { label: 'Detenido', status: 'warning' as const }
|
||||
case 'ralenti':
|
||||
return { label: 'Ralenti', status: 'warning' as const }
|
||||
case 'sin_senal':
|
||||
return { label: 'Sin senal', status: 'offline' as const }
|
||||
default:
|
||||
return { label: 'Desconocido', status: 'offline' as const }
|
||||
}
|
||||
}
|
||||
|
||||
const movStatus = getMovimientoStatus()
|
||||
|
||||
return (
|
||||
<div className="p-1">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={clsx(
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center',
|
||||
vehiculo.movimiento === 'movimiento'
|
||||
? 'bg-success-500/20'
|
||||
: vehiculo.movimiento === 'detenido'
|
||||
? 'bg-warning-500/20'
|
||||
: 'bg-slate-700'
|
||||
)}
|
||||
>
|
||||
<TruckIcon
|
||||
className={clsx(
|
||||
'w-5 h-5',
|
||||
vehiculo.movimiento === 'movimiento'
|
||||
? 'text-success-400'
|
||||
: vehiculo.movimiento === 'detenido'
|
||||
? 'text-warning-400'
|
||||
: 'text-slate-400'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">{vehiculo.nombre}</h3>
|
||||
<p className="text-xs text-slate-500">{vehiculo.placa}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={movStatus.status} label={movStatus.label} size="xs" />
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<BoltIcon className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-slate-400">Velocidad:</span>
|
||||
<span className="text-white font-medium">{vehiculo.velocidad || 0} km/h</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<ArrowTrendingUpIcon className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-slate-400">Rumbo:</span>
|
||||
<span className="text-white font-medium">{vehiculo.rumbo || 0}°</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
{ubicacion && (
|
||||
<div className="flex items-start gap-2 mb-3 p-2 bg-slate-800/50 rounded-lg">
|
||||
<MapPinIcon className="w-4 h-4 text-slate-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-slate-300 truncate">
|
||||
{ubicacion.lat.toFixed(6)}, {ubicacion.lng.toFixed(6)}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<ClockIcon className="w-3 h-3 text-slate-500" />
|
||||
<span className="text-xs text-slate-500">{lastUpdate}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conductor */}
|
||||
{conductor && (
|
||||
<div className="flex items-center gap-2 mb-3 text-xs">
|
||||
<UserIcon className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-slate-400">Conductor:</span>
|
||||
<span className="text-white">{conductor.nombre} {conductor.apellido}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-slate-700">
|
||||
<Link
|
||||
to={`/vehiculos/${vehiculo.id}`}
|
||||
className="flex-1 px-3 py-1.5 text-xs font-medium text-center text-white bg-accent-500 hover:bg-accent-600 rounded-lg transition-colors"
|
||||
>
|
||||
Ver detalles
|
||||
</Link>
|
||||
<Link
|
||||
to={`/viajes?vehiculo=${vehiculo.id}`}
|
||||
className="flex-1 px-3 py-1.5 text-xs font-medium text-center text-slate-300 hover:text-white bg-slate-700 hover:bg-slate-600 rounded-lg transition-colors"
|
||||
>
|
||||
Ver viajes
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
frontend/src/components/mapa/index.ts
Normal file
7
frontend/src/components/mapa/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { default as MapContainer } from './MapContainer'
|
||||
export { default as VehiculoMarker } from './VehiculoMarker'
|
||||
export { default as VehiculoPopup } from './VehiculoPopup'
|
||||
export { default as GeocercaLayer } from './GeocercaLayer'
|
||||
export { default as POILayer } from './POILayer'
|
||||
export { default as RutaLayer } from './RutaLayer'
|
||||
export { default as DrawingTools } from './DrawingTools'
|
||||
185
frontend/src/components/ui/Badge.tsx
Normal file
185
frontend/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export type BadgeVariant =
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'error'
|
||||
| 'info'
|
||||
|
||||
export type BadgeSize = 'xs' | 'sm' | 'md'
|
||||
|
||||
export interface BadgeProps {
|
||||
children: ReactNode
|
||||
variant?: BadgeVariant
|
||||
size?: BadgeSize
|
||||
dot?: boolean
|
||||
pulse?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variantStyles: Record<BadgeVariant, string> = {
|
||||
default: 'bg-slate-700 text-slate-300',
|
||||
primary: 'bg-accent-500/20 text-accent-400 border border-accent-500/30',
|
||||
success: 'bg-success-500/20 text-success-400 border border-success-500/30',
|
||||
warning: 'bg-warning-500/20 text-warning-400 border border-warning-500/30',
|
||||
error: 'bg-error-500/20 text-error-400 border border-error-500/30',
|
||||
info: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
|
||||
}
|
||||
|
||||
const dotStyles: Record<BadgeVariant, string> = {
|
||||
default: 'bg-slate-400',
|
||||
primary: 'bg-accent-500',
|
||||
success: 'bg-success-500',
|
||||
warning: 'bg-warning-500',
|
||||
error: 'bg-error-500',
|
||||
info: 'bg-blue-500',
|
||||
}
|
||||
|
||||
const sizeStyles: Record<BadgeSize, string> = {
|
||||
xs: 'px-1.5 py-0.5 text-xs',
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-2.5 py-1 text-sm',
|
||||
}
|
||||
|
||||
export default function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'sm',
|
||||
dot = false,
|
||||
pulse = false,
|
||||
className,
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 font-medium rounded-full',
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{dot && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
{pulse && (
|
||||
<span
|
||||
className={clsx(
|
||||
'animate-ping absolute inline-flex h-full w-full rounded-full opacity-75',
|
||||
dotStyles[variant]
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={clsx(
|
||||
'relative inline-flex rounded-full h-2 w-2',
|
||||
dotStyles[variant]
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Status badge for vehiculos/dispositivos
|
||||
export type StatusType = 'online' | 'offline' | 'warning' | 'error' | 'idle'
|
||||
|
||||
export interface StatusBadgeProps {
|
||||
status: StatusType
|
||||
label?: string
|
||||
size?: BadgeSize
|
||||
showDot?: boolean
|
||||
}
|
||||
|
||||
const statusConfig: Record<
|
||||
StatusType,
|
||||
{ variant: BadgeVariant; label: string }
|
||||
> = {
|
||||
online: { variant: 'success', label: 'En linea' },
|
||||
offline: { variant: 'default', label: 'Sin conexion' },
|
||||
warning: { variant: 'warning', label: 'Advertencia' },
|
||||
error: { variant: 'error', label: 'Error' },
|
||||
idle: { variant: 'info', label: 'Inactivo' },
|
||||
}
|
||||
|
||||
export function StatusBadge({
|
||||
status,
|
||||
label,
|
||||
size = 'sm',
|
||||
showDot = true,
|
||||
}: StatusBadgeProps) {
|
||||
const config = statusConfig[status]
|
||||
return (
|
||||
<Badge
|
||||
variant={config.variant}
|
||||
size={size}
|
||||
dot={showDot}
|
||||
pulse={status === 'online'}
|
||||
>
|
||||
{label || config.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
// Priority badge for alertas
|
||||
export type PriorityType = 'baja' | 'media' | 'alta' | 'critica'
|
||||
|
||||
export interface PriorityBadgeProps {
|
||||
priority: PriorityType
|
||||
size?: BadgeSize
|
||||
}
|
||||
|
||||
const priorityConfig: Record<PriorityType, { variant: BadgeVariant; label: string }> = {
|
||||
baja: { variant: 'info', label: 'Baja' },
|
||||
media: { variant: 'warning', label: 'Media' },
|
||||
alta: { variant: 'error', label: 'Alta' },
|
||||
critica: { variant: 'error', label: 'Critica' },
|
||||
}
|
||||
|
||||
export function PriorityBadge({ priority, size = 'sm' }: PriorityBadgeProps) {
|
||||
const config = priorityConfig[priority]
|
||||
return (
|
||||
<Badge variant={config.variant} size={size} dot={priority === 'critica'} pulse={priority === 'critica'}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
// Counter badge (for notifications, alerts, etc.)
|
||||
export interface CounterBadgeProps {
|
||||
count: number
|
||||
max?: number
|
||||
variant?: BadgeVariant
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CounterBadge({
|
||||
count,
|
||||
max = 99,
|
||||
variant = 'error',
|
||||
className,
|
||||
}: CounterBadgeProps) {
|
||||
if (count === 0) return null
|
||||
|
||||
const displayCount = count > max ? `${max}+` : count.toString()
|
||||
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5',
|
||||
'text-xs font-bold rounded-full',
|
||||
variant === 'error' && 'bg-error-500 text-white',
|
||||
variant === 'warning' && 'bg-warning-500 text-black',
|
||||
variant === 'success' && 'bg-success-500 text-white',
|
||||
variant === 'primary' && 'bg-accent-500 text-white',
|
||||
variant === 'default' && 'bg-slate-600 text-white',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{displayCount}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
105
frontend/src/components/ui/Button.tsx
Normal file
105
frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { forwardRef, ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'outline' | 'ghost' | 'success'
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
isLoading?: boolean
|
||||
leftIcon?: ReactNode
|
||||
rightIcon?: ReactNode
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
fullWidth = false,
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const baseStyles =
|
||||
'inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-background-900 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
|
||||
const variantStyles = {
|
||||
primary:
|
||||
'bg-accent-500 text-white hover:bg-accent-600 focus:ring-accent-500 shadow-lg shadow-accent-500/25',
|
||||
secondary:
|
||||
'bg-slate-700 text-white hover:bg-slate-600 focus:ring-slate-500',
|
||||
danger:
|
||||
'bg-error-500 text-white hover:bg-error-600 focus:ring-error-500 shadow-lg shadow-error-500/25',
|
||||
outline:
|
||||
'border border-slate-600 text-slate-300 hover:bg-slate-800 hover:border-slate-500 focus:ring-slate-500',
|
||||
ghost:
|
||||
'text-slate-300 hover:bg-slate-800 hover:text-white focus:ring-slate-500',
|
||||
success:
|
||||
'bg-success-500 text-white hover:bg-success-600 focus:ring-success-500 shadow-lg shadow-success-500/25',
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
xs: 'px-2.5 py-1 text-xs',
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
baseStyles,
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
fullWidth && 'w-full',
|
||||
className
|
||||
)}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
<span>Cargando...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{leftIcon && <span className="flex-shrink-0">{leftIcon}</span>}
|
||||
{children}
|
||||
{rightIcon && <span className="flex-shrink-0">{rightIcon}</span>}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export default Button
|
||||
94
frontend/src/components/ui/Card.tsx
Normal file
94
frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { ReactNode, HTMLAttributes, forwardRef } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg'
|
||||
hover?: boolean
|
||||
glow?: boolean
|
||||
}
|
||||
|
||||
const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, children, padding = 'md', hover = false, glow = false, ...props }, ref) => {
|
||||
const paddingStyles = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'bg-card rounded-xl border border-slate-700/50',
|
||||
paddingStyles[padding],
|
||||
hover && 'hover:bg-card-hover hover:border-slate-600/50 transition-all duration-200 cursor-pointer',
|
||||
glow && 'shadow-glow',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Card.displayName = 'Card'
|
||||
|
||||
// Card Header
|
||||
export interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title: string
|
||||
subtitle?: string
|
||||
action?: ReactNode
|
||||
}
|
||||
|
||||
export function CardHeader({ title, subtitle, action, className, ...props }: CardHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx('flex items-start justify-between mb-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
{subtitle && <p className="text-sm text-slate-400 mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
{action && <div className="flex-shrink-0">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Card Content
|
||||
export interface CardContentProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function CardContent({ children, className, ...props }: CardContentProps) {
|
||||
return (
|
||||
<div className={clsx('', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Card Footer
|
||||
export interface CardFooterProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function CardFooter({ children, className, ...props }: CardFooterProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'mt-4 pt-4 border-t border-slate-700/50 flex items-center justify-end gap-3',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Card
|
||||
171
frontend/src/components/ui/Checkbox.tsx
Normal file
171
frontend/src/components/ui/Checkbox.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { forwardRef, InputHTMLAttributes, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface CheckboxProps
|
||||
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
label?: string | ReactNode
|
||||
description?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ className, label, description, error, id, ...props }, ref) => {
|
||||
const checkboxId = id || (typeof label === 'string' ? label.toLowerCase().replace(/\s+/g, '-') : undefined)
|
||||
|
||||
return (
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
id={checkboxId}
|
||||
className={clsx(
|
||||
'h-4 w-4 rounded border-slate-600 bg-background-800',
|
||||
'text-accent-500 focus:ring-accent-500 focus:ring-2 focus:ring-offset-2 focus:ring-offset-background-900',
|
||||
'transition-colors duration-200 cursor-pointer',
|
||||
error && 'border-error-500',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{(label || description) && (
|
||||
<div className="ml-3">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={checkboxId}
|
||||
className="text-sm font-medium text-slate-300 cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-sm text-slate-500">{description}</p>
|
||||
)}
|
||||
{error && <p className="text-sm text-error-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Checkbox.displayName = 'Checkbox'
|
||||
|
||||
export default Checkbox
|
||||
|
||||
// Switch component
|
||||
export interface SwitchProps {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
label?: string
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
description,
|
||||
disabled = false,
|
||||
size = 'md',
|
||||
}: SwitchProps) {
|
||||
const sizeStyles = {
|
||||
sm: {
|
||||
track: 'w-8 h-4',
|
||||
thumb: 'w-3 h-3',
|
||||
translate: 'translate-x-4',
|
||||
},
|
||||
md: {
|
||||
track: 'w-11 h-6',
|
||||
thumb: 'w-5 h-5',
|
||||
translate: 'translate-x-5',
|
||||
},
|
||||
lg: {
|
||||
track: 'w-14 h-7',
|
||||
thumb: 'w-6 h-6',
|
||||
translate: 'translate-x-7',
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
{(label || description) && (
|
||||
<div className="mr-4">
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-slate-300">{label}</span>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-sm text-slate-500">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
className={clsx(
|
||||
'relative inline-flex flex-shrink-0 rounded-full cursor-pointer',
|
||||
'transition-colors duration-200 ease-in-out',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:ring-offset-2 focus:ring-offset-background-900',
|
||||
sizeStyles[size].track,
|
||||
checked ? 'bg-accent-500' : 'bg-slate-600',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'pointer-events-none inline-block rounded-full bg-white shadow-lg',
|
||||
'transform ring-0 transition duration-200 ease-in-out',
|
||||
sizeStyles[size].thumb,
|
||||
checked ? sizeStyles[size].translate : 'translate-x-0.5',
|
||||
'mt-0.5 ml-0.5'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Radio button component
|
||||
export interface RadioProps
|
||||
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
label?: string
|
||||
}
|
||||
|
||||
export const Radio = forwardRef<HTMLInputElement, RadioProps>(
|
||||
({ className, label, id, ...props }, ref) => {
|
||||
const radioId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
ref={ref}
|
||||
type="radio"
|
||||
id={radioId}
|
||||
className={clsx(
|
||||
'h-4 w-4 border-slate-600 bg-background-800',
|
||||
'text-accent-500 focus:ring-accent-500 focus:ring-2 focus:ring-offset-2 focus:ring-offset-background-900',
|
||||
'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={radioId}
|
||||
className="ml-3 text-sm font-medium text-slate-300 cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Radio.displayName = 'Radio'
|
||||
186
frontend/src/components/ui/Dropdown.tsx
Normal file
186
frontend/src/components/ui/Dropdown.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Fragment, ReactNode } from 'react'
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface DropdownItem {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
onClick?: () => void
|
||||
href?: string
|
||||
danger?: boolean
|
||||
disabled?: boolean
|
||||
divider?: boolean
|
||||
}
|
||||
|
||||
export interface DropdownProps {
|
||||
trigger: ReactNode
|
||||
items: DropdownItem[]
|
||||
align?: 'left' | 'right'
|
||||
width?: 'auto' | 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const widthStyles = {
|
||||
auto: 'w-auto min-w-[160px]',
|
||||
sm: 'w-40',
|
||||
md: 'w-48',
|
||||
lg: 'w-56',
|
||||
}
|
||||
|
||||
export default function Dropdown({
|
||||
trigger,
|
||||
items,
|
||||
align = 'right',
|
||||
width = 'auto',
|
||||
}: DropdownProps) {
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<Menu.Button as={Fragment}>{trigger}</Menu.Button>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className={clsx(
|
||||
'absolute z-20 mt-2 origin-top-right rounded-lg',
|
||||
'bg-card border border-slate-700 shadow-xl',
|
||||
'divide-y divide-slate-700/50',
|
||||
'focus:outline-none',
|
||||
align === 'right' ? 'right-0' : 'left-0',
|
||||
widthStyles[width]
|
||||
)}
|
||||
>
|
||||
<div className="py-1">
|
||||
{items.map((item, index) => {
|
||||
if (item.divider) {
|
||||
return <div key={index} className="my-1 border-t border-slate-700" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu.Item key={index} disabled={item.disabled}>
|
||||
{({ active }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-2 px-4 py-2 text-sm',
|
||||
'transition-colors duration-100',
|
||||
active && 'bg-slate-700',
|
||||
item.danger
|
||||
? 'text-error-400 hover:text-error-300'
|
||||
: 'text-slate-300 hover:text-white',
|
||||
item.disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className="flex-shrink-0 w-5 h-5">{item.icon}</span>
|
||||
)}
|
||||
{item.label}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
// Dropdown button (with default styling)
|
||||
export interface DropdownButtonProps extends Omit<DropdownProps, 'trigger'> {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
variant?: 'primary' | 'secondary' | 'outline'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export function DropdownButton({
|
||||
label,
|
||||
icon,
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
...props
|
||||
}: DropdownButtonProps) {
|
||||
const variantStyles = {
|
||||
primary: 'bg-accent-500 text-white hover:bg-accent-600',
|
||||
secondary: 'bg-slate-700 text-white hover:bg-slate-600',
|
||||
outline: 'border border-slate-600 text-slate-300 hover:bg-slate-800',
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-5 py-2.5 text-base',
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
{...props}
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-2 rounded-lg font-medium',
|
||||
'transition-colors duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:ring-offset-2 focus:ring-offset-background-900',
|
||||
variantStyles[variant],
|
||||
sizeStyles[size]
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
<ChevronDownIcon className="w-4 h-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Action menu (icon-only trigger)
|
||||
export interface ActionMenuProps extends Omit<DropdownProps, 'trigger'> {
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
export function ActionMenu({ icon, ...props }: ActionMenuProps) {
|
||||
return (
|
||||
<Dropdown
|
||||
{...props}
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
'p-2 rounded-lg text-slate-400',
|
||||
'hover:text-white hover:bg-slate-700',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500',
|
||||
'transition-colors duration-200'
|
||||
)}
|
||||
>
|
||||
{icon || (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
134
frontend/src/components/ui/Input.tsx
Normal file
134
frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { forwardRef, InputHTMLAttributes, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helperText?: string
|
||||
leftIcon?: ReactNode
|
||||
rightIcon?: ReactNode
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
fullWidth = true,
|
||||
type = 'text',
|
||||
id,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
return (
|
||||
<div className={clsx(fullWidth && 'w-full')}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-slate-300 mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{leftIcon && (
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-400">
|
||||
{leftIcon}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
id={inputId}
|
||||
className={clsx(
|
||||
'w-full px-4 py-2.5 bg-background-800 border rounded-lg',
|
||||
'text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:border-transparent',
|
||||
'transition-all duration-200',
|
||||
error
|
||||
? 'border-error-500 focus:ring-error-500'
|
||||
: 'border-slate-700 focus:ring-accent-500',
|
||||
leftIcon && 'pl-10',
|
||||
rightIcon && 'pr-10',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400">
|
||||
{rightIcon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="mt-1.5 text-sm text-error-500">{error}</p>}
|
||||
{helperText && !error && (
|
||||
<p className="mt-1.5 text-sm text-slate-500">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export default Input
|
||||
|
||||
// Textarea component
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helperText?: string
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
(
|
||||
{ className, label, error, helperText, fullWidth = true, id, ...props },
|
||||
ref
|
||||
) => {
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
return (
|
||||
<div className={clsx(fullWidth && 'w-full')}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-slate-300 mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={clsx(
|
||||
'w-full px-4 py-2.5 bg-background-800 border rounded-lg',
|
||||
'text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:border-transparent',
|
||||
'transition-all duration-200 resize-none',
|
||||
error
|
||||
? 'border-error-500 focus:ring-error-500'
|
||||
: 'border-slate-700 focus:ring-accent-500',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="mt-1.5 text-sm text-error-500">{error}</p>}
|
||||
{helperText && !error && (
|
||||
<p className="mt-1.5 text-sm text-slate-500">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Textarea.displayName = 'Textarea'
|
||||
193
frontend/src/components/ui/Modal.tsx
Normal file
193
frontend/src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { Fragment, ReactNode } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface ModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
||||
showCloseButton?: boolean
|
||||
closeOnOverlayClick?: boolean
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
full: 'max-w-4xl',
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
size = 'md',
|
||||
showCloseButton = true,
|
||||
closeOnOverlayClick = true,
|
||||
}: ModalProps) {
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-50"
|
||||
onClose={closeOnOverlayClick ? onClose : () => {}}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
</Transition.Child>
|
||||
|
||||
{/* Modal container */}
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel
|
||||
className={clsx(
|
||||
'w-full transform overflow-hidden rounded-2xl',
|
||||
'bg-card border border-slate-700/50 shadow-2xl',
|
||||
'transition-all',
|
||||
sizeStyles[size]
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || showCloseButton) && (
|
||||
<div className="flex items-start justify-between p-6 border-b border-slate-700/50">
|
||||
<div>
|
||||
{title && (
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-semibold text-white"
|
||||
>
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
)}
|
||||
{description && (
|
||||
<Dialog.Description className="mt-1 text-sm text-slate-400">
|
||||
{description}
|
||||
</Dialog.Description>
|
||||
)}
|
||||
</div>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1 text-slate-400 hover:text-white hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">{children}</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
)
|
||||
}
|
||||
|
||||
// Modal Footer helper
|
||||
export interface ModalFooterProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ModalFooter({ children, className }: ModalFooterProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-end gap-3 mt-6 pt-6 border-t border-slate-700/50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Confirm dialog shortcut
|
||||
export interface ConfirmModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirmar',
|
||||
cancelText = 'Cancelar',
|
||||
variant = 'danger',
|
||||
isLoading = false,
|
||||
}: ConfirmModalProps) {
|
||||
const variantStyles = {
|
||||
danger: 'bg-error-500 hover:bg-error-600 focus:ring-error-500',
|
||||
warning: 'bg-warning-500 hover:bg-warning-600 focus:ring-warning-500',
|
||||
info: 'bg-accent-500 hover:bg-accent-600 focus:ring-accent-500',
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
||||
<p className="text-slate-300">{message}</p>
|
||||
<ModalFooter>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white rounded-lg hover:bg-slate-700 transition-colors"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
className={clsx(
|
||||
'px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-background-900',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
variantStyles[variant]
|
||||
)}
|
||||
>
|
||||
{isLoading ? 'Procesando...' : confirmText}
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
186
frontend/src/components/ui/Select.tsx
Normal file
186
frontend/src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Fragment, ReactNode } from 'react'
|
||||
import { Listbox, Transition } from '@headlessui/react'
|
||||
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface SelectProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: SelectOption[]
|
||||
label?: string
|
||||
placeholder?: string
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export default function Select({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
label,
|
||||
placeholder = 'Seleccionar...',
|
||||
error,
|
||||
disabled = false,
|
||||
fullWidth = true,
|
||||
}: SelectProps) {
|
||||
const selectedOption = options.find((opt) => opt.value === value)
|
||||
|
||||
return (
|
||||
<div className={clsx(fullWidth && 'w-full')}>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<Listbox value={value} onChange={onChange} disabled={disabled}>
|
||||
<div className="relative">
|
||||
<Listbox.Button
|
||||
className={clsx(
|
||||
'relative w-full px-4 py-2.5 bg-background-800 border rounded-lg',
|
||||
'text-left cursor-pointer',
|
||||
'focus:outline-none focus:ring-2 focus:border-transparent',
|
||||
'transition-all duration-200',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
error
|
||||
? 'border-error-500 focus:ring-error-500'
|
||||
: 'border-slate-700 focus:ring-accent-500'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'flex items-center gap-2 truncate',
|
||||
!selectedOption && 'text-slate-500'
|
||||
)}
|
||||
>
|
||||
{selectedOption?.icon}
|
||||
{selectedOption?.label || placeholder}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<ChevronUpDownIcon
|
||||
className="h-5 w-5 text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options
|
||||
className={clsx(
|
||||
'absolute z-10 mt-1 w-full overflow-auto rounded-lg',
|
||||
'bg-card border border-slate-700 shadow-xl',
|
||||
'max-h-60 py-1',
|
||||
'focus:outline-none'
|
||||
)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Listbox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
className={({ active, selected }) =>
|
||||
clsx(
|
||||
'relative cursor-pointer select-none py-2.5 px-4',
|
||||
'transition-colors duration-100',
|
||||
active && 'bg-slate-700',
|
||||
selected && 'bg-accent-500/20',
|
||||
option.disabled && 'opacity-50 cursor-not-allowed'
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={clsx(
|
||||
'flex items-center gap-2 truncate',
|
||||
selected ? 'text-white font-medium' : 'text-slate-300'
|
||||
)}
|
||||
>
|
||||
{option.icon}
|
||||
{option.label}
|
||||
</span>
|
||||
{selected && (
|
||||
<CheckIcon className="h-5 w-5 text-accent-500" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
{error && <p className="mt-1.5 text-sm text-error-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Native select for simpler use cases
|
||||
export interface NativeSelectProps
|
||||
extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
options: SelectOption[]
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export function NativeSelect({
|
||||
label,
|
||||
error,
|
||||
options,
|
||||
fullWidth = true,
|
||||
className,
|
||||
id,
|
||||
...props
|
||||
}: NativeSelectProps) {
|
||||
const selectId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
return (
|
||||
<div className={clsx(fullWidth && 'w-full')}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="block text-sm font-medium text-slate-300 mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
id={selectId}
|
||||
className={clsx(
|
||||
'w-full px-4 py-2.5 bg-background-800 border rounded-lg',
|
||||
'text-white appearance-none cursor-pointer',
|
||||
'focus:outline-none focus:ring-2 focus:border-transparent',
|
||||
'transition-all duration-200',
|
||||
error
|
||||
? 'border-error-500 focus:ring-error-500'
|
||||
: 'border-slate-700 focus:ring-accent-500',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <p className="mt-1.5 text-sm text-error-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
140
frontend/src/components/ui/Skeleton.tsx
Normal file
140
frontend/src/components/ui/Skeleton.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string
|
||||
variant?: 'text' | 'circular' | 'rectangular' | 'rounded'
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
animate?: boolean
|
||||
}
|
||||
|
||||
export default function Skeleton({
|
||||
className,
|
||||
variant = 'text',
|
||||
width,
|
||||
height,
|
||||
animate = true,
|
||||
}: SkeletonProps) {
|
||||
const baseStyles = 'bg-slate-700'
|
||||
|
||||
const variantStyles = {
|
||||
text: 'h-4 rounded',
|
||||
circular: 'rounded-full',
|
||||
rectangular: '',
|
||||
rounded: 'rounded-lg',
|
||||
}
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
width: width,
|
||||
height: height,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
baseStyles,
|
||||
variantStyles[variant],
|
||||
animate && 'animate-shimmer',
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for card
|
||||
export function SkeletonCard() {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-slate-700/50 p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton variant="circular" width={48} height={48} />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton variant="text" width="60%" />
|
||||
<Skeleton variant="text" width="40%" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<Skeleton variant="text" />
|
||||
<Skeleton variant="text" width="80%" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for table row
|
||||
export function SkeletonTableRow({ columns = 5 }: { columns?: number }) {
|
||||
return (
|
||||
<tr>
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<td key={i} className="px-4 py-3">
|
||||
<Skeleton variant="text" width={i === 0 ? '80%' : '60%'} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for list item
|
||||
export function SkeletonListItem() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<Skeleton variant="circular" width={40} height={40} />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton variant="text" width="70%" />
|
||||
<Skeleton variant="text" width="50%" height={12} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for stats card
|
||||
export function SkeletonStats() {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-slate-700/50 p-4">
|
||||
<Skeleton variant="text" width="40%" height={14} className="mb-2" />
|
||||
<Skeleton variant="text" width="60%" height={32} className="mb-1" />
|
||||
<Skeleton variant="text" width="30%" height={14} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for chart
|
||||
export function SkeletonChart({ height = 200 }: { height?: number }) {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-slate-700/50 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Skeleton variant="text" width={120} height={20} />
|
||||
<Skeleton variant="rounded" width={100} height={32} />
|
||||
</div>
|
||||
<Skeleton variant="rounded" height={height} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for map
|
||||
export function SkeletonMap() {
|
||||
return (
|
||||
<div className="relative w-full h-full bg-slate-800 rounded-xl overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-slate-600 border-t-accent-500 rounded-full animate-spin mx-auto mb-4" />
|
||||
<Skeleton variant="text" width={120} className="mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for form
|
||||
export function SkeletonForm({ fields = 4 }: { fields?: number }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: fields }).map((_, i) => (
|
||||
<div key={i}>
|
||||
<Skeleton variant="text" width={100} height={14} className="mb-1.5" />
|
||||
<Skeleton variant="rounded" height={42} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
312
frontend/src/components/ui/Table.tsx
Normal file
312
frontend/src/components/ui/Table.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { ReactNode, useState, useMemo } from 'react'
|
||||
import {
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// Types
|
||||
export interface Column<T> {
|
||||
key: string
|
||||
header: string
|
||||
width?: string
|
||||
sortable?: boolean
|
||||
render?: (item: T, index: number) => ReactNode
|
||||
align?: 'left' | 'center' | 'right'
|
||||
}
|
||||
|
||||
export interface TableProps<T> {
|
||||
data: T[]
|
||||
columns: Column<T>[]
|
||||
keyExtractor: (item: T) => string
|
||||
onRowClick?: (item: T) => void
|
||||
selectedKey?: string
|
||||
emptyMessage?: string
|
||||
isLoading?: boolean
|
||||
loadingRows?: number
|
||||
|
||||
// Sorting
|
||||
sortable?: boolean
|
||||
defaultSortKey?: string
|
||||
defaultSortOrder?: 'asc' | 'desc'
|
||||
onSort?: (key: string, order: 'asc' | 'desc') => void
|
||||
|
||||
// Pagination
|
||||
pagination?: boolean
|
||||
pageSize?: number
|
||||
currentPage?: number
|
||||
totalItems?: number
|
||||
onPageChange?: (page: number) => void
|
||||
|
||||
// Styling
|
||||
compact?: boolean
|
||||
striped?: boolean
|
||||
hoverable?: boolean
|
||||
stickyHeader?: boolean
|
||||
}
|
||||
|
||||
export default function Table<T>({
|
||||
data,
|
||||
columns,
|
||||
keyExtractor,
|
||||
onRowClick,
|
||||
selectedKey,
|
||||
emptyMessage = 'No hay datos disponibles',
|
||||
isLoading = false,
|
||||
loadingRows = 5,
|
||||
sortable = true,
|
||||
defaultSortKey,
|
||||
defaultSortOrder = 'asc',
|
||||
onSort,
|
||||
pagination = false,
|
||||
pageSize = 10,
|
||||
currentPage: controlledPage,
|
||||
totalItems,
|
||||
onPageChange,
|
||||
compact = false,
|
||||
striped = false,
|
||||
hoverable = true,
|
||||
stickyHeader = false,
|
||||
}: TableProps<T>) {
|
||||
const [sortKey, setSortKey] = useState(defaultSortKey)
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(defaultSortOrder)
|
||||
const [internalPage, setInternalPage] = useState(1)
|
||||
|
||||
const currentPage = controlledPage ?? internalPage
|
||||
const setCurrentPage = onPageChange ?? setInternalPage
|
||||
|
||||
// Handle sorting
|
||||
const handleSort = (key: string) => {
|
||||
const newOrder = sortKey === key && sortOrder === 'asc' ? 'desc' : 'asc'
|
||||
setSortKey(key)
|
||||
setSortOrder(newOrder)
|
||||
onSort?.(key, newOrder)
|
||||
}
|
||||
|
||||
// Sort data locally if no external handler
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortKey || onSort) return data
|
||||
|
||||
return [...data].sort((a, b) => {
|
||||
const aVal = (a as Record<string, unknown>)[sortKey]
|
||||
const bVal = (b as Record<string, unknown>)[sortKey]
|
||||
|
||||
if (aVal === bVal) return 0
|
||||
if (aVal === null || aVal === undefined) return 1
|
||||
if (bVal === null || bVal === undefined) return -1
|
||||
|
||||
const comparison = aVal < bVal ? -1 : 1
|
||||
return sortOrder === 'asc' ? comparison : -comparison
|
||||
})
|
||||
}, [data, sortKey, sortOrder, onSort])
|
||||
|
||||
// Pagination
|
||||
const total = totalItems ?? sortedData.length
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
if (!pagination || onPageChange) return sortedData
|
||||
const start = (currentPage - 1) * pageSize
|
||||
return sortedData.slice(start, start + pageSize)
|
||||
}, [sortedData, pagination, currentPage, pageSize, onPageChange])
|
||||
|
||||
// Cell padding based on compact mode
|
||||
const cellPadding = compact ? 'px-3 py-2' : 'px-4 py-3'
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-700/50">
|
||||
<table className="min-w-full divide-y divide-slate-700/50">
|
||||
{/* Header */}
|
||||
<thead
|
||||
className={clsx(
|
||||
'bg-slate-800/50',
|
||||
stickyHeader && 'sticky top-0 z-10'
|
||||
)}
|
||||
>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={clsx(
|
||||
cellPadding,
|
||||
'text-xs font-semibold text-slate-400 uppercase tracking-wider',
|
||||
'text-left',
|
||||
column.align === 'center' && 'text-center',
|
||||
column.align === 'right' && 'text-right',
|
||||
column.sortable !== false && sortable && 'cursor-pointer hover:text-white',
|
||||
column.width
|
||||
)}
|
||||
style={{ width: column.width }}
|
||||
onClick={() =>
|
||||
column.sortable !== false &&
|
||||
sortable &&
|
||||
handleSort(column.key)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{column.header}</span>
|
||||
{column.sortable !== false && sortable && (
|
||||
<span className="flex flex-col">
|
||||
<ChevronUpIcon
|
||||
className={clsx(
|
||||
'w-3 h-3 -mb-1',
|
||||
sortKey === column.key && sortOrder === 'asc'
|
||||
? 'text-accent-500'
|
||||
: 'text-slate-600'
|
||||
)}
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
className={clsx(
|
||||
'w-3 h-3 -mt-1',
|
||||
sortKey === column.key && sortOrder === 'desc'
|
||||
? 'text-accent-500'
|
||||
: 'text-slate-600'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* Body */}
|
||||
<tbody className="divide-y divide-slate-700/30 bg-card">
|
||||
{isLoading ? (
|
||||
// Loading skeleton
|
||||
Array.from({ length: loadingRows }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} className={cellPadding}>
|
||||
<div className="h-4 bg-slate-700 rounded animate-shimmer" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : paginatedData.length === 0 ? (
|
||||
// Empty state
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className="px-4 py-12 text-center text-slate-500"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
// Data rows
|
||||
paginatedData.map((item, index) => {
|
||||
const key = keyExtractor(item)
|
||||
const isSelected = selectedKey === key
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
className={clsx(
|
||||
'transition-colors duration-100',
|
||||
striped && index % 2 === 1 && 'bg-slate-800/30',
|
||||
hoverable && 'hover:bg-slate-700/30',
|
||||
onRowClick && 'cursor-pointer',
|
||||
isSelected && 'bg-accent-500/10 hover:bg-accent-500/20'
|
||||
)}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={clsx(
|
||||
cellPadding,
|
||||
'text-sm text-slate-300',
|
||||
column.align === 'center' && 'text-center',
|
||||
column.align === 'right' && 'text-right'
|
||||
)}
|
||||
>
|
||||
{column.render
|
||||
? column.render(item, index)
|
||||
: String((item as Record<string, unknown>)[column.key] ?? '-')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 px-2">
|
||||
<p className="text-sm text-slate-500">
|
||||
Mostrando {(currentPage - 1) * pageSize + 1} -{' '}
|
||||
{Math.min(currentPage * pageSize, total)} de {total}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className={clsx(
|
||||
'p-2 rounded-lg transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'hover:bg-slate-700 text-slate-400 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Page numbers */}
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let pageNum: number
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1
|
||||
} else if (currentPage <= 3) {
|
||||
pageNum = i + 1
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i
|
||||
} else {
|
||||
pageNum = currentPage - 2 + i
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className={clsx(
|
||||
'w-8 h-8 rounded-lg text-sm font-medium transition-colors',
|
||||
currentPage === pageNum
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'text-slate-400 hover:bg-slate-700 hover:text-white'
|
||||
)}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className={clsx(
|
||||
'p-2 rounded-lg transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'hover:bg-slate-700 text-slate-400 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<ChevronRightIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
208
frontend/src/components/ui/Tabs.tsx
Normal file
208
frontend/src/components/ui/Tabs.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { ReactNode, useState, createContext, useContext } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// Types
|
||||
export interface Tab {
|
||||
id: string
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
badge?: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface TabsContextValue {
|
||||
activeTab: string
|
||||
setActiveTab: (id: string) => void
|
||||
}
|
||||
|
||||
// Context
|
||||
const TabsContext = createContext<TabsContextValue | undefined>(undefined)
|
||||
|
||||
function useTabsContext() {
|
||||
const context = useContext(TabsContext)
|
||||
if (!context) {
|
||||
throw new Error('Tab components must be used within a Tabs component')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Tabs container
|
||||
export interface TabsProps {
|
||||
tabs: Tab[]
|
||||
defaultTab?: string
|
||||
activeTab?: string
|
||||
onChange?: (id: string) => void
|
||||
children: ReactNode
|
||||
variant?: 'default' | 'pills' | 'underline'
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export default function Tabs({
|
||||
tabs,
|
||||
defaultTab,
|
||||
activeTab: controlledActiveTab,
|
||||
onChange,
|
||||
children,
|
||||
variant = 'default',
|
||||
fullWidth = false,
|
||||
}: TabsProps) {
|
||||
const [internalActiveTab, setInternalActiveTab] = useState(
|
||||
defaultTab || tabs[0]?.id
|
||||
)
|
||||
|
||||
const activeTab = controlledActiveTab ?? internalActiveTab
|
||||
|
||||
const setActiveTab = (id: string) => {
|
||||
if (!controlledActiveTab) {
|
||||
setInternalActiveTab(id)
|
||||
}
|
||||
onChange?.(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
|
||||
<div>
|
||||
{/* Tab list */}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex',
|
||||
variant === 'default' && 'border-b border-slate-700',
|
||||
variant === 'pills' && 'gap-2 p-1 bg-slate-800/50 rounded-lg',
|
||||
variant === 'underline' && 'border-b border-slate-700',
|
||||
fullWidth && 'w-full'
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<TabButton
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
variant={variant}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab panels */}
|
||||
<div className="mt-4">{children}</div>
|
||||
</div>
|
||||
</TabsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Tab button
|
||||
interface TabButtonProps {
|
||||
tab: Tab
|
||||
variant: 'default' | 'pills' | 'underline'
|
||||
fullWidth: boolean
|
||||
}
|
||||
|
||||
function TabButton({ tab, variant, fullWidth }: TabButtonProps) {
|
||||
const { activeTab, setActiveTab } = useTabsContext()
|
||||
const isActive = activeTab === tab.id
|
||||
|
||||
const baseStyles =
|
||||
'relative flex items-center justify-center gap-2 font-medium transition-all duration-200'
|
||||
|
||||
const variantStyles = {
|
||||
default: clsx(
|
||||
'px-4 py-3 text-sm -mb-px border-b-2',
|
||||
isActive
|
||||
? 'border-accent-500 text-white'
|
||||
: 'border-transparent text-slate-400 hover:text-white hover:border-slate-600'
|
||||
),
|
||||
pills: clsx(
|
||||
'px-4 py-2 text-sm rounded-md',
|
||||
isActive
|
||||
? 'bg-accent-500 text-white shadow-lg shadow-accent-500/25'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-700'
|
||||
),
|
||||
underline: clsx(
|
||||
'px-4 py-3 text-sm border-b-2 -mb-px',
|
||||
isActive
|
||||
? 'border-accent-500 text-accent-500'
|
||||
: 'border-transparent text-slate-400 hover:text-white'
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !tab.disabled && setActiveTab(tab.id)}
|
||||
disabled={tab.disabled}
|
||||
className={clsx(
|
||||
baseStyles,
|
||||
variantStyles[variant],
|
||||
fullWidth && 'flex-1',
|
||||
tab.disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{tab.icon && <span className="w-5 h-5">{tab.icon}</span>}
|
||||
{tab.label}
|
||||
{tab.badge !== undefined && tab.badge > 0 && (
|
||||
<span
|
||||
className={clsx(
|
||||
'ml-1.5 px-1.5 py-0.5 text-xs font-bold rounded-full',
|
||||
isActive
|
||||
? 'bg-white/20 text-white'
|
||||
: 'bg-slate-700 text-slate-300'
|
||||
)}
|
||||
>
|
||||
{tab.badge > 99 ? '99+' : tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Tab panel
|
||||
export interface TabPanelProps {
|
||||
id: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TabPanel({ id, children, className }: TabPanelProps) {
|
||||
const { activeTab } = useTabsContext()
|
||||
|
||||
if (activeTab !== id) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('animate-fade-in', className)} role="tabpanel">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Simple tabs (all-in-one component)
|
||||
export interface SimpleTabsProps {
|
||||
tabs: Array<{
|
||||
id: string
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
content: ReactNode
|
||||
}>
|
||||
defaultTab?: string
|
||||
variant?: 'default' | 'pills' | 'underline'
|
||||
}
|
||||
|
||||
export function SimpleTabs({
|
||||
tabs,
|
||||
defaultTab,
|
||||
variant = 'default',
|
||||
}: SimpleTabsProps) {
|
||||
return (
|
||||
<Tabs
|
||||
tabs={tabs.map((t) => ({ id: t.id, label: t.label, icon: t.icon }))}
|
||||
defaultTab={defaultTab}
|
||||
variant={variant}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<TabPanel key={tab.id} id={tab.id}>
|
||||
{tab.content}
|
||||
</TabPanel>
|
||||
))}
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
214
frontend/src/components/ui/Toast.tsx
Normal file
214
frontend/src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
|
||||
import { Transition } from '@headlessui/react'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// Types
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
export interface Toast {
|
||||
id: string
|
||||
type: ToastType
|
||||
title: string
|
||||
message?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toasts: Toast[]
|
||||
addToast: (toast: Omit<Toast, 'id'>) => void
|
||||
removeToast: (id: string) => void
|
||||
success: (title: string, message?: string) => void
|
||||
error: (title: string, message?: string) => void
|
||||
warning: (title: string, message?: string) => void
|
||||
info: (title: string, message?: string) => void
|
||||
}
|
||||
|
||||
// Context
|
||||
const ToastContext = createContext<ToastContextValue | undefined>(undefined)
|
||||
|
||||
// Hook
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Provider
|
||||
interface ToastProviderProps {
|
||||
children: ReactNode
|
||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
||||
}
|
||||
|
||||
export function ToastProvider({
|
||||
children,
|
||||
position = 'top-right',
|
||||
}: ToastProviderProps) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
|
||||
const addToast = useCallback((toast: Omit<Toast, 'id'>) => {
|
||||
const id = Math.random().toString(36).substr(2, 9)
|
||||
const newToast: Toast = { ...toast, id }
|
||||
|
||||
setToasts((prev) => [...prev, newToast])
|
||||
|
||||
// Auto remove after duration
|
||||
const duration = toast.duration ?? 5000
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id)
|
||||
}, duration)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const success = useCallback(
|
||||
(title: string, message?: string) => {
|
||||
addToast({ type: 'success', title, message })
|
||||
},
|
||||
[addToast]
|
||||
)
|
||||
|
||||
const error = useCallback(
|
||||
(title: string, message?: string) => {
|
||||
addToast({ type: 'error', title, message, duration: 8000 })
|
||||
},
|
||||
[addToast]
|
||||
)
|
||||
|
||||
const warning = useCallback(
|
||||
(title: string, message?: string) => {
|
||||
addToast({ type: 'warning', title, message })
|
||||
},
|
||||
[addToast]
|
||||
)
|
||||
|
||||
const info = useCallback(
|
||||
(title: string, message?: string) => {
|
||||
addToast({ type: 'info', title, message })
|
||||
},
|
||||
[addToast]
|
||||
)
|
||||
|
||||
const positionStyles = {
|
||||
'top-right': 'top-4 right-4',
|
||||
'top-left': 'top-4 left-4',
|
||||
'bottom-right': 'bottom-4 right-4',
|
||||
'bottom-left': 'bottom-4 left-4',
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastContext.Provider
|
||||
value={{ toasts, addToast, removeToast, success, error, warning, info }}
|
||||
>
|
||||
{children}
|
||||
|
||||
{/* Toast container */}
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed z-50 flex flex-col gap-3 pointer-events-none',
|
||||
positionStyles[position]
|
||||
)}
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Toast item
|
||||
interface ToastItemProps {
|
||||
toast: Toast
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const icons: Record<ToastType, typeof CheckCircleIcon> = {
|
||||
success: CheckCircleIcon,
|
||||
error: ExclamationCircleIcon,
|
||||
warning: ExclamationTriangleIcon,
|
||||
info: InformationCircleIcon,
|
||||
}
|
||||
|
||||
const styles: Record<ToastType, { icon: string; border: string }> = {
|
||||
success: {
|
||||
icon: 'text-success-500',
|
||||
border: 'border-l-success-500',
|
||||
},
|
||||
error: {
|
||||
icon: 'text-error-500',
|
||||
border: 'border-l-error-500',
|
||||
},
|
||||
warning: {
|
||||
icon: 'text-warning-500',
|
||||
border: 'border-l-warning-500',
|
||||
},
|
||||
info: {
|
||||
icon: 'text-accent-500',
|
||||
border: 'border-l-accent-500',
|
||||
},
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onClose }: ToastItemProps) {
|
||||
const Icon = icons[toast.type]
|
||||
const style = styles[toast.type]
|
||||
|
||||
return (
|
||||
<Transition
|
||||
appear
|
||||
show={true}
|
||||
enter="transform ease-out duration-300 transition"
|
||||
enterFrom="translate-x-full opacity-0"
|
||||
enterTo="translate-x-0 opacity-100"
|
||||
leave="transition ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'pointer-events-auto w-80 overflow-hidden rounded-lg',
|
||||
'bg-card border border-slate-700/50 shadow-xl',
|
||||
'border-l-4',
|
||||
style.border
|
||||
)}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<Icon className={clsx('h-5 w-5', style.icon)} />
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<p className="text-sm font-medium text-white">{toast.title}</p>
|
||||
{toast.message && (
|
||||
<p className="mt-1 text-sm text-slate-400">{toast.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex rounded-md text-slate-400 hover:text-slate-300 focus:outline-none focus:ring-2 focus:ring-accent-500 focus:ring-offset-2 focus:ring-offset-card"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
)
|
||||
}
|
||||
|
||||
export default ToastProvider
|
||||
52
frontend/src/components/ui/index.ts
Normal file
52
frontend/src/components/ui/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export { default as Button } from './Button'
|
||||
export type { ButtonProps } from './Button'
|
||||
|
||||
export { default as Card, CardHeader, CardContent, CardFooter } from './Card'
|
||||
export type { CardProps, CardHeaderProps, CardContentProps, CardFooterProps } from './Card'
|
||||
|
||||
export { default as Modal, ModalFooter, ConfirmModal } from './Modal'
|
||||
export type { ModalProps, ModalFooterProps, ConfirmModalProps } from './Modal'
|
||||
|
||||
export { default as Input, Textarea } from './Input'
|
||||
export type { InputProps, TextareaProps } from './Input'
|
||||
|
||||
export { default as Select, NativeSelect } from './Select'
|
||||
export type { SelectProps, SelectOption, NativeSelectProps } from './Select'
|
||||
|
||||
export { default as Checkbox, Switch, Radio } from './Checkbox'
|
||||
export type { CheckboxProps, SwitchProps, RadioProps } from './Checkbox'
|
||||
|
||||
export { default as Badge, StatusBadge, PriorityBadge, CounterBadge } from './Badge'
|
||||
export type {
|
||||
BadgeProps,
|
||||
BadgeVariant,
|
||||
BadgeSize,
|
||||
StatusBadgeProps,
|
||||
StatusType,
|
||||
PriorityBadgeProps,
|
||||
PriorityType,
|
||||
CounterBadgeProps,
|
||||
} from './Badge'
|
||||
|
||||
export { ToastProvider, useToast } from './Toast'
|
||||
export type { Toast, ToastType } from './Toast'
|
||||
|
||||
export {
|
||||
default as Skeleton,
|
||||
SkeletonCard,
|
||||
SkeletonTableRow,
|
||||
SkeletonListItem,
|
||||
SkeletonStats,
|
||||
SkeletonChart,
|
||||
SkeletonMap,
|
||||
SkeletonForm,
|
||||
} from './Skeleton'
|
||||
|
||||
export { default as Dropdown, DropdownButton, ActionMenu } from './Dropdown'
|
||||
export type { DropdownProps, DropdownItem, DropdownButtonProps, ActionMenuProps } from './Dropdown'
|
||||
|
||||
export { default as Tabs, TabPanel, SimpleTabs } from './Tabs'
|
||||
export type { Tab, TabsProps, TabPanelProps, SimpleTabsProps } from './Tabs'
|
||||
|
||||
export { default as Table } from './Table'
|
||||
export type { TableProps, Column } from './Table'
|
||||
218
frontend/src/components/vehiculos/VehiculoCard.tsx
Normal file
218
frontend/src/components/vehiculos/VehiculoCard.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { format } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import {
|
||||
TruckIcon,
|
||||
MapPinIcon,
|
||||
BoltIcon,
|
||||
UserIcon,
|
||||
SignalIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { Vehiculo } from '@/types'
|
||||
import { StatusBadge } from '@/components/ui/Badge'
|
||||
import Card from '@/components/ui/Card'
|
||||
|
||||
interface VehiculoCardProps {
|
||||
vehiculo: Vehiculo
|
||||
isSelected?: boolean
|
||||
onClick?: () => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export default function VehiculoCard({
|
||||
vehiculo,
|
||||
isSelected = false,
|
||||
onClick,
|
||||
compact = false,
|
||||
}: VehiculoCardProps) {
|
||||
const { ubicacion, conductor, movimiento, velocidad, alertasActivas } = vehiculo
|
||||
|
||||
// Movement status
|
||||
const getMovimientoConfig = () => {
|
||||
switch (movimiento) {
|
||||
case 'movimiento':
|
||||
return { status: 'online' as const, label: 'En movimiento', color: 'success' }
|
||||
case 'detenido':
|
||||
return { status: 'warning' as const, label: 'Detenido', color: 'warning' }
|
||||
case 'ralenti':
|
||||
return { status: 'warning' as const, label: 'Ralenti', color: 'warning' }
|
||||
case 'sin_senal':
|
||||
return { status: 'offline' as const, label: 'Sin senal', color: 'slate' }
|
||||
default:
|
||||
return { status: 'offline' as const, label: 'Desconocido', color: 'slate' }
|
||||
}
|
||||
}
|
||||
|
||||
const movConfig = getMovimientoConfig()
|
||||
const lastUpdate = ubicacion?.timestamp
|
||||
? format(new Date(ubicacion.timestamp), 'HH:mm', { locale: es })
|
||||
: '--:--'
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all duration-200',
|
||||
isSelected
|
||||
? 'bg-accent-500/20 border border-accent-500/30'
|
||||
: 'hover:bg-slate-800/50'
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={clsx(
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0',
|
||||
movConfig.color === 'success' && 'bg-success-500/20',
|
||||
movConfig.color === 'warning' && 'bg-warning-500/20',
|
||||
movConfig.color === 'slate' && 'bg-slate-700'
|
||||
)}
|
||||
>
|
||||
<TruckIcon
|
||||
className={clsx(
|
||||
'w-5 h-5',
|
||||
movConfig.color === 'success' && 'text-success-400',
|
||||
movConfig.color === 'warning' && 'text-warning-400',
|
||||
movConfig.color === 'slate' && 'text-slate-400'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-white truncate">{vehiculo.nombre}</h4>
|
||||
{alertasActivas && alertasActivas > 0 && (
|
||||
<ExclamationTriangleIcon className="w-4 h-4 text-error-400 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-slate-500">{vehiculo.placa}</span>
|
||||
<span className="text-xs text-slate-600">|</span>
|
||||
<span className="text-xs text-slate-500">{velocidad || 0} km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={clsx(
|
||||
'w-2 h-2 rounded-full',
|
||||
movConfig.color === 'success' && 'bg-success-500',
|
||||
movConfig.color === 'warning' && 'bg-warning-500',
|
||||
movConfig.color === 'slate' && 'bg-slate-500'
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-slate-500">{lastUpdate}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
hover
|
||||
padding="md"
|
||||
className={clsx(
|
||||
'cursor-pointer',
|
||||
isSelected && 'ring-2 ring-accent-500 ring-offset-2 ring-offset-background-900'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={clsx(
|
||||
'w-12 h-12 rounded-xl flex items-center justify-center',
|
||||
movConfig.color === 'success' && 'bg-success-500/20',
|
||||
movConfig.color === 'warning' && 'bg-warning-500/20',
|
||||
movConfig.color === 'slate' && 'bg-slate-700'
|
||||
)}
|
||||
>
|
||||
<TruckIcon
|
||||
className={clsx(
|
||||
'w-6 h-6',
|
||||
movConfig.color === 'success' && 'text-success-400',
|
||||
movConfig.color === 'warning' && 'text-warning-400',
|
||||
movConfig.color === 'slate' && 'text-slate-400'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white">{vehiculo.nombre}</h3>
|
||||
<p className="text-sm text-slate-500">{vehiculo.placa}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={movConfig.status} label={movConfig.label} size="sm" />
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BoltIcon className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-sm text-slate-400">
|
||||
<span className="text-white font-medium">{velocidad || 0}</span> km/h
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SignalIcon className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-sm text-slate-400">{lastUpdate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conductor */}
|
||||
{conductor && (
|
||||
<div className="flex items-center gap-2 mb-3 text-sm">
|
||||
<UserIcon className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-slate-400">
|
||||
{conductor.nombre} {conductor.apellido}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
{ubicacion && (
|
||||
<div className="flex items-start gap-2 p-2 bg-slate-800/50 rounded-lg">
|
||||
<MapPinIcon className="w-4 h-4 text-slate-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-xs text-slate-400 line-clamp-2">
|
||||
{ubicacion.lat.toFixed(6)}, {ubicacion.lng.toFixed(6)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alerts indicator */}
|
||||
{alertasActivas && alertasActivas > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-slate-700/50">
|
||||
<div className="flex items-center gap-2 text-error-400">
|
||||
<ExclamationTriangleIcon className="w-4 h-4" />
|
||||
<span className="text-sm">
|
||||
{alertasActivas} alerta{alertasActivas > 1 ? 's' : ''} activa
|
||||
{alertasActivas > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-3 pt-3 border-t border-slate-700/50 flex gap-2">
|
||||
<Link
|
||||
to={`/vehiculos/${vehiculo.id}`}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-center text-white bg-accent-500 hover:bg-accent-600 rounded-lg transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Ver detalles
|
||||
</Link>
|
||||
<Link
|
||||
to={`/mapa?vehiculo=${vehiculo.id}`}
|
||||
className="px-3 py-2 text-sm font-medium text-slate-300 hover:text-white bg-slate-700 hover:bg-slate-600 rounded-lg transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MapPinIcon className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
214
frontend/src/components/vehiculos/VehiculoList.tsx
Normal file
214
frontend/src/components/vehiculos/VehiculoList.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Squares2X2Icon,
|
||||
ListBulletIcon,
|
||||
FunnelIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { Vehiculo, VehiculoEstado, VehiculoMovimiento } from '@/types'
|
||||
import VehiculoCard from './VehiculoCard'
|
||||
import { SkeletonCard } from '@/components/ui/Skeleton'
|
||||
|
||||
interface VehiculoListProps {
|
||||
vehiculos: Vehiculo[]
|
||||
isLoading?: boolean
|
||||
selectedId?: string | null
|
||||
onSelect?: (id: string) => void
|
||||
showFilters?: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export default function VehiculoList({
|
||||
vehiculos,
|
||||
isLoading = false,
|
||||
selectedId,
|
||||
onSelect,
|
||||
showFilters = true,
|
||||
compact = false,
|
||||
}: VehiculoListProps) {
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const [search, setSearch] = useState('')
|
||||
const [filters, setFilters] = useState<{
|
||||
estados: VehiculoEstado[]
|
||||
movimientos: VehiculoMovimiento[]
|
||||
}>({ estados: [], movimientos: [] })
|
||||
|
||||
// Filter vehiculos
|
||||
const filteredVehiculos = vehiculos.filter((v) => {
|
||||
// Search filter
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase()
|
||||
if (
|
||||
!v.nombre.toLowerCase().includes(searchLower) &&
|
||||
!v.placa.toLowerCase().includes(searchLower)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Estado filter
|
||||
if (filters.estados.length > 0 && !filters.estados.includes(v.estado)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Movimiento filter
|
||||
if (filters.movimientos.length > 0 && !filters.movimientos.includes(v.movimiento)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
total: vehiculos.length,
|
||||
enMovimiento: vehiculos.filter((v) => v.movimiento === 'movimiento').length,
|
||||
detenidos: vehiculos.filter((v) => v.movimiento === 'detenido').length,
|
||||
sinSenal: vehiculos.filter((v) => v.movimiento === 'sin_senal').length,
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{isLoading
|
||||
? Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-16 bg-slate-800 rounded-lg animate-shimmer" />
|
||||
))
|
||||
: filteredVehiculos.map((vehiculo) => (
|
||||
<VehiculoCard
|
||||
key={vehiculo.id}
|
||||
vehiculo={vehiculo}
|
||||
isSelected={selectedId === vehiculo.id}
|
||||
onClick={() => onSelect?.(vehiculo.id)}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Filters and controls */}
|
||||
{showFilters && (
|
||||
<div className="mb-4">
|
||||
{/* Stats bar */}
|
||||
<div className="flex items-center gap-4 mb-4 text-sm">
|
||||
<span className="text-slate-400">
|
||||
<span className="text-white font-medium">{stats.total}</span> vehiculos
|
||||
</span>
|
||||
<span className="text-slate-600">|</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-success-500" />
|
||||
<span className="text-slate-400">{stats.enMovimiento} en movimiento</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-warning-500" />
|
||||
<span className="text-slate-400">{stats.detenidos} detenidos</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-slate-500" />
|
||||
<span className="text-slate-400">{stats.sinSenal} sin senal</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Search and view controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar vehiculos..."
|
||||
className={clsx(
|
||||
'w-full pl-10 pr-4 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent',
|
||||
'transition-all duration-200'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter button */}
|
||||
<button
|
||||
className={clsx(
|
||||
'px-3 py-2 rounded-lg flex items-center gap-2',
|
||||
'text-slate-400 hover:text-white hover:bg-slate-800',
|
||||
'transition-colors duration-200'
|
||||
)}
|
||||
>
|
||||
<FunnelIcon className="w-5 h-5" />
|
||||
<span className="text-sm">Filtros</span>
|
||||
</button>
|
||||
|
||||
{/* View mode */}
|
||||
<div className="flex items-center bg-slate-800/50 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={clsx(
|
||||
'p-2 rounded-lg transition-colors',
|
||||
viewMode === 'grid'
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<Squares2X2Icon className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={clsx(
|
||||
'p-2 rounded-lg transition-colors',
|
||||
viewMode === 'list'
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<ListBulletIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vehiculos grid/list */}
|
||||
{isLoading ? (
|
||||
<div
|
||||
className={clsx(
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4'
|
||||
: 'space-y-3'
|
||||
)}
|
||||
>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : filteredVehiculos.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-500">No se encontraron vehiculos</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={clsx(
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4'
|
||||
: 'space-y-3'
|
||||
)}
|
||||
>
|
||||
{filteredVehiculos.map((vehiculo) => (
|
||||
<VehiculoCard
|
||||
key={vehiculo.id}
|
||||
vehiculo={vehiculo}
|
||||
isSelected={selectedId === vehiculo.id}
|
||||
onClick={() => onSelect?.(vehiculo.id)}
|
||||
compact={viewMode === 'list'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2
frontend/src/components/vehiculos/index.ts
Normal file
2
frontend/src/components/vehiculos/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as VehiculoCard } from './VehiculoCard'
|
||||
export { default as VehiculoList } from './VehiculoList'
|
||||
240
frontend/src/components/viajes/ViajeCard.tsx
Normal file
240
frontend/src/components/viajes/ViajeCard.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { format, formatDuration, intervalToDuration } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import {
|
||||
MapPinIcon,
|
||||
ClockIcon,
|
||||
TruckIcon,
|
||||
UserIcon,
|
||||
ArrowPathIcon,
|
||||
PlayIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { Viaje } from '@/types'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
|
||||
interface ViajeCardProps {
|
||||
viaje: Viaje
|
||||
onReplay?: () => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export default function ViajeCard({ viaje, onReplay, compact = false }: ViajeCardProps) {
|
||||
const { vehiculo, conductor, estado, inicio, fin, distancia, duracion } = viaje
|
||||
|
||||
// Format duration
|
||||
const formatTripDuration = () => {
|
||||
if (!duracion) return '--'
|
||||
const hours = Math.floor(duracion / 60)
|
||||
const minutes = duracion % 60
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`
|
||||
}
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
// Estado config
|
||||
const getEstadoConfig = () => {
|
||||
switch (estado) {
|
||||
case 'en_curso':
|
||||
return { variant: 'success' as const, label: 'En curso' }
|
||||
case 'completado':
|
||||
return { variant: 'default' as const, label: 'Completado' }
|
||||
case 'cancelado':
|
||||
return { variant: 'error' as const, label: 'Cancelado' }
|
||||
default:
|
||||
return { variant: 'default' as const, label: estado }
|
||||
}
|
||||
}
|
||||
|
||||
const estadoConfig = getEstadoConfig()
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 p-3 hover:bg-slate-800/50 rounded-lg transition-colors">
|
||||
<div
|
||||
className={clsx(
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center',
|
||||
estado === 'en_curso' ? 'bg-success-500/20' : 'bg-slate-700'
|
||||
)}
|
||||
>
|
||||
<ArrowPathIcon
|
||||
className={clsx(
|
||||
'w-5 h-5',
|
||||
estado === 'en_curso' ? 'text-success-400' : 'text-slate-400'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{vehiculo?.nombre || 'Sin vehiculo'}
|
||||
</span>
|
||||
<Badge variant={estadoConfig.variant} size="xs">
|
||||
{estadoConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5 text-xs text-slate-500">
|
||||
<span>{format(new Date(inicio), 'd MMM, HH:mm', { locale: es })}</span>
|
||||
{distancia && (
|
||||
<>
|
||||
<span className="text-slate-600">|</span>
|
||||
<span>{distancia.toFixed(1)} km</span>
|
||||
</>
|
||||
)}
|
||||
{duracion && (
|
||||
<>
|
||||
<span className="text-slate-600">|</span>
|
||||
<span>{formatTripDuration()}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{estado === 'completado' && (
|
||||
<Button size="xs" variant="ghost" onClick={onReplay}>
|
||||
<PlayIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding="md" hover>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={clsx(
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center',
|
||||
estado === 'en_curso' ? 'bg-success-500/20' : 'bg-slate-700'
|
||||
)}
|
||||
>
|
||||
<ArrowPathIcon
|
||||
className={clsx(
|
||||
'w-5 h-5',
|
||||
estado === 'en_curso'
|
||||
? 'text-success-400 animate-spin-slow'
|
||||
: 'text-slate-400'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">
|
||||
{format(new Date(inicio), "d 'de' MMMM, HH:mm", { locale: es })}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{fin
|
||||
? `Fin: ${format(new Date(fin), 'HH:mm', { locale: es })}`
|
||||
: 'En progreso...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={estadoConfig.variant} dot={estado === 'en_curso'} pulse={estado === 'en_curso'}>
|
||||
{estadoConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Vehicle and conductor */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
{vehiculo && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<TruckIcon className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-slate-400">
|
||||
{vehiculo.nombre} ({vehiculo.placa})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{conductor && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<UserIcon className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-slate-400">
|
||||
{conductor.nombre} {conductor.apellido}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-3">
|
||||
<div className="text-center p-2 bg-slate-800/50 rounded-lg">
|
||||
<p className="text-lg font-bold text-white">
|
||||
{distancia ? distancia.toFixed(1) : '--'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">km</p>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-slate-800/50 rounded-lg">
|
||||
<p className="text-lg font-bold text-white">{formatTripDuration()}</p>
|
||||
<p className="text-xs text-slate-500">duracion</p>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-slate-800/50 rounded-lg">
|
||||
<p className="text-lg font-bold text-white">
|
||||
{viaje.velocidadPromedio?.toFixed(0) || '--'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">km/h prom</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Origin/Destination */}
|
||||
<div className="space-y-2 mb-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-5 h-5 rounded-full bg-success-500/20 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Origen</p>
|
||||
<p className="text-sm text-slate-300">
|
||||
{viaje.origenDireccion || `${viaje.origenLat.toFixed(4)}, ${viaje.origenLng.toFixed(4)}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{(viaje.destinoLat || viaje.destinoDireccion) && (
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-5 h-5 rounded-full bg-error-500/20 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="w-2 h-2 bg-error-500 rounded-full" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Destino</p>
|
||||
<p className="text-sm text-slate-300">
|
||||
{viaje.destinoDireccion ||
|
||||
(viaje.destinoLat
|
||||
? `${viaje.destinoLat.toFixed(4)}, ${viaje.destinoLng?.toFixed(4)}`
|
||||
: 'En progreso')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-slate-700/50">
|
||||
<Link
|
||||
to={`/viajes/${viaje.id}/replay`}
|
||||
className={clsx(
|
||||
'flex-1',
|
||||
estado !== 'completado' && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
fullWidth
|
||||
leftIcon={<PlayIcon className="w-4 h-4" />}
|
||||
disabled={estado !== 'completado'}
|
||||
>
|
||||
Ver replay
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={`/mapa?viaje=${viaje.id}`}>
|
||||
<Button size="sm" variant="outline" leftIcon={<MapPinIcon className="w-4 h-4" />}>
|
||||
Ver en mapa
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
1
frontend/src/components/viajes/index.ts
Normal file
1
frontend/src/components/viajes/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ViajeCard } from './ViajeCard'
|
||||
148
frontend/src/components/video/CamaraCard.tsx
Normal file
148
frontend/src/components/video/CamaraCard.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
VideoCameraIcon,
|
||||
SignalIcon,
|
||||
PlayIcon,
|
||||
StopIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { Camara } from '@/types'
|
||||
import { StatusBadge } from '@/components/ui/Badge'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
|
||||
interface CamaraCardProps {
|
||||
camara: Camara
|
||||
onStartRecording?: () => void
|
||||
onStopRecording?: () => void
|
||||
onView?: () => void
|
||||
showActions?: boolean
|
||||
}
|
||||
|
||||
export default function CamaraCard({
|
||||
camara,
|
||||
onStartRecording,
|
||||
onStopRecording,
|
||||
onView,
|
||||
showActions = true,
|
||||
}: CamaraCardProps) {
|
||||
const getEstadoConfig = () => {
|
||||
switch (camara.estado) {
|
||||
case 'online':
|
||||
return { status: 'online' as const, label: 'En linea' }
|
||||
case 'grabando':
|
||||
return { status: 'online' as const, label: 'Grabando' }
|
||||
case 'offline':
|
||||
return { status: 'offline' as const, label: 'Sin conexion' }
|
||||
case 'error':
|
||||
return { status: 'error' as const, label: 'Error' }
|
||||
default:
|
||||
return { status: 'offline' as const, label: 'Desconocido' }
|
||||
}
|
||||
}
|
||||
|
||||
const estadoConfig = getEstadoConfig()
|
||||
|
||||
return (
|
||||
<Card padding="md" hover>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={clsx(
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center',
|
||||
camara.estado === 'online' || camara.estado === 'grabando'
|
||||
? 'bg-success-500/20'
|
||||
: 'bg-slate-700'
|
||||
)}
|
||||
>
|
||||
<VideoCameraIcon
|
||||
className={clsx(
|
||||
'w-5 h-5',
|
||||
camara.estado === 'online' || camara.estado === 'grabando'
|
||||
? 'text-success-400'
|
||||
: 'text-slate-400'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">{camara.nombre}</h3>
|
||||
<p className="text-xs text-slate-500 capitalize">{camara.posicion}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge
|
||||
status={estadoConfig.status}
|
||||
label={estadoConfig.label}
|
||||
size="xs"
|
||||
showDot={camara.estado === 'grabando'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="space-y-2 mb-3 text-sm">
|
||||
{camara.vehiculo && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Vehiculo:</span>
|
||||
<span className="text-slate-300">{camara.vehiculo.placa}</span>
|
||||
</div>
|
||||
)}
|
||||
{camara.resolucion && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Resolucion:</span>
|
||||
<span className="text-slate-300">{camara.resolucion}</span>
|
||||
</div>
|
||||
)}
|
||||
{camara.fps && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">FPS:</span>
|
||||
<span className="text-slate-300">{camara.fps}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recording indicator */}
|
||||
{camara.estado === 'grabando' && (
|
||||
<div className="mb-3 p-2 bg-error-500/10 border border-error-500/20 rounded-lg flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-error-500 rounded-full animate-pulse" />
|
||||
<span className="text-xs text-error-400 font-medium">Grabando...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{showActions && (
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-slate-700/50">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
leftIcon={<PlayIcon className="w-4 h-4" />}
|
||||
onClick={onView}
|
||||
disabled={camara.estado === 'offline' || camara.estado === 'error'}
|
||||
>
|
||||
Ver
|
||||
</Button>
|
||||
|
||||
{camara.estado === 'grabando' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
leftIcon={<StopIcon className="w-4 h-4" />}
|
||||
onClick={onStopRecording}
|
||||
>
|
||||
Detener
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
leftIcon={<span className="w-2 h-2 bg-error-500 rounded-full" />}
|
||||
onClick={onStartRecording}
|
||||
disabled={camara.estado !== 'online'}
|
||||
>
|
||||
Grabar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
200
frontend/src/components/video/VideoGrid.tsx
Normal file
200
frontend/src/components/video/VideoGrid.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Squares2X2Icon,
|
||||
Square2StackIcon,
|
||||
ArrowsPointingOutIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { Camara } from '@/types'
|
||||
import VideoPlayer from './VideoPlayer'
|
||||
import CamaraCard from './CamaraCard'
|
||||
|
||||
interface VideoGridProps {
|
||||
camaras: Camara[]
|
||||
layout?: '1x1' | '2x2' | '3x3' | '4x4'
|
||||
onCamaraSelect?: (camara: Camara) => void
|
||||
selectedCamaraId?: string | null
|
||||
}
|
||||
|
||||
export default function VideoGrid({
|
||||
camaras,
|
||||
layout = '2x2',
|
||||
onCamaraSelect,
|
||||
selectedCamaraId,
|
||||
}: VideoGridProps) {
|
||||
const [currentLayout, setCurrentLayout] = useState(layout)
|
||||
const [fullscreenCamara, setFullscreenCamara] = useState<Camara | null>(null)
|
||||
|
||||
const layoutConfig = {
|
||||
'1x1': { cols: 1, rows: 1, max: 1 },
|
||||
'2x2': { cols: 2, rows: 2, max: 4 },
|
||||
'3x3': { cols: 3, rows: 3, max: 9 },
|
||||
'4x4': { cols: 4, rows: 4, max: 16 },
|
||||
}
|
||||
|
||||
const config = layoutConfig[currentLayout]
|
||||
const visibleCamaras = camaras.slice(0, config.max)
|
||||
|
||||
// Fullscreen mode
|
||||
if (fullscreenCamara) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black">
|
||||
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
|
||||
<div className="bg-black/50 backdrop-blur rounded-lg px-3 py-1.5">
|
||||
<span className="text-sm text-white">{fullscreenCamara.nombre}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setFullscreenCamara(null)}
|
||||
className="p-2 bg-black/50 backdrop-blur rounded-lg hover:bg-black/70 transition-colors"
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
<VideoPlayer
|
||||
hlsUrl={fullscreenCamara.hlsUrl}
|
||||
webrtcUrl={fullscreenCamara.webrtcUrl}
|
||||
className="w-full h-full"
|
||||
autoPlay
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Layout controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-500">
|
||||
{visibleCamaras.length} de {camaras.length} camaras
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 bg-slate-800/50 rounded-lg p-1">
|
||||
{(['1x1', '2x2', '3x3', '4x4'] as const).map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => setCurrentLayout(l)}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 text-xs font-medium rounded transition-colors',
|
||||
currentLayout === l
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
)}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video grid */}
|
||||
<div
|
||||
className={clsx(
|
||||
'grid gap-3',
|
||||
currentLayout === '1x1' && 'grid-cols-1',
|
||||
currentLayout === '2x2' && 'grid-cols-2',
|
||||
currentLayout === '3x3' && 'grid-cols-3',
|
||||
currentLayout === '4x4' && 'grid-cols-4'
|
||||
)}
|
||||
style={{
|
||||
aspectRatio: config.cols === config.rows ? `${config.cols}/${config.rows}` : undefined,
|
||||
}}
|
||||
>
|
||||
{visibleCamaras.map((camara) => (
|
||||
<div
|
||||
key={camara.id}
|
||||
className={clsx(
|
||||
'relative bg-slate-900 rounded-lg overflow-hidden group',
|
||||
selectedCamaraId === camara.id && 'ring-2 ring-accent-500'
|
||||
)}
|
||||
>
|
||||
{/* Video */}
|
||||
{camara.estado === 'online' || camara.estado === 'grabando' ? (
|
||||
<VideoPlayer
|
||||
hlsUrl={camara.hlsUrl}
|
||||
webrtcUrl={camara.webrtcUrl}
|
||||
className="w-full h-full"
|
||||
autoPlay
|
||||
muted
|
||||
controls={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-slate-800">
|
||||
<div className="text-center">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center mx-auto mb-2">
|
||||
<XMarkIcon className="w-5 h-5 text-slate-500" />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{camara.estado === 'offline' ? 'Camara sin conexion' : 'Error'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent',
|
||||
'opacity-0 group-hover:opacity-100 transition-opacity'
|
||||
)}
|
||||
>
|
||||
{/* Camara info */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-white truncate">
|
||||
{camara.nombre}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{camara.vehiculo?.placa || 'Sin vehiculo'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setFullscreenCamara(camara)}
|
||||
className="p-1.5 rounded hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<ArrowsPointingOutIcon className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div className="absolute top-2 left-2">
|
||||
{camara.estado === 'grabando' && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-0.5 bg-error-500 rounded text-xs font-medium text-white">
|
||||
<span className="w-1.5 h-1.5 bg-white rounded-full animate-pulse" />
|
||||
REC
|
||||
</div>
|
||||
)}
|
||||
{camara.estado === 'online' && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-0.5 bg-success-500/80 rounded text-xs font-medium text-white">
|
||||
<span className="w-1.5 h-1.5 bg-white rounded-full" />
|
||||
LIVE
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Click handler */}
|
||||
<button
|
||||
onClick={() => onCamaraSelect?.(camara)}
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Empty slots */}
|
||||
{Array.from({ length: config.max - visibleCamaras.length }).map((_, i) => (
|
||||
<div
|
||||
key={`empty-${i}`}
|
||||
className="bg-slate-800/50 rounded-lg border-2 border-dashed border-slate-700 flex items-center justify-center"
|
||||
>
|
||||
<p className="text-sm text-slate-600">Sin camara</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
264
frontend/src/components/video/VideoPlayer.tsx
Normal file
264
frontend/src/components/video/VideoPlayer.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
PlayIcon,
|
||||
PauseIcon,
|
||||
SpeakerWaveIcon,
|
||||
SpeakerXMarkIcon,
|
||||
ArrowsPointingOutIcon,
|
||||
Cog6ToothIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface VideoPlayerProps {
|
||||
src?: string
|
||||
hlsUrl?: string
|
||||
webrtcUrl?: string
|
||||
poster?: string
|
||||
autoPlay?: boolean
|
||||
muted?: boolean
|
||||
controls?: boolean
|
||||
className?: string
|
||||
onError?: (error: string) => void
|
||||
onLoad?: () => void
|
||||
}
|
||||
|
||||
export default function VideoPlayer({
|
||||
src,
|
||||
hlsUrl,
|
||||
webrtcUrl,
|
||||
poster,
|
||||
autoPlay = true,
|
||||
muted = true,
|
||||
controls = true,
|
||||
className,
|
||||
onError,
|
||||
onLoad,
|
||||
}: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(autoPlay)
|
||||
const [isMuted, setIsMuted] = useState(muted)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Use the appropriate source
|
||||
const videoSrc = src || hlsUrl
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video || !videoSrc) return
|
||||
|
||||
// Handle HLS if needed
|
||||
if (hlsUrl && hlsUrl.includes('.m3u8')) {
|
||||
// Dynamic import of hls.js would be needed here
|
||||
// For now, native HLS support in Safari
|
||||
video.src = hlsUrl
|
||||
} else if (videoSrc) {
|
||||
video.src = videoSrc
|
||||
}
|
||||
|
||||
const handlePlay = () => setIsPlaying(true)
|
||||
const handlePause = () => setIsPlaying(false)
|
||||
const handleError = () => {
|
||||
const errorMsg = 'Error al cargar el video'
|
||||
setError(errorMsg)
|
||||
onError?.(errorMsg)
|
||||
}
|
||||
const handleLoadedData = () => {
|
||||
setIsLoading(false)
|
||||
onLoad?.()
|
||||
}
|
||||
|
||||
video.addEventListener('play', handlePlay)
|
||||
video.addEventListener('pause', handlePause)
|
||||
video.addEventListener('error', handleError)
|
||||
video.addEventListener('loadeddata', handleLoadedData)
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('play', handlePlay)
|
||||
video.removeEventListener('pause', handlePause)
|
||||
video.removeEventListener('error', handleError)
|
||||
video.removeEventListener('loadeddata', handleLoadedData)
|
||||
}
|
||||
}, [videoSrc, hlsUrl, onError, onLoad])
|
||||
|
||||
// Autoplay
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (video && autoPlay) {
|
||||
video.play().catch(() => {
|
||||
// Autoplay blocked, that's ok
|
||||
})
|
||||
}
|
||||
}, [autoPlay])
|
||||
|
||||
// Controls visibility
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
|
||||
const handleMouseMove = () => {
|
||||
setShowControls(true)
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => setShowControls(false), 3000)
|
||||
}
|
||||
|
||||
const container = containerRef.current
|
||||
if (container) {
|
||||
container.addEventListener('mousemove', handleMouseMove)
|
||||
container.addEventListener('mouseenter', handleMouseMove)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (container) {
|
||||
container.removeEventListener('mousemove', handleMouseMove)
|
||||
container.removeEventListener('mouseenter', handleMouseMove)
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const togglePlay = () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
if (isPlaying) {
|
||||
video.pause()
|
||||
} else {
|
||||
video.play()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
video.muted = !isMuted
|
||||
setIsMuted(!isMuted)
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
container.requestFullscreen()
|
||||
setIsFullscreen(true)
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
setIsFullscreen(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'relative bg-slate-900 rounded-lg overflow-hidden flex items-center justify-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="text-center p-4">
|
||||
<div className="w-12 h-12 rounded-full bg-error-500/20 flex items-center justify-center mx-auto mb-3">
|
||||
<SpeakerXMarkIcon className="w-6 h-6 text-error-400" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-400">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(
|
||||
'relative bg-black rounded-lg overflow-hidden group',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Video element */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain"
|
||||
poster={poster}
|
||||
muted={isMuted}
|
||||
playsInline
|
||||
/>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<div className="w-10 h-10 border-4 border-slate-600 border-t-accent-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls overlay */}
|
||||
{controls && (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute inset-0 transition-opacity duration-300',
|
||||
showControls ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
{/* Gradient overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
||||
|
||||
{/* Center play button */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-16 h-16 rounded-full bg-white/20 backdrop-blur flex items-center justify-center hover:bg-white/30 transition-colors"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<PauseIcon className="w-8 h-8 text-white" />
|
||||
) : (
|
||||
<PlayIcon className="w-8 h-8 text-white ml-1" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Bottom controls */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3 flex items-center gap-3">
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="p-2 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<PauseIcon className="w-5 h-5 text-white" />
|
||||
) : (
|
||||
<PlayIcon className="w-5 h-5 text-white" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="p-2 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{isMuted ? (
|
||||
<SpeakerXMarkIcon className="w-5 h-5 text-white" />
|
||||
) : (
|
||||
<SpeakerWaveIcon className="w-5 h-5 text-white" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-2 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<ArrowsPointingOutIcon className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Live indicator */}
|
||||
{!src && (hlsUrl || webrtcUrl) && (
|
||||
<div className="absolute top-3 left-3 flex items-center gap-2 px-2 py-1 bg-error-500 rounded text-xs font-medium text-white">
|
||||
<span className="w-2 h-2 bg-white rounded-full animate-pulse" />
|
||||
EN VIVO
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
frontend/src/components/video/index.ts
Normal file
3
frontend/src/components/video/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as VideoPlayer } from './VideoPlayer'
|
||||
export { default as VideoGrid } from './VideoGrid'
|
||||
export { default as CamaraCard } from './CamaraCard'
|
||||
30
frontend/src/hooks/index.ts
Normal file
30
frontend/src/hooks/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export { useAuth, default as useAuthDefault } from './useAuth'
|
||||
export { useWebSocket, useWSSubscription, default as useWebSocketDefault } from './useWebSocket'
|
||||
export {
|
||||
useVehiculos,
|
||||
useVehiculo,
|
||||
useVehiculoStats,
|
||||
useFleetStats,
|
||||
useCreateVehiculo,
|
||||
useUpdateVehiculo,
|
||||
useDeleteVehiculo,
|
||||
useVehiculosRealtime,
|
||||
useVehiculosPaginated,
|
||||
useVehiculoUbicaciones,
|
||||
vehiculosKeys,
|
||||
} from './useVehiculos'
|
||||
export {
|
||||
useAlertasActivas,
|
||||
useAlertas,
|
||||
useAlerta,
|
||||
useAlertasConteo,
|
||||
useAlertasStats,
|
||||
useReconocerAlerta,
|
||||
useResolverAlerta,
|
||||
useIgnorarAlerta,
|
||||
useAlertasRealtime,
|
||||
useAlertasConfig,
|
||||
useUpdateAlertasConfig,
|
||||
alertasKeys,
|
||||
} from './useAlertas'
|
||||
export { useMap, default as useMapDefault } from './useMap'
|
||||
222
frontend/src/hooks/useAlertas.ts
Normal file
222
frontend/src/hooks/useAlertas.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { alertasApi } from '@/api/alertas'
|
||||
import { useAlertasStore } from '@/store/alertasStore'
|
||||
import { useWSSubscription } from './useWebSocket'
|
||||
import { Alerta, WSAlertaPayload, FiltrosAlertas } from '@/types'
|
||||
|
||||
// Query keys
|
||||
export const alertasKeys = {
|
||||
all: ['alertas'] as const,
|
||||
lists: () => [...alertasKeys.all, 'list'] as const,
|
||||
list: (filters: object) => [...alertasKeys.lists(), filters] as const,
|
||||
activas: () => [...alertasKeys.all, 'activas'] as const,
|
||||
details: () => [...alertasKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...alertasKeys.details(), id] as const,
|
||||
conteo: () => [...alertasKeys.all, 'conteo'] as const,
|
||||
stats: () => [...alertasKeys.all, 'stats'] as const,
|
||||
config: () => [...alertasKeys.all, 'config'] as const,
|
||||
}
|
||||
|
||||
// Get active alerts
|
||||
export function useAlertasActivas() {
|
||||
const { setAlertasActivas, setLoading, setError } = useAlertasStore()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: alertasKeys.activas(),
|
||||
queryFn: alertasApi.getActivas,
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
setAlertasActivas(query.data)
|
||||
}
|
||||
}, [query.data, setAlertasActivas])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(query.isLoading)
|
||||
}, [query.isLoading, setLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (query.error) {
|
||||
setError(query.error.message)
|
||||
}
|
||||
}, [query.error, setError])
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
// Get paginated alerts with filters
|
||||
export function useAlertas(filters?: Partial<FiltrosAlertas> & {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
const { setAlertas } = useAlertasStore()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: alertasKeys.list(filters || {}),
|
||||
queryFn: () => alertasApi.list(filters),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data?.items) {
|
||||
setAlertas(query.data.items)
|
||||
}
|
||||
}, [query.data, setAlertas])
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
// Get single alert
|
||||
export function useAlerta(id: string) {
|
||||
return useQuery({
|
||||
queryKey: alertasKeys.detail(id),
|
||||
queryFn: () => alertasApi.get(id),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
// Get alert counts
|
||||
export function useAlertasConteo() {
|
||||
return useQuery({
|
||||
queryKey: alertasKeys.conteo(),
|
||||
queryFn: alertasApi.getConteo,
|
||||
refetchInterval: 30000,
|
||||
})
|
||||
}
|
||||
|
||||
// Get alert stats
|
||||
export function useAlertasStats(params?: { desde?: string; hasta?: string }) {
|
||||
return useQuery({
|
||||
queryKey: alertasKeys.stats(),
|
||||
queryFn: () => alertasApi.getStats(params),
|
||||
})
|
||||
}
|
||||
|
||||
// Acknowledge alert mutation
|
||||
export function useReconocerAlerta() {
|
||||
const queryClient = useQueryClient()
|
||||
const { updateAlerta } = useAlertasStore()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, notas }: { id: string; notas?: string }) =>
|
||||
alertasApi.reconocer(id, notas),
|
||||
onSuccess: (alerta) => {
|
||||
updateAlerta(alerta)
|
||||
queryClient.invalidateQueries({ queryKey: alertasKeys.activas() })
|
||||
queryClient.invalidateQueries({ queryKey: alertasKeys.detail(alerta.id) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve alert mutation
|
||||
export function useResolverAlerta() {
|
||||
const queryClient = useQueryClient()
|
||||
const { updateAlerta } = useAlertasStore()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, notas }: { id: string; notas?: string }) =>
|
||||
alertasApi.resolver(id, notas),
|
||||
onSuccess: (alerta) => {
|
||||
updateAlerta(alerta)
|
||||
queryClient.invalidateQueries({ queryKey: alertasKeys.activas() })
|
||||
queryClient.invalidateQueries({ queryKey: alertasKeys.detail(alerta.id) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Ignore alert mutation
|
||||
export function useIgnorarAlerta() {
|
||||
const queryClient = useQueryClient()
|
||||
const { updateAlerta } = useAlertasStore()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, notas }: { id: string; notas?: string }) =>
|
||||
alertasApi.ignorar(id, notas),
|
||||
onSuccess: (alerta) => {
|
||||
updateAlerta(alerta)
|
||||
queryClient.invalidateQueries({ queryKey: alertasKeys.activas() })
|
||||
queryClient.invalidateQueries({ queryKey: alertasKeys.detail(alerta.id) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Real-time alert updates via WebSocket
|
||||
export function useAlertasRealtime() {
|
||||
const { addAlerta, soundEnabled } = useAlertasStore()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Play alert sound
|
||||
const playAlertSound = useCallback(() => {
|
||||
if (soundEnabled) {
|
||||
// Create a simple beep sound
|
||||
const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)()
|
||||
const oscillator = audioContext.createOscillator()
|
||||
const gainNode = audioContext.createGain()
|
||||
|
||||
oscillator.connect(gainNode)
|
||||
gainNode.connect(audioContext.destination)
|
||||
|
||||
oscillator.frequency.value = 800
|
||||
oscillator.type = 'sine'
|
||||
gainNode.gain.value = 0.3
|
||||
|
||||
oscillator.start()
|
||||
setTimeout(() => {
|
||||
oscillator.stop()
|
||||
audioContext.close()
|
||||
}, 200)
|
||||
}
|
||||
}, [soundEnabled])
|
||||
|
||||
// Show browser notification
|
||||
const showNotification = useCallback((alerta: Alerta) => {
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification(alerta.titulo, {
|
||||
body: alerta.mensaje,
|
||||
icon: '/favicon.svg',
|
||||
tag: alerta.id,
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Subscribe to new alerts
|
||||
useWSSubscription<WSAlertaPayload>('alerta', (payload) => {
|
||||
addAlerta(payload.alerta)
|
||||
playAlertSound()
|
||||
showNotification(payload.alerta)
|
||||
|
||||
// Invalidate queries to refresh lists
|
||||
queryClient.invalidateQueries({ queryKey: alertasKeys.activas() })
|
||||
queryClient.invalidateQueries({ queryKey: alertasKeys.conteo() })
|
||||
})
|
||||
|
||||
// Request notification permission on mount
|
||||
useEffect(() => {
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
// Hook for alert configuration
|
||||
export function useAlertasConfig() {
|
||||
return useQuery({
|
||||
queryKey: alertasKeys.config(),
|
||||
queryFn: alertasApi.getConfiguracion,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateAlertasConfig() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: alertasApi.updateConfiguracion,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: alertasKeys.config() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default useAlertasActivas
|
||||
74
frontend/src/hooks/useAuth.ts
Normal file
74
frontend/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { LoginCredentials } from '@/types'
|
||||
|
||||
export function useAuth() {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
user,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
error,
|
||||
login: storeLogin,
|
||||
logout: storeLogout,
|
||||
loadUser,
|
||||
clearError,
|
||||
} = useAuthStore()
|
||||
|
||||
// Load user on mount if token exists
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated && !isLoading) {
|
||||
loadUser()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const login = useCallback(
|
||||
async (credentials: LoginCredentials) => {
|
||||
try {
|
||||
await storeLogin(credentials)
|
||||
navigate('/')
|
||||
} catch {
|
||||
// Error is handled in store
|
||||
}
|
||||
},
|
||||
[storeLogin, navigate]
|
||||
)
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await storeLogout()
|
||||
navigate('/login')
|
||||
}, [storeLogout, navigate])
|
||||
|
||||
const hasRole = useCallback(
|
||||
(roles: string | string[]) => {
|
||||
if (!user) return false
|
||||
const roleArray = Array.isArray(roles) ? roles : [roles]
|
||||
return roleArray.includes(user.rol)
|
||||
},
|
||||
[user]
|
||||
)
|
||||
|
||||
const isAdmin = useCallback(() => {
|
||||
return user?.rol === 'admin'
|
||||
}, [user])
|
||||
|
||||
const canEdit = useCallback(() => {
|
||||
return user?.rol === 'admin' || user?.rol === 'operador'
|
||||
}, [user])
|
||||
|
||||
return {
|
||||
user,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
error,
|
||||
login,
|
||||
logout,
|
||||
clearError,
|
||||
hasRole,
|
||||
isAdmin,
|
||||
canEdit,
|
||||
}
|
||||
}
|
||||
|
||||
export default useAuth
|
||||
286
frontend/src/hooks/useMap.ts
Normal file
286
frontend/src/hooks/useMap.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { useCallback, useRef, useEffect } from 'react'
|
||||
import { Map as LeafletMap } from 'leaflet'
|
||||
import { useMapaStore } from '@/store/mapaStore'
|
||||
import { useVehiculosStore } from '@/store/vehiculosStore'
|
||||
import { Coordenadas, Geocerca, Vehiculo } from '@/types'
|
||||
|
||||
export function useMap() {
|
||||
const mapRef = useRef<LeafletMap | null>(null)
|
||||
|
||||
const {
|
||||
centro,
|
||||
zoom,
|
||||
vehiculoSeleccionado,
|
||||
geocercaSeleccionada,
|
||||
filtros,
|
||||
capas,
|
||||
herramienta,
|
||||
dibujando,
|
||||
puntosDibujo,
|
||||
geocercas,
|
||||
pois,
|
||||
estilo,
|
||||
siguiendoVehiculo,
|
||||
setCentro,
|
||||
setZoom,
|
||||
setView,
|
||||
setVehiculoSeleccionado,
|
||||
setGeocercaSeleccionada,
|
||||
setFiltros,
|
||||
toggleFiltro,
|
||||
resetFiltros,
|
||||
setCapa,
|
||||
toggleCapa,
|
||||
setHerramienta,
|
||||
startDibujo,
|
||||
addPuntoDibujo,
|
||||
finishDibujo,
|
||||
cancelDibujo,
|
||||
setGeocercas,
|
||||
setPois,
|
||||
setEstilo,
|
||||
setSiguiendoVehiculo,
|
||||
centrarEnVehiculo,
|
||||
centrarEnGeocerca,
|
||||
} = useMapaStore()
|
||||
|
||||
const { getVehiculoById, getVehiculosFiltrados } = useVehiculosStore()
|
||||
|
||||
// Set map reference
|
||||
const setMapRef = useCallback((map: LeafletMap | null) => {
|
||||
mapRef.current = map
|
||||
}, [])
|
||||
|
||||
// Pan to location
|
||||
const panTo = useCallback(
|
||||
(lat: number, lng: number, newZoom?: number) => {
|
||||
if (mapRef.current) {
|
||||
mapRef.current.setView([lat, lng], newZoom || zoom)
|
||||
}
|
||||
setCentro({ lat, lng })
|
||||
if (newZoom) setZoom(newZoom)
|
||||
},
|
||||
[zoom, setCentro, setZoom]
|
||||
)
|
||||
|
||||
// Fly to location with animation
|
||||
const flyTo = useCallback(
|
||||
(lat: number, lng: number, newZoom?: number) => {
|
||||
if (mapRef.current) {
|
||||
mapRef.current.flyTo([lat, lng], newZoom || zoom, {
|
||||
duration: 1.5,
|
||||
})
|
||||
}
|
||||
setCentro({ lat, lng })
|
||||
if (newZoom) setZoom(newZoom)
|
||||
},
|
||||
[zoom, setCentro, setZoom]
|
||||
)
|
||||
|
||||
// Fit bounds to show all points
|
||||
const fitBounds = useCallback(
|
||||
(points: Coordenadas[], padding?: number) => {
|
||||
if (mapRef.current && points.length > 0) {
|
||||
const bounds = points.map((p) => [p.lat, p.lng] as [number, number])
|
||||
mapRef.current.fitBounds(bounds, {
|
||||
padding: [padding || 50, padding || 50],
|
||||
})
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Fit to show all vehiculos
|
||||
const fitToVehiculos = useCallback(() => {
|
||||
const vehiculos = getVehiculosFiltrados()
|
||||
const points = vehiculos
|
||||
.filter((v) => v.ubicacion)
|
||||
.map((v) => ({
|
||||
lat: v.ubicacion!.lat,
|
||||
lng: v.ubicacion!.lng,
|
||||
}))
|
||||
if (points.length > 0) {
|
||||
fitBounds(points)
|
||||
}
|
||||
}, [getVehiculosFiltrados, fitBounds])
|
||||
|
||||
// Select and center on vehiculo
|
||||
const selectVehiculo = useCallback(
|
||||
(id: string | null) => {
|
||||
setVehiculoSeleccionado(id)
|
||||
if (id) {
|
||||
const vehiculo = getVehiculoById(id)
|
||||
if (vehiculo?.ubicacion) {
|
||||
flyTo(vehiculo.ubicacion.lat, vehiculo.ubicacion.lng, 16)
|
||||
}
|
||||
}
|
||||
},
|
||||
[setVehiculoSeleccionado, getVehiculoById, flyTo]
|
||||
)
|
||||
|
||||
// Select and center on geocerca
|
||||
const selectGeocerca = useCallback(
|
||||
(id: string | null) => {
|
||||
setGeocercaSeleccionada(id)
|
||||
if (id) {
|
||||
const geocerca = geocercas.find((g) => g.id === id)
|
||||
if (geocerca) {
|
||||
centrarEnGeocerca(geocerca)
|
||||
}
|
||||
}
|
||||
},
|
||||
[setGeocercaSeleccionada, geocercas, centrarEnGeocerca]
|
||||
)
|
||||
|
||||
// Follow vehiculo (auto-center when it moves)
|
||||
const followVehiculo = useCallback(
|
||||
(id: string | null) => {
|
||||
setSiguiendoVehiculo(id)
|
||||
if (id) {
|
||||
selectVehiculo(id)
|
||||
}
|
||||
},
|
||||
[setSiguiendoVehiculo, selectVehiculo]
|
||||
)
|
||||
|
||||
// Auto-center when following vehiculo moves
|
||||
useEffect(() => {
|
||||
if (siguiendoVehiculo) {
|
||||
const vehiculo = getVehiculoById(siguiendoVehiculo)
|
||||
if (vehiculo?.ubicacion) {
|
||||
panTo(vehiculo.ubicacion.lat, vehiculo.ubicacion.lng)
|
||||
}
|
||||
}
|
||||
}, [siguiendoVehiculo, getVehiculoById, panTo])
|
||||
|
||||
// Get filtered vehiculos for map display
|
||||
const getVehiculosVisibles = useCallback(() => {
|
||||
if (!capas.vehiculos) return []
|
||||
|
||||
return getVehiculosFiltrados().filter((v) => {
|
||||
// Must have location
|
||||
if (!v.ubicacion) return false
|
||||
|
||||
// Apply mapa-specific filters
|
||||
if (filtros.estados.length > 0 && !filtros.estados.includes(v.estado)) {
|
||||
return false
|
||||
}
|
||||
if (filtros.movimientos.length > 0 && !filtros.movimientos.includes(v.movimiento)) {
|
||||
return false
|
||||
}
|
||||
if (filtros.tipos.length > 0 && !filtros.tipos.includes(v.tipo)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}, [capas.vehiculos, getVehiculosFiltrados, filtros])
|
||||
|
||||
// Get visible geocercas
|
||||
const getGeocercasVisibles = useCallback(() => {
|
||||
if (!capas.geocercas) return []
|
||||
return geocercas.filter((g) => g.activa)
|
||||
}, [capas.geocercas, geocercas])
|
||||
|
||||
// Get visible POIs
|
||||
const getPoisVisibles = useCallback(() => {
|
||||
if (!capas.pois) return []
|
||||
return pois.filter((p) => p.activo)
|
||||
}, [capas.pois, pois])
|
||||
|
||||
// Handle map click for drawing
|
||||
const handleMapClick = useCallback(
|
||||
(latlng: Coordenadas) => {
|
||||
if (dibujando && herramienta) {
|
||||
if (herramienta === 'dibujar_circulo' && puntosDibujo.length === 0) {
|
||||
addPuntoDibujo(latlng)
|
||||
} else if (herramienta === 'dibujar_poligono') {
|
||||
addPuntoDibujo(latlng)
|
||||
}
|
||||
}
|
||||
},
|
||||
[dibujando, herramienta, puntosDibujo.length, addPuntoDibujo]
|
||||
)
|
||||
|
||||
// Calculate circle radius for drawing tool
|
||||
const calculateRadius = useCallback(
|
||||
(centerPoint: Coordenadas, edgePoint: Coordenadas) => {
|
||||
if (mapRef.current) {
|
||||
const center = mapRef.current.latLngToLayerPoint([
|
||||
centerPoint.lat,
|
||||
centerPoint.lng,
|
||||
])
|
||||
const edge = mapRef.current.latLngToLayerPoint([edgePoint.lat, edgePoint.lng])
|
||||
const pixelDistance = Math.sqrt(
|
||||
Math.pow(center.x - edge.x, 2) + Math.pow(center.y - edge.y, 2)
|
||||
)
|
||||
// Convert pixel distance to meters (approximate)
|
||||
const zoom = mapRef.current.getZoom()
|
||||
const metersPerPixel = (40075016.686 * Math.abs(Math.cos((centerPoint.lat * Math.PI) / 180))) / Math.pow(2, zoom + 8)
|
||||
return pixelDistance * metersPerPixel
|
||||
}
|
||||
return 0
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return {
|
||||
// Refs
|
||||
mapRef,
|
||||
setMapRef,
|
||||
|
||||
// State
|
||||
centro,
|
||||
zoom,
|
||||
vehiculoSeleccionado,
|
||||
geocercaSeleccionada,
|
||||
filtros,
|
||||
capas,
|
||||
herramienta,
|
||||
dibujando,
|
||||
puntosDibujo,
|
||||
geocercas,
|
||||
pois,
|
||||
estilo,
|
||||
siguiendoVehiculo,
|
||||
|
||||
// Actions
|
||||
setCentro,
|
||||
setZoom,
|
||||
setView,
|
||||
panTo,
|
||||
flyTo,
|
||||
fitBounds,
|
||||
fitToVehiculos,
|
||||
|
||||
selectVehiculo,
|
||||
selectGeocerca,
|
||||
followVehiculo,
|
||||
|
||||
setFiltros,
|
||||
toggleFiltro,
|
||||
resetFiltros,
|
||||
|
||||
setCapa,
|
||||
toggleCapa,
|
||||
|
||||
setHerramienta,
|
||||
startDibujo,
|
||||
addPuntoDibujo,
|
||||
finishDibujo,
|
||||
cancelDibujo,
|
||||
handleMapClick,
|
||||
calculateRadius,
|
||||
|
||||
setGeocercas,
|
||||
setPois,
|
||||
setEstilo,
|
||||
|
||||
// Computed
|
||||
getVehiculosVisibles,
|
||||
getGeocercasVisibles,
|
||||
getPoisVisibles,
|
||||
}
|
||||
}
|
||||
|
||||
export default useMap
|
||||
173
frontend/src/hooks/useVehiculos.ts
Normal file
173
frontend/src/hooks/useVehiculos.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { vehiculosApi } from '@/api/vehiculos'
|
||||
import { useVehiculosStore } from '@/store/vehiculosStore'
|
||||
import { useWSSubscription } from './useWebSocket'
|
||||
import {
|
||||
Vehiculo,
|
||||
VehiculoCreate,
|
||||
VehiculoUpdate,
|
||||
WSUbicacionPayload,
|
||||
WSEstadoVehiculoPayload,
|
||||
} from '@/types'
|
||||
|
||||
// Query keys
|
||||
export const vehiculosKeys = {
|
||||
all: ['vehiculos'] as const,
|
||||
lists: () => [...vehiculosKeys.all, 'list'] as const,
|
||||
list: (filters: object) => [...vehiculosKeys.lists(), filters] as const,
|
||||
details: () => [...vehiculosKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...vehiculosKeys.details(), id] as const,
|
||||
stats: (id: string) => [...vehiculosKeys.detail(id), 'stats'] as const,
|
||||
ubicaciones: (id: string, params: object) =>
|
||||
[...vehiculosKeys.detail(id), 'ubicaciones', params] as const,
|
||||
fleetStats: () => [...vehiculosKeys.all, 'fleet-stats'] as const,
|
||||
}
|
||||
|
||||
// List all vehiculos
|
||||
export function useVehiculos() {
|
||||
const { setVehiculos, setLoading, setError } = useVehiculosStore()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: vehiculosKeys.lists(),
|
||||
queryFn: vehiculosApi.listAll,
|
||||
})
|
||||
|
||||
// Sync to store
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
setVehiculos(query.data)
|
||||
}
|
||||
}, [query.data, setVehiculos])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(query.isLoading)
|
||||
}, [query.isLoading, setLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (query.error) {
|
||||
setError(query.error.message)
|
||||
}
|
||||
}, [query.error, setError])
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
// Get single vehiculo
|
||||
export function useVehiculo(id: string) {
|
||||
return useQuery({
|
||||
queryKey: vehiculosKeys.detail(id),
|
||||
queryFn: () => vehiculosApi.get(id),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
// Get vehiculo stats
|
||||
export function useVehiculoStats(id: string, periodo?: 'dia' | 'semana' | 'mes') {
|
||||
return useQuery({
|
||||
queryKey: vehiculosKeys.stats(id),
|
||||
queryFn: () => vehiculosApi.getStats(id, periodo),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
// Get fleet stats
|
||||
export function useFleetStats() {
|
||||
return useQuery({
|
||||
queryKey: vehiculosKeys.fleetStats(),
|
||||
queryFn: vehiculosApi.getFleetStats,
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
})
|
||||
}
|
||||
|
||||
// Create vehiculo mutation
|
||||
export function useCreateVehiculo() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: VehiculoCreate) => vehiculosApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: vehiculosKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Update vehiculo mutation
|
||||
export function useUpdateVehiculo() {
|
||||
const queryClient = useQueryClient()
|
||||
const { updateVehiculo } = useVehiculosStore()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: VehiculoUpdate }) =>
|
||||
vehiculosApi.update(id, data),
|
||||
onSuccess: (vehiculo) => {
|
||||
updateVehiculo(vehiculo)
|
||||
queryClient.invalidateQueries({ queryKey: vehiculosKeys.detail(vehiculo.id) })
|
||||
queryClient.invalidateQueries({ queryKey: vehiculosKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Delete vehiculo mutation
|
||||
export function useDeleteVehiculo() {
|
||||
const queryClient = useQueryClient()
|
||||
const { removeVehiculo } = useVehiculosStore()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => vehiculosApi.delete(id),
|
||||
onSuccess: (_, id) => {
|
||||
removeVehiculo(id)
|
||||
queryClient.invalidateQueries({ queryKey: vehiculosKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Real-time updates via WebSocket
|
||||
export function useVehiculosRealtime() {
|
||||
const { updateUbicacion, updateVehiculo, getVehiculoById } = useVehiculosStore()
|
||||
|
||||
// Subscribe to ubicacion updates
|
||||
useWSSubscription<WSUbicacionPayload>('ubicacion', (payload) => {
|
||||
updateUbicacion(payload.ubicacion)
|
||||
})
|
||||
|
||||
// Subscribe to estado updates
|
||||
useWSSubscription<WSEstadoVehiculoPayload>('estado_vehiculo', (payload) => {
|
||||
const vehiculo = getVehiculoById(payload.vehiculoId)
|
||||
if (vehiculo) {
|
||||
updateVehiculo({
|
||||
...vehiculo,
|
||||
estado: payload.estado,
|
||||
movimiento: payload.movimiento,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Hook for vehicle search/filter with pagination
|
||||
export function useVehiculosPaginated(filters?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
busqueda?: string
|
||||
estados?: string[]
|
||||
tipos?: string[]
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: vehiculosKeys.list(filters || {}),
|
||||
queryFn: () => vehiculosApi.list(filters),
|
||||
})
|
||||
}
|
||||
|
||||
// Hook for vehicle ubicacion history
|
||||
export function useVehiculoUbicaciones(
|
||||
id: string,
|
||||
params: { desde: string; hasta: string }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: vehiculosKeys.ubicaciones(id, params),
|
||||
queryFn: () => vehiculosApi.getUbicaciones(id, params),
|
||||
enabled: !!id && !!params.desde && !!params.hasta,
|
||||
})
|
||||
}
|
||||
|
||||
export default useVehiculos
|
||||
101
frontend/src/hooks/useWebSocket.ts
Normal file
101
frontend/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useCallback, useState, useRef } from 'react'
|
||||
import { wsClient } from '@/api/websocket'
|
||||
import { WSMessage, WSMessageType } from '@/types'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
|
||||
interface UseWebSocketOptions {
|
||||
autoConnect?: boolean
|
||||
reconnectOnAuth?: boolean
|
||||
}
|
||||
|
||||
export function useWebSocket(options: UseWebSocketOptions = {}) {
|
||||
const { autoConnect = true, reconnectOnAuth = true } = options
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||
const [isConnected, setIsConnected] = useState(wsClient.isConnected)
|
||||
const [connectionState, setConnectionState] = useState(wsClient.connectionState)
|
||||
|
||||
// Track connect/disconnect handlers
|
||||
const unsubscribeRef = useRef<(() => void)[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
// Set up connection handlers
|
||||
const unsubConnect = wsClient.onConnect(() => {
|
||||
setIsConnected(true)
|
||||
setConnectionState('connected')
|
||||
})
|
||||
|
||||
const unsubDisconnect = wsClient.onDisconnect(() => {
|
||||
setIsConnected(false)
|
||||
setConnectionState('disconnected')
|
||||
})
|
||||
|
||||
unsubscribeRef.current = [unsubConnect, unsubDisconnect]
|
||||
|
||||
// Auto connect if authenticated and autoConnect is true
|
||||
if (autoConnect && isAuthenticated && !wsClient.isConnected) {
|
||||
wsClient.connect()
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribeRef.current.forEach((unsub) => unsub())
|
||||
}
|
||||
}, [autoConnect, isAuthenticated])
|
||||
|
||||
// Reconnect on auth change
|
||||
useEffect(() => {
|
||||
if (reconnectOnAuth) {
|
||||
if (isAuthenticated && !wsClient.isConnected) {
|
||||
wsClient.connect()
|
||||
} else if (!isAuthenticated && wsClient.isConnected) {
|
||||
wsClient.disconnect()
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, reconnectOnAuth])
|
||||
|
||||
const connect = useCallback(() => {
|
||||
wsClient.connect()
|
||||
}, [])
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
wsClient.disconnect()
|
||||
}, [])
|
||||
|
||||
const send = useCallback((message: WSMessage) => {
|
||||
wsClient.send(message)
|
||||
}, [])
|
||||
|
||||
const subscribe = useCallback(
|
||||
(type: WSMessageType | 'all', handler: (message: WSMessage) => void) => {
|
||||
return wsClient.subscribe(type, handler)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
connectionState,
|
||||
connect,
|
||||
disconnect,
|
||||
send,
|
||||
subscribe,
|
||||
}
|
||||
}
|
||||
|
||||
// Hook for subscribing to specific message types
|
||||
export function useWSSubscription<T = unknown>(
|
||||
type: WSMessageType | 'all',
|
||||
handler: (payload: T, message: WSMessage<T>) => void
|
||||
) {
|
||||
const handlerRef = useRef(handler)
|
||||
handlerRef.current = handler
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = wsClient.subscribe(type, (message) => {
|
||||
handlerRef.current(message.payload as T, message as WSMessage<T>)
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [type])
|
||||
}
|
||||
|
||||
export default useWebSocket
|
||||
29
frontend/src/main.tsx
Normal file
29
frontend/src/main.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import { ToastProvider } from './components/ui/Toast'
|
||||
import './styles/globals.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
136
frontend/src/pages/Alertas.tsx
Normal file
136
frontend/src/pages/Alertas.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react'
|
||||
import { useAlertasStore } from '@/store/alertasStore'
|
||||
import { useAlertas, useReconocerAlerta, useResolverAlerta, useIgnorarAlerta } from '@/hooks/useAlertas'
|
||||
import { AlertaList } from '@/components/alertas'
|
||||
import Card, { CardHeader } from '@/components/ui/Card'
|
||||
import { DonutChart } from '@/components/charts/PieChart'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
|
||||
export default function Alertas() {
|
||||
const toast = useToast()
|
||||
const { data, isLoading } = useAlertas({ pageSize: 100 })
|
||||
const alertas = data?.items || []
|
||||
|
||||
const conteoPorPrioridad = useAlertasStore((state) => state.getConteoPorPrioridad())
|
||||
|
||||
const reconocerMutation = useReconocerAlerta()
|
||||
const resolverMutation = useResolverAlerta()
|
||||
const ignorarMutation = useIgnorarAlerta()
|
||||
|
||||
const handleReconocer = async (id: string) => {
|
||||
try {
|
||||
await reconocerMutation.mutateAsync({ id })
|
||||
toast.success('Alerta reconocida')
|
||||
} catch {
|
||||
toast.error('Error al reconocer alerta')
|
||||
}
|
||||
}
|
||||
|
||||
const handleResolver = async (id: string) => {
|
||||
try {
|
||||
await resolverMutation.mutateAsync({ id })
|
||||
toast.success('Alerta resuelta')
|
||||
} catch {
|
||||
toast.error('Error al resolver alerta')
|
||||
}
|
||||
}
|
||||
|
||||
const handleIgnorar = async (id: string) => {
|
||||
try {
|
||||
await ignorarMutation.mutateAsync({ id })
|
||||
toast.info('Alerta ignorada')
|
||||
} catch {
|
||||
toast.error('Error al ignorar alerta')
|
||||
}
|
||||
}
|
||||
|
||||
// Chart data
|
||||
const prioridadData = [
|
||||
{ name: 'Critica', value: conteoPorPrioridad.critica, color: '#ef4444' },
|
||||
{ name: 'Alta', value: conteoPorPrioridad.alta, color: '#f97316' },
|
||||
{ name: 'Media', value: conteoPorPrioridad.media, color: '#eab308' },
|
||||
{ name: 'Baja', value: conteoPorPrioridad.baja, color: '#3b82f6' },
|
||||
].filter((d) => d.value > 0)
|
||||
|
||||
const totalActivas = Object.values(conteoPorPrioridad).reduce((a, b) => a + b, 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Centro de Alertas</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Monitorea y gestiona las alertas del sistema
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Stats sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Priority chart */}
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Por prioridad" />
|
||||
{totalActivas > 0 ? (
|
||||
<DonutChart
|
||||
data={prioridadData}
|
||||
centerValue={totalActivas}
|
||||
centerLabel="activas"
|
||||
height={180}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-slate-500">Sin alertas activas</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Quick stats */}
|
||||
<Card padding="md">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-error-500" />
|
||||
<span className="text-sm text-slate-400">Criticas</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-white">{conteoPorPrioridad.critica}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-orange-500" />
|
||||
<span className="text-sm text-slate-400">Altas</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-white">{conteoPorPrioridad.alta}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-warning-500" />
|
||||
<span className="text-sm text-slate-400">Medias</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-white">{conteoPorPrioridad.media}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-accent-500" />
|
||||
<span className="text-sm text-slate-400">Bajas</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-white">{conteoPorPrioridad.baja}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Alerts list */}
|
||||
<div className="lg:col-span-3">
|
||||
<AlertaList
|
||||
alertas={alertas}
|
||||
isLoading={isLoading}
|
||||
onReconocer={handleReconocer}
|
||||
onResolver={handleResolver}
|
||||
onIgnorar={handleIgnorar}
|
||||
showFilters
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
334
frontend/src/pages/Combustible.tsx
Normal file
334
frontend/src/pages/Combustible.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
CalendarIcon,
|
||||
FunnelIcon,
|
||||
ArrowTrendingUpIcon,
|
||||
ArrowTrendingDownIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { format } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import { combustibleApi, vehiculosApi } from '@/api'
|
||||
import Card, { CardHeader } from '@/components/ui/Card'
|
||||
import Table from '@/components/ui/Table'
|
||||
import Select from '@/components/ui/Select'
|
||||
import { SkeletonCard } from '@/components/ui/Skeleton'
|
||||
import { LineChart } from '@/components/charts/LineChart'
|
||||
import { BarChart } from '@/components/charts/BarChart'
|
||||
import { FuelGauge } from '@/components/charts/FuelGauge'
|
||||
import { Combustible } from '@/types'
|
||||
|
||||
export default function CombustiblePage() {
|
||||
const [filtros, setFiltros] = useState({
|
||||
vehiculoId: '',
|
||||
desde: '',
|
||||
hasta: '',
|
||||
})
|
||||
|
||||
const { data: vehiculos } = useQuery({
|
||||
queryKey: ['vehiculos'],
|
||||
queryFn: () => vehiculosApi.list(),
|
||||
})
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['combustible', filtros],
|
||||
queryFn: () =>
|
||||
combustibleApi.list({
|
||||
vehiculoId: filtros.vehiculoId || undefined,
|
||||
desde: filtros.desde || undefined,
|
||||
hasta: filtros.hasta || undefined,
|
||||
pageSize: 100,
|
||||
}),
|
||||
})
|
||||
|
||||
const registros = data?.items || []
|
||||
|
||||
// Calculate stats
|
||||
const stats = {
|
||||
totalLitros: registros.reduce((sum, r) => sum + r.litros, 0),
|
||||
totalCosto: registros.reduce((sum, r) => sum + r.costo, 0),
|
||||
promedioRendimiento:
|
||||
registros.length > 0
|
||||
? registros.reduce((sum, r) => sum + (r.rendimiento || 0), 0) / registros.length
|
||||
: 0,
|
||||
totalCargas: registros.length,
|
||||
}
|
||||
|
||||
// Chart data - consumption over time
|
||||
const consumoData = registros
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((r) => ({
|
||||
fecha: format(new Date(r.fecha), 'd MMM', { locale: es }),
|
||||
litros: r.litros,
|
||||
costo: r.costo,
|
||||
}))
|
||||
|
||||
// Rendimiento por vehiculo
|
||||
const rendimientoPorVehiculo: Record<string, { nombre: string; total: number; count: number }> = {}
|
||||
registros.forEach((r) => {
|
||||
const vehiculo = r.vehiculo
|
||||
if (vehiculo && r.rendimiento) {
|
||||
if (!rendimientoPorVehiculo[vehiculo.id]) {
|
||||
rendimientoPorVehiculo[vehiculo.id] = {
|
||||
nombre: vehiculo.nombre || vehiculo.placa,
|
||||
total: 0,
|
||||
count: 0,
|
||||
}
|
||||
}
|
||||
rendimientoPorVehiculo[vehiculo.id].total += r.rendimiento
|
||||
rendimientoPorVehiculo[vehiculo.id].count += 1
|
||||
}
|
||||
})
|
||||
|
||||
const rendimientoData = Object.values(rendimientoPorVehiculo)
|
||||
.map((v) => ({
|
||||
name: v.nombre,
|
||||
value: v.total / v.count,
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 10)
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'vehiculo',
|
||||
header: 'Vehiculo',
|
||||
render: (r: Combustible) => (
|
||||
<div>
|
||||
<p className="text-white">{r.vehiculo?.nombre || 'Sin vehiculo'}</p>
|
||||
<p className="text-xs text-slate-500">{r.vehiculo?.placa}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'fecha',
|
||||
header: 'Fecha',
|
||||
render: (r: Combustible) => (
|
||||
<div>
|
||||
<p className="text-slate-300">
|
||||
{format(new Date(r.fecha), 'd MMM yyyy', { locale: es })}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{format(new Date(r.fecha), 'HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'litros',
|
||||
header: 'Litros',
|
||||
render: (r: Combustible) => (
|
||||
<span className="text-white font-medium">{r.litros.toFixed(1)} L</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'costo',
|
||||
header: 'Costo',
|
||||
render: (r: Combustible) => (
|
||||
<span className="text-success-400 font-medium">
|
||||
${r.costo.toFixed(2)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'odometro',
|
||||
header: 'Odometro',
|
||||
render: (r: Combustible) => (
|
||||
<span className="text-slate-300">
|
||||
{r.odometro ? `${r.odometro.toLocaleString()} km` : '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'rendimiento',
|
||||
header: 'Rendimiento',
|
||||
render: (r: Combustible) => {
|
||||
if (!r.rendimiento) return <span className="text-slate-500">-</span>
|
||||
const isGood = r.rendimiento >= 10
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{isGood ? (
|
||||
<ArrowTrendingUpIcon className="w-4 h-4 text-success-400" />
|
||||
) : (
|
||||
<ArrowTrendingDownIcon className="w-4 h-4 text-error-400" />
|
||||
)}
|
||||
<span className={isGood ? 'text-success-400' : 'text-error-400'}>
|
||||
{r.rendimiento.toFixed(1)} km/L
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'tipo',
|
||||
header: 'Tipo',
|
||||
render: (r: Combustible) => (
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs rounded capitalize',
|
||||
r.tipo === 'gasolina' && 'bg-orange-500/20 text-orange-400',
|
||||
r.tipo === 'diesel' && 'bg-slate-700 text-slate-300',
|
||||
r.tipo === 'electrico' && 'bg-success-500/20 text-success-400'
|
||||
)}
|
||||
>
|
||||
{r.tipo}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'gasolinera',
|
||||
header: 'Gasolinera',
|
||||
render: (r: Combustible) => (
|
||||
<span className="text-slate-400 text-sm">{r.gasolinera || '-'}</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Historial de Combustible</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Monitorea el consumo y rendimiento de combustible
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card padding="md">
|
||||
<p className="text-sm text-slate-500">Total cargas</p>
|
||||
<p className="text-2xl font-bold text-white">{stats.totalCargas}</p>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<p className="text-sm text-slate-500">Litros totales</p>
|
||||
<p className="text-2xl font-bold text-accent-400">
|
||||
{stats.totalLitros.toFixed(0)} L
|
||||
</p>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<p className="text-sm text-slate-500">Gasto total</p>
|
||||
<p className="text-2xl font-bold text-success-400">
|
||||
${stats.totalCosto.toFixed(0)}
|
||||
</p>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<p className="text-sm text-slate-500">Rendimiento promedio</p>
|
||||
<p className="text-2xl font-bold text-warning-400">
|
||||
{stats.promedioRendimiento.toFixed(1)} km/L
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Consumption chart */}
|
||||
<Card padding="lg" className="lg:col-span-2">
|
||||
<CardHeader title="Consumo de combustible" />
|
||||
{consumoData.length > 0 ? (
|
||||
<LineChart
|
||||
data={consumoData}
|
||||
xKey="fecha"
|
||||
lines={[
|
||||
{ dataKey: 'litros', name: 'Litros', color: '#3b82f6' },
|
||||
{ dataKey: 'costo', name: 'Costo ($)', color: '#22c55e' },
|
||||
]}
|
||||
height={300}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-slate-500">
|
||||
Sin datos de consumo
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Fuel gauge */}
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Rendimiento promedio" />
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
<FuelGauge
|
||||
value={stats.promedioRendimiento}
|
||||
maxValue={20}
|
||||
label="km/L"
|
||||
size={200}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Rendimiento por vehiculo */}
|
||||
{rendimientoData.length > 0 && (
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Rendimiento por vehiculo" />
|
||||
<BarChart
|
||||
data={rendimientoData}
|
||||
xKey="name"
|
||||
bars={[{ dataKey: 'value', name: 'km/L', color: '#3b82f6' }]}
|
||||
height={250}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<Card padding="md">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<FunnelIcon className="w-5 h-5 text-slate-500" />
|
||||
|
||||
<Select
|
||||
value={filtros.vehiculoId}
|
||||
onChange={(e) => setFiltros({ ...filtros, vehiculoId: e.target.value })}
|
||||
options={[
|
||||
{ value: '', label: 'Todos los vehiculos' },
|
||||
...(vehiculos?.items?.map((v) => ({
|
||||
value: v.id,
|
||||
label: v.nombre || v.placa,
|
||||
})) || []),
|
||||
]}
|
||||
className="w-48"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="date"
|
||||
value={filtros.desde}
|
||||
onChange={(e) => setFiltros({ ...filtros, desde: e.target.value })}
|
||||
className={clsx(
|
||||
'px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
/>
|
||||
<span className="text-slate-500">-</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filtros.hasta}
|
||||
onChange={(e) => setFiltros({ ...filtros, hasta: e.target.value })}
|
||||
className={clsx(
|
||||
'px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-slate-500 ml-auto">
|
||||
{registros.length} registros
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Table */}
|
||||
<Card padding="none">
|
||||
<Table
|
||||
data={registros}
|
||||
columns={columns}
|
||||
keyExtractor={(r) => r.id}
|
||||
isLoading={isLoading}
|
||||
pagination
|
||||
pageSize={20}
|
||||
emptyMessage="No hay registros de combustible"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
185
frontend/src/pages/Conductores.tsx
Normal file
185
frontend/src/pages/Conductores.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
PlusIcon,
|
||||
MagnifyingGlassIcon,
|
||||
UserIcon,
|
||||
PhoneIcon,
|
||||
TruckIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { conductoresApi } from '@/api'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import Table from '@/components/ui/Table'
|
||||
import { SkeletonTableRow } from '@/components/ui/Skeleton'
|
||||
import { Conductor } from '@/types'
|
||||
|
||||
export default function Conductores() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['conductores'],
|
||||
queryFn: () => conductoresApi.listAll(),
|
||||
})
|
||||
|
||||
const conductores = data || []
|
||||
|
||||
// Filter
|
||||
const filteredConductores = conductores.filter((c) => {
|
||||
if (!search) return true
|
||||
const searchLower = search.toLowerCase()
|
||||
return (
|
||||
c.nombre.toLowerCase().includes(searchLower) ||
|
||||
c.apellido.toLowerCase().includes(searchLower) ||
|
||||
c.telefono.includes(search)
|
||||
)
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'nombre',
|
||||
header: 'Conductor',
|
||||
render: (conductor: Conductor) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center">
|
||||
{conductor.foto ? (
|
||||
<img
|
||||
src={conductor.foto}
|
||||
alt={conductor.nombre}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<UserIcon className="w-5 h-5 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-white">
|
||||
{conductor.nombre} {conductor.apellido}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">{conductor.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'telefono',
|
||||
header: 'Telefono',
|
||||
render: (conductor: Conductor) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<PhoneIcon className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-slate-300">{conductor.telefono}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'licencia',
|
||||
header: 'Licencia',
|
||||
render: (conductor: Conductor) => (
|
||||
<div>
|
||||
<p className="text-slate-300">{conductor.licencia}</p>
|
||||
<p className="text-xs text-slate-500">Vence: {new Date(conductor.licenciaVencimiento).toLocaleDateString()}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'vehiculoActual',
|
||||
header: 'Vehiculo',
|
||||
render: (conductor: Conductor) =>
|
||||
conductor.vehiculoActual ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<TruckIcon className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-slate-300">{conductor.vehiculoActual.placa}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-500">Sin asignar</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'estado',
|
||||
header: 'Estado',
|
||||
render: (conductor: Conductor) => {
|
||||
const config = {
|
||||
disponible: { variant: 'success' as const, label: 'Disponible' },
|
||||
en_viaje: { variant: 'primary' as const, label: 'En viaje' },
|
||||
descanso: { variant: 'warning' as const, label: 'Descanso' },
|
||||
inactivo: { variant: 'default' as const, label: 'Inactivo' },
|
||||
}
|
||||
const estado = config[conductor.estado]
|
||||
return <Badge variant={estado.variant}>{estado.label}</Badge>
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Conductores</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Gestiona los conductores de tu flota
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<PlusIcon className="w-5 h-5" />}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
Agregar conductor
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar por nombre, telefono..."
|
||||
className={clsx(
|
||||
'w-full pl-10 pr-4 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-slate-500">
|
||||
{filteredConductores.length} conductores
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Table */}
|
||||
<Card padding="none">
|
||||
<Table
|
||||
data={filteredConductores}
|
||||
columns={columns}
|
||||
keyExtractor={(c) => c.id}
|
||||
isLoading={isLoading}
|
||||
pagination
|
||||
pageSize={10}
|
||||
emptyMessage="No hay conductores registrados"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Create modal */}
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
title="Agregar conductor"
|
||||
size="lg"
|
||||
>
|
||||
<p className="text-slate-400">
|
||||
Formulario de creacion de conductor (por implementar)
|
||||
</p>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
421
frontend/src/pages/Configuracion.tsx
Normal file
421
frontend/src/pages/Configuracion.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import {
|
||||
UserIcon,
|
||||
BellIcon,
|
||||
MapIcon,
|
||||
PaintBrushIcon,
|
||||
ShieldCheckIcon,
|
||||
GlobeAltIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { useConfigStore } from '@/store/configStore'
|
||||
import { authApi } from '@/api'
|
||||
import Card, { CardHeader } from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Select from '@/components/ui/Select'
|
||||
import Checkbox from '@/components/ui/Checkbox'
|
||||
import { Tabs, TabPanel } from '@/components/ui/Tabs'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
|
||||
export default function Configuracion() {
|
||||
const toast = useToast()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const config = useConfigStore()
|
||||
|
||||
const [activeTab, setActiveTab] = useState('perfil')
|
||||
const [passwordForm, setPasswordForm] = useState({
|
||||
current: '',
|
||||
new: '',
|
||||
confirm: '',
|
||||
})
|
||||
|
||||
const cambiarPasswordMutation = useMutation({
|
||||
mutationFn: (data: { currentPassword: string; newPassword: string }) =>
|
||||
authApi.cambiarPassword(data),
|
||||
onSuccess: () => {
|
||||
toast.success('Contrasena actualizada')
|
||||
setPasswordForm({ current: '', new: '', confirm: '' })
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al cambiar contrasena')
|
||||
},
|
||||
})
|
||||
|
||||
const handleCambiarPassword = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (passwordForm.new !== passwordForm.confirm) {
|
||||
toast.error('Las contrasenas no coinciden')
|
||||
return
|
||||
}
|
||||
if (passwordForm.new.length < 8) {
|
||||
toast.error('La contrasena debe tener al menos 8 caracteres')
|
||||
return
|
||||
}
|
||||
cambiarPasswordMutation.mutate({
|
||||
currentPassword: passwordForm.current,
|
||||
newPassword: passwordForm.new,
|
||||
})
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'perfil', label: 'Perfil', icon: UserIcon },
|
||||
{ id: 'notificaciones', label: 'Notificaciones', icon: BellIcon },
|
||||
{ id: 'mapa', label: 'Mapa', icon: MapIcon },
|
||||
{ id: 'apariencia', label: 'Apariencia', icon: PaintBrushIcon },
|
||||
{ id: 'seguridad', label: 'Seguridad', icon: ShieldCheckIcon },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Configuracion</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Personaliza tu experiencia en la plataforma
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Sidebar tabs */}
|
||||
<div className="space-y-2">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors text-left',
|
||||
activeTab === tab.id
|
||||
? 'bg-accent-500/20 text-accent-400'
|
||||
: 'text-slate-400 hover:bg-slate-800/50 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<tab.icon className="w-5 h-5" />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="lg:col-span-3">
|
||||
{/* Perfil */}
|
||||
{activeTab === 'perfil' && (
|
||||
<Card padding="lg">
|
||||
<CardHeader
|
||||
title="Informacion del perfil"
|
||||
subtitle="Actualiza tu informacion personal"
|
||||
/>
|
||||
<div className="space-y-4 mt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-20 h-20 bg-accent-500/20 rounded-full flex items-center justify-center">
|
||||
<span className="text-2xl font-bold text-accent-400">
|
||||
{user?.nombre?.charAt(0) || 'U'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">{user?.nombre}</p>
|
||||
<p className="text-sm text-slate-500">{user?.email}</p>
|
||||
<p className="text-xs text-slate-500 capitalize">Rol: {user?.rol}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-4">
|
||||
<Input
|
||||
label="Nombre"
|
||||
value={user?.nombre || ''}
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={user?.email || ''}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500">
|
||||
Para cambiar tu nombre o email, contacta al administrador.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Notificaciones */}
|
||||
{activeTab === 'notificaciones' && (
|
||||
<Card padding="lg">
|
||||
<CardHeader
|
||||
title="Preferencias de notificaciones"
|
||||
subtitle="Configura como deseas recibir alertas"
|
||||
/>
|
||||
<div className="space-y-6 mt-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-white font-medium">Notificaciones en la app</h3>
|
||||
<Checkbox
|
||||
label="Alertas criticas"
|
||||
description="Recibir notificaciones de alertas criticas"
|
||||
checked={config.notificaciones.sonido}
|
||||
onChange={(checked) =>
|
||||
config.setNotificaciones({ sonido: checked })
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Alertas de velocidad"
|
||||
description="Excesos de velocidad y geocercas"
|
||||
checked={config.notificaciones.escritorio}
|
||||
onChange={(checked) =>
|
||||
config.setNotificaciones({ escritorio: checked })
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Alertas de mantenimiento"
|
||||
description="Recordatorios de servicios programados"
|
||||
checked={config.notificaciones.email}
|
||||
onChange={(checked) =>
|
||||
config.setNotificaciones({ email: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t border-slate-700/50">
|
||||
<h3 className="text-white font-medium">Sonidos</h3>
|
||||
<Checkbox
|
||||
label="Reproducir sonido"
|
||||
description="Sonido al recibir alertas importantes"
|
||||
checked={config.notificaciones.sonido}
|
||||
onChange={(checked) =>
|
||||
config.setNotificaciones({ sonido: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Mapa */}
|
||||
{activeTab === 'mapa' && (
|
||||
<Card padding="lg">
|
||||
<CardHeader
|
||||
title="Configuracion del mapa"
|
||||
subtitle="Personaliza la visualizacion del mapa"
|
||||
/>
|
||||
<div className="space-y-6 mt-6">
|
||||
<Select
|
||||
label="Estilo del mapa"
|
||||
value={config.mapa.estilo}
|
||||
onChange={(e) => config.setMapa({ estilo: e.target.value as any })}
|
||||
options={[
|
||||
{ value: 'dark', label: 'Oscuro' },
|
||||
{ value: 'light', label: 'Claro' },
|
||||
{ value: 'satellite', label: 'Satelite' },
|
||||
{ value: 'streets', label: 'Calles' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Latitud inicial"
|
||||
type="number"
|
||||
step="any"
|
||||
value={config.mapa.centroInicial.lat}
|
||||
onChange={(e) =>
|
||||
config.setMapa({
|
||||
centroInicial: {
|
||||
...config.mapa.centroInicial,
|
||||
lat: parseFloat(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="Longitud inicial"
|
||||
type="number"
|
||||
step="any"
|
||||
value={config.mapa.centroInicial.lng}
|
||||
onChange={(e) =>
|
||||
config.setMapa({
|
||||
centroInicial: {
|
||||
...config.mapa.centroInicial,
|
||||
lng: parseFloat(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Zoom inicial"
|
||||
type="number"
|
||||
min={1}
|
||||
max={18}
|
||||
value={config.mapa.zoomInicial}
|
||||
onChange={(e) =>
|
||||
config.setMapa({ zoomInicial: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t border-slate-700/50">
|
||||
<h3 className="text-white font-medium">Capas visibles</h3>
|
||||
<Checkbox
|
||||
label="Mostrar trafico"
|
||||
checked={config.mapa.mostrarTrafico}
|
||||
onChange={(checked) => config.setMapa({ mostrarTrafico: checked })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Mostrar geocercas"
|
||||
checked={config.mapa.mostrarGeocercas}
|
||||
onChange={(checked) => config.setMapa({ mostrarGeocercas: checked })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Mostrar POIs"
|
||||
checked={config.mapa.mostrarPOIs}
|
||||
onChange={(checked) => config.setMapa({ mostrarPOIs: checked })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Agrupar vehiculos cercanos"
|
||||
checked={config.mapa.clusterVehiculos}
|
||||
onChange={(checked) => config.setMapa({ clusterVehiculos: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Apariencia */}
|
||||
{activeTab === 'apariencia' && (
|
||||
<Card padding="lg">
|
||||
<CardHeader
|
||||
title="Apariencia"
|
||||
subtitle="Personaliza el aspecto visual"
|
||||
/>
|
||||
<div className="space-y-6 mt-6">
|
||||
<Select
|
||||
label="Tema"
|
||||
value={config.tema}
|
||||
onChange={(e) => config.setTema(e.target.value as any)}
|
||||
options={[
|
||||
{ value: 'dark', label: 'Oscuro' },
|
||||
{ value: 'light', label: 'Claro' },
|
||||
{ value: 'system', label: 'Sistema' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Idioma"
|
||||
value={config.idioma}
|
||||
onChange={(e) => config.setIdioma(e.target.value)}
|
||||
options={[
|
||||
{ value: 'es', label: 'Espanol' },
|
||||
{ value: 'en', label: 'English' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Zona horaria"
|
||||
value={config.zonaHoraria}
|
||||
onChange={(e) => config.setZonaHoraria(e.target.value)}
|
||||
options={[
|
||||
{ value: 'America/Mexico_City', label: 'Ciudad de Mexico (GMT-6)' },
|
||||
{ value: 'America/Monterrey', label: 'Monterrey (GMT-6)' },
|
||||
{ value: 'America/Tijuana', label: 'Tijuana (GMT-8)' },
|
||||
{ value: 'America/Cancun', label: 'Cancun (GMT-5)' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t border-slate-700/50">
|
||||
<h3 className="text-white font-medium">Unidades</h3>
|
||||
<Select
|
||||
label="Sistema de unidades"
|
||||
value={config.unidades.distancia}
|
||||
onChange={(e) =>
|
||||
config.setUnidades({
|
||||
distancia: e.target.value as 'km' | 'mi',
|
||||
velocidad: e.target.value === 'km' ? 'kmh' : 'mph',
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: 'km', label: 'Metrico (km, km/h)' },
|
||||
{ value: 'mi', label: 'Imperial (mi, mph)' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Seguridad */}
|
||||
{activeTab === 'seguridad' && (
|
||||
<Card padding="lg">
|
||||
<CardHeader
|
||||
title="Seguridad"
|
||||
subtitle="Gestiona la seguridad de tu cuenta"
|
||||
/>
|
||||
<div className="space-y-6 mt-6">
|
||||
<form onSubmit={handleCambiarPassword} className="space-y-4">
|
||||
<h3 className="text-white font-medium">Cambiar contrasena</h3>
|
||||
<Input
|
||||
label="Contrasena actual"
|
||||
type="password"
|
||||
value={passwordForm.current}
|
||||
onChange={(e) =>
|
||||
setPasswordForm({ ...passwordForm, current: e.target.value })
|
||||
}
|
||||
placeholder="Ingresa tu contrasena actual"
|
||||
/>
|
||||
<Input
|
||||
label="Nueva contrasena"
|
||||
type="password"
|
||||
value={passwordForm.new}
|
||||
onChange={(e) =>
|
||||
setPasswordForm({ ...passwordForm, new: e.target.value })
|
||||
}
|
||||
placeholder="Minimo 8 caracteres"
|
||||
/>
|
||||
<Input
|
||||
label="Confirmar contrasena"
|
||||
type="password"
|
||||
value={passwordForm.confirm}
|
||||
onChange={(e) =>
|
||||
setPasswordForm({ ...passwordForm, confirm: e.target.value })
|
||||
}
|
||||
placeholder="Repite la nueva contrasena"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={cambiarPasswordMutation.isPending}
|
||||
>
|
||||
Cambiar contrasena
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="pt-4 border-t border-slate-700/50 space-y-4">
|
||||
<h3 className="text-white font-medium">Sesiones activas</h3>
|
||||
<div className="p-4 bg-slate-800/50 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<GlobeAltIcon className="w-8 h-8 text-accent-400" />
|
||||
<div>
|
||||
<p className="text-white">Sesion actual</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
Este navegador - Activo ahora
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-success-400">Activo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-slate-700/50">
|
||||
<h3 className="text-white font-medium mb-4">Zona de peligro</h3>
|
||||
<Button variant="danger">Cerrar todas las sesiones</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
272
frontend/src/pages/Dashboard.tsx
Normal file
272
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
TruckIcon,
|
||||
BellAlertIcon,
|
||||
ArrowPathIcon,
|
||||
MapPinIcon,
|
||||
UserGroupIcon,
|
||||
ChartBarIcon,
|
||||
ArrowRightIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { useVehiculosStore } from '@/store/vehiculosStore'
|
||||
import { useAlertasStore } from '@/store/alertasStore'
|
||||
import { useFleetStats } from '@/hooks/useVehiculos'
|
||||
import { KPICard, MiniKPI } from '@/components/charts/KPICard'
|
||||
import { DonutChart } from '@/components/charts/PieChart'
|
||||
import LineChart from '@/components/charts/LineChart'
|
||||
import Card, { CardHeader } from '@/components/ui/Card'
|
||||
import { AlertaCard } from '@/components/alertas'
|
||||
import { VehiculoCard } from '@/components/vehiculos'
|
||||
import { SkeletonStats, SkeletonCard } from '@/components/ui/Skeleton'
|
||||
|
||||
export default function Dashboard() {
|
||||
const vehiculos = useVehiculosStore((state) => state.getVehiculosArray())
|
||||
const estadisticas = useVehiculosStore((state) => state.getEstadisticas())
|
||||
const alertasActivas = useAlertasStore((state) => state.alertasActivas)
|
||||
const { data: fleetStats, isLoading: isLoadingStats } = useFleetStats()
|
||||
|
||||
// Mock data for charts (replace with real API data)
|
||||
const activityData = [
|
||||
{ hora: '00:00', vehiculos: 2, kilometros: 15 },
|
||||
{ hora: '04:00', vehiculos: 1, kilometros: 8 },
|
||||
{ hora: '08:00', vehiculos: 8, kilometros: 120 },
|
||||
{ hora: '12:00', vehiculos: 12, kilometros: 280 },
|
||||
{ hora: '16:00', vehiculos: 15, kilometros: 350 },
|
||||
{ hora: '20:00', vehiculos: 10, kilometros: 180 },
|
||||
]
|
||||
|
||||
const vehiculosPorEstado = [
|
||||
{ name: 'En movimiento', value: estadisticas.enMovimiento, color: '#22c55e' },
|
||||
{ name: 'Detenidos', value: estadisticas.detenidos, color: '#eab308' },
|
||||
{ name: 'Sin senal', value: estadisticas.sinSenal, color: '#64748b' },
|
||||
]
|
||||
|
||||
// Get recent vehicles with activity
|
||||
const vehiculosActivos = vehiculos
|
||||
.filter((v) => v.movimiento === 'movimiento' || v.movimiento === 'detenido')
|
||||
.slice(0, 4)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Resumen general de tu flota
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full animate-pulse" />
|
||||
Actualizacion en tiempo real
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{isLoadingStats ? (
|
||||
<>
|
||||
<SkeletonStats />
|
||||
<SkeletonStats />
|
||||
<SkeletonStats />
|
||||
<SkeletonStats />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<KPICard
|
||||
title="Vehiculos Activos"
|
||||
value={estadisticas.enMovimiento}
|
||||
subtitle={`de ${estadisticas.total} total`}
|
||||
icon={<TruckIcon className="w-5 h-5" />}
|
||||
color="green"
|
||||
trend={{ value: 12, label: 'vs ayer' }}
|
||||
/>
|
||||
<KPICard
|
||||
title="Alertas Activas"
|
||||
value={alertasActivas.length}
|
||||
subtitle={alertasActivas.filter((a) => a.prioridad === 'critica').length + ' criticas'}
|
||||
icon={<BellAlertIcon className="w-5 h-5" />}
|
||||
color={alertasActivas.length > 5 ? 'red' : 'yellow'}
|
||||
/>
|
||||
<KPICard
|
||||
title="Viajes Hoy"
|
||||
value={fleetStats?.alertasActivas || 24}
|
||||
subtitle="completados"
|
||||
icon={<ArrowPathIcon className="w-5 h-5" />}
|
||||
color="blue"
|
||||
trend={{ value: 8, isPositive: true }}
|
||||
/>
|
||||
<KPICard
|
||||
title="Km Recorridos"
|
||||
value="1,245"
|
||||
subtitle="hoy"
|
||||
icon={<MapPinIcon className="w-5 h-5" />}
|
||||
trend={{ value: 5, label: 'vs promedio' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left column - Activity chart and alerts */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Activity chart */}
|
||||
<Card padding="lg">
|
||||
<CardHeader
|
||||
title="Actividad del dia"
|
||||
subtitle="Vehiculos activos y kilometros recorridos"
|
||||
action={
|
||||
<Link
|
||||
to="/reportes"
|
||||
className="text-sm text-accent-400 hover:text-accent-300 flex items-center gap-1"
|
||||
>
|
||||
Ver reportes <ArrowRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<LineChart
|
||||
data={activityData}
|
||||
xAxisKey="hora"
|
||||
lines={[
|
||||
{ dataKey: 'vehiculos', name: 'Vehiculos', color: '#3b82f6' },
|
||||
{ dataKey: 'kilometros', name: 'Km (x10)', color: '#22c55e' },
|
||||
]}
|
||||
height={250}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Recent alerts */}
|
||||
<Card padding="lg">
|
||||
<CardHeader
|
||||
title="Alertas recientes"
|
||||
subtitle={`${alertasActivas.length} alertas activas`}
|
||||
action={
|
||||
<Link
|
||||
to="/alertas"
|
||||
className="text-sm text-accent-400 hover:text-accent-300 flex items-center gap-1"
|
||||
>
|
||||
Ver todas <ArrowRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
{alertasActivas.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 rounded-full bg-success-500/20 flex items-center justify-center mx-auto mb-3">
|
||||
<BellAlertIcon className="w-6 h-6 text-success-400" />
|
||||
</div>
|
||||
<p className="text-slate-400">No hay alertas activas</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{alertasActivas.slice(0, 5).map((alerta) => (
|
||||
<AlertaCard key={alerta.id} alerta={alerta} compact />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right column - Fleet status and vehicles */}
|
||||
<div className="space-y-6">
|
||||
{/* Fleet status donut */}
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Estado de la flota" />
|
||||
<DonutChart
|
||||
data={vehiculosPorEstado}
|
||||
centerValue={estadisticas.total}
|
||||
centerLabel="vehiculos"
|
||||
height={180}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-2 mt-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<span className="w-2 h-2 rounded-full bg-success-500" />
|
||||
<span className="text-xs text-slate-500">Movimiento</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-white">{estadisticas.enMovimiento}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<span className="w-2 h-2 rounded-full bg-warning-500" />
|
||||
<span className="text-xs text-slate-500">Detenidos</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-white">{estadisticas.detenidos}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<span className="w-2 h-2 rounded-full bg-slate-500" />
|
||||
<span className="text-xs text-slate-500">Sin senal</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-white">{estadisticas.sinSenal}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Active vehicles */}
|
||||
<Card padding="lg">
|
||||
<CardHeader
|
||||
title="Vehiculos activos"
|
||||
action={
|
||||
<Link
|
||||
to="/vehiculos"
|
||||
className="text-sm text-accent-400 hover:text-accent-300 flex items-center gap-1"
|
||||
>
|
||||
Ver todos <ArrowRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
{vehiculosActivos.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<TruckIcon className="w-12 h-12 text-slate-600 mx-auto mb-3" />
|
||||
<p className="text-slate-500">No hay vehiculos activos</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{vehiculosActivos.map((vehiculo) => (
|
||||
<VehiculoCard key={vehiculo.id} vehiculo={vehiculo} compact />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Quick links */}
|
||||
<Card padding="md">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Link
|
||||
to="/mapa"
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-slate-800/50 hover:bg-slate-700/50 transition-colors"
|
||||
>
|
||||
<MapPinIcon className="w-5 h-5 text-accent-400" />
|
||||
<span className="text-sm text-slate-300">Ver mapa</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/conductores"
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-slate-800/50 hover:bg-slate-700/50 transition-colors"
|
||||
>
|
||||
<UserGroupIcon className="w-5 h-5 text-accent-400" />
|
||||
<span className="text-sm text-slate-300">Conductores</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/viajes"
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-slate-800/50 hover:bg-slate-700/50 transition-colors"
|
||||
>
|
||||
<ArrowPathIcon className="w-5 h-5 text-accent-400" />
|
||||
<span className="text-sm text-slate-300">Viajes</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/reportes"
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-slate-800/50 hover:bg-slate-700/50 transition-colors"
|
||||
>
|
||||
<ChartBarIcon className="w-5 h-5 text-accent-400" />
|
||||
<span className="text-sm text-slate-300">Reportes</span>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
413
frontend/src/pages/Geocercas.tsx
Normal file
413
frontend/src/pages/Geocercas.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
PlusIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
MapPinIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { geocercasApi } from '@/api'
|
||||
import { MapContainer } from '@/components/mapa'
|
||||
import GeocercaLayer from '@/components/mapa/GeocercaLayer'
|
||||
import DrawingTools from '@/components/mapa/DrawingTools'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Select from '@/components/ui/Select'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import { Geocerca } from '@/types'
|
||||
|
||||
type DrawingMode = 'circle' | 'polygon' | null
|
||||
|
||||
interface GeocercaForm {
|
||||
nombre: string
|
||||
tipo: 'permitida' | 'restringida' | 'velocidad'
|
||||
forma: 'circulo' | 'poligono'
|
||||
centro?: { lat: number; lng: number }
|
||||
radio?: number
|
||||
puntos?: { lat: number; lng: number }[]
|
||||
velocidadMaxima?: number
|
||||
color: string
|
||||
}
|
||||
|
||||
const defaultForm: GeocercaForm = {
|
||||
nombre: '',
|
||||
tipo: 'permitida',
|
||||
forma: 'circulo',
|
||||
color: '#3b82f6',
|
||||
}
|
||||
|
||||
export default function Geocercas() {
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
const [selectedGeocerca, setSelectedGeocerca] = useState<Geocerca | null>(null)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [drawingMode, setDrawingMode] = useState<DrawingMode>(null)
|
||||
const [form, setForm] = useState<GeocercaForm>(defaultForm)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
const { data: geocercas, isLoading } = useQuery({
|
||||
queryKey: ['geocercas'],
|
||||
queryFn: () => geocercasApi.list(),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: geocercasApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['geocercas'] })
|
||||
toast.success('Geocerca creada exitosamente')
|
||||
handleCloseModal()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al crear geocerca')
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<Geocerca> }) =>
|
||||
geocercasApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['geocercas'] })
|
||||
toast.success('Geocerca actualizada')
|
||||
handleCloseModal()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al actualizar geocerca')
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: geocercasApi.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['geocercas'] })
|
||||
toast.success('Geocerca eliminada')
|
||||
setSelectedGeocerca(null)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al eliminar geocerca')
|
||||
},
|
||||
})
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false)
|
||||
setForm(defaultForm)
|
||||
setDrawingMode(null)
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const handleNewGeocerca = () => {
|
||||
setForm(defaultForm)
|
||||
setIsEditing(false)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleEditGeocerca = (geocerca: Geocerca) => {
|
||||
setForm({
|
||||
nombre: geocerca.nombre,
|
||||
tipo: geocerca.tipo,
|
||||
forma: geocerca.forma,
|
||||
centro: geocerca.centro,
|
||||
radio: geocerca.radio,
|
||||
puntos: geocerca.puntos,
|
||||
velocidadMaxima: geocerca.velocidadMaxima,
|
||||
color: geocerca.color || '#3b82f6',
|
||||
})
|
||||
setSelectedGeocerca(geocerca)
|
||||
setIsEditing(true)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleDeleteGeocerca = (id: string) => {
|
||||
if (confirm('¿Estas seguro de eliminar esta geocerca?')) {
|
||||
deleteMutation.mutate(id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrawComplete = useCallback(
|
||||
(data: { type: 'circle'; center: { lat: number; lng: number }; radius: number } | { type: 'polygon'; points: { lat: number; lng: number }[] }) => {
|
||||
if (data.type === 'circle') {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
forma: 'circulo',
|
||||
centro: data.center,
|
||||
radio: data.radius,
|
||||
puntos: undefined,
|
||||
}))
|
||||
} else {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
forma: 'poligono',
|
||||
puntos: data.points,
|
||||
centro: undefined,
|
||||
radio: undefined,
|
||||
}))
|
||||
}
|
||||
setDrawingMode(null)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!form.nombre) {
|
||||
toast.error('Ingresa un nombre para la geocerca')
|
||||
return
|
||||
}
|
||||
|
||||
if (form.forma === 'circulo' && (!form.centro || !form.radio)) {
|
||||
toast.error('Dibuja un circulo en el mapa')
|
||||
return
|
||||
}
|
||||
|
||||
if (form.forma === 'poligono' && (!form.puntos || form.puntos.length < 3)) {
|
||||
toast.error('Dibuja un poligono en el mapa')
|
||||
return
|
||||
}
|
||||
|
||||
const geocercaData = {
|
||||
nombre: form.nombre,
|
||||
tipo: form.tipo,
|
||||
forma: form.forma,
|
||||
centro: form.centro,
|
||||
radio: form.radio,
|
||||
puntos: form.puntos,
|
||||
velocidadMaxima: form.tipo === 'velocidad' ? form.velocidadMaxima : undefined,
|
||||
color: form.color,
|
||||
activa: true,
|
||||
}
|
||||
|
||||
if (isEditing && selectedGeocerca) {
|
||||
updateMutation.mutate({ id: selectedGeocerca.id, data: geocercaData })
|
||||
} else {
|
||||
createMutation.mutate(geocercaData as Omit<Geocerca, 'id' | 'createdAt' | 'updatedAt'>)
|
||||
}
|
||||
}
|
||||
|
||||
const geocercasList = geocercas || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Geocercas</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Crea y gestiona zonas geograficas para tus vehiculos
|
||||
</p>
|
||||
</div>
|
||||
<Button leftIcon={<PlusIcon className="w-5 h-5" />} onClick={handleNewGeocerca}>
|
||||
Nueva geocerca
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Map */}
|
||||
<div className="lg:col-span-3 h-[600px] rounded-xl overflow-hidden">
|
||||
<MapContainer showControls>
|
||||
<GeocercaLayer
|
||||
geocercas={geocercasList}
|
||||
onGeocercaClick={setSelectedGeocerca}
|
||||
/>
|
||||
{drawingMode && (
|
||||
<DrawingTools mode={drawingMode} onComplete={handleDrawComplete} />
|
||||
)}
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
<Card padding="md">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Total geocercas</span>
|
||||
<span className="text-white font-medium">{geocercasList.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Permitidas</span>
|
||||
<span className="text-success-400 font-medium">
|
||||
{geocercasList.filter((g) => g.tipo === 'permitida').length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Restringidas</span>
|
||||
<span className="text-error-400 font-medium">
|
||||
{geocercasList.filter((g) => g.tipo === 'restringida').length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Velocidad</span>
|
||||
<span className="text-warning-400 font-medium">
|
||||
{geocercasList.filter((g) => g.tipo === 'velocidad').length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Geocercas list */}
|
||||
<Card padding="none">
|
||||
<div className="p-4 border-b border-slate-700/50">
|
||||
<h3 className="font-medium text-white">Lista de geocercas</h3>
|
||||
</div>
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-center text-slate-500">Cargando...</div>
|
||||
) : geocercasList.length === 0 ? (
|
||||
<div className="p-4 text-center text-slate-500">
|
||||
No hay geocercas creadas
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-700/50">
|
||||
{geocercasList.map((geocerca) => (
|
||||
<div
|
||||
key={geocerca.id}
|
||||
className={clsx(
|
||||
'p-3 hover:bg-slate-800/50 cursor-pointer transition-colors',
|
||||
selectedGeocerca?.id === geocerca.id && 'bg-slate-800/50'
|
||||
)}
|
||||
onClick={() => setSelectedGeocerca(geocerca)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: geocerca.color || '#3b82f6' }}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-white text-sm">{geocerca.nombre}</p>
|
||||
<p className="text-xs text-slate-500 capitalize">
|
||||
{geocerca.tipo} - {geocerca.forma}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEditGeocerca(geocerca)
|
||||
}}
|
||||
className="p-1 text-slate-500 hover:text-white"
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteGeocerca(geocerca.id)
|
||||
}}
|
||||
className="p-1 text-slate-500 hover:text-error-400"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
title={isEditing ? 'Editar geocerca' : 'Nueva geocerca'}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Nombre"
|
||||
value={form.nombre}
|
||||
onChange={(e) => setForm({ ...form, nombre: e.target.value })}
|
||||
placeholder="Ej: Zona de carga"
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Tipo"
|
||||
value={form.tipo}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, tipo: e.target.value as GeocercaForm['tipo'] })
|
||||
}
|
||||
options={[
|
||||
{ value: 'permitida', label: 'Zona permitida' },
|
||||
{ value: 'restringida', label: 'Zona restringida' },
|
||||
{ value: 'velocidad', label: 'Control de velocidad' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{form.tipo === 'velocidad' && (
|
||||
<Input
|
||||
label="Velocidad maxima (km/h)"
|
||||
type="number"
|
||||
value={form.velocidadMaxima || ''}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, velocidadMaxima: parseInt(e.target.value) })
|
||||
}
|
||||
placeholder="Ej: 60"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-400 mb-2">
|
||||
Color
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={form.color}
|
||||
onChange={(e) => setForm({ ...form, color: e.target.value })}
|
||||
className="w-full h-10 rounded-lg cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-400 mb-2">
|
||||
Dibujar en el mapa
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={drawingMode === 'circle' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setDrawingMode(drawingMode === 'circle' ? null : 'circle')}
|
||||
>
|
||||
Circulo
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={drawingMode === 'polygon' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setDrawingMode(drawingMode === 'polygon' ? null : 'polygon')}
|
||||
>
|
||||
Poligono
|
||||
</Button>
|
||||
</div>
|
||||
{(form.centro || form.puntos) && (
|
||||
<p className="mt-2 text-sm text-success-400">
|
||||
<MapPinIcon className="w-4 h-4 inline mr-1" />
|
||||
Forma dibujada: {form.forma}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button type="button" variant="ghost" onClick={handleCloseModal}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{isEditing ? 'Guardar cambios' : 'Crear geocerca'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
221
frontend/src/pages/Grabaciones.tsx
Normal file
221
frontend/src/pages/Grabaciones.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
CalendarIcon,
|
||||
PlayIcon,
|
||||
ArrowDownTrayIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { format } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import { videoApi } from '@/api'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Table from '@/components/ui/Table'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import { VideoPlayer } from '@/components/video'
|
||||
import { Grabacion } from '@/types'
|
||||
|
||||
export default function Grabaciones() {
|
||||
const [filtros, setFiltros] = useState({
|
||||
vehiculoId: '',
|
||||
desde: '',
|
||||
hasta: '',
|
||||
tipo: '',
|
||||
})
|
||||
const [selectedGrabacion, setSelectedGrabacion] = useState<Grabacion | null>(null)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['grabaciones', filtros],
|
||||
queryFn: () =>
|
||||
videoApi.listGrabaciones({
|
||||
...filtros,
|
||||
vehiculoId: filtros.vehiculoId || undefined,
|
||||
desde: filtros.desde || undefined,
|
||||
hasta: filtros.hasta || undefined,
|
||||
tipo: filtros.tipo || undefined,
|
||||
pageSize: 50,
|
||||
}),
|
||||
})
|
||||
|
||||
const grabaciones = data?.items || []
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'vehiculo',
|
||||
header: 'Vehiculo',
|
||||
render: (g: Grabacion) => (
|
||||
<div>
|
||||
<p className="text-white">{g.vehiculo?.nombre || 'Sin vehiculo'}</p>
|
||||
<p className="text-xs text-slate-500">{g.vehiculo?.placa}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'camara',
|
||||
header: 'Camara',
|
||||
render: (g: Grabacion) => (
|
||||
<span className="text-slate-300 capitalize">
|
||||
{g.camara?.posicion || g.camara?.nombre}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'fecha',
|
||||
header: 'Fecha',
|
||||
render: (g: Grabacion) => (
|
||||
<div>
|
||||
<p className="text-slate-300">
|
||||
{format(new Date(g.inicio), 'd MMM yyyy', { locale: es })}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{format(new Date(g.inicio), 'HH:mm')} - {format(new Date(g.fin), 'HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'duracion',
|
||||
header: 'Duracion',
|
||||
render: (g: Grabacion) => {
|
||||
const minutos = Math.floor(g.duracion / 60)
|
||||
const segundos = g.duracion % 60
|
||||
return (
|
||||
<span className="text-slate-300">
|
||||
{minutos}:{segundos.toString().padStart(2, '0')}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'tipo',
|
||||
header: 'Tipo',
|
||||
render: (g: Grabacion) => (
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs rounded capitalize',
|
||||
g.tipo === 'evento' && 'bg-warning-500/20 text-warning-400',
|
||||
g.tipo === 'manual' && 'bg-accent-500/20 text-accent-400',
|
||||
g.tipo === 'continua' && 'bg-slate-700 text-slate-300'
|
||||
)}
|
||||
>
|
||||
{g.tipo}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'acciones',
|
||||
header: '',
|
||||
sortable: false,
|
||||
render: (g: Grabacion) => (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<PlayIcon className="w-4 h-4" />}
|
||||
onClick={() => setSelectedGrabacion(g)}
|
||||
>
|
||||
Ver
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<ArrowDownTrayIcon className="w-4 h-4" />}
|
||||
>
|
||||
Descargar
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Grabaciones</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Busca y reproduce grabaciones de video
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card padding="md">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<select
|
||||
value={filtros.tipo}
|
||||
onChange={(e) => setFiltros({ ...filtros, tipo: e.target.value })}
|
||||
className={clsx(
|
||||
'px-4 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
>
|
||||
<option value="">Todos los tipos</option>
|
||||
<option value="continua">Continua</option>
|
||||
<option value="evento">Evento</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="date"
|
||||
value={filtros.desde}
|
||||
onChange={(e) => setFiltros({ ...filtros, desde: e.target.value })}
|
||||
className={clsx(
|
||||
'px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
/>
|
||||
<span className="text-slate-500">-</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filtros.hasta}
|
||||
onChange={(e) => setFiltros({ ...filtros, hasta: e.target.value })}
|
||||
className={clsx(
|
||||
'px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-slate-500 ml-auto">
|
||||
{grabaciones.length} grabaciones
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Table */}
|
||||
<Card padding="none">
|
||||
<Table
|
||||
data={grabaciones}
|
||||
columns={columns}
|
||||
keyExtractor={(g) => g.id}
|
||||
isLoading={isLoading}
|
||||
pagination
|
||||
pageSize={20}
|
||||
emptyMessage="No hay grabaciones disponibles"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Video player modal */}
|
||||
<Modal
|
||||
isOpen={!!selectedGrabacion}
|
||||
onClose={() => setSelectedGrabacion(null)}
|
||||
title={`Grabacion - ${selectedGrabacion?.vehiculo?.placa}`}
|
||||
size="xl"
|
||||
>
|
||||
{selectedGrabacion && (
|
||||
<div className="aspect-video">
|
||||
<VideoPlayer
|
||||
src={selectedGrabacion.url}
|
||||
className="w-full h-full rounded-lg"
|
||||
autoPlay
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
240
frontend/src/pages/Login.tsx
Normal file
240
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
MapPinIcon,
|
||||
EnvelopeIcon,
|
||||
LockClosedIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Input from '@/components/ui/Input'
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const { login, isLoading, error, clearError } = useAuthStore()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setFormError('')
|
||||
clearError()
|
||||
|
||||
if (!email || !password) {
|
||||
setFormError('Por favor complete todos los campos')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await login({ email, password })
|
||||
navigate('/')
|
||||
} catch {
|
||||
// Error is handled in store
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background-900 flex">
|
||||
{/* Left side - Form */}
|
||||
<div className="w-full lg:w-1/2 flex items-center justify-center p-8">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-accent-500 to-accent-700 flex items-center justify-center shadow-lg shadow-accent-500/25">
|
||||
<MapPinIcon className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Flotillas GPS</h1>
|
||||
<p className="text-sm text-slate-500">Sistema de Monitoreo</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Welcome text */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold text-white mb-2">Bienvenido</h2>
|
||||
<p className="text-slate-400">
|
||||
Ingresa tus credenciales para acceder al sistema
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{(error || formError) && (
|
||||
<div className="mb-6 p-4 bg-error-500/10 border border-error-500/20 rounded-lg">
|
||||
<p className="text-sm text-error-400">{error || formError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<Input
|
||||
label="Correo electronico"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@empresa.com"
|
||||
leftIcon={<EnvelopeIcon className="w-5 h-5" />}
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="Contrasena"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
leftIcon={<LockClosedIcon className="w-5 h-5" />}
|
||||
rightIcon={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeSlashIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<div className="mt-2 text-right">
|
||||
<a
|
||||
href="#"
|
||||
className="text-sm text-accent-400 hover:text-accent-300 transition-colors"
|
||||
>
|
||||
¿Olvidaste tu contrasena?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Iniciar sesion
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Demo credentials */}
|
||||
<div className="mt-8 p-4 bg-slate-800/50 border border-slate-700/50 rounded-lg">
|
||||
<p className="text-xs text-slate-500 mb-2">Credenciales de demo:</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
<span className="text-slate-500">Email:</span> admin@demo.com
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
<span className="text-slate-500">Password:</span> demo123
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-8 text-center text-sm text-slate-600">
|
||||
Flotillas GPS v1.0.0 | Sistema de Monitoreo de Flota
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Background */}
|
||||
<div className="hidden lg:flex lg:w-1/2 relative bg-gradient-to-br from-accent-600 to-accent-900 overflow-hidden">
|
||||
{/* Animated background */}
|
||||
<div className="absolute inset-0">
|
||||
{/* Floating elements */}
|
||||
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-white/10 rounded-full blur-3xl animate-pulse" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-accent-400/20 rounded-full blur-3xl animate-pulse animation-delay-500" />
|
||||
<div className="absolute top-1/2 right-1/3 w-48 h-48 bg-white/5 rounded-full blur-2xl animate-bounce-slow" />
|
||||
|
||||
{/* Grid pattern */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-10"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '50px 50px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Route lines */}
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full opacity-20"
|
||||
viewBox="0 0 800 800"
|
||||
>
|
||||
<path
|
||||
d="M100,400 Q200,100 400,200 T700,400"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="10,5"
|
||||
className="animate-[dash_20s_linear_infinite]"
|
||||
/>
|
||||
<path
|
||||
d="M100,500 Q300,700 500,500 T800,300"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="10,5"
|
||||
className="animate-[dash_15s_linear_infinite]"
|
||||
/>
|
||||
{/* Vehicle markers */}
|
||||
<circle cx="200" cy="300" r="8" fill="white" className="animate-pulse" />
|
||||
<circle cx="500" cy="450" r="8" fill="white" className="animate-pulse animation-delay-200" />
|
||||
<circle cx="350" cy="200" r="8" fill="white" className="animate-pulse animation-delay-300" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 flex items-center justify-center w-full p-12">
|
||||
<div className="text-center text-white">
|
||||
<div className="w-24 h-24 mx-auto mb-8 rounded-2xl bg-white/10 backdrop-blur flex items-center justify-center">
|
||||
<MapPinIcon className="w-12 h-12" />
|
||||
</div>
|
||||
<h2 className="text-4xl font-bold mb-4">
|
||||
Monitoreo en tiempo real
|
||||
</h2>
|
||||
<p className="text-xl text-white/80 max-w-md mx-auto">
|
||||
Rastrea tu flota, optimiza rutas y mejora la eficiencia operativa con nuestro sistema GPS avanzado.
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
<div className="mt-12 grid grid-cols-3 gap-6">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-white/70">GPS en vivo</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-white/70">Video streaming</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-white/70">Reportes</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
560
frontend/src/pages/Mantenimiento.tsx
Normal file
560
frontend/src/pages/Mantenimiento.tsx
Normal file
@@ -0,0 +1,560 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
PlusIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
CalendarDaysIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { format, differenceInDays, addDays } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import { mantenimientoApi, vehiculosApi } from '@/api'
|
||||
import Card, { CardHeader } from '@/components/ui/Card'
|
||||
import Table from '@/components/ui/Table'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Select from '@/components/ui/Select'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import { Mantenimiento } from '@/types'
|
||||
|
||||
interface MantenimientoForm {
|
||||
vehiculoId: string
|
||||
tipo: Mantenimiento['tipo']
|
||||
descripcion: string
|
||||
fechaProgramada: string
|
||||
kilometrajeProgramado?: number
|
||||
costo?: number
|
||||
proveedor?: string
|
||||
notas?: string
|
||||
}
|
||||
|
||||
const defaultForm: MantenimientoForm = {
|
||||
vehiculoId: '',
|
||||
tipo: 'preventivo',
|
||||
descripcion: '',
|
||||
fechaProgramada: format(addDays(new Date(), 7), 'yyyy-MM-dd'),
|
||||
}
|
||||
|
||||
export default function MantenimientoPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [form, setForm] = useState<MantenimientoForm>(defaultForm)
|
||||
const [editingMant, setEditingMant] = useState<Mantenimiento | null>(null)
|
||||
const [filtroEstado, setFiltroEstado] = useState<string>('')
|
||||
|
||||
const { data: vehiculos } = useQuery({
|
||||
queryKey: ['vehiculos'],
|
||||
queryFn: () => vehiculosApi.list(),
|
||||
})
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['mantenimientos', filtroEstado],
|
||||
queryFn: () =>
|
||||
mantenimientoApi.list({
|
||||
estado: filtroEstado || undefined,
|
||||
pageSize: 100,
|
||||
}),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: mantenimientoApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['mantenimientos'] })
|
||||
toast.success('Mantenimiento programado')
|
||||
handleCloseModal()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al programar mantenimiento')
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<Mantenimiento> }) =>
|
||||
mantenimientoApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['mantenimientos'] })
|
||||
toast.success('Mantenimiento actualizado')
|
||||
handleCloseModal()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al actualizar')
|
||||
},
|
||||
})
|
||||
|
||||
const completarMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
mantenimientoApi.update(id, {
|
||||
estado: 'completado',
|
||||
fechaRealizada: new Date().toISOString(),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['mantenimientos'] })
|
||||
toast.success('Mantenimiento marcado como completado')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al completar')
|
||||
},
|
||||
})
|
||||
|
||||
const mantenimientos = data?.items || []
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
pendientes: mantenimientos.filter((m) => m.estado === 'pendiente').length,
|
||||
enProceso: mantenimientos.filter((m) => m.estado === 'en_proceso').length,
|
||||
completados: mantenimientos.filter((m) => m.estado === 'completado').length,
|
||||
vencidos: mantenimientos.filter(
|
||||
(m) =>
|
||||
m.estado === 'pendiente' &&
|
||||
differenceInDays(new Date(), new Date(m.fechaProgramada)) > 0
|
||||
).length,
|
||||
}
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false)
|
||||
setForm(defaultForm)
|
||||
setEditingMant(null)
|
||||
}
|
||||
|
||||
const handleNewMant = () => {
|
||||
setForm(defaultForm)
|
||||
setEditingMant(null)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleEditMant = (mant: Mantenimiento) => {
|
||||
setForm({
|
||||
vehiculoId: mant.vehiculoId,
|
||||
tipo: mant.tipo,
|
||||
descripcion: mant.descripcion,
|
||||
fechaProgramada: format(new Date(mant.fechaProgramada), 'yyyy-MM-dd'),
|
||||
kilometrajeProgramado: mant.kilometrajeProgramado,
|
||||
costo: mant.costo,
|
||||
proveedor: mant.proveedor,
|
||||
notas: mant.notas,
|
||||
})
|
||||
setEditingMant(mant)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!form.vehiculoId || !form.descripcion) {
|
||||
toast.error('Completa los campos requeridos')
|
||||
return
|
||||
}
|
||||
|
||||
const mantData = {
|
||||
...form,
|
||||
estado: 'pendiente' as const,
|
||||
}
|
||||
|
||||
if (editingMant) {
|
||||
updateMutation.mutate({ id: editingMant.id, data: mantData })
|
||||
} else {
|
||||
createMutation.mutate(mantData as Omit<Mantenimiento, 'id' | 'createdAt' | 'updatedAt'>)
|
||||
}
|
||||
}
|
||||
|
||||
const getEstadoBadge = (mant: Mantenimiento) => {
|
||||
const diasRestantes = differenceInDays(new Date(mant.fechaProgramada), new Date())
|
||||
|
||||
if (mant.estado === 'completado') {
|
||||
return <Badge variant="success">Completado</Badge>
|
||||
}
|
||||
if (mant.estado === 'en_proceso') {
|
||||
return <Badge variant="warning">En proceso</Badge>
|
||||
}
|
||||
if (diasRestantes < 0) {
|
||||
return <Badge variant="error">Vencido</Badge>
|
||||
}
|
||||
if (diasRestantes <= 7) {
|
||||
return <Badge variant="warning">Proximo</Badge>
|
||||
}
|
||||
return <Badge variant="info">Programado</Badge>
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'vehiculo',
|
||||
header: 'Vehiculo',
|
||||
render: (m: Mantenimiento) => (
|
||||
<div>
|
||||
<p className="text-white">{m.vehiculo?.nombre || 'Sin vehiculo'}</p>
|
||||
<p className="text-xs text-slate-500">{m.vehiculo?.placa}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tipo',
|
||||
header: 'Tipo',
|
||||
render: (m: Mantenimiento) => (
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs rounded capitalize',
|
||||
m.tipo === 'preventivo' && 'bg-accent-500/20 text-accent-400',
|
||||
m.tipo === 'correctivo' && 'bg-error-500/20 text-error-400',
|
||||
m.tipo === 'revision' && 'bg-warning-500/20 text-warning-400'
|
||||
)}
|
||||
>
|
||||
{m.tipo}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'descripcion',
|
||||
header: 'Descripcion',
|
||||
render: (m: Mantenimiento) => (
|
||||
<div className="max-w-xs">
|
||||
<p className="text-white truncate">{m.descripcion}</p>
|
||||
{m.proveedor && (
|
||||
<p className="text-xs text-slate-500">Proveedor: {m.proveedor}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'fechaProgramada',
|
||||
header: 'Fecha programada',
|
||||
render: (m: Mantenimiento) => {
|
||||
const diasRestantes = differenceInDays(new Date(m.fechaProgramada), new Date())
|
||||
return (
|
||||
<div>
|
||||
<p className="text-slate-300">
|
||||
{format(new Date(m.fechaProgramada), 'd MMM yyyy', { locale: es })}
|
||||
</p>
|
||||
{m.estado === 'pendiente' && (
|
||||
<p
|
||||
className={clsx(
|
||||
'text-xs',
|
||||
diasRestantes < 0
|
||||
? 'text-error-400'
|
||||
: diasRestantes <= 7
|
||||
? 'text-warning-400'
|
||||
: 'text-slate-500'
|
||||
)}
|
||||
>
|
||||
{diasRestantes < 0
|
||||
? `Vencido hace ${Math.abs(diasRestantes)} dias`
|
||||
: diasRestantes === 0
|
||||
? 'Hoy'
|
||||
: `En ${diasRestantes} dias`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'costo',
|
||||
header: 'Costo',
|
||||
render: (m: Mantenimiento) => (
|
||||
<span className="text-success-400">
|
||||
{m.costo ? `$${m.costo.toFixed(0)}` : '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'estado',
|
||||
header: 'Estado',
|
||||
render: (m: Mantenimiento) => getEstadoBadge(m),
|
||||
},
|
||||
{
|
||||
key: 'acciones',
|
||||
header: '',
|
||||
sortable: false,
|
||||
render: (m: Mantenimiento) => (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
{m.estado !== 'completado' && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<CheckCircleIcon className="w-4 h-4" />}
|
||||
onClick={() => completarMutation.mutate(m.id)}
|
||||
>
|
||||
Completar
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<WrenchScrewdriverIcon className="w-4 h-4" />}
|
||||
onClick={() => handleEditMant(m)}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// Upcoming maintenance (next 30 days)
|
||||
const proximosMantenimientos = mantenimientos
|
||||
.filter(
|
||||
(m) =>
|
||||
m.estado === 'pendiente' &&
|
||||
differenceInDays(new Date(m.fechaProgramada), new Date()) <= 30 &&
|
||||
differenceInDays(new Date(m.fechaProgramada), new Date()) >= 0
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.fechaProgramada).getTime() - new Date(b.fechaProgramada).getTime()
|
||||
)
|
||||
.slice(0, 5)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Mantenimiento</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Programa y gestiona el mantenimiento de tu flota
|
||||
</p>
|
||||
</div>
|
||||
<Button leftIcon={<PlusIcon className="w-5 h-5" />} onClick={handleNewMant}>
|
||||
Programar mantenimiento
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card padding="md" className="cursor-pointer" onClick={() => setFiltroEstado('pendiente')}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-accent-500/20 rounded-lg">
|
||||
<ClockIcon className="w-6 h-6 text-accent-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{stats.pendientes}</p>
|
||||
<p className="text-sm text-slate-500">Pendientes</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md" className="cursor-pointer" onClick={() => setFiltroEstado('en_proceso')}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-warning-500/20 rounded-lg">
|
||||
<WrenchScrewdriverIcon className="w-6 h-6 text-warning-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{stats.enProceso}</p>
|
||||
<p className="text-sm text-slate-500">En proceso</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md" className="cursor-pointer" onClick={() => setFiltroEstado('completado')}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-success-500/20 rounded-lg">
|
||||
<CheckCircleIcon className="w-6 h-6 text-success-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{stats.completados}</p>
|
||||
<p className="text-sm text-slate-500">Completados</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-error-500/20 rounded-lg">
|
||||
<ExclamationTriangleIcon className="w-6 h-6 text-error-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-error-400">{stats.vencidos}</p>
|
||||
<p className="text-sm text-slate-500">Vencidos</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Upcoming */}
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Proximos mantenimientos" />
|
||||
{proximosMantenimientos.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{proximosMantenimientos.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="p-3 bg-slate-800/50 rounded-lg cursor-pointer hover:bg-slate-800"
|
||||
onClick={() => handleEditMant(m)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">{m.vehiculo?.nombre}</p>
|
||||
<p className="text-xs text-slate-500 truncate max-w-[150px]">
|
||||
{m.descripcion}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-slate-400">
|
||||
{format(new Date(m.fechaProgramada), 'd MMM', { locale: es })}
|
||||
</p>
|
||||
<p className="text-xs text-warning-400">
|
||||
{differenceInDays(new Date(m.fechaProgramada), new Date())} dias
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500 text-center py-4">
|
||||
Sin mantenimientos proximos
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Table */}
|
||||
<div className="lg:col-span-3">
|
||||
<Card padding="none">
|
||||
<div className="p-4 border-b border-slate-700/50 flex items-center justify-between">
|
||||
<h3 className="font-medium text-white">Historial de mantenimiento</h3>
|
||||
<Select
|
||||
value={filtroEstado}
|
||||
onChange={(e) => setFiltroEstado(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'Todos los estados' },
|
||||
{ value: 'pendiente', label: 'Pendiente' },
|
||||
{ value: 'en_proceso', label: 'En proceso' },
|
||||
{ value: 'completado', label: 'Completado' },
|
||||
]}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
data={mantenimientos}
|
||||
columns={columns}
|
||||
keyExtractor={(m) => m.id}
|
||||
isLoading={isLoading}
|
||||
pagination
|
||||
pageSize={15}
|
||||
emptyMessage="No hay registros de mantenimiento"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
title={editingMant ? 'Editar mantenimiento' : 'Programar mantenimiento'}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Select
|
||||
label="Vehiculo *"
|
||||
value={form.vehiculoId}
|
||||
onChange={(e) => setForm({ ...form, vehiculoId: e.target.value })}
|
||||
options={[
|
||||
{ value: '', label: 'Selecciona un vehiculo' },
|
||||
...(vehiculos?.items?.map((v) => ({
|
||||
value: v.id,
|
||||
label: `${v.nombre || v.placa} - ${v.placa}`,
|
||||
})) || []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Tipo"
|
||||
value={form.tipo}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, tipo: e.target.value as Mantenimiento['tipo'] })
|
||||
}
|
||||
options={[
|
||||
{ value: 'preventivo', label: 'Preventivo' },
|
||||
{ value: 'correctivo', label: 'Correctivo' },
|
||||
{ value: 'revision', label: 'Revision' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
label="Fecha programada *"
|
||||
type="date"
|
||||
value={form.fechaProgramada}
|
||||
onChange={(e) => setForm({ ...form, fechaProgramada: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Descripcion *"
|
||||
value={form.descripcion}
|
||||
onChange={(e) => setForm({ ...form, descripcion: e.target.value })}
|
||||
placeholder="Ej: Cambio de aceite y filtros"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Kilometraje programado"
|
||||
type="number"
|
||||
value={form.kilometrajeProgramado || ''}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
kilometrajeProgramado: e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="Ej: 50000"
|
||||
/>
|
||||
<Input
|
||||
label="Costo estimado"
|
||||
type="number"
|
||||
value={form.costo || ''}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
costo: e.target.value ? parseFloat(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
placeholder="Ej: 2500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Proveedor"
|
||||
value={form.proveedor || ''}
|
||||
onChange={(e) => setForm({ ...form, proveedor: e.target.value })}
|
||||
placeholder="Ej: Taller Mecanico XYZ"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-400 mb-2">
|
||||
Notas
|
||||
</label>
|
||||
<textarea
|
||||
value={form.notas || ''}
|
||||
onChange={(e) => setForm({ ...form, notas: e.target.value })}
|
||||
rows={3}
|
||||
className={clsx(
|
||||
'w-full px-4 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white placeholder:text-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
placeholder="Notas adicionales..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button type="button" variant="ghost" onClick={handleCloseModal}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{editingMant ? 'Guardar cambios' : 'Programar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
255
frontend/src/pages/Mapa.tsx
Normal file
255
frontend/src/pages/Mapa.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
FunnelIcon,
|
||||
ListBulletIcon,
|
||||
XMarkIcon,
|
||||
MagnifyingGlassIcon,
|
||||
MapPinIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { useVehiculosStore } from '@/store/vehiculosStore'
|
||||
import { useMapaStore } from '@/store/mapaStore'
|
||||
import { MapContainer } from '@/components/mapa'
|
||||
import { VehiculoCard } from '@/components/vehiculos'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
|
||||
export default function Mapa() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const vehiculos = useVehiculosStore((state) => state.getVehiculosArray())
|
||||
const estadisticas = useVehiculosStore((state) => state.getEstadisticas())
|
||||
const { vehiculoSeleccionado, setVehiculoSeleccionado, centrarEnVehiculo } = useMapaStore()
|
||||
|
||||
// Handle URL params
|
||||
useEffect(() => {
|
||||
const vehiculoId = searchParams.get('vehiculo')
|
||||
if (vehiculoId) {
|
||||
setVehiculoSeleccionado(vehiculoId)
|
||||
const vehiculo = vehiculos.find((v) => v.id === vehiculoId)
|
||||
if (vehiculo?.ubicacion) {
|
||||
centrarEnVehiculo(vehiculo.ubicacion.lat, vehiculo.ubicacion.lng)
|
||||
}
|
||||
}
|
||||
}, [searchParams, vehiculos])
|
||||
|
||||
// Filter vehiculos
|
||||
const filteredVehiculos = vehiculos.filter((v) => {
|
||||
if (!search) return true
|
||||
const searchLower = search.toLowerCase()
|
||||
return (
|
||||
v.nombre.toLowerCase().includes(searchLower) ||
|
||||
v.placa.toLowerCase().includes(searchLower)
|
||||
)
|
||||
})
|
||||
|
||||
const handleVehiculoSelect = (id: string) => {
|
||||
setVehiculoSeleccionado(id)
|
||||
const vehiculo = vehiculos.find((v) => v.id === id)
|
||||
if (vehiculo?.ubicacion) {
|
||||
centrarEnVehiculo(vehiculo.ubicacion.lat, vehiculo.ubicacion.lng)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] -m-6 flex">
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={clsx(
|
||||
'relative bg-background-900 border-r border-slate-800 transition-all duration-300',
|
||||
sidebarOpen ? 'w-80' : 'w-0'
|
||||
)}
|
||||
>
|
||||
{sidebarOpen && (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-slate-800">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white">Vehiculos</h2>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="p-1 text-slate-400 hover:text-white rounded"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar vehiculo..."
|
||||
className={clsx(
|
||||
'w-full pl-9 pr-4 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-sm text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-3 mt-3 text-xs">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-success-500" />
|
||||
<span className="text-slate-400">{estadisticas.enMovimiento}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-warning-500" />
|
||||
<span className="text-slate-400">{estadisticas.detenidos}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-slate-500" />
|
||||
<span className="text-slate-400">{estadisticas.sinSenal}</span>
|
||||
</span>
|
||||
<span className="text-slate-600 ml-auto">{estadisticas.total} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vehicle list */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{filteredVehiculos.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<MapPinIcon className="w-10 h-10 text-slate-600 mx-auto mb-2" />
|
||||
<p className="text-sm text-slate-500">No se encontraron vehiculos</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredVehiculos.map((vehiculo) => (
|
||||
<VehiculoCard
|
||||
key={vehiculo.id}
|
||||
vehiculo={vehiculo}
|
||||
compact
|
||||
isSelected={vehiculoSeleccionado === vehiculo.id}
|
||||
onClick={() => handleVehiculoSelect(vehiculo.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toggle button */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className={clsx(
|
||||
'absolute top-1/2 -translate-y-1/2 -right-4 z-10',
|
||||
'w-8 h-16 bg-card border border-slate-700 rounded-r-lg',
|
||||
'flex items-center justify-center',
|
||||
'text-slate-400 hover:text-white transition-colors'
|
||||
)}
|
||||
>
|
||||
{sidebarOpen ? (
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<ChevronRightIcon className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<div className="flex-1 relative">
|
||||
<MapContainer
|
||||
selectedVehiculoId={vehiculoSeleccionado}
|
||||
onVehiculoSelect={handleVehiculoSelect}
|
||||
showControls
|
||||
/>
|
||||
|
||||
{/* Selected vehicle info */}
|
||||
{vehiculoSeleccionado && (
|
||||
<SelectedVehiculoPanel
|
||||
vehiculoId={vehiculoSeleccionado}
|
||||
onClose={() => setVehiculoSeleccionado(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Selected vehicle panel
|
||||
function SelectedVehiculoPanel({
|
||||
vehiculoId,
|
||||
onClose,
|
||||
}: {
|
||||
vehiculoId: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const vehiculo = useVehiculosStore((state) => state.getVehiculoById(vehiculoId))
|
||||
|
||||
if (!vehiculo) return null
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 left-4 w-80 bg-card border border-slate-700 rounded-xl shadow-xl">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{vehiculo.nombre}</h3>
|
||||
<p className="text-sm text-slate-500">{vehiculo.placa}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-slate-400 hover:text-white rounded"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<div className="p-2 bg-slate-800/50 rounded-lg">
|
||||
<p className="text-xs text-slate-500">Velocidad</p>
|
||||
<p className="text-lg font-bold text-white">{vehiculo.velocidad || 0} km/h</p>
|
||||
</div>
|
||||
<div className="p-2 bg-slate-800/50 rounded-lg">
|
||||
<p className="text-xs text-slate-500">Estado</p>
|
||||
<Badge
|
||||
variant={
|
||||
vehiculo.movimiento === 'movimiento'
|
||||
? 'success'
|
||||
: vehiculo.movimiento === 'detenido'
|
||||
? 'warning'
|
||||
: 'default'
|
||||
}
|
||||
dot
|
||||
>
|
||||
{vehiculo.movimiento === 'movimiento'
|
||||
? 'En movimiento'
|
||||
: vehiculo.movimiento === 'detenido'
|
||||
? 'Detenido'
|
||||
: 'Sin senal'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vehiculo.conductor && (
|
||||
<div className="text-sm text-slate-400 mb-3">
|
||||
Conductor: {vehiculo.conductor.nombre} {vehiculo.conductor.apellido}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={`/vehiculos/${vehiculo.id}`}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-center text-white bg-accent-500 hover:bg-accent-600 rounded-lg transition-colors"
|
||||
>
|
||||
Ver detalles
|
||||
</a>
|
||||
<a
|
||||
href={`/viajes?vehiculo=${vehiculo.id}`}
|
||||
className="px-3 py-2 text-sm font-medium text-slate-300 hover:text-white bg-slate-700 hover:bg-slate-600 rounded-lg transition-colors"
|
||||
>
|
||||
Viajes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
frontend/src/pages/NotFound.tsx
Normal file
97
frontend/src/pages/NotFound.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { HomeIcon, ArrowLeftIcon } from '@heroicons/react/24/outline'
|
||||
import Button from '@/components/ui/Button'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
|
||||
<div className="text-center">
|
||||
{/* 404 Graphic */}
|
||||
<div className="relative">
|
||||
<h1 className="text-[200px] font-bold text-slate-800 select-none leading-none">
|
||||
404
|
||||
</h1>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-24 h-24 bg-accent-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="w-12 h-12 text-accent-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="mt-8 space-y-4">
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
Pagina no encontrada
|
||||
</h2>
|
||||
<p className="text-slate-400 max-w-md mx-auto">
|
||||
Lo sentimos, la pagina que buscas no existe o ha sido movida.
|
||||
Verifica la URL o regresa al inicio.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-8 flex items-center justify-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
leftIcon={<ArrowLeftIcon className="w-5 h-5" />}
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
Volver atras
|
||||
</Button>
|
||||
<Link to="/">
|
||||
<Button leftIcon={<HomeIcon className="w-5 h-5" />}>
|
||||
Ir al inicio
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="mt-12 pt-8 border-t border-slate-800">
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
O visita alguna de estas secciones:
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<Link
|
||||
to="/mapa"
|
||||
className="text-accent-400 hover:text-accent-300 text-sm"
|
||||
>
|
||||
Mapa
|
||||
</Link>
|
||||
<Link
|
||||
to="/vehiculos"
|
||||
className="text-accent-400 hover:text-accent-300 text-sm"
|
||||
>
|
||||
Vehiculos
|
||||
</Link>
|
||||
<Link
|
||||
to="/alertas"
|
||||
className="text-accent-400 hover:text-accent-300 text-sm"
|
||||
>
|
||||
Alertas
|
||||
</Link>
|
||||
<Link
|
||||
to="/viajes"
|
||||
className="text-accent-400 hover:text-accent-300 text-sm"
|
||||
>
|
||||
Viajes
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
447
frontend/src/pages/POIs.tsx
Normal file
447
frontend/src/pages/POIs.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
PlusIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
MapPinIcon,
|
||||
BuildingOfficeIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
ArchiveBoxIcon,
|
||||
TruckIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { poisApi } from '@/api'
|
||||
import { MapContainer } from '@/components/mapa'
|
||||
import POILayer from '@/components/mapa/POILayer'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Select from '@/components/ui/Select'
|
||||
import Table from '@/components/ui/Table'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import { POI } from '@/types'
|
||||
|
||||
interface POIForm {
|
||||
nombre: string
|
||||
tipo: POI['tipo']
|
||||
direccion: string
|
||||
lat: number | ''
|
||||
lng: number | ''
|
||||
telefono?: string
|
||||
notas?: string
|
||||
}
|
||||
|
||||
const defaultForm: POIForm = {
|
||||
nombre: '',
|
||||
tipo: 'cliente',
|
||||
direccion: '',
|
||||
lat: '',
|
||||
lng: '',
|
||||
}
|
||||
|
||||
const tipoIcons: Record<POI['tipo'], React.ReactNode> = {
|
||||
cliente: <BuildingOfficeIcon className="w-5 h-5" />,
|
||||
taller: <WrenchScrewdriverIcon className="w-5 h-5" />,
|
||||
gasolinera: <TruckIcon className="w-5 h-5" />,
|
||||
almacen: <ArchiveBoxIcon className="w-5 h-5" />,
|
||||
otro: <MapPinIcon className="w-5 h-5" />,
|
||||
}
|
||||
|
||||
const tipoColors: Record<POI['tipo'], string> = {
|
||||
cliente: 'text-accent-400',
|
||||
taller: 'text-warning-400',
|
||||
gasolinera: 'text-success-400',
|
||||
almacen: 'text-purple-400',
|
||||
otro: 'text-slate-400',
|
||||
}
|
||||
|
||||
export default function POIs() {
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [form, setForm] = useState<POIForm>(defaultForm)
|
||||
const [editingPOI, setEditingPOI] = useState<POI | null>(null)
|
||||
const [viewMode, setViewMode] = useState<'map' | 'table'>('map')
|
||||
const [filterTipo, setFilterTipo] = useState<string>('')
|
||||
|
||||
const { data: pois, isLoading } = useQuery({
|
||||
queryKey: ['pois'],
|
||||
queryFn: () => poisApi.list(),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: poisApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pois'] })
|
||||
toast.success('Punto de interes creado')
|
||||
handleCloseModal()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al crear punto de interes')
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<POI> }) =>
|
||||
poisApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pois'] })
|
||||
toast.success('Punto de interes actualizado')
|
||||
handleCloseModal()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al actualizar')
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: poisApi.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pois'] })
|
||||
toast.success('Punto de interes eliminado')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al eliminar')
|
||||
},
|
||||
})
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false)
|
||||
setForm(defaultForm)
|
||||
setEditingPOI(null)
|
||||
}
|
||||
|
||||
const handleNewPOI = () => {
|
||||
setForm(defaultForm)
|
||||
setEditingPOI(null)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleEditPOI = (poi: POI) => {
|
||||
setForm({
|
||||
nombre: poi.nombre,
|
||||
tipo: poi.tipo,
|
||||
direccion: poi.direccion,
|
||||
lat: poi.lat,
|
||||
lng: poi.lng,
|
||||
telefono: poi.telefono,
|
||||
notas: poi.notas,
|
||||
})
|
||||
setEditingPOI(poi)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleDeletePOI = (id: string) => {
|
||||
if (confirm('¿Estas seguro de eliminar este punto de interes?')) {
|
||||
deleteMutation.mutate(id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMapClick = (lat: number, lng: number) => {
|
||||
if (isModalOpen) {
|
||||
setForm({ ...form, lat, lng })
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!form.nombre || !form.direccion) {
|
||||
toast.error('Completa los campos requeridos')
|
||||
return
|
||||
}
|
||||
|
||||
if (form.lat === '' || form.lng === '') {
|
||||
toast.error('Selecciona una ubicacion en el mapa')
|
||||
return
|
||||
}
|
||||
|
||||
const poiData = {
|
||||
nombre: form.nombre,
|
||||
tipo: form.tipo,
|
||||
direccion: form.direccion,
|
||||
lat: form.lat as number,
|
||||
lng: form.lng as number,
|
||||
telefono: form.telefono,
|
||||
notas: form.notas,
|
||||
}
|
||||
|
||||
if (editingPOI) {
|
||||
updateMutation.mutate({ id: editingPOI.id, data: poiData })
|
||||
} else {
|
||||
createMutation.mutate(poiData as Omit<POI, 'id' | 'createdAt' | 'updatedAt'>)
|
||||
}
|
||||
}
|
||||
|
||||
const poisList = pois || []
|
||||
const filteredPOIs = filterTipo
|
||||
? poisList.filter((p) => p.tipo === filterTipo)
|
||||
: poisList
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'nombre',
|
||||
header: 'Nombre',
|
||||
render: (poi: POI) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={tipoColors[poi.tipo]}>{tipoIcons[poi.tipo]}</span>
|
||||
<div>
|
||||
<p className="text-white font-medium">{poi.nombre}</p>
|
||||
<p className="text-xs text-slate-500 capitalize">{poi.tipo}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'direccion',
|
||||
header: 'Direccion',
|
||||
render: (poi: POI) => <span className="text-slate-300">{poi.direccion}</span>,
|
||||
},
|
||||
{
|
||||
key: 'telefono',
|
||||
header: 'Telefono',
|
||||
render: (poi: POI) => (
|
||||
<span className="text-slate-300">{poi.telefono || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'coordenadas',
|
||||
header: 'Coordenadas',
|
||||
render: (poi: POI) => (
|
||||
<span className="text-slate-400 text-xs font-mono">
|
||||
{poi.lat.toFixed(5)}, {poi.lng.toFixed(5)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'acciones',
|
||||
header: '',
|
||||
sortable: false,
|
||||
render: (poi: POI) => (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<PencilIcon className="w-4 h-4" />}
|
||||
onClick={() => handleEditPOI(poi)}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<TrashIcon className="w-4 h-4" />}
|
||||
onClick={() => handleDeletePOI(poi.id)}
|
||||
>
|
||||
Eliminar
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// Stats by type
|
||||
const stats = {
|
||||
cliente: poisList.filter((p) => p.tipo === 'cliente').length,
|
||||
taller: poisList.filter((p) => p.tipo === 'taller').length,
|
||||
gasolinera: poisList.filter((p) => p.tipo === 'gasolinera').length,
|
||||
almacen: poisList.filter((p) => p.tipo === 'almacen').length,
|
||||
otro: poisList.filter((p) => p.tipo === 'otro').length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Puntos de Interes</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Gestiona ubicaciones importantes para tu flota
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex rounded-lg overflow-hidden border border-slate-700">
|
||||
<button
|
||||
onClick={() => setViewMode('map')}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 text-sm',
|
||||
viewMode === 'map'
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'bg-slate-800 text-slate-400 hover:text-white'
|
||||
)}
|
||||
>
|
||||
Mapa
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('table')}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 text-sm',
|
||||
viewMode === 'table'
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'bg-slate-800 text-slate-400 hover:text-white'
|
||||
)}
|
||||
>
|
||||
Tabla
|
||||
</button>
|
||||
</div>
|
||||
<Button leftIcon={<PlusIcon className="w-5 h-5" />} onClick={handleNewPOI}>
|
||||
Nuevo POI
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
||||
{Object.entries(stats).map(([tipo, count]) => (
|
||||
<Card
|
||||
key={tipo}
|
||||
padding="md"
|
||||
className={clsx(
|
||||
'cursor-pointer transition-all',
|
||||
filterTipo === tipo && 'ring-2 ring-accent-500'
|
||||
)}
|
||||
onClick={() => setFilterTipo(filterTipo === tipo ? '' : tipo)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={tipoColors[tipo as POI['tipo']]}>
|
||||
{tipoIcons[tipo as POI['tipo']]}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{count}</p>
|
||||
<p className="text-xs text-slate-500 capitalize">{tipo}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{viewMode === 'map' ? (
|
||||
<div className="h-[500px] rounded-xl overflow-hidden">
|
||||
<MapContainer showControls>
|
||||
<POILayer pois={filteredPOIs} onPOIClick={handleEditPOI} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
) : (
|
||||
<Card padding="none">
|
||||
<Table
|
||||
data={filteredPOIs}
|
||||
columns={columns}
|
||||
keyExtractor={(p) => p.id}
|
||||
isLoading={isLoading}
|
||||
pagination
|
||||
pageSize={15}
|
||||
emptyMessage="No hay puntos de interes"
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
title={editingPOI ? 'Editar punto de interes' : 'Nuevo punto de interes'}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Nombre *"
|
||||
value={form.nombre}
|
||||
onChange={(e) => setForm({ ...form, nombre: e.target.value })}
|
||||
placeholder="Ej: Cliente ABC"
|
||||
/>
|
||||
<Select
|
||||
label="Tipo"
|
||||
value={form.tipo}
|
||||
onChange={(e) => setForm({ ...form, tipo: e.target.value as POI['tipo'] })}
|
||||
options={[
|
||||
{ value: 'cliente', label: 'Cliente' },
|
||||
{ value: 'taller', label: 'Taller' },
|
||||
{ value: 'gasolinera', label: 'Gasolinera' },
|
||||
{ value: 'almacen', label: 'Almacen' },
|
||||
{ value: 'otro', label: 'Otro' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Direccion *"
|
||||
value={form.direccion}
|
||||
onChange={(e) => setForm({ ...form, direccion: e.target.value })}
|
||||
placeholder="Ej: Av. Principal 123, Ciudad"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Telefono"
|
||||
value={form.telefono || ''}
|
||||
onChange={(e) => setForm({ ...form, telefono: e.target.value })}
|
||||
placeholder="Ej: +52 555 123 4567"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-400 mb-2">
|
||||
Notas
|
||||
</label>
|
||||
<textarea
|
||||
value={form.notas || ''}
|
||||
onChange={(e) => setForm({ ...form, notas: e.target.value })}
|
||||
rows={3}
|
||||
className={clsx(
|
||||
'w-full px-4 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white placeholder:text-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
placeholder="Notas adicionales..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-400 mb-2">
|
||||
Ubicacion
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4 mb-2">
|
||||
<Input
|
||||
label="Latitud"
|
||||
type="number"
|
||||
step="any"
|
||||
value={form.lat}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, lat: e.target.value ? parseFloat(e.target.value) : '' })
|
||||
}
|
||||
placeholder="19.4326"
|
||||
/>
|
||||
<Input
|
||||
label="Longitud"
|
||||
type="number"
|
||||
step="any"
|
||||
value={form.lng}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, lng: e.target.value ? parseFloat(e.target.value) : '' })
|
||||
}
|
||||
placeholder="-99.1332"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
Ingresa las coordenadas manualmente o usa el geocodificador
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button type="button" variant="ghost" onClick={handleCloseModal}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{editingPOI ? 'Guardar cambios' : 'Crear POI'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
383
frontend/src/pages/Reportes.tsx
Normal file
383
frontend/src/pages/Reportes.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import {
|
||||
DocumentChartBarIcon,
|
||||
ArrowDownTrayIcon,
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationCircleIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { format } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import { reportesApi, vehiculosApi } from '@/api'
|
||||
import Card, { CardHeader } from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Select from '@/components/ui/Select'
|
||||
import Table from '@/components/ui/Table'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import { Reporte } from '@/types'
|
||||
|
||||
const tiposReporte = [
|
||||
{
|
||||
id: 'actividad',
|
||||
nombre: 'Reporte de Actividad',
|
||||
descripcion: 'Resumen de actividad de vehiculos incluyendo viajes, distancia y tiempo de operacion',
|
||||
icono: DocumentChartBarIcon,
|
||||
},
|
||||
{
|
||||
id: 'combustible',
|
||||
nombre: 'Reporte de Combustible',
|
||||
descripcion: 'Analisis de consumo de combustible, costos y rendimiento por vehiculo',
|
||||
icono: DocumentChartBarIcon,
|
||||
},
|
||||
{
|
||||
id: 'alertas',
|
||||
nombre: 'Reporte de Alertas',
|
||||
descripcion: 'Historial de alertas generadas, tiempos de respuesta y resolucion',
|
||||
icono: DocumentChartBarIcon,
|
||||
},
|
||||
{
|
||||
id: 'velocidad',
|
||||
nombre: 'Reporte de Velocidad',
|
||||
descripcion: 'Analisis de velocidades, excesos y patrones de conduccion',
|
||||
icono: DocumentChartBarIcon,
|
||||
},
|
||||
{
|
||||
id: 'geocercas',
|
||||
nombre: 'Reporte de Geocercas',
|
||||
descripcion: 'Entradas, salidas y tiempo de permanencia en geocercas',
|
||||
icono: DocumentChartBarIcon,
|
||||
},
|
||||
{
|
||||
id: 'mantenimiento',
|
||||
nombre: 'Reporte de Mantenimiento',
|
||||
descripcion: 'Historial de servicios, costos y programacion de mantenimiento',
|
||||
icono: DocumentChartBarIcon,
|
||||
},
|
||||
]
|
||||
|
||||
export default function Reportes() {
|
||||
const toast = useToast()
|
||||
const [selectedTipo, setSelectedTipo] = useState<string | null>(null)
|
||||
const [params, setParams] = useState({
|
||||
vehiculoId: '',
|
||||
desde: format(new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), 'yyyy-MM-dd'),
|
||||
hasta: format(new Date(), 'yyyy-MM-dd'),
|
||||
formato: 'pdf' as 'pdf' | 'excel' | 'csv',
|
||||
})
|
||||
|
||||
const { data: vehiculos } = useQuery({
|
||||
queryKey: ['vehiculos'],
|
||||
queryFn: () => vehiculosApi.list(),
|
||||
})
|
||||
|
||||
const { data: historial, isLoading: loadingHistorial } = useQuery({
|
||||
queryKey: ['reportes-historial'],
|
||||
queryFn: () => reportesApi.listHistorial({ pageSize: 20 }),
|
||||
})
|
||||
|
||||
const generarMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
reportesApi.generar({
|
||||
tipo: selectedTipo!,
|
||||
vehiculoId: params.vehiculoId || undefined,
|
||||
desde: params.desde,
|
||||
hasta: params.hasta,
|
||||
formato: params.formato,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
toast.success('Reporte generado exitosamente')
|
||||
// Download file
|
||||
if (data.url) {
|
||||
window.open(data.url, '_blank')
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al generar reporte')
|
||||
},
|
||||
})
|
||||
|
||||
const handleGenerarReporte = () => {
|
||||
if (!selectedTipo) {
|
||||
toast.error('Selecciona un tipo de reporte')
|
||||
return
|
||||
}
|
||||
generarMutation.mutate()
|
||||
}
|
||||
|
||||
const historialReportes = historial?.items || []
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'tipo',
|
||||
header: 'Tipo',
|
||||
render: (r: Reporte) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<DocumentChartBarIcon className="w-5 h-5 text-accent-400" />
|
||||
<span className="text-white capitalize">{r.tipo.replace('_', ' ')}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'periodo',
|
||||
header: 'Periodo',
|
||||
render: (r: Reporte) => (
|
||||
<span className="text-slate-300">
|
||||
{format(new Date(r.desde), 'd MMM', { locale: es })} -{' '}
|
||||
{format(new Date(r.hasta), 'd MMM yyyy', { locale: es })}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'vehiculo',
|
||||
header: 'Vehiculo',
|
||||
render: (r: Reporte) => (
|
||||
<span className="text-slate-300">
|
||||
{r.vehiculo?.nombre || 'Todos los vehiculos'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'generado',
|
||||
header: 'Generado',
|
||||
render: (r: Reporte) => (
|
||||
<div>
|
||||
<p className="text-slate-300">
|
||||
{format(new Date(r.createdAt), 'd MMM yyyy', { locale: es })}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{format(new Date(r.createdAt), 'HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'estado',
|
||||
header: 'Estado',
|
||||
render: (r: Reporte) => {
|
||||
switch (r.estado) {
|
||||
case 'completado':
|
||||
return <Badge variant="success">Completado</Badge>
|
||||
case 'procesando':
|
||||
return <Badge variant="warning">Procesando</Badge>
|
||||
case 'error':
|
||||
return <Badge variant="error">Error</Badge>
|
||||
default:
|
||||
return <Badge variant="default">{r.estado}</Badge>
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'formato',
|
||||
header: 'Formato',
|
||||
render: (r: Reporte) => (
|
||||
<span className="text-slate-400 uppercase text-sm">{r.formato}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'acciones',
|
||||
header: '',
|
||||
sortable: false,
|
||||
render: (r: Reporte) => (
|
||||
<div className="flex justify-end">
|
||||
{r.estado === 'completado' && r.url && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<ArrowDownTrayIcon className="w-4 h-4" />}
|
||||
onClick={() => window.open(r.url, '_blank')}
|
||||
>
|
||||
Descargar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Generador de Reportes</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Crea reportes personalizados de tu flota
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Report types */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-white">Tipos de reporte</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{tiposReporte.map((tipo) => (
|
||||
<Card
|
||||
key={tipo.id}
|
||||
padding="md"
|
||||
className={clsx(
|
||||
'cursor-pointer transition-all',
|
||||
selectedTipo === tipo.id
|
||||
? 'ring-2 ring-accent-500 bg-accent-500/10'
|
||||
: 'hover:bg-slate-800/50'
|
||||
)}
|
||||
onClick={() => setSelectedTipo(tipo.id)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={clsx(
|
||||
'p-2 rounded-lg',
|
||||
selectedTipo === tipo.id
|
||||
? 'bg-accent-500/20'
|
||||
: 'bg-slate-800'
|
||||
)}
|
||||
>
|
||||
<tipo.icono
|
||||
className={clsx(
|
||||
'w-6 h-6',
|
||||
selectedTipo === tipo.id
|
||||
? 'text-accent-400'
|
||||
: 'text-slate-400'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-medium">{tipo.nombre}</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">{tipo.descripcion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Report configuration */}
|
||||
<div>
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Configuracion" />
|
||||
<div className="space-y-4">
|
||||
<Select
|
||||
label="Vehiculo"
|
||||
value={params.vehiculoId}
|
||||
onChange={(e) => setParams({ ...params, vehiculoId: e.target.value })}
|
||||
options={[
|
||||
{ value: '', label: 'Todos los vehiculos' },
|
||||
...(vehiculos?.items?.map((v) => ({
|
||||
value: v.id,
|
||||
label: v.nombre || v.placa,
|
||||
})) || []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-slate-400">
|
||||
Periodo
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={params.desde}
|
||||
onChange={(e) => setParams({ ...params, desde: e.target.value })}
|
||||
className={clsx(
|
||||
'flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
/>
|
||||
<span className="text-slate-500">-</span>
|
||||
<input
|
||||
type="date"
|
||||
value={params.hasta}
|
||||
onChange={(e) => setParams({ ...params, hasta: e.target.value })}
|
||||
className={clsx(
|
||||
'flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Formato"
|
||||
value={params.formato}
|
||||
onChange={(e) =>
|
||||
setParams({ ...params, formato: e.target.value as 'pdf' | 'excel' | 'csv' })
|
||||
}
|
||||
options={[
|
||||
{ value: 'pdf', label: 'PDF' },
|
||||
{ value: 'excel', label: 'Excel' },
|
||||
{ value: 'csv', label: 'CSV' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
leftIcon={<DocumentChartBarIcon className="w-5 h-5" />}
|
||||
onClick={handleGenerarReporte}
|
||||
isLoading={generarMutation.isPending}
|
||||
disabled={!selectedTipo}
|
||||
>
|
||||
Generar reporte
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick stats */}
|
||||
<Card padding="md" className="mt-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClockIcon className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-sm text-slate-400">Ultimo generado</span>
|
||||
</div>
|
||||
<span className="text-white text-sm">
|
||||
{historialReportes[0]
|
||||
? format(new Date(historialReportes[0].createdAt), 'd MMM HH:mm', {
|
||||
locale: es,
|
||||
})
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleIcon className="w-4 h-4 text-success-400" />
|
||||
<span className="text-sm text-slate-400">Exitosos</span>
|
||||
</div>
|
||||
<span className="text-success-400 text-sm">
|
||||
{historialReportes.filter((r) => r.estado === 'completado').length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ExclamationCircleIcon className="w-4 h-4 text-error-400" />
|
||||
<span className="text-sm text-slate-400">Con errores</span>
|
||||
</div>
|
||||
<span className="text-error-400 text-sm">
|
||||
{historialReportes.filter((r) => r.estado === 'error').length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History */}
|
||||
<Card padding="none">
|
||||
<div className="p-4 border-b border-slate-700/50">
|
||||
<h3 className="font-medium text-white">Historial de reportes</h3>
|
||||
</div>
|
||||
<Table
|
||||
data={historialReportes}
|
||||
columns={columns}
|
||||
keyExtractor={(r) => r.id}
|
||||
isLoading={loadingHistorial}
|
||||
pagination
|
||||
pageSize={10}
|
||||
emptyMessage="No hay reportes generados"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
274
frontend/src/pages/VehiculoDetalle.tsx
Normal file
274
frontend/src/pages/VehiculoDetalle.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
MapPinIcon,
|
||||
UserIcon,
|
||||
CalendarIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
BellAlertIcon,
|
||||
VideoCameraIcon,
|
||||
ArrowPathIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { useVehiculo, useVehiculoStats } from '@/hooks/useVehiculos'
|
||||
import Tabs, { TabPanel } from '@/components/ui/Tabs'
|
||||
import Card, { CardHeader } from '@/components/ui/Card'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { KPICard } from '@/components/charts/KPICard'
|
||||
import { FuelGauge } from '@/components/charts/FuelGauge'
|
||||
import LineChart from '@/components/charts/LineChart'
|
||||
import { SkeletonCard, SkeletonStats } from '@/components/ui/Skeleton'
|
||||
|
||||
export default function VehiculoDetalle() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { data: vehiculo, isLoading } = useVehiculo(id!)
|
||||
const { data: stats } = useVehiculoStats(id!, 'semana')
|
||||
|
||||
if (isLoading || !vehiculo) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 bg-slate-800 rounded w-48 animate-shimmer" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<SkeletonStats />
|
||||
<SkeletonStats />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'resumen', label: 'Resumen', icon: <MapPinIcon className="w-4 h-4" /> },
|
||||
{ id: 'viajes', label: 'Viajes', icon: <ArrowPathIcon className="w-4 h-4" /> },
|
||||
{ id: 'alertas', label: 'Alertas', icon: <BellAlertIcon className="w-4 h-4" /> },
|
||||
{ id: 'video', label: 'Video', icon: <VideoCameraIcon className="w-4 h-4" /> },
|
||||
{ id: 'mantenimiento', label: 'Mantenimiento', icon: <WrenchScrewdriverIcon className="w-4 h-4" /> },
|
||||
]
|
||||
|
||||
// Mock activity data
|
||||
const activityData = [
|
||||
{ dia: 'Lun', km: 120, viajes: 5 },
|
||||
{ dia: 'Mar', km: 85, viajes: 3 },
|
||||
{ dia: 'Mie', km: 150, viajes: 6 },
|
||||
{ dia: 'Jue', km: 95, viajes: 4 },
|
||||
{ dia: 'Vie', km: 180, viajes: 7 },
|
||||
{ dia: 'Sab', km: 60, viajes: 2 },
|
||||
{ dia: 'Dom', km: 30, viajes: 1 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back button and header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/vehiculos"
|
||||
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5" />
|
||||
</Link>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-white">{vehiculo.nombre}</h1>
|
||||
<Badge
|
||||
variant={
|
||||
vehiculo.movimiento === 'movimiento'
|
||||
? 'success'
|
||||
: vehiculo.movimiento === 'detenido'
|
||||
? 'warning'
|
||||
: 'default'
|
||||
}
|
||||
dot
|
||||
pulse={vehiculo.movimiento === 'movimiento'}
|
||||
>
|
||||
{vehiculo.movimiento === 'movimiento'
|
||||
? 'En movimiento'
|
||||
: vehiculo.movimiento === 'detenido'
|
||||
? 'Detenido'
|
||||
: 'Sin senal'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-slate-500">
|
||||
{vehiculo.placa} | {vehiculo.marca} {vehiculo.modelo} {vehiculo.anio}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to={`/mapa?vehiculo=${vehiculo.id}`}>
|
||||
<Button variant="outline" leftIcon={<MapPinIcon className="w-4 h-4" />}>
|
||||
Ver en mapa
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="primary">Editar</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs tabs={tabs} defaultTab="resumen">
|
||||
<TabPanel id="resumen">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left column */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Quick stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<KPICard
|
||||
title="Velocidad"
|
||||
value={`${vehiculo.velocidad || 0} km/h`}
|
||||
color="blue"
|
||||
/>
|
||||
<KPICard
|
||||
title="Odometro"
|
||||
value={`${vehiculo.odometro?.toLocaleString() || 0} km`}
|
||||
/>
|
||||
<KPICard
|
||||
title="Horas motor"
|
||||
value={`${vehiculo.horasMotor || 0} h`}
|
||||
/>
|
||||
<KPICard
|
||||
title="Viajes hoy"
|
||||
value={vehiculo.viajesHoy || 0}
|
||||
color="green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Activity chart */}
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Actividad semanal" subtitle="Kilometros y viajes" />
|
||||
<LineChart
|
||||
data={activityData}
|
||||
xAxisKey="dia"
|
||||
lines={[
|
||||
{ dataKey: 'km', name: 'Kilometros', color: '#3b82f6' },
|
||||
{ dataKey: 'viajes', name: 'Viajes (x20)', color: '#22c55e' },
|
||||
]}
|
||||
height={250}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Location */}
|
||||
{vehiculo.ubicacion && (
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Ubicacion actual" />
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-accent-500/20 flex items-center justify-center">
|
||||
<MapPinIcon className="w-5 h-5 text-accent-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white">
|
||||
{vehiculo.ubicacion.lat.toFixed(6)}, {vehiculo.ubicacion.lng.toFixed(6)}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
Ultima actualizacion: {new Date(vehiculo.ubicacion.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
<div className="space-y-6">
|
||||
{/* Fuel gauge */}
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Combustible" />
|
||||
<FuelGauge
|
||||
value={vehiculo.combustibleNivel || 65}
|
||||
label="Nivel de tanque"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Conductor */}
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Conductor asignado" />
|
||||
{vehiculo.conductor ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-slate-700 flex items-center justify-center">
|
||||
<UserIcon className="w-6 h-6 text-slate-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-white">
|
||||
{vehiculo.conductor.nombre} {vehiculo.conductor.apellido}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">{vehiculo.conductor.telefono}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500">Sin conductor asignado</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Vehicle info */}
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Informacion" />
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Tipo</span>
|
||||
<span className="text-white capitalize">{vehiculo.tipo}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Marca</span>
|
||||
<span className="text-white">{vehiculo.marca}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Modelo</span>
|
||||
<span className="text-white">{vehiculo.modelo}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Anio</span>
|
||||
<span className="text-white">{vehiculo.anio}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Color</span>
|
||||
<span className="text-white capitalize">{vehiculo.color}</span>
|
||||
</div>
|
||||
{vehiculo.vin && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">VIN</span>
|
||||
<span className="text-white font-mono text-xs">{vehiculo.vin}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="viajes">
|
||||
<Card padding="lg">
|
||||
<p className="text-slate-400 text-center py-8">
|
||||
Historial de viajes del vehiculo (por implementar)
|
||||
</p>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="alertas">
|
||||
<Card padding="lg">
|
||||
<p className="text-slate-400 text-center py-8">
|
||||
Alertas del vehiculo (por implementar)
|
||||
</p>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="video">
|
||||
<Card padding="lg">
|
||||
<p className="text-slate-400 text-center py-8">
|
||||
Camaras y grabaciones del vehiculo (por implementar)
|
||||
</p>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="mantenimiento">
|
||||
<Card padding="lg">
|
||||
<p className="text-slate-400 text-center py-8">
|
||||
Historial de mantenimiento (por implementar)
|
||||
</p>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
frontend/src/pages/Vehiculos.tsx
Normal file
53
frontend/src/pages/Vehiculos.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { PlusIcon } from '@heroicons/react/24/outline'
|
||||
import { useVehiculosStore } from '@/store/vehiculosStore'
|
||||
import { VehiculoList } from '@/components/vehiculos'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
|
||||
export default function Vehiculos() {
|
||||
const vehiculos = useVehiculosStore((state) => state.getVehiculosArray())
|
||||
const isLoading = useVehiculosStore((state) => state.isLoading)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Vehiculos</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Gestiona tu flota de vehiculos
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<PlusIcon className="w-5 h-5" />}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
Agregar vehiculo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Vehicle list */}
|
||||
<VehiculoList
|
||||
vehiculos={vehiculos}
|
||||
isLoading={isLoading}
|
||||
showFilters
|
||||
/>
|
||||
|
||||
{/* Create modal */}
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
title="Agregar vehiculo"
|
||||
size="lg"
|
||||
>
|
||||
<p className="text-slate-400">
|
||||
Formulario de creacion de vehiculo (por implementar)
|
||||
</p>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
255
frontend/src/pages/ViajeReplay.tsx
Normal file
255
frontend/src/pages/ViajeReplay.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
PlayIcon,
|
||||
PauseIcon,
|
||||
ForwardIcon,
|
||||
BackwardIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import clsx from 'clsx'
|
||||
import { viajesApi } from '@/api'
|
||||
import { MapContainer } from '@/components/mapa'
|
||||
import RutaLayer from '@/components/mapa/RutaLayer'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { SkeletonMap } from '@/components/ui/Skeleton'
|
||||
|
||||
export default function ViajeReplay() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(1)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const { data: replayData, isLoading } = useQuery({
|
||||
queryKey: ['viaje-replay', id],
|
||||
queryFn: () => viajesApi.getReplayData(id!),
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
const puntos = replayData?.puntos || []
|
||||
const viaje = replayData?.viaje
|
||||
|
||||
// Playback control
|
||||
useEffect(() => {
|
||||
if (isPlaying && puntos.length > 0) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
setCurrentIndex((prev) => {
|
||||
if (prev >= puntos.length - 1) {
|
||||
setIsPlaying(false)
|
||||
return prev
|
||||
}
|
||||
return prev + 1
|
||||
})
|
||||
}, 1000 / playbackSpeed)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [isPlaying, playbackSpeed, puntos.length])
|
||||
|
||||
const handlePlayPause = () => {
|
||||
setIsPlaying(!isPlaying)
|
||||
}
|
||||
|
||||
const handleSpeedChange = () => {
|
||||
const speeds = [1, 2, 4, 8]
|
||||
const currentSpeedIndex = speeds.indexOf(playbackSpeed)
|
||||
const nextIndex = (currentSpeedIndex + 1) % speeds.length
|
||||
setPlaybackSpeed(speeds[nextIndex])
|
||||
}
|
||||
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCurrentIndex(parseInt(e.target.value))
|
||||
}
|
||||
|
||||
const handleStepBack = () => {
|
||||
setCurrentIndex(Math.max(0, currentIndex - 10))
|
||||
}
|
||||
|
||||
const handleStepForward = () => {
|
||||
setCurrentIndex(Math.min(puntos.length - 1, currentIndex + 10))
|
||||
}
|
||||
|
||||
// Current point
|
||||
const currentPoint = puntos[currentIndex]
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 bg-slate-800 rounded w-48 animate-shimmer" />
|
||||
<SkeletonMap />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!viaje) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-500">Viaje no encontrado</p>
|
||||
<Link to="/viajes" className="text-accent-400 hover:text-accent-300 mt-2 inline-block">
|
||||
Volver a viajes
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/viajes"
|
||||
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
Replay de viaje - {viaje.vehiculo?.nombre}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
{viaje.vehiculo?.placa} | {new Date(viaje.inicio).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map and controls */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||
{/* Map */}
|
||||
<div className="lg:col-span-3 h-[500px] rounded-xl overflow-hidden">
|
||||
<MapContainer showControls={false}>
|
||||
{puntos.length > 0 && (
|
||||
<RutaLayer
|
||||
puntos={puntos.slice(0, currentIndex + 1)}
|
||||
showStartEnd
|
||||
animated={isPlaying}
|
||||
/>
|
||||
)}
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
{/* Info panel */}
|
||||
<div className="space-y-4">
|
||||
{/* Current stats */}
|
||||
<Card padding="md">
|
||||
<h3 className="text-sm font-medium text-slate-400 mb-3">Posicion actual</h3>
|
||||
{currentPoint ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Hora</span>
|
||||
<span className="text-white">
|
||||
{currentPoint.timestamp
|
||||
? new Date(currentPoint.timestamp).toLocaleTimeString()
|
||||
: '--'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Velocidad</span>
|
||||
<span className="text-white">{currentPoint.velocidad || 0} km/h</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Coordenadas</span>
|
||||
<span className="text-white text-xs font-mono">
|
||||
{currentPoint.lat.toFixed(5)}, {currentPoint.lng.toFixed(5)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500">Sin datos</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Trip summary */}
|
||||
<Card padding="md">
|
||||
<h3 className="text-sm font-medium text-slate-400 mb-3">Resumen del viaje</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Distancia</span>
|
||||
<span className="text-white">{viaje.distancia?.toFixed(1) || 0} km</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Duracion</span>
|
||||
<span className="text-white">{viaje.duracion || 0} min</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Vel. promedio</span>
|
||||
<span className="text-white">{viaje.velocidadPromedio?.toFixed(0) || 0} km/h</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Vel. maxima</span>
|
||||
<span className="text-white">{viaje.velocidadMaxima?.toFixed(0) || 0} km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playback controls */}
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Play controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleStepBack}
|
||||
disabled={currentIndex === 0}
|
||||
>
|
||||
<BackwardIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handlePlayPause}
|
||||
leftIcon={
|
||||
isPlaying ? (
|
||||
<PauseIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<PlayIcon className="w-5 h-5" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{isPlaying ? 'Pausar' : 'Reproducir'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleStepForward}
|
||||
disabled={currentIndex >= puntos.length - 1}
|
||||
>
|
||||
<ForwardIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Speed */}
|
||||
<button
|
||||
onClick={handleSpeedChange}
|
||||
className="px-3 py-1.5 text-sm font-medium text-slate-400 hover:text-white bg-slate-800 rounded-lg"
|
||||
>
|
||||
{playbackSpeed}x
|
||||
</button>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="flex-1 flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={puntos.length - 1}
|
||||
value={currentIndex}
|
||||
onChange={handleSeek}
|
||||
className="flex-1 h-2 bg-slate-700 rounded-full appearance-none cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-slate-500 whitespace-nowrap">
|
||||
{currentIndex + 1} / {puntos.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
146
frontend/src/pages/Viajes.tsx
Normal file
146
frontend/src/pages/Viajes.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
CalendarIcon,
|
||||
FunnelIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { viajesApi } from '@/api'
|
||||
import { ViajeCard } from '@/components/viajes'
|
||||
import Card from '@/components/ui/Card'
|
||||
import { SkeletonCard } from '@/components/ui/Skeleton'
|
||||
|
||||
export default function Viajes() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const vehiculoId = searchParams.get('vehiculo')
|
||||
const [filtros, setFiltros] = useState({
|
||||
estado: '',
|
||||
desde: '',
|
||||
hasta: '',
|
||||
})
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['viajes', vehiculoId, filtros],
|
||||
queryFn: () =>
|
||||
viajesApi.list({
|
||||
vehiculoId: vehiculoId || undefined,
|
||||
estado: filtros.estado || undefined,
|
||||
desde: filtros.desde || undefined,
|
||||
hasta: filtros.hasta || undefined,
|
||||
pageSize: 50,
|
||||
}),
|
||||
})
|
||||
|
||||
const viajes = data?.items || []
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
total: viajes.length,
|
||||
enCurso: viajes.filter((v) => v.estado === 'en_curso').length,
|
||||
completados: viajes.filter((v) => v.estado === 'completado').length,
|
||||
kmTotal: viajes.reduce((sum, v) => sum + (v.distancia || 0), 0),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Viajes</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Historial y seguimiento de viajes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<Card padding="md">
|
||||
<p className="text-sm text-slate-500">Total viajes</p>
|
||||
<p className="text-2xl font-bold text-white">{stats.total}</p>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<p className="text-sm text-slate-500">En curso</p>
|
||||
<p className="text-2xl font-bold text-success-400">{stats.enCurso}</p>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<p className="text-sm text-slate-500">Completados</p>
|
||||
<p className="text-2xl font-bold text-white">{stats.completados}</p>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<p className="text-sm text-slate-500">Km totales</p>
|
||||
<p className="text-2xl font-bold text-accent-400">{stats.kmTotal.toFixed(0)}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card padding="md">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<select
|
||||
value={filtros.estado}
|
||||
onChange={(e) => setFiltros({ ...filtros, estado: e.target.value })}
|
||||
className={clsx(
|
||||
'px-4 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
<option value="en_curso">En curso</option>
|
||||
<option value="completado">Completado</option>
|
||||
<option value="cancelado">Cancelado</option>
|
||||
</select>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="date"
|
||||
value={filtros.desde}
|
||||
onChange={(e) => setFiltros({ ...filtros, desde: e.target.value })}
|
||||
className={clsx(
|
||||
'px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
placeholder="Desde"
|
||||
/>
|
||||
<span className="text-slate-500">-</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filtros.hasta}
|
||||
onChange={(e) => setFiltros({ ...filtros, hasta: e.target.value })}
|
||||
className={clsx(
|
||||
'px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
placeholder="Hasta"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-slate-500 ml-auto">
|
||||
{viajes.length} viajes encontrados
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Viajes list */}
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : viajes.length === 0 ? (
|
||||
<Card padding="lg">
|
||||
<div className="text-center py-8">
|
||||
<p className="text-slate-500">No hay viajes que mostrar</p>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{viajes.map((viaje) => (
|
||||
<ViajeCard key={viaje.id} viaje={viaje} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
frontend/src/pages/VideoLive.tsx
Normal file
65
frontend/src/pages/VideoLive.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { videoApi } from '@/api'
|
||||
import { VideoGrid, CamaraCard } from '@/components/video'
|
||||
import Card from '@/components/ui/Card'
|
||||
import { SkeletonCard } from '@/components/ui/Skeleton'
|
||||
|
||||
export default function VideoLive() {
|
||||
const { data: camaras, isLoading } = useQuery({
|
||||
queryKey: ['camaras'],
|
||||
queryFn: () => videoApi.listCamaras(),
|
||||
})
|
||||
|
||||
const camarasOnline = camaras?.filter((c) => c.estado === 'online' || c.estado === 'grabando') || []
|
||||
const camarasOffline = camaras?.filter((c) => c.estado === 'offline' || c.estado === 'error') || []
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 bg-slate-800 rounded w-48 animate-shimmer" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Video en vivo</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
{camarasOnline.length} camaras en linea de {camaras?.length || 0} total
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Video grid */}
|
||||
{camarasOnline.length > 0 ? (
|
||||
<VideoGrid camaras={camarasOnline} layout="2x2" />
|
||||
) : (
|
||||
<Card padding="lg">
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-500">No hay camaras en linea</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Offline cameras */}
|
||||
{camarasOffline.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">
|
||||
Camaras sin conexion ({camarasOffline.length})
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{camarasOffline.map((camara) => (
|
||||
<CamaraCard key={camara.id} camara={camara} showActions={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
frontend/src/pages/index.ts
Normal file
18
frontend/src/pages/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export { default as Login } from './Login'
|
||||
export { default as Dashboard } from './Dashboard'
|
||||
export { default as Mapa } from './Mapa'
|
||||
export { default as Vehiculos } from './Vehiculos'
|
||||
export { default as VehiculoDetalle } from './VehiculoDetalle'
|
||||
export { default as Conductores } from './Conductores'
|
||||
export { default as Alertas } from './Alertas'
|
||||
export { default as Viajes } from './Viajes'
|
||||
export { default as ViajeReplay } from './ViajeReplay'
|
||||
export { default as VideoLive } from './VideoLive'
|
||||
export { default as Grabaciones } from './Grabaciones'
|
||||
export { default as Geocercas } from './Geocercas'
|
||||
export { default as POIs } from './POIs'
|
||||
export { default as Combustible } from './Combustible'
|
||||
export { default as Mantenimiento } from './Mantenimiento'
|
||||
export { default as Reportes } from './Reportes'
|
||||
export { default as Configuracion } from './Configuracion'
|
||||
export { default as NotFound } from './NotFound'
|
||||
256
frontend/src/store/alertasStore.ts
Normal file
256
frontend/src/store/alertasStore.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { create } from 'zustand'
|
||||
import { subscribeWithSelector } from 'zustand/middleware'
|
||||
import { Alerta, AlertaTipo, AlertaPrioridad, AlertaEstado, FiltrosAlertas } from '@/types'
|
||||
|
||||
interface AlertasState {
|
||||
// Data
|
||||
alertas: Map<string, Alerta>
|
||||
alertasActivas: Alerta[]
|
||||
alertaSeleccionada: string | null
|
||||
|
||||
// Filters
|
||||
filtros: FiltrosAlertas
|
||||
|
||||
// UI State
|
||||
nuevasAlertas: string[] // IDs of new alerts for notification
|
||||
soundEnabled: boolean
|
||||
|
||||
// Loading
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
setAlertas: (alertas: Alerta[]) => void
|
||||
setAlertasActivas: (alertas: Alerta[]) => void
|
||||
addAlerta: (alerta: Alerta) => void
|
||||
updateAlerta: (alerta: Alerta) => void
|
||||
removeAlerta: (id: string) => void
|
||||
|
||||
setAlertaSeleccionada: (id: string | null) => void
|
||||
|
||||
setFiltros: (filtros: Partial<FiltrosAlertas>) => void
|
||||
resetFiltros: () => void
|
||||
|
||||
markAsViewed: (id: string) => void
|
||||
clearNuevasAlertas: () => void
|
||||
setSoundEnabled: (enabled: boolean) => void
|
||||
|
||||
setLoading: (loading: boolean) => void
|
||||
setError: (error: string | null) => void
|
||||
|
||||
// Getters
|
||||
getAlertaById: (id: string) => Alerta | undefined
|
||||
getAlertasArray: () => Alerta[]
|
||||
getAlertasFiltradas: () => Alerta[]
|
||||
getConteoPorPrioridad: () => Record<AlertaPrioridad, number>
|
||||
getConteoPorTipo: () => Record<string, number>
|
||||
getCantidadActivas: () => number
|
||||
getCantidadCriticas: () => number
|
||||
}
|
||||
|
||||
const defaultFiltros: FiltrosAlertas = {
|
||||
busqueda: '',
|
||||
tipos: [],
|
||||
prioridades: [],
|
||||
estados: [],
|
||||
vehiculoId: undefined,
|
||||
fechaDesde: undefined,
|
||||
fechaHasta: undefined,
|
||||
}
|
||||
|
||||
export const useAlertasStore = create<AlertasState>()(
|
||||
subscribeWithSelector((set, get) => ({
|
||||
alertas: new Map(),
|
||||
alertasActivas: [],
|
||||
alertaSeleccionada: null,
|
||||
filtros: defaultFiltros,
|
||||
nuevasAlertas: [],
|
||||
soundEnabled: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
setAlertas: (alertas: Alerta[]) => {
|
||||
const alertasMap = new Map(alertas.map((a) => [a.id, a]))
|
||||
set({ alertas: alertasMap })
|
||||
},
|
||||
|
||||
setAlertasActivas: (alertas: Alerta[]) => {
|
||||
set({ alertasActivas: alertas })
|
||||
},
|
||||
|
||||
addAlerta: (alerta: Alerta) => {
|
||||
set((state) => {
|
||||
const newAlertas = new Map(state.alertas)
|
||||
newAlertas.set(alerta.id, alerta)
|
||||
|
||||
// Add to nuevasAlertas if active
|
||||
const nuevasAlertas =
|
||||
alerta.estado === 'activa'
|
||||
? [...state.nuevasAlertas, alerta.id]
|
||||
: state.nuevasAlertas
|
||||
|
||||
// Update alertasActivas
|
||||
const alertasActivas =
|
||||
alerta.estado === 'activa'
|
||||
? [alerta, ...state.alertasActivas]
|
||||
: state.alertasActivas
|
||||
|
||||
return {
|
||||
alertas: newAlertas,
|
||||
nuevasAlertas,
|
||||
alertasActivas,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
updateAlerta: (alerta: Alerta) => {
|
||||
set((state) => {
|
||||
const newAlertas = new Map(state.alertas)
|
||||
newAlertas.set(alerta.id, alerta)
|
||||
|
||||
// Update alertasActivas
|
||||
let alertasActivas = state.alertasActivas.filter((a) => a.id !== alerta.id)
|
||||
if (alerta.estado === 'activa') {
|
||||
alertasActivas = [alerta, ...alertasActivas]
|
||||
}
|
||||
|
||||
return { alertas: newAlertas, alertasActivas }
|
||||
})
|
||||
},
|
||||
|
||||
removeAlerta: (id: string) => {
|
||||
set((state) => {
|
||||
const newAlertas = new Map(state.alertas)
|
||||
newAlertas.delete(id)
|
||||
return {
|
||||
alertas: newAlertas,
|
||||
alertasActivas: state.alertasActivas.filter((a) => a.id !== id),
|
||||
nuevasAlertas: state.nuevasAlertas.filter((i) => i !== id),
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
setAlertaSeleccionada: (id: string | null) => {
|
||||
set({ alertaSeleccionada: id })
|
||||
},
|
||||
|
||||
setFiltros: (filtros: Partial<FiltrosAlertas>) => {
|
||||
set((state) => ({
|
||||
filtros: { ...state.filtros, ...filtros },
|
||||
}))
|
||||
},
|
||||
|
||||
resetFiltros: () => {
|
||||
set({ filtros: defaultFiltros })
|
||||
},
|
||||
|
||||
markAsViewed: (id: string) => {
|
||||
set((state) => ({
|
||||
nuevasAlertas: state.nuevasAlertas.filter((i) => i !== id),
|
||||
}))
|
||||
},
|
||||
|
||||
clearNuevasAlertas: () => {
|
||||
set({ nuevasAlertas: [] })
|
||||
},
|
||||
|
||||
setSoundEnabled: (enabled: boolean) => {
|
||||
set({ soundEnabled: enabled })
|
||||
},
|
||||
|
||||
setLoading: (loading: boolean) => set({ isLoading: loading }),
|
||||
setError: (error: string | null) => set({ error }),
|
||||
|
||||
// Getters
|
||||
getAlertaById: (id: string) => {
|
||||
return get().alertas.get(id)
|
||||
},
|
||||
|
||||
getAlertasArray: () => {
|
||||
return Array.from(get().alertas.values()).sort(
|
||||
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
)
|
||||
},
|
||||
|
||||
getAlertasFiltradas: () => {
|
||||
const { alertas, filtros } = get()
|
||||
let result = Array.from(alertas.values())
|
||||
|
||||
// Search
|
||||
if (filtros.busqueda) {
|
||||
const search = filtros.busqueda.toLowerCase()
|
||||
result = result.filter(
|
||||
(a) =>
|
||||
a.titulo.toLowerCase().includes(search) ||
|
||||
a.mensaje.toLowerCase().includes(search) ||
|
||||
a.vehiculo?.placa?.toLowerCase().includes(search)
|
||||
)
|
||||
}
|
||||
|
||||
// Tipos
|
||||
if (filtros.tipos.length > 0) {
|
||||
result = result.filter((a) => filtros.tipos.includes(a.tipo))
|
||||
}
|
||||
|
||||
// Prioridades
|
||||
if (filtros.prioridades.length > 0) {
|
||||
result = result.filter((a) => filtros.prioridades.includes(a.prioridad))
|
||||
}
|
||||
|
||||
// Estados
|
||||
if (filtros.estados.length > 0) {
|
||||
result = result.filter((a) => filtros.estados.includes(a.estado))
|
||||
}
|
||||
|
||||
// Vehiculo
|
||||
if (filtros.vehiculoId) {
|
||||
result = result.filter((a) => a.vehiculoId === filtros.vehiculoId)
|
||||
}
|
||||
|
||||
// Dates
|
||||
if (filtros.fechaDesde) {
|
||||
const desde = new Date(filtros.fechaDesde).getTime()
|
||||
result = result.filter((a) => new Date(a.timestamp).getTime() >= desde)
|
||||
}
|
||||
|
||||
if (filtros.fechaHasta) {
|
||||
const hasta = new Date(filtros.fechaHasta).getTime()
|
||||
result = result.filter((a) => new Date(a.timestamp).getTime() <= hasta)
|
||||
}
|
||||
|
||||
// Sort by timestamp descending
|
||||
return result.sort(
|
||||
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
)
|
||||
},
|
||||
|
||||
getConteoPorPrioridad: () => {
|
||||
const alertas = Array.from(get().alertas.values()).filter(
|
||||
(a) => a.estado === 'activa'
|
||||
)
|
||||
return {
|
||||
critica: alertas.filter((a) => a.prioridad === 'critica').length,
|
||||
alta: alertas.filter((a) => a.prioridad === 'alta').length,
|
||||
media: alertas.filter((a) => a.prioridad === 'media').length,
|
||||
baja: alertas.filter((a) => a.prioridad === 'baja').length,
|
||||
}
|
||||
},
|
||||
|
||||
getConteoPorTipo: () => {
|
||||
const alertas = Array.from(get().alertas.values())
|
||||
const conteo: Record<string, number> = {}
|
||||
alertas.forEach((a) => {
|
||||
conteo[a.tipo] = (conteo[a.tipo] || 0) + 1
|
||||
})
|
||||
return conteo
|
||||
},
|
||||
|
||||
getCantidadActivas: () => {
|
||||
return get().alertasActivas.length
|
||||
},
|
||||
|
||||
getCantidadCriticas: () => {
|
||||
return get().alertasActivas.filter((a) => a.prioridad === 'critica').length
|
||||
},
|
||||
}))
|
||||
)
|
||||
113
frontend/src/store/authStore.ts
Normal file
113
frontend/src/store/authStore.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||
import { User, LoginCredentials, AuthResponse } from '@/types'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { clearTokens, getAccessToken } from '@/api/client'
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
login: (credentials: LoginCredentials) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
loadUser: () => Promise<void>
|
||||
updateUser: (data: Partial<User>) => void
|
||||
clearError: () => void
|
||||
setLoading: (loading: boolean) => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
login: async (credentials: LoginCredentials) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const response: AuthResponse = await authApi.login(credentials)
|
||||
set({
|
||||
user: response.user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
} catch (error) {
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Error de autenticacion',
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
set({ isLoading: true })
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch {
|
||||
// Ignore logout errors
|
||||
} finally {
|
||||
clearTokens()
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
loadUser: async () => {
|
||||
const token = getAccessToken()
|
||||
if (!token) {
|
||||
set({ isAuthenticated: false, user: null })
|
||||
return
|
||||
}
|
||||
|
||||
set({ isLoading: true })
|
||||
try {
|
||||
const user = await authApi.getProfile()
|
||||
set({
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
})
|
||||
} catch {
|
||||
clearTokens()
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
updateUser: (data: Partial<User>) => {
|
||||
const { user } = get()
|
||||
if (user) {
|
||||
set({ user: { ...user, ...data } })
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
|
||||
setLoading: (loading: boolean) => set({ isLoading: loading }),
|
||||
}),
|
||||
{
|
||||
name: 'flotillas-auth',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
170
frontend/src/store/configStore.ts
Normal file
170
frontend/src/store/configStore.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||
|
||||
interface UIConfig {
|
||||
// Sidebar
|
||||
sidebarCollapsed: boolean
|
||||
sidebarPinned: boolean
|
||||
|
||||
// Theme
|
||||
theme: 'dark' | 'light' | 'system'
|
||||
accentColor: string
|
||||
|
||||
// Notifications
|
||||
soundEnabled: boolean
|
||||
desktopNotifications: boolean
|
||||
showAlertBadge: boolean
|
||||
|
||||
// Map preferences
|
||||
mapDefaultZoom: number
|
||||
mapClusterMarkers: boolean
|
||||
mapShowLabels: boolean
|
||||
mapAnimations: boolean
|
||||
|
||||
// Table preferences
|
||||
tablePageSize: number
|
||||
tableCompactMode: boolean
|
||||
|
||||
// Dashboard
|
||||
dashboardLayout: 'default' | 'compact' | 'wide'
|
||||
dashboardWidgets: string[]
|
||||
|
||||
// Time format
|
||||
use24HourFormat: boolean
|
||||
dateFormat: 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD'
|
||||
|
||||
// Units
|
||||
distanceUnit: 'km' | 'mi'
|
||||
speedUnit: 'km/h' | 'mph'
|
||||
volumeUnit: 'l' | 'gal'
|
||||
|
||||
// Language
|
||||
language: 'es' | 'en'
|
||||
}
|
||||
|
||||
interface ConfigState {
|
||||
config: UIConfig
|
||||
isLoading: boolean
|
||||
|
||||
// Actions
|
||||
setConfig: (config: Partial<UIConfig>) => void
|
||||
resetConfig: () => void
|
||||
|
||||
// Individual setters for common operations
|
||||
toggleSidebar: () => void
|
||||
setSidebarCollapsed: (collapsed: boolean) => void
|
||||
setSidebarPinned: (pinned: boolean) => void
|
||||
setTheme: (theme: UIConfig['theme']) => void
|
||||
toggleSound: () => void
|
||||
toggleDesktopNotifications: () => void
|
||||
setTablePageSize: (size: number) => void
|
||||
setLanguage: (lang: UIConfig['language']) => void
|
||||
}
|
||||
|
||||
const defaultConfig: UIConfig = {
|
||||
sidebarCollapsed: false,
|
||||
sidebarPinned: true,
|
||||
theme: 'dark',
|
||||
accentColor: '#3b82f6',
|
||||
soundEnabled: true,
|
||||
desktopNotifications: true,
|
||||
showAlertBadge: true,
|
||||
mapDefaultZoom: 12,
|
||||
mapClusterMarkers: true,
|
||||
mapShowLabels: true,
|
||||
mapAnimations: true,
|
||||
tablePageSize: 20,
|
||||
tableCompactMode: false,
|
||||
dashboardLayout: 'default',
|
||||
dashboardWidgets: [
|
||||
'fleet-stats',
|
||||
'map-preview',
|
||||
'alerts',
|
||||
'activity',
|
||||
'fuel-chart',
|
||||
'trips-chart',
|
||||
],
|
||||
use24HourFormat: true,
|
||||
dateFormat: 'DD/MM/YYYY',
|
||||
distanceUnit: 'km',
|
||||
speedUnit: 'km/h',
|
||||
volumeUnit: 'l',
|
||||
language: 'es',
|
||||
}
|
||||
|
||||
export const useConfigStore = create<ConfigState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
config: defaultConfig,
|
||||
isLoading: false,
|
||||
|
||||
setConfig: (newConfig) =>
|
||||
set((state) => ({
|
||||
config: { ...state.config, ...newConfig },
|
||||
})),
|
||||
|
||||
resetConfig: () => set({ config: defaultConfig }),
|
||||
|
||||
toggleSidebar: () =>
|
||||
set((state) => ({
|
||||
config: {
|
||||
...state.config,
|
||||
sidebarCollapsed: !state.config.sidebarCollapsed,
|
||||
},
|
||||
})),
|
||||
|
||||
setSidebarCollapsed: (collapsed) =>
|
||||
set((state) => ({
|
||||
config: { ...state.config, sidebarCollapsed: collapsed },
|
||||
})),
|
||||
|
||||
setSidebarPinned: (pinned) =>
|
||||
set((state) => ({
|
||||
config: { ...state.config, sidebarPinned: pinned },
|
||||
})),
|
||||
|
||||
setTheme: (theme) =>
|
||||
set((state) => ({
|
||||
config: { ...state.config, theme },
|
||||
})),
|
||||
|
||||
toggleSound: () =>
|
||||
set((state) => ({
|
||||
config: { ...state.config, soundEnabled: !state.config.soundEnabled },
|
||||
})),
|
||||
|
||||
toggleDesktopNotifications: () =>
|
||||
set((state) => ({
|
||||
config: {
|
||||
...state.config,
|
||||
desktopNotifications: !state.config.desktopNotifications,
|
||||
},
|
||||
})),
|
||||
|
||||
setTablePageSize: (size) =>
|
||||
set((state) => ({
|
||||
config: { ...state.config, tablePageSize: size },
|
||||
})),
|
||||
|
||||
setLanguage: (language) =>
|
||||
set((state) => ({
|
||||
config: { ...state.config, language },
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: 'flotillas-config',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// Selector hooks for common uses
|
||||
export const useUIConfig = () => useConfigStore((state) => state.config)
|
||||
export const useSidebarState = () =>
|
||||
useConfigStore((state) => ({
|
||||
collapsed: state.config.sidebarCollapsed,
|
||||
pinned: state.config.sidebarPinned,
|
||||
toggle: state.toggleSidebar,
|
||||
setCollapsed: state.setSidebarCollapsed,
|
||||
setPinned: state.setSidebarPinned,
|
||||
}))
|
||||
5
frontend/src/store/index.ts
Normal file
5
frontend/src/store/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { useAuthStore } from './authStore'
|
||||
export { useVehiculosStore } from './vehiculosStore'
|
||||
export { useAlertasStore } from './alertasStore'
|
||||
export { useMapaStore } from './mapaStore'
|
||||
export { useConfigStore, useUIConfig, useSidebarState } from './configStore'
|
||||
265
frontend/src/store/mapaStore.ts
Normal file
265
frontend/src/store/mapaStore.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||
import {
|
||||
Coordenadas,
|
||||
MapaEstado,
|
||||
Geocerca,
|
||||
POI,
|
||||
VehiculoEstado,
|
||||
VehiculoMovimiento,
|
||||
VehiculoTipo,
|
||||
} from '@/types'
|
||||
|
||||
interface MapaStoreState {
|
||||
// Map state
|
||||
centro: Coordenadas
|
||||
zoom: number
|
||||
bounds: { north: number; south: number; east: number; west: number } | null
|
||||
|
||||
// Selection
|
||||
vehiculoSeleccionado: string | null
|
||||
geocercaSeleccionada: string | null
|
||||
poiSeleccionado: string | null
|
||||
|
||||
// Filters
|
||||
filtros: {
|
||||
estados: VehiculoEstado[]
|
||||
movimientos: VehiculoMovimiento[]
|
||||
tipos: VehiculoTipo[]
|
||||
grupos: string[]
|
||||
}
|
||||
|
||||
// Layers visibility
|
||||
capas: {
|
||||
vehiculos: boolean
|
||||
geocercas: boolean
|
||||
pois: boolean
|
||||
trafico: boolean
|
||||
rutas: boolean
|
||||
labels: boolean
|
||||
}
|
||||
|
||||
// Drawing
|
||||
herramienta: 'seleccionar' | 'medir' | 'dibujar_circulo' | 'dibujar_poligono' | null
|
||||
dibujando: boolean
|
||||
puntosDibujo: Coordenadas[]
|
||||
|
||||
// Data (cached for map display)
|
||||
geocercas: Geocerca[]
|
||||
pois: POI[]
|
||||
|
||||
// Map style
|
||||
estilo: 'dark' | 'light' | 'satellite'
|
||||
|
||||
// Following vehicle
|
||||
siguiendoVehiculo: string | null
|
||||
|
||||
// Actions
|
||||
setCentro: (centro: Coordenadas) => void
|
||||
setZoom: (zoom: number) => void
|
||||
setBounds: (bounds: { north: number; south: number; east: number; west: number } | null) => void
|
||||
setView: (centro: Coordenadas, zoom: number) => void
|
||||
fitBounds: (bounds: { north: number; south: number; east: number; west: number }) => void
|
||||
|
||||
setVehiculoSeleccionado: (id: string | null) => void
|
||||
setGeocercaSeleccionada: (id: string | null) => void
|
||||
setPoiSeleccionado: (id: string | null) => void
|
||||
|
||||
setFiltros: (filtros: Partial<MapaStoreState['filtros']>) => void
|
||||
toggleFiltro: <K extends keyof MapaStoreState['filtros']>(
|
||||
key: K,
|
||||
value: MapaStoreState['filtros'][K][number]
|
||||
) => void
|
||||
resetFiltros: () => void
|
||||
|
||||
setCapa: (capa: keyof MapaStoreState['capas'], visible: boolean) => void
|
||||
toggleCapa: (capa: keyof MapaStoreState['capas']) => void
|
||||
|
||||
setHerramienta: (herramienta: MapaStoreState['herramienta']) => void
|
||||
startDibujo: () => void
|
||||
addPuntoDibujo: (punto: Coordenadas) => void
|
||||
removePuntoDibujo: (index: number) => void
|
||||
finishDibujo: () => void
|
||||
cancelDibujo: () => void
|
||||
|
||||
setGeocercas: (geocercas: Geocerca[]) => void
|
||||
setPois: (pois: POI[]) => void
|
||||
|
||||
setEstilo: (estilo: MapaStoreState['estilo']) => void
|
||||
|
||||
setSiguiendoVehiculo: (id: string | null) => void
|
||||
|
||||
// Utils
|
||||
centrarEnVehiculo: (lat: number, lng: number) => void
|
||||
centrarEnGeocerca: (geocerca: Geocerca) => void
|
||||
}
|
||||
|
||||
const defaultFiltros = {
|
||||
estados: [] as VehiculoEstado[],
|
||||
movimientos: [] as VehiculoMovimiento[],
|
||||
tipos: [] as VehiculoTipo[],
|
||||
grupos: [] as string[],
|
||||
}
|
||||
|
||||
const defaultCapas = {
|
||||
vehiculos: true,
|
||||
geocercas: true,
|
||||
pois: true,
|
||||
trafico: false,
|
||||
rutas: false,
|
||||
labels: true,
|
||||
}
|
||||
|
||||
// Default center (Mexico City)
|
||||
const defaultCentro: Coordenadas = { lat: 19.4326, lng: -99.1332 }
|
||||
|
||||
export const useMapaStore = create<MapaStoreState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
centro: defaultCentro,
|
||||
zoom: 12,
|
||||
bounds: null,
|
||||
vehiculoSeleccionado: null,
|
||||
geocercaSeleccionada: null,
|
||||
poiSeleccionado: null,
|
||||
filtros: defaultFiltros,
|
||||
capas: defaultCapas,
|
||||
herramienta: null,
|
||||
dibujando: false,
|
||||
puntosDibujo: [],
|
||||
geocercas: [],
|
||||
pois: [],
|
||||
estilo: 'dark',
|
||||
siguiendoVehiculo: null,
|
||||
|
||||
setCentro: (centro) => set({ centro }),
|
||||
setZoom: (zoom) => set({ zoom }),
|
||||
setBounds: (bounds) => set({ bounds }),
|
||||
setView: (centro, zoom) => set({ centro, zoom }),
|
||||
fitBounds: (bounds) => set({ bounds }),
|
||||
|
||||
setVehiculoSeleccionado: (id) =>
|
||||
set({
|
||||
vehiculoSeleccionado: id,
|
||||
geocercaSeleccionada: null,
|
||||
poiSeleccionado: null,
|
||||
}),
|
||||
|
||||
setGeocercaSeleccionada: (id) =>
|
||||
set({
|
||||
geocercaSeleccionada: id,
|
||||
vehiculoSeleccionado: null,
|
||||
poiSeleccionado: null,
|
||||
}),
|
||||
|
||||
setPoiSeleccionado: (id) =>
|
||||
set({
|
||||
poiSeleccionado: id,
|
||||
vehiculoSeleccionado: null,
|
||||
geocercaSeleccionada: null,
|
||||
}),
|
||||
|
||||
setFiltros: (filtros) =>
|
||||
set((state) => ({
|
||||
filtros: { ...state.filtros, ...filtros },
|
||||
})),
|
||||
|
||||
toggleFiltro: (key, value) =>
|
||||
set((state) => {
|
||||
const current = state.filtros[key] as unknown[]
|
||||
const newValues = current.includes(value)
|
||||
? current.filter((v) => v !== value)
|
||||
: [...current, value]
|
||||
return {
|
||||
filtros: { ...state.filtros, [key]: newValues },
|
||||
}
|
||||
}),
|
||||
|
||||
resetFiltros: () => set({ filtros: defaultFiltros }),
|
||||
|
||||
setCapa: (capa, visible) =>
|
||||
set((state) => ({
|
||||
capas: { ...state.capas, [capa]: visible },
|
||||
})),
|
||||
|
||||
toggleCapa: (capa) =>
|
||||
set((state) => ({
|
||||
capas: { ...state.capas, [capa]: !state.capas[capa] },
|
||||
})),
|
||||
|
||||
setHerramienta: (herramienta) =>
|
||||
set({
|
||||
herramienta,
|
||||
dibujando: false,
|
||||
puntosDibujo: [],
|
||||
}),
|
||||
|
||||
startDibujo: () => set({ dibujando: true, puntosDibujo: [] }),
|
||||
|
||||
addPuntoDibujo: (punto) =>
|
||||
set((state) => ({
|
||||
puntosDibujo: [...state.puntosDibujo, punto],
|
||||
})),
|
||||
|
||||
removePuntoDibujo: (index) =>
|
||||
set((state) => ({
|
||||
puntosDibujo: state.puntosDibujo.filter((_, i) => i !== index),
|
||||
})),
|
||||
|
||||
finishDibujo: () =>
|
||||
set({
|
||||
dibujando: false,
|
||||
herramienta: null,
|
||||
}),
|
||||
|
||||
cancelDibujo: () =>
|
||||
set({
|
||||
dibujando: false,
|
||||
puntosDibujo: [],
|
||||
herramienta: null,
|
||||
}),
|
||||
|
||||
setGeocercas: (geocercas) => set({ geocercas }),
|
||||
setPois: (pois) => set({ pois }),
|
||||
|
||||
setEstilo: (estilo) => set({ estilo }),
|
||||
|
||||
setSiguiendoVehiculo: (id) => set({ siguiendoVehiculo: id }),
|
||||
|
||||
centrarEnVehiculo: (lat, lng) =>
|
||||
set({
|
||||
centro: { lat, lng },
|
||||
zoom: 16,
|
||||
}),
|
||||
|
||||
centrarEnGeocerca: (geocerca) => {
|
||||
if (geocerca.tipo === 'circulo' && geocerca.centroLat && geocerca.centroLng) {
|
||||
set({
|
||||
centro: { lat: geocerca.centroLat, lng: geocerca.centroLng },
|
||||
zoom: 14,
|
||||
})
|
||||
} else if (geocerca.vertices && geocerca.vertices.length > 0) {
|
||||
// Calculate center of polygon
|
||||
const lats = geocerca.vertices.map((v) => v.lat)
|
||||
const lngs = geocerca.vertices.map((v) => v.lng)
|
||||
const centerLat = (Math.max(...lats) + Math.min(...lats)) / 2
|
||||
const centerLng = (Math.max(...lngs) + Math.min(...lngs)) / 2
|
||||
set({
|
||||
centro: { lat: centerLat, lng: centerLng },
|
||||
zoom: 14,
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'flotillas-mapa',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
centro: state.centro,
|
||||
zoom: state.zoom,
|
||||
capas: state.capas,
|
||||
estilo: state.estilo,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
247
frontend/src/store/vehiculosStore.ts
Normal file
247
frontend/src/store/vehiculosStore.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { create } from 'zustand'
|
||||
import { subscribeWithSelector } from 'zustand/middleware'
|
||||
import {
|
||||
Vehiculo,
|
||||
UbicacionRealTime,
|
||||
VehiculoEstado,
|
||||
VehiculoMovimiento,
|
||||
FiltrosVehiculos,
|
||||
} from '@/types'
|
||||
|
||||
interface VehiculosState {
|
||||
// Data
|
||||
vehiculos: Map<string, Vehiculo>
|
||||
ubicaciones: Map<string, UbicacionRealTime>
|
||||
vehiculoSeleccionado: string | null
|
||||
|
||||
// Filters
|
||||
filtros: FiltrosVehiculos
|
||||
|
||||
// Loading states
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
setVehiculos: (vehiculos: Vehiculo[]) => void
|
||||
updateVehiculo: (vehiculo: Vehiculo) => void
|
||||
removeVehiculo: (id: string) => void
|
||||
|
||||
updateUbicacion: (ubicacion: UbicacionRealTime) => void
|
||||
updateUbicaciones: (ubicaciones: UbicacionRealTime[]) => void
|
||||
|
||||
setVehiculoSeleccionado: (id: string | null) => void
|
||||
|
||||
setFiltros: (filtros: Partial<FiltrosVehiculos>) => void
|
||||
resetFiltros: () => void
|
||||
|
||||
setLoading: (loading: boolean) => void
|
||||
setError: (error: string | null) => void
|
||||
|
||||
// Getters (computed)
|
||||
getVehiculoById: (id: string) => Vehiculo | undefined
|
||||
getVehiculosArray: () => Vehiculo[]
|
||||
getVehiculosFiltrados: () => Vehiculo[]
|
||||
getVehiculosPorEstado: (estado: VehiculoEstado) => Vehiculo[]
|
||||
getVehiculosPorMovimiento: (movimiento: VehiculoMovimiento) => Vehiculo[]
|
||||
getEstadisticas: () => {
|
||||
total: number
|
||||
activos: number
|
||||
inactivos: number
|
||||
mantenimiento: number
|
||||
enMovimiento: number
|
||||
detenidos: number
|
||||
sinSenal: number
|
||||
}
|
||||
}
|
||||
|
||||
const defaultFiltros: FiltrosVehiculos = {
|
||||
busqueda: '',
|
||||
estados: [],
|
||||
movimientos: [],
|
||||
tipos: [],
|
||||
grupos: [],
|
||||
ordenar: 'nombre',
|
||||
orden: 'asc',
|
||||
}
|
||||
|
||||
export const useVehiculosStore = create<VehiculosState>()(
|
||||
subscribeWithSelector((set, get) => ({
|
||||
vehiculos: new Map(),
|
||||
ubicaciones: new Map(),
|
||||
vehiculoSeleccionado: null,
|
||||
filtros: defaultFiltros,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
setVehiculos: (vehiculos: Vehiculo[]) => {
|
||||
const vehiculosMap = new Map(vehiculos.map((v) => [v.id, v]))
|
||||
set({ vehiculos: vehiculosMap })
|
||||
},
|
||||
|
||||
updateVehiculo: (vehiculo: Vehiculo) => {
|
||||
set((state) => {
|
||||
const newVehiculos = new Map(state.vehiculos)
|
||||
newVehiculos.set(vehiculo.id, vehiculo)
|
||||
return { vehiculos: newVehiculos }
|
||||
})
|
||||
},
|
||||
|
||||
removeVehiculo: (id: string) => {
|
||||
set((state) => {
|
||||
const newVehiculos = new Map(state.vehiculos)
|
||||
newVehiculos.delete(id)
|
||||
return { vehiculos: newVehiculos }
|
||||
})
|
||||
},
|
||||
|
||||
updateUbicacion: (ubicacion: UbicacionRealTime) => {
|
||||
set((state) => {
|
||||
const newUbicaciones = new Map(state.ubicaciones)
|
||||
newUbicaciones.set(ubicacion.vehiculoId, ubicacion)
|
||||
|
||||
// Also update vehiculo location data
|
||||
const newVehiculos = new Map(state.vehiculos)
|
||||
const vehiculo = newVehiculos.get(ubicacion.vehiculoId)
|
||||
if (vehiculo) {
|
||||
newVehiculos.set(ubicacion.vehiculoId, {
|
||||
...vehiculo,
|
||||
ubicacion: ubicacion,
|
||||
velocidad: ubicacion.velocidad,
|
||||
rumbo: ubicacion.rumbo,
|
||||
movimiento: ubicacion.velocidad > 3 ? 'movimiento' : 'detenido',
|
||||
})
|
||||
}
|
||||
|
||||
return { ubicaciones: newUbicaciones, vehiculos: newVehiculos }
|
||||
})
|
||||
},
|
||||
|
||||
updateUbicaciones: (ubicaciones: UbicacionRealTime[]) => {
|
||||
set((state) => {
|
||||
const newUbicaciones = new Map(state.ubicaciones)
|
||||
const newVehiculos = new Map(state.vehiculos)
|
||||
|
||||
ubicaciones.forEach((ubicacion) => {
|
||||
newUbicaciones.set(ubicacion.vehiculoId, ubicacion)
|
||||
|
||||
const vehiculo = newVehiculos.get(ubicacion.vehiculoId)
|
||||
if (vehiculo) {
|
||||
newVehiculos.set(ubicacion.vehiculoId, {
|
||||
...vehiculo,
|
||||
ubicacion: ubicacion,
|
||||
velocidad: ubicacion.velocidad,
|
||||
rumbo: ubicacion.rumbo,
|
||||
movimiento: ubicacion.velocidad > 3 ? 'movimiento' : 'detenido',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return { ubicaciones: newUbicaciones, vehiculos: newVehiculos }
|
||||
})
|
||||
},
|
||||
|
||||
setVehiculoSeleccionado: (id: string | null) => {
|
||||
set({ vehiculoSeleccionado: id })
|
||||
},
|
||||
|
||||
setFiltros: (filtros: Partial<FiltrosVehiculos>) => {
|
||||
set((state) => ({
|
||||
filtros: { ...state.filtros, ...filtros },
|
||||
}))
|
||||
},
|
||||
|
||||
resetFiltros: () => {
|
||||
set({ filtros: defaultFiltros })
|
||||
},
|
||||
|
||||
setLoading: (loading: boolean) => set({ isLoading: loading }),
|
||||
setError: (error: string | null) => set({ error }),
|
||||
|
||||
// Getters
|
||||
getVehiculoById: (id: string) => {
|
||||
return get().vehiculos.get(id)
|
||||
},
|
||||
|
||||
getVehiculosArray: () => {
|
||||
return Array.from(get().vehiculos.values())
|
||||
},
|
||||
|
||||
getVehiculosFiltrados: () => {
|
||||
const { vehiculos, filtros } = get()
|
||||
let result = Array.from(vehiculos.values())
|
||||
|
||||
// Search filter
|
||||
if (filtros.busqueda) {
|
||||
const search = filtros.busqueda.toLowerCase()
|
||||
result = result.filter(
|
||||
(v) =>
|
||||
v.nombre.toLowerCase().includes(search) ||
|
||||
v.placa.toLowerCase().includes(search) ||
|
||||
v.conductor?.nombre?.toLowerCase().includes(search)
|
||||
)
|
||||
}
|
||||
|
||||
// Estado filter
|
||||
if (filtros.estados.length > 0) {
|
||||
result = result.filter((v) => filtros.estados.includes(v.estado))
|
||||
}
|
||||
|
||||
// Movimiento filter
|
||||
if (filtros.movimientos.length > 0) {
|
||||
result = result.filter((v) => filtros.movimientos.includes(v.movimiento))
|
||||
}
|
||||
|
||||
// Tipo filter
|
||||
if (filtros.tipos.length > 0) {
|
||||
result = result.filter((v) => filtros.tipos.includes(v.tipo))
|
||||
}
|
||||
|
||||
// Sort
|
||||
result.sort((a, b) => {
|
||||
let comparison = 0
|
||||
switch (filtros.ordenar) {
|
||||
case 'nombre':
|
||||
comparison = a.nombre.localeCompare(b.nombre)
|
||||
break
|
||||
case 'placa':
|
||||
comparison = a.placa.localeCompare(b.placa)
|
||||
break
|
||||
case 'estado':
|
||||
comparison = a.estado.localeCompare(b.estado)
|
||||
break
|
||||
case 'ultimo_reporte':
|
||||
comparison = (a.ubicacion?.timestamp || '').localeCompare(
|
||||
b.ubicacion?.timestamp || ''
|
||||
)
|
||||
break
|
||||
}
|
||||
return filtros.orden === 'asc' ? comparison : -comparison
|
||||
})
|
||||
|
||||
return result
|
||||
},
|
||||
|
||||
getVehiculosPorEstado: (estado: VehiculoEstado) => {
|
||||
return Array.from(get().vehiculos.values()).filter((v) => v.estado === estado)
|
||||
},
|
||||
|
||||
getVehiculosPorMovimiento: (movimiento: VehiculoMovimiento) => {
|
||||
return Array.from(get().vehiculos.values()).filter(
|
||||
(v) => v.movimiento === movimiento
|
||||
)
|
||||
},
|
||||
|
||||
getEstadisticas: () => {
|
||||
const vehiculos = Array.from(get().vehiculos.values())
|
||||
return {
|
||||
total: vehiculos.length,
|
||||
activos: vehiculos.filter((v) => v.estado === 'activo').length,
|
||||
inactivos: vehiculos.filter((v) => v.estado === 'inactivo').length,
|
||||
mantenimiento: vehiculos.filter((v) => v.estado === 'mantenimiento').length,
|
||||
enMovimiento: vehiculos.filter((v) => v.movimiento === 'movimiento').length,
|
||||
detenidos: vehiculos.filter((v) => v.movimiento === 'detenido').length,
|
||||
sinSenal: vehiculos.filter((v) => v.movimiento === 'sin_senal').length,
|
||||
}
|
||||
},
|
||||
}))
|
||||
)
|
||||
388
frontend/src/styles/globals.css
Normal file
388
frontend/src/styles/globals.css
Normal file
@@ -0,0 +1,388 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-slate-700;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background-900 text-slate-100 font-sans;
|
||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
@apply w-2 h-2;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-background-800 rounded;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-slate-600 rounded hover:bg-slate-500 transition-colors;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
@apply bg-accent-500/30;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Card styles */
|
||||
.card {
|
||||
@apply bg-card rounded-xl border border-slate-700/50 shadow-lg;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply hover:bg-card-hover hover:border-slate-600/50 transition-all duration-200;
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.input-base {
|
||||
@apply w-full px-4 py-2.5 bg-background-800 border border-slate-700 rounded-lg
|
||||
text-white placeholder-slate-500
|
||||
focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent
|
||||
transition-all duration-200;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
@apply border-error-500 focus:ring-error-500;
|
||||
}
|
||||
|
||||
/* Button base */
|
||||
.btn-base {
|
||||
@apply inline-flex items-center justify-center gap-2 px-4 py-2
|
||||
font-medium rounded-lg transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-background-900
|
||||
disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.status-dot {
|
||||
@apply w-2.5 h-2.5 rounded-full;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
@apply bg-success-500 shadow-[0_0_8px_rgba(34,197,94,0.5)];
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
@apply bg-slate-500;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
@apply bg-warning-500 shadow-[0_0_8px_rgba(234,179,8,0.5)];
|
||||
}
|
||||
|
||||
.status-error {
|
||||
@apply bg-error-500 shadow-[0_0_8px_rgba(239,68,68,0.5)];
|
||||
}
|
||||
|
||||
/* Glow effects */
|
||||
.glow-accent {
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.glow-success {
|
||||
box-shadow: 0 0 20px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.glow-warning {
|
||||
box-shadow: 0 0 20px rgba(234, 179, 8, 0.3);
|
||||
}
|
||||
|
||||
.glow-error {
|
||||
box-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* Glass effect */
|
||||
.glass {
|
||||
@apply bg-background-800/80 backdrop-blur-md border border-slate-700/50;
|
||||
}
|
||||
|
||||
/* Gradient backgrounds */
|
||||
.gradient-accent {
|
||||
@apply bg-gradient-to-r from-accent-600 to-accent-500;
|
||||
}
|
||||
|
||||
.gradient-success {
|
||||
@apply bg-gradient-to-r from-success-600 to-success-500;
|
||||
}
|
||||
|
||||
/* Map marker pulse */
|
||||
.marker-pulse {
|
||||
animation: marker-pulse 2s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes marker-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Text gradient */
|
||||
.text-gradient {
|
||||
@apply bg-gradient-to-r from-accent-400 to-accent-600 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Smooth scroll */
|
||||
.scroll-smooth {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Animation delays */
|
||||
.animation-delay-100 {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
|
||||
.animation-delay-200 {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.animation-delay-300 {
|
||||
animation-delay: 300ms;
|
||||
}
|
||||
|
||||
.animation-delay-500 {
|
||||
animation-delay: 500ms;
|
||||
}
|
||||
|
||||
/* Line clamp */
|
||||
.line-clamp-1 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
}
|
||||
|
||||
/* Leaflet map customization */
|
||||
.leaflet-container {
|
||||
@apply bg-background-900 font-sans;
|
||||
}
|
||||
|
||||
.leaflet-popup-content-wrapper {
|
||||
@apply bg-card border border-slate-700 rounded-lg shadow-xl;
|
||||
}
|
||||
|
||||
.leaflet-popup-content {
|
||||
@apply text-white m-0;
|
||||
}
|
||||
|
||||
.leaflet-popup-tip {
|
||||
@apply bg-card border-l border-b border-slate-700;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom {
|
||||
@apply border-none !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a {
|
||||
@apply bg-card border border-slate-700 text-white hover:bg-card-hover !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a:first-child {
|
||||
@apply rounded-t-lg border-b-0 !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a:last-child {
|
||||
@apply rounded-b-lg !important;
|
||||
}
|
||||
|
||||
.leaflet-control-attribution {
|
||||
@apply bg-background-900/80 text-slate-500 text-xs;
|
||||
}
|
||||
|
||||
/* Custom vehicle marker */
|
||||
.vehicle-marker {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.vehicle-marker-icon {
|
||||
@apply w-10 h-10 rounded-full flex items-center justify-center
|
||||
border-2 border-white shadow-lg transform -translate-x-1/2 -translate-y-1/2;
|
||||
}
|
||||
|
||||
.vehicle-marker-moving {
|
||||
@apply bg-success-500;
|
||||
}
|
||||
|
||||
.vehicle-marker-stopped {
|
||||
@apply bg-warning-500;
|
||||
}
|
||||
|
||||
.vehicle-marker-offline {
|
||||
@apply bg-slate-500;
|
||||
}
|
||||
|
||||
.vehicle-marker-alert {
|
||||
@apply bg-error-500;
|
||||
}
|
||||
|
||||
/* Loading animations */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 1.5s infinite;
|
||||
background: linear-gradient(90deg,
|
||||
theme('colors.slate.800') 25%,
|
||||
theme('colors.slate.700') 50%,
|
||||
theme('colors.slate.800') 75%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
/* Toast animations */
|
||||
.toast-enter {
|
||||
animation: toast-slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast-exit {
|
||||
animation: toast-slide-out 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes toast-slide-in {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-slide-out {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal animations */
|
||||
.modal-overlay-enter {
|
||||
animation: modal-fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
.modal-overlay-exit {
|
||||
animation: modal-fade-out 0.2s ease-out;
|
||||
}
|
||||
|
||||
.modal-content-enter {
|
||||
animation: modal-scale-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
.modal-content-exit {
|
||||
animation: modal-scale-out 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modal-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-scale-in {
|
||||
from {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-scale-out {
|
||||
from {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Recharts customization */
|
||||
.recharts-cartesian-grid-horizontal line,
|
||||
.recharts-cartesian-grid-vertical line {
|
||||
stroke: theme('colors.slate.700');
|
||||
}
|
||||
|
||||
.recharts-text {
|
||||
fill: theme('colors.slate.400');
|
||||
}
|
||||
|
||||
.recharts-tooltip-wrapper {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.recharts-default-tooltip {
|
||||
@apply bg-card border border-slate-700 rounded-lg shadow-xl !important;
|
||||
}
|
||||
|
||||
.recharts-tooltip-label {
|
||||
@apply text-white font-medium !important;
|
||||
}
|
||||
|
||||
.recharts-tooltip-item {
|
||||
@apply text-slate-300 !important;
|
||||
}
|
||||
1
frontend/src/styles/tailwind.css
Normal file
1
frontend/src/styles/tailwind.css
Normal file
@@ -0,0 +1 @@
|
||||
@import './globals.css';
|
||||
918
frontend/src/types/index.ts
Normal file
918
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,918 @@
|
||||
// ==========================================
|
||||
// BASE TYPES
|
||||
// ==========================================
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
message: string
|
||||
code: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// AUTH TYPES
|
||||
// ==========================================
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
nombre: string
|
||||
apellido: string
|
||||
rol: 'admin' | 'operador' | 'visualizador'
|
||||
avatar?: string
|
||||
telefono?: string
|
||||
activo: boolean
|
||||
ultimoAcceso?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
user: User
|
||||
expiresIn: number
|
||||
}
|
||||
|
||||
export interface TokenPayload {
|
||||
sub: string
|
||||
email: string
|
||||
rol: string
|
||||
exp: number
|
||||
iat: number
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VEHICULO TYPES
|
||||
// ==========================================
|
||||
|
||||
export type VehiculoEstado = 'activo' | 'inactivo' | 'mantenimiento' | 'fuera_servicio'
|
||||
export type VehiculoMovimiento = 'movimiento' | 'detenido' | 'ralenti' | 'sin_senal'
|
||||
export type VehiculoTipo = 'sedan' | 'suv' | 'pickup' | 'van' | 'camion' | 'trailer' | 'motocicleta' | 'otro'
|
||||
|
||||
export interface Vehiculo {
|
||||
id: string
|
||||
placa: string
|
||||
nombre: string
|
||||
tipo: VehiculoTipo
|
||||
marca: string
|
||||
modelo: string
|
||||
anio: number
|
||||
color: string
|
||||
vin?: string
|
||||
estado: VehiculoEstado
|
||||
movimiento: VehiculoMovimiento
|
||||
conductorId?: string
|
||||
conductor?: Conductor
|
||||
dispositivoId?: string
|
||||
dispositivo?: Dispositivo
|
||||
camaras?: Camara[]
|
||||
|
||||
// Location data
|
||||
ubicacion?: Ubicacion
|
||||
velocidad?: number
|
||||
rumbo?: number
|
||||
odometro?: number
|
||||
horasMotor?: number
|
||||
combustibleNivel?: number
|
||||
|
||||
// Metadata
|
||||
icono?: string
|
||||
grupoId?: string
|
||||
tags?: string[]
|
||||
notas?: string
|
||||
|
||||
// Stats
|
||||
kilometrajeTotal?: number
|
||||
viajesHoy?: number
|
||||
alertasActivas?: number
|
||||
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface VehiculoCreate {
|
||||
placa: string
|
||||
nombre: string
|
||||
tipo: VehiculoTipo
|
||||
marca: string
|
||||
modelo: string
|
||||
anio: number
|
||||
color: string
|
||||
vin?: string
|
||||
conductorId?: string
|
||||
grupoId?: string
|
||||
tags?: string[]
|
||||
notas?: string
|
||||
}
|
||||
|
||||
export interface VehiculoUpdate extends Partial<VehiculoCreate> {
|
||||
estado?: VehiculoEstado
|
||||
}
|
||||
|
||||
export interface VehiculoStats {
|
||||
vehiculoId: string
|
||||
periodo: 'dia' | 'semana' | 'mes'
|
||||
kilometraje: number
|
||||
combustibleConsumido: number
|
||||
rendimiento: number
|
||||
viajes: number
|
||||
tiempoMovimiento: number
|
||||
tiempoDetenido: number
|
||||
tiempoRalenti: number
|
||||
velocidadPromedio: number
|
||||
velocidadMaxima: number
|
||||
alertas: number
|
||||
infracciones: number
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// CONDUCTOR TYPES
|
||||
// ==========================================
|
||||
|
||||
export type ConductorEstado = 'disponible' | 'en_viaje' | 'descanso' | 'inactivo'
|
||||
|
||||
export interface Conductor {
|
||||
id: string
|
||||
nombre: string
|
||||
apellido: string
|
||||
telefono: string
|
||||
email?: string
|
||||
licencia: string
|
||||
licenciaTipo: string
|
||||
licenciaVencimiento: string
|
||||
foto?: string
|
||||
estado: ConductorEstado
|
||||
vehiculoActualId?: string
|
||||
vehiculoActual?: Vehiculo
|
||||
|
||||
// Contact
|
||||
telefonoEmergencia?: string
|
||||
direccion?: string
|
||||
|
||||
// Stats
|
||||
viajesCompletados?: number
|
||||
calificacion?: number
|
||||
alertasActivas?: number
|
||||
|
||||
activo: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ConductorCreate {
|
||||
nombre: string
|
||||
apellido: string
|
||||
telefono: string
|
||||
email?: string
|
||||
licencia: string
|
||||
licenciaTipo: string
|
||||
licenciaVencimiento: string
|
||||
telefonoEmergencia?: string
|
||||
direccion?: string
|
||||
}
|
||||
|
||||
export interface ConductorUpdate extends Partial<ConductorCreate> {
|
||||
estado?: ConductorEstado
|
||||
activo?: boolean
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// DISPOSITIVO TYPES
|
||||
// ==========================================
|
||||
|
||||
export type DispositivoTipo = 'gps' | 'obd' | 'meshtastic' | 'dashcam'
|
||||
export type DispositivoEstado = 'online' | 'offline' | 'sin_configurar'
|
||||
|
||||
export interface Dispositivo {
|
||||
id: string
|
||||
identificador: string
|
||||
tipo: DispositivoTipo
|
||||
modelo?: string
|
||||
protocolo: string
|
||||
estado: DispositivoEstado
|
||||
vehiculoId?: string
|
||||
|
||||
// Connection info
|
||||
ultimaConexion?: string
|
||||
ip?: string
|
||||
puerto?: number
|
||||
|
||||
// Config
|
||||
intervaloReporte: number
|
||||
configuracion?: Record<string, unknown>
|
||||
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// UBICACION TYPES
|
||||
// ==========================================
|
||||
|
||||
export interface Coordenadas {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
export interface Ubicacion {
|
||||
id: string
|
||||
vehiculoId: string
|
||||
dispositivoId: string
|
||||
timestamp: string
|
||||
|
||||
// Position
|
||||
lat: number
|
||||
lng: number
|
||||
altitud?: number
|
||||
precision?: number
|
||||
|
||||
// Movement
|
||||
velocidad: number
|
||||
rumbo: number
|
||||
|
||||
// Vehicle data
|
||||
odometro?: number
|
||||
combustible?: number
|
||||
motor?: boolean
|
||||
|
||||
// Raw data
|
||||
atributos?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface UbicacionRealTime extends Ubicacion {
|
||||
vehiculo: Pick<Vehiculo, 'id' | 'placa' | 'nombre' | 'tipo' | 'color'>
|
||||
conductor?: Pick<Conductor, 'id' | 'nombre' | 'apellido'>
|
||||
direccion?: string
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VIAJE TYPES
|
||||
// ==========================================
|
||||
|
||||
export type ViajeEstado = 'en_curso' | 'completado' | 'cancelado'
|
||||
|
||||
export interface Viaje {
|
||||
id: string
|
||||
vehiculoId: string
|
||||
vehiculo?: Vehiculo
|
||||
conductorId?: string
|
||||
conductor?: Conductor
|
||||
|
||||
estado: ViajeEstado
|
||||
|
||||
// Time
|
||||
inicio: string
|
||||
fin?: string
|
||||
duracion?: number // minutes
|
||||
|
||||
// Location
|
||||
origenLat: number
|
||||
origenLng: number
|
||||
origenDireccion?: string
|
||||
destinoLat?: number
|
||||
destinoLng?: number
|
||||
destinoDireccion?: string
|
||||
|
||||
// Stats
|
||||
distancia?: number // km
|
||||
velocidadPromedio?: number
|
||||
velocidadMaxima?: number
|
||||
combustibleUsado?: number
|
||||
|
||||
// Route
|
||||
ruta?: Coordenadas[]
|
||||
|
||||
// Events
|
||||
paradas?: Parada[]
|
||||
eventos?: EventoViaje[]
|
||||
alertas?: Alerta[]
|
||||
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Parada {
|
||||
id: string
|
||||
viajeId: string
|
||||
lat: number
|
||||
lng: number
|
||||
direccion?: string
|
||||
inicio: string
|
||||
fin?: string
|
||||
duracion?: number
|
||||
tipo: 'programada' | 'no_programada' | 'carga' | 'descanso'
|
||||
notas?: string
|
||||
}
|
||||
|
||||
export interface EventoViaje {
|
||||
id: string
|
||||
viajeId: string
|
||||
tipo: string
|
||||
timestamp: string
|
||||
lat: number
|
||||
lng: number
|
||||
descripcion?: string
|
||||
datos?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ViajeReplayData {
|
||||
viaje: Viaje
|
||||
puntos: UbicacionRealTime[]
|
||||
eventos: EventoViaje[]
|
||||
grabaciones?: Grabacion[]
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ALERTA TYPES
|
||||
// ==========================================
|
||||
|
||||
export type AlertaTipo =
|
||||
| 'exceso_velocidad'
|
||||
| 'frenado_brusco'
|
||||
| 'aceleracion_brusca'
|
||||
| 'entrada_geocerca'
|
||||
| 'salida_geocerca'
|
||||
| 'motor_encendido'
|
||||
| 'motor_apagado'
|
||||
| 'bateria_baja'
|
||||
| 'desconexion'
|
||||
| 'sos'
|
||||
| 'impacto'
|
||||
| 'combustible_bajo'
|
||||
| 'mantenimiento'
|
||||
| 'licencia_vencida'
|
||||
| 'otro'
|
||||
|
||||
export type AlertaPrioridad = 'baja' | 'media' | 'alta' | 'critica'
|
||||
export type AlertaEstado = 'activa' | 'reconocida' | 'resuelta' | 'ignorada'
|
||||
|
||||
export interface Alerta {
|
||||
id: string
|
||||
tipo: AlertaTipo
|
||||
prioridad: AlertaPrioridad
|
||||
estado: AlertaEstado
|
||||
|
||||
titulo: string
|
||||
mensaje: string
|
||||
|
||||
vehiculoId?: string
|
||||
vehiculo?: Vehiculo
|
||||
conductorId?: string
|
||||
conductor?: Conductor
|
||||
viajeId?: string
|
||||
geocercaId?: string
|
||||
geocerca?: Geocerca
|
||||
|
||||
// Location
|
||||
lat?: number
|
||||
lng?: number
|
||||
direccion?: string
|
||||
|
||||
// Data
|
||||
valor?: number
|
||||
umbral?: number
|
||||
datos?: Record<string, unknown>
|
||||
|
||||
// Actions
|
||||
reconocidaPor?: string
|
||||
reconocidaAt?: string
|
||||
resueltaPor?: string
|
||||
resueltaAt?: string
|
||||
notas?: string
|
||||
|
||||
timestamp: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AlertaCreate {
|
||||
tipo: AlertaTipo
|
||||
prioridad: AlertaPrioridad
|
||||
titulo: string
|
||||
mensaje: string
|
||||
vehiculoId?: string
|
||||
conductorId?: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
valor?: number
|
||||
umbral?: number
|
||||
}
|
||||
|
||||
export interface AlertaConfiguracion {
|
||||
tipo: AlertaTipo
|
||||
activa: boolean
|
||||
prioridad: AlertaPrioridad
|
||||
umbral?: number
|
||||
notificarEmail: boolean
|
||||
notificarPush: boolean
|
||||
notificarSonido: boolean
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// GEOCERCA TYPES
|
||||
// ==========================================
|
||||
|
||||
export type GeocercaTipo = 'circulo' | 'poligono' | 'ruta'
|
||||
export type GeocercaAccion = 'entrada' | 'salida' | 'ambos'
|
||||
|
||||
export interface Geocerca {
|
||||
id: string
|
||||
nombre: string
|
||||
descripcion?: string
|
||||
tipo: 'permitida' | 'restringida' | 'velocidad'
|
||||
forma: 'circulo' | 'poligono'
|
||||
color?: string
|
||||
|
||||
// Circle - usando formato simplificado para la pagina
|
||||
centro?: Coordenadas
|
||||
radio?: number // meters
|
||||
|
||||
// Polygon/Route
|
||||
puntos?: Coordenadas[]
|
||||
|
||||
// Legacy Circle format
|
||||
centroLat?: number
|
||||
centroLng?: number
|
||||
|
||||
// Legacy Polygon format
|
||||
vertices?: Coordenadas[]
|
||||
|
||||
// Rules
|
||||
accion?: GeocercaAccion
|
||||
velocidadMaxima?: number
|
||||
horariosActivos?: string[] // e.g., ["08:00-18:00"]
|
||||
diasActivos?: number[] // 0-6 (domingo-sabado)
|
||||
|
||||
// Alerts
|
||||
alertaEntrada?: boolean
|
||||
alertaSalida?: boolean
|
||||
alertaVelocidad?: boolean
|
||||
|
||||
// Assignment
|
||||
vehiculosAsignados?: string[]
|
||||
gruposAsignados?: string[]
|
||||
|
||||
activa: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface GeocercaCreate {
|
||||
nombre: string
|
||||
descripcion?: string
|
||||
tipo: GeocercaTipo
|
||||
color: string
|
||||
centroLat?: number
|
||||
centroLng?: number
|
||||
radio?: number
|
||||
vertices?: Coordenadas[]
|
||||
accion: GeocercaAccion
|
||||
velocidadMaxima?: number
|
||||
alertaEntrada?: boolean
|
||||
alertaSalida?: boolean
|
||||
alertaVelocidad?: boolean
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// POI TYPES
|
||||
// ==========================================
|
||||
|
||||
export type POICategoria =
|
||||
| 'oficina'
|
||||
| 'cliente'
|
||||
| 'gasolinera'
|
||||
| 'taller'
|
||||
| 'estacionamiento'
|
||||
| 'restaurante'
|
||||
| 'hotel'
|
||||
| 'almacen'
|
||||
| 'otro'
|
||||
|
||||
export interface POI {
|
||||
id: string
|
||||
nombre: string
|
||||
descripcion?: string
|
||||
tipo: 'cliente' | 'taller' | 'gasolinera' | 'almacen' | 'otro'
|
||||
categoria?: POICategoria
|
||||
icono?: string
|
||||
color?: string
|
||||
|
||||
lat: number
|
||||
lng: number
|
||||
direccion: string
|
||||
|
||||
telefono?: string
|
||||
horario?: string
|
||||
notas?: string
|
||||
|
||||
activo?: boolean
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface POICreate {
|
||||
nombre: string
|
||||
descripcion?: string
|
||||
categoria: POICategoria
|
||||
color: string
|
||||
lat: number
|
||||
lng: number
|
||||
direccion?: string
|
||||
telefono?: string
|
||||
horario?: string
|
||||
notas?: string
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// COMBUSTIBLE TYPES
|
||||
// ==========================================
|
||||
|
||||
export interface CargaCombustible {
|
||||
id: string
|
||||
vehiculoId: string
|
||||
vehiculo?: Vehiculo
|
||||
conductorId?: string
|
||||
conductor?: Conductor
|
||||
|
||||
fecha: string
|
||||
litros: number
|
||||
costo: number
|
||||
precioLitro: number
|
||||
odometro: number
|
||||
lleno: boolean
|
||||
|
||||
estacion?: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
|
||||
factura?: string
|
||||
notas?: string
|
||||
|
||||
// Calculated
|
||||
rendimiento?: number // km/l
|
||||
distanciaRecorrida?: number
|
||||
|
||||
// For fuel page compatibility
|
||||
tipo?: 'gasolina' | 'diesel' | 'electrico'
|
||||
gasolinera?: string
|
||||
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// Alias for fuel page compatibility
|
||||
export type Combustible = CargaCombustible
|
||||
|
||||
export interface CargaCombustibleCreate {
|
||||
vehiculoId: string
|
||||
conductorId?: string
|
||||
fecha: string
|
||||
litros: number
|
||||
costo: number
|
||||
odometro: number
|
||||
lleno: boolean
|
||||
estacion?: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
notas?: string
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// MANTENIMIENTO TYPES
|
||||
// ==========================================
|
||||
|
||||
export type MantenimientoTipo =
|
||||
| 'preventivo'
|
||||
| 'correctivo'
|
||||
| 'revision'
|
||||
| 'cambio_aceite'
|
||||
| 'cambio_llantas'
|
||||
| 'frenos'
|
||||
| 'bateria'
|
||||
| 'revision_general'
|
||||
| 'otro'
|
||||
|
||||
export type MantenimientoEstado = 'pendiente' | 'programado' | 'en_proceso' | 'completado' | 'cancelado'
|
||||
|
||||
export interface Mantenimiento {
|
||||
id: string
|
||||
vehiculoId: string
|
||||
vehiculo?: Vehiculo
|
||||
|
||||
tipo: MantenimientoTipo
|
||||
estado: MantenimientoEstado
|
||||
|
||||
titulo?: string
|
||||
descripcion: string
|
||||
|
||||
fechaProgramada: string
|
||||
fechaRealizada?: string
|
||||
|
||||
odometroActual?: number
|
||||
odometroProximo?: number
|
||||
kilometrajeProgramado?: number
|
||||
diasProximo?: number
|
||||
|
||||
costo?: number
|
||||
taller?: string
|
||||
proveedor?: string
|
||||
tecnico?: string
|
||||
|
||||
repuestos?: string[]
|
||||
documentos?: string[]
|
||||
notas?: string
|
||||
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface MantenimientoCreate {
|
||||
vehiculoId: string
|
||||
tipo: MantenimientoTipo
|
||||
titulo: string
|
||||
descripcion?: string
|
||||
fechaProgramada: string
|
||||
odometroProximo?: number
|
||||
diasProximo?: number
|
||||
taller?: string
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VIDEO TYPES
|
||||
// ==========================================
|
||||
|
||||
export type CamaraEstado = 'online' | 'offline' | 'grabando' | 'error'
|
||||
export type CamaraPosicion = 'frontal' | 'trasera' | 'interior' | 'lateral_izq' | 'lateral_der'
|
||||
|
||||
export interface Camara {
|
||||
id: string
|
||||
vehiculoId: string
|
||||
vehiculo?: Vehiculo
|
||||
|
||||
nombre: string
|
||||
posicion: CamaraPosicion
|
||||
estado: CamaraEstado
|
||||
|
||||
streamUrl?: string
|
||||
rtspUrl?: string
|
||||
hlsUrl?: string
|
||||
webrtcUrl?: string
|
||||
|
||||
resolucion?: string
|
||||
fps?: number
|
||||
|
||||
grabar: boolean
|
||||
detectarEventos: boolean
|
||||
|
||||
ultimaActividad?: string
|
||||
|
||||
activa: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Grabacion {
|
||||
id: string
|
||||
camaraId: string
|
||||
camara?: Camara
|
||||
vehiculoId: string
|
||||
vehiculo?: Vehiculo
|
||||
viajeId?: string
|
||||
|
||||
inicio: string
|
||||
fin: string
|
||||
duracion: number // seconds
|
||||
|
||||
url: string
|
||||
thumbnail?: string
|
||||
tamano?: number // bytes
|
||||
|
||||
tipo: 'continua' | 'evento' | 'manual'
|
||||
evento?: string
|
||||
|
||||
lat?: number
|
||||
lng?: number
|
||||
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface EventoVideo {
|
||||
id: string
|
||||
grabacionId: string
|
||||
camaraId: string
|
||||
vehiculoId: string
|
||||
|
||||
tipo: string
|
||||
timestamp: string
|
||||
confianza: number
|
||||
|
||||
lat?: number
|
||||
lng?: number
|
||||
|
||||
thumbnail?: string
|
||||
clipUrl?: string
|
||||
|
||||
datos?: Record<string, unknown>
|
||||
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// REPORTE TYPES
|
||||
// ==========================================
|
||||
|
||||
export type ReporteTipo =
|
||||
| 'actividad_flota'
|
||||
| 'viajes'
|
||||
| 'alertas'
|
||||
| 'combustible'
|
||||
| 'mantenimiento'
|
||||
| 'geocercas'
|
||||
| 'velocidad'
|
||||
| 'paradas'
|
||||
| 'utilizacion'
|
||||
|
||||
export interface ReporteConfiguracion {
|
||||
tipo: ReporteTipo
|
||||
nombre: string
|
||||
descripcion?: string
|
||||
|
||||
fechaInicio: string
|
||||
fechaFin: string
|
||||
|
||||
vehiculos?: string[]
|
||||
conductores?: string[]
|
||||
|
||||
formato: 'pdf' | 'excel' | 'csv'
|
||||
|
||||
programado: boolean
|
||||
frecuencia?: 'diario' | 'semanal' | 'mensual'
|
||||
horaEnvio?: string
|
||||
emailsDestino?: string[]
|
||||
}
|
||||
|
||||
export interface Reporte {
|
||||
id: string
|
||||
tipo: string
|
||||
nombre?: string
|
||||
|
||||
desde: string
|
||||
hasta: string
|
||||
|
||||
fechaInicio?: string
|
||||
fechaFin?: string
|
||||
|
||||
vehiculoId?: string
|
||||
vehiculo?: Vehiculo
|
||||
|
||||
formato: 'pdf' | 'excel' | 'csv'
|
||||
estado: 'pendiente' | 'procesando' | 'completado' | 'error'
|
||||
url?: string
|
||||
|
||||
generadoPor?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// MENSAJE TYPES
|
||||
// ==========================================
|
||||
|
||||
export interface Mensaje {
|
||||
id: string
|
||||
de: string
|
||||
para: string
|
||||
asunto?: string
|
||||
contenido: string
|
||||
|
||||
leido: boolean
|
||||
leidoAt?: string
|
||||
|
||||
vehiculoId?: string
|
||||
viajeId?: string
|
||||
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// CONFIGURACION TYPES
|
||||
// ==========================================
|
||||
|
||||
export interface ConfiguracionSistema {
|
||||
empresa: {
|
||||
nombre: string
|
||||
logo?: string
|
||||
direccion?: string
|
||||
telefono?: string
|
||||
email?: string
|
||||
}
|
||||
mapa: {
|
||||
centroLat: number
|
||||
centroLng: number
|
||||
zoom: number
|
||||
estilo: 'dark' | 'light' | 'satellite'
|
||||
}
|
||||
alertas: AlertaConfiguracion[]
|
||||
notificaciones: {
|
||||
email: boolean
|
||||
push: boolean
|
||||
sonido: boolean
|
||||
}
|
||||
unidades: {
|
||||
distancia: 'km' | 'mi'
|
||||
volumen: 'l' | 'gal'
|
||||
velocidad: 'km/h' | 'mph'
|
||||
}
|
||||
timezone: string
|
||||
idioma: string
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// UI STATE TYPES
|
||||
// ==========================================
|
||||
|
||||
export interface MapaEstado {
|
||||
centro: Coordenadas
|
||||
zoom: number
|
||||
vehiculoSeleccionado?: string
|
||||
filtros: {
|
||||
estados: VehiculoEstado[]
|
||||
movimientos: VehiculoMovimiento[]
|
||||
tipos: VehiculoTipo[]
|
||||
grupos: string[]
|
||||
}
|
||||
capas: {
|
||||
vehiculos: boolean
|
||||
geocercas: boolean
|
||||
pois: boolean
|
||||
trafico: boolean
|
||||
rutas: boolean
|
||||
}
|
||||
herramienta?: 'seleccionar' | 'medir' | 'dibujar_circulo' | 'dibujar_poligono'
|
||||
}
|
||||
|
||||
export interface FiltrosVehiculos {
|
||||
busqueda: string
|
||||
estados: VehiculoEstado[]
|
||||
movimientos: VehiculoMovimiento[]
|
||||
tipos: VehiculoTipo[]
|
||||
grupos: string[]
|
||||
ordenar: 'nombre' | 'placa' | 'estado' | 'ultimo_reporte'
|
||||
orden: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface FiltrosAlertas {
|
||||
busqueda: string
|
||||
tipos: AlertaTipo[]
|
||||
prioridades: AlertaPrioridad[]
|
||||
estados: AlertaEstado[]
|
||||
vehiculoId?: string
|
||||
fechaDesde?: string
|
||||
fechaHasta?: string
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// WEBSOCKET TYPES
|
||||
// ==========================================
|
||||
|
||||
export type WSMessageType =
|
||||
| 'ubicacion'
|
||||
| 'alerta'
|
||||
| 'estado_vehiculo'
|
||||
| 'estado_camara'
|
||||
| 'mensaje'
|
||||
| 'ping'
|
||||
| 'pong'
|
||||
|
||||
export interface WSMessage<T = unknown> {
|
||||
type: WSMessageType
|
||||
payload: T
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface WSUbicacionPayload {
|
||||
vehiculoId: string
|
||||
ubicacion: UbicacionRealTime
|
||||
}
|
||||
|
||||
export interface WSAlertaPayload {
|
||||
alerta: Alerta
|
||||
}
|
||||
|
||||
export interface WSEstadoVehiculoPayload {
|
||||
vehiculoId: string
|
||||
estado: VehiculoEstado
|
||||
movimiento: VehiculoMovimiento
|
||||
}
|
||||
131
frontend/tailwind.config.js
Normal file
131
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Primary dark theme colors
|
||||
background: {
|
||||
DEFAULT: '#0f172a',
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
950: '#020617',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: '#1e293b',
|
||||
hover: '#334155',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: '#3b82f6',
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
success: {
|
||||
DEFAULT: '#22c55e',
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: '#eab308',
|
||||
50: '#fefce8',
|
||||
100: '#fef9c3',
|
||||
200: '#fef08a',
|
||||
300: '#fde047',
|
||||
400: '#facc15',
|
||||
500: '#eab308',
|
||||
600: '#ca8a04',
|
||||
700: '#a16207',
|
||||
800: '#854d0e',
|
||||
900: '#713f12',
|
||||
},
|
||||
error: {
|
||||
DEFAULT: '#ef4444',
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.3s ease-out',
|
||||
'fade-out': 'fadeOut 0.3s ease-out',
|
||||
'slide-in': 'slideIn 0.3s ease-out',
|
||||
'slide-out': 'slideOut 0.3s ease-out',
|
||||
'scale-in': 'scaleIn 0.2s ease-out',
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'spin-slow': 'spin 3s linear infinite',
|
||||
'bounce-slow': 'bounce 2s infinite',
|
||||
'ping-slow': 'ping 2s cubic-bezier(0, 0, 0.2, 1) infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
fadeOut: {
|
||||
'0%': { opacity: '1' },
|
||||
'100%': { opacity: '0' },
|
||||
},
|
||||
slideIn: {
|
||||
'0%': { transform: 'translateX(-100%)' },
|
||||
'100%': { transform: 'translateX(0)' },
|
||||
},
|
||||
slideOut: {
|
||||
'0%': { transform: 'translateX(0)' },
|
||||
'100%': { transform: 'translateX(-100%)' },
|
||||
},
|
||||
scaleIn: {
|
||||
'0%': { transform: 'scale(0.95)', opacity: '0' },
|
||||
'100%': { transform: 'scale(1)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
boxShadow: {
|
||||
'glow': '0 0 20px rgba(59, 130, 246, 0.3)',
|
||||
'glow-lg': '0 0 40px rgba(59, 130, 246, 0.4)',
|
||||
'inner-glow': 'inset 0 0 20px rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
backdropBlur: {
|
||||
xs: '2px',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
31
frontend/tsconfig.json
Normal file
31
frontend/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user