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:
FlotillasGPS Developer
2026-01-21 08:18:00 +00:00
commit 51d78bacf4
248 changed files with 50171 additions and 0 deletions

45
frontend/.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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"
}
}

View File

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

View 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
View 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

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

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

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

View 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')
},
}

View 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')
},
}

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

View 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

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

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

View File

@@ -0,0 +1,2 @@
export { default as AlertaCard } from './AlertaCard'
export { default as AlertaList } from './AlertaList'

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

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

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

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

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

View 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'

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

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

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

View File

@@ -0,0 +1,3 @@
export { default as Sidebar } from './Sidebar'
export { default as Header } from './Header'
export { default as MainLayout, FullscreenLayout } from './MainLayout'

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

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

View 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='&copy; <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>
)
}

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

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

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

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

View 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'

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

View 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

View 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

View 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'

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

View 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'

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

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

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

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

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

View 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

View 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'

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

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

View File

@@ -0,0 +1,2 @@
export { default as VehiculoCard } from './VehiculoCard'
export { default as VehiculoList } from './VehiculoList'

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

View File

@@ -0,0 +1 @@
export { default as ViajeCard } from './ViajeCard'

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

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

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

View File

@@ -0,0 +1,3 @@
export { default as VideoPlayer } from './VideoPlayer'
export { default as VideoGrid } from './VideoGrid'
export { default as CamaraCard } from './CamaraCard'

View 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'

View 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

View 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

View 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

View 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

View 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
View 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>,
)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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'

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

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

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

View 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'

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

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

View 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;
}

View File

@@ -0,0 +1 @@
@import './globals.css';

918
frontend/src/types/index.ts Normal file
View 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
View 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
View 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" }]
}

View 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