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

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'