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:
136
frontend/src/pages/Alertas.tsx
Normal file
136
frontend/src/pages/Alertas.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react'
|
||||
import { useAlertasStore } from '@/store/alertasStore'
|
||||
import { useAlertas, useReconocerAlerta, useResolverAlerta, useIgnorarAlerta } from '@/hooks/useAlertas'
|
||||
import { AlertaList } from '@/components/alertas'
|
||||
import Card, { CardHeader } from '@/components/ui/Card'
|
||||
import { DonutChart } from '@/components/charts/PieChart'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
|
||||
export default function Alertas() {
|
||||
const toast = useToast()
|
||||
const { data, isLoading } = useAlertas({ pageSize: 100 })
|
||||
const alertas = data?.items || []
|
||||
|
||||
const conteoPorPrioridad = useAlertasStore((state) => state.getConteoPorPrioridad())
|
||||
|
||||
const reconocerMutation = useReconocerAlerta()
|
||||
const resolverMutation = useResolverAlerta()
|
||||
const ignorarMutation = useIgnorarAlerta()
|
||||
|
||||
const handleReconocer = async (id: string) => {
|
||||
try {
|
||||
await reconocerMutation.mutateAsync({ id })
|
||||
toast.success('Alerta reconocida')
|
||||
} catch {
|
||||
toast.error('Error al reconocer alerta')
|
||||
}
|
||||
}
|
||||
|
||||
const handleResolver = async (id: string) => {
|
||||
try {
|
||||
await resolverMutation.mutateAsync({ id })
|
||||
toast.success('Alerta resuelta')
|
||||
} catch {
|
||||
toast.error('Error al resolver alerta')
|
||||
}
|
||||
}
|
||||
|
||||
const handleIgnorar = async (id: string) => {
|
||||
try {
|
||||
await ignorarMutation.mutateAsync({ id })
|
||||
toast.info('Alerta ignorada')
|
||||
} catch {
|
||||
toast.error('Error al ignorar alerta')
|
||||
}
|
||||
}
|
||||
|
||||
// Chart data
|
||||
const prioridadData = [
|
||||
{ name: 'Critica', value: conteoPorPrioridad.critica, color: '#ef4444' },
|
||||
{ name: 'Alta', value: conteoPorPrioridad.alta, color: '#f97316' },
|
||||
{ name: 'Media', value: conteoPorPrioridad.media, color: '#eab308' },
|
||||
{ name: 'Baja', value: conteoPorPrioridad.baja, color: '#3b82f6' },
|
||||
].filter((d) => d.value > 0)
|
||||
|
||||
const totalActivas = Object.values(conteoPorPrioridad).reduce((a, b) => a + b, 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Centro de Alertas</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Monitorea y gestiona las alertas del sistema
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Stats sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Priority chart */}
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Por prioridad" />
|
||||
{totalActivas > 0 ? (
|
||||
<DonutChart
|
||||
data={prioridadData}
|
||||
centerValue={totalActivas}
|
||||
centerLabel="activas"
|
||||
height={180}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-slate-500">Sin alertas activas</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Quick stats */}
|
||||
<Card padding="md">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-error-500" />
|
||||
<span className="text-sm text-slate-400">Criticas</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-white">{conteoPorPrioridad.critica}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-orange-500" />
|
||||
<span className="text-sm text-slate-400">Altas</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-white">{conteoPorPrioridad.alta}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-warning-500" />
|
||||
<span className="text-sm text-slate-400">Medias</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-white">{conteoPorPrioridad.media}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-accent-500" />
|
||||
<span className="text-sm text-slate-400">Bajas</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-white">{conteoPorPrioridad.baja}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Alerts list */}
|
||||
<div className="lg:col-span-3">
|
||||
<AlertaList
|
||||
alertas={alertas}
|
||||
isLoading={isLoading}
|
||||
onReconocer={handleReconocer}
|
||||
onResolver={handleResolver}
|
||||
onIgnorar={handleIgnorar}
|
||||
showFilters
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
334
frontend/src/pages/Combustible.tsx
Normal file
334
frontend/src/pages/Combustible.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
CalendarIcon,
|
||||
FunnelIcon,
|
||||
ArrowTrendingUpIcon,
|
||||
ArrowTrendingDownIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { format } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import { combustibleApi, vehiculosApi } from '@/api'
|
||||
import Card, { CardHeader } from '@/components/ui/Card'
|
||||
import Table from '@/components/ui/Table'
|
||||
import Select from '@/components/ui/Select'
|
||||
import { SkeletonCard } from '@/components/ui/Skeleton'
|
||||
import { LineChart } from '@/components/charts/LineChart'
|
||||
import { BarChart } from '@/components/charts/BarChart'
|
||||
import { FuelGauge } from '@/components/charts/FuelGauge'
|
||||
import { Combustible } from '@/types'
|
||||
|
||||
export default function CombustiblePage() {
|
||||
const [filtros, setFiltros] = useState({
|
||||
vehiculoId: '',
|
||||
desde: '',
|
||||
hasta: '',
|
||||
})
|
||||
|
||||
const { data: vehiculos } = useQuery({
|
||||
queryKey: ['vehiculos'],
|
||||
queryFn: () => vehiculosApi.list(),
|
||||
})
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['combustible', filtros],
|
||||
queryFn: () =>
|
||||
combustibleApi.list({
|
||||
vehiculoId: filtros.vehiculoId || undefined,
|
||||
desde: filtros.desde || undefined,
|
||||
hasta: filtros.hasta || undefined,
|
||||
pageSize: 100,
|
||||
}),
|
||||
})
|
||||
|
||||
const registros = data?.items || []
|
||||
|
||||
// Calculate stats
|
||||
const stats = {
|
||||
totalLitros: registros.reduce((sum, r) => sum + r.litros, 0),
|
||||
totalCosto: registros.reduce((sum, r) => sum + r.costo, 0),
|
||||
promedioRendimiento:
|
||||
registros.length > 0
|
||||
? registros.reduce((sum, r) => sum + (r.rendimiento || 0), 0) / registros.length
|
||||
: 0,
|
||||
totalCargas: registros.length,
|
||||
}
|
||||
|
||||
// Chart data - consumption over time
|
||||
const consumoData = registros
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((r) => ({
|
||||
fecha: format(new Date(r.fecha), 'd MMM', { locale: es }),
|
||||
litros: r.litros,
|
||||
costo: r.costo,
|
||||
}))
|
||||
|
||||
// Rendimiento por vehiculo
|
||||
const rendimientoPorVehiculo: Record<string, { nombre: string; total: number; count: number }> = {}
|
||||
registros.forEach((r) => {
|
||||
const vehiculo = r.vehiculo
|
||||
if (vehiculo && r.rendimiento) {
|
||||
if (!rendimientoPorVehiculo[vehiculo.id]) {
|
||||
rendimientoPorVehiculo[vehiculo.id] = {
|
||||
nombre: vehiculo.nombre || vehiculo.placa,
|
||||
total: 0,
|
||||
count: 0,
|
||||
}
|
||||
}
|
||||
rendimientoPorVehiculo[vehiculo.id].total += r.rendimiento
|
||||
rendimientoPorVehiculo[vehiculo.id].count += 1
|
||||
}
|
||||
})
|
||||
|
||||
const rendimientoData = Object.values(rendimientoPorVehiculo)
|
||||
.map((v) => ({
|
||||
name: v.nombre,
|
||||
value: v.total / v.count,
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 10)
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'vehiculo',
|
||||
header: 'Vehiculo',
|
||||
render: (r: Combustible) => (
|
||||
<div>
|
||||
<p className="text-white">{r.vehiculo?.nombre || 'Sin vehiculo'}</p>
|
||||
<p className="text-xs text-slate-500">{r.vehiculo?.placa}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'fecha',
|
||||
header: 'Fecha',
|
||||
render: (r: Combustible) => (
|
||||
<div>
|
||||
<p className="text-slate-300">
|
||||
{format(new Date(r.fecha), 'd MMM yyyy', { locale: es })}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{format(new Date(r.fecha), 'HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'litros',
|
||||
header: 'Litros',
|
||||
render: (r: Combustible) => (
|
||||
<span className="text-white font-medium">{r.litros.toFixed(1)} L</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'costo',
|
||||
header: 'Costo',
|
||||
render: (r: Combustible) => (
|
||||
<span className="text-success-400 font-medium">
|
||||
${r.costo.toFixed(2)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'odometro',
|
||||
header: 'Odometro',
|
||||
render: (r: Combustible) => (
|
||||
<span className="text-slate-300">
|
||||
{r.odometro ? `${r.odometro.toLocaleString()} km` : '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'rendimiento',
|
||||
header: 'Rendimiento',
|
||||
render: (r: Combustible) => {
|
||||
if (!r.rendimiento) return <span className="text-slate-500">-</span>
|
||||
const isGood = r.rendimiento >= 10
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{isGood ? (
|
||||
<ArrowTrendingUpIcon className="w-4 h-4 text-success-400" />
|
||||
) : (
|
||||
<ArrowTrendingDownIcon className="w-4 h-4 text-error-400" />
|
||||
)}
|
||||
<span className={isGood ? 'text-success-400' : 'text-error-400'}>
|
||||
{r.rendimiento.toFixed(1)} km/L
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'tipo',
|
||||
header: 'Tipo',
|
||||
render: (r: Combustible) => (
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs rounded capitalize',
|
||||
r.tipo === 'gasolina' && 'bg-orange-500/20 text-orange-400',
|
||||
r.tipo === 'diesel' && 'bg-slate-700 text-slate-300',
|
||||
r.tipo === 'electrico' && 'bg-success-500/20 text-success-400'
|
||||
)}
|
||||
>
|
||||
{r.tipo}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'gasolinera',
|
||||
header: 'Gasolinera',
|
||||
render: (r: Combustible) => (
|
||||
<span className="text-slate-400 text-sm">{r.gasolinera || '-'}</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Historial de Combustible</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Monitorea el consumo y rendimiento de combustible
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card padding="md">
|
||||
<p className="text-sm text-slate-500">Total cargas</p>
|
||||
<p className="text-2xl font-bold text-white">{stats.totalCargas}</p>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<p className="text-sm text-slate-500">Litros totales</p>
|
||||
<p className="text-2xl font-bold text-accent-400">
|
||||
{stats.totalLitros.toFixed(0)} L
|
||||
</p>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<p className="text-sm text-slate-500">Gasto total</p>
|
||||
<p className="text-2xl font-bold text-success-400">
|
||||
${stats.totalCosto.toFixed(0)}
|
||||
</p>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<p className="text-sm text-slate-500">Rendimiento promedio</p>
|
||||
<p className="text-2xl font-bold text-warning-400">
|
||||
{stats.promedioRendimiento.toFixed(1)} km/L
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Consumption chart */}
|
||||
<Card padding="lg" className="lg:col-span-2">
|
||||
<CardHeader title="Consumo de combustible" />
|
||||
{consumoData.length > 0 ? (
|
||||
<LineChart
|
||||
data={consumoData}
|
||||
xKey="fecha"
|
||||
lines={[
|
||||
{ dataKey: 'litros', name: 'Litros', color: '#3b82f6' },
|
||||
{ dataKey: 'costo', name: 'Costo ($)', color: '#22c55e' },
|
||||
]}
|
||||
height={300}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-slate-500">
|
||||
Sin datos de consumo
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Fuel gauge */}
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Rendimiento promedio" />
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
<FuelGauge
|
||||
value={stats.promedioRendimiento}
|
||||
maxValue={20}
|
||||
label="km/L"
|
||||
size={200}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Rendimiento por vehiculo */}
|
||||
{rendimientoData.length > 0 && (
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Rendimiento por vehiculo" />
|
||||
<BarChart
|
||||
data={rendimientoData}
|
||||
xKey="name"
|
||||
bars={[{ dataKey: 'value', name: 'km/L', color: '#3b82f6' }]}
|
||||
height={250}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<Card padding="md">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<FunnelIcon className="w-5 h-5 text-slate-500" />
|
||||
|
||||
<Select
|
||||
value={filtros.vehiculoId}
|
||||
onChange={(e) => setFiltros({ ...filtros, vehiculoId: e.target.value })}
|
||||
options={[
|
||||
{ value: '', label: 'Todos los vehiculos' },
|
||||
...(vehiculos?.items?.map((v) => ({
|
||||
value: v.id,
|
||||
label: v.nombre || v.placa,
|
||||
})) || []),
|
||||
]}
|
||||
className="w-48"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="date"
|
||||
value={filtros.desde}
|
||||
onChange={(e) => setFiltros({ ...filtros, desde: e.target.value })}
|
||||
className={clsx(
|
||||
'px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
/>
|
||||
<span className="text-slate-500">-</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filtros.hasta}
|
||||
onChange={(e) => setFiltros({ ...filtros, hasta: e.target.value })}
|
||||
className={clsx(
|
||||
'px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-slate-500 ml-auto">
|
||||
{registros.length} registros
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Table */}
|
||||
<Card padding="none">
|
||||
<Table
|
||||
data={registros}
|
||||
columns={columns}
|
||||
keyExtractor={(r) => r.id}
|
||||
isLoading={isLoading}
|
||||
pagination
|
||||
pageSize={20}
|
||||
emptyMessage="No hay registros de combustible"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
185
frontend/src/pages/Conductores.tsx
Normal file
185
frontend/src/pages/Conductores.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
PlusIcon,
|
||||
MagnifyingGlassIcon,
|
||||
UserIcon,
|
||||
PhoneIcon,
|
||||
TruckIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { conductoresApi } from '@/api'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import Table from '@/components/ui/Table'
|
||||
import { SkeletonTableRow } from '@/components/ui/Skeleton'
|
||||
import { Conductor } from '@/types'
|
||||
|
||||
export default function Conductores() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['conductores'],
|
||||
queryFn: () => conductoresApi.listAll(),
|
||||
})
|
||||
|
||||
const conductores = data || []
|
||||
|
||||
// Filter
|
||||
const filteredConductores = conductores.filter((c) => {
|
||||
if (!search) return true
|
||||
const searchLower = search.toLowerCase()
|
||||
return (
|
||||
c.nombre.toLowerCase().includes(searchLower) ||
|
||||
c.apellido.toLowerCase().includes(searchLower) ||
|
||||
c.telefono.includes(search)
|
||||
)
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'nombre',
|
||||
header: 'Conductor',
|
||||
render: (conductor: Conductor) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center">
|
||||
{conductor.foto ? (
|
||||
<img
|
||||
src={conductor.foto}
|
||||
alt={conductor.nombre}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<UserIcon className="w-5 h-5 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-white">
|
||||
{conductor.nombre} {conductor.apellido}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">{conductor.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'telefono',
|
||||
header: 'Telefono',
|
||||
render: (conductor: Conductor) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<PhoneIcon className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-slate-300">{conductor.telefono}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'licencia',
|
||||
header: 'Licencia',
|
||||
render: (conductor: Conductor) => (
|
||||
<div>
|
||||
<p className="text-slate-300">{conductor.licencia}</p>
|
||||
<p className="text-xs text-slate-500">Vence: {new Date(conductor.licenciaVencimiento).toLocaleDateString()}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'vehiculoActual',
|
||||
header: 'Vehiculo',
|
||||
render: (conductor: Conductor) =>
|
||||
conductor.vehiculoActual ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<TruckIcon className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-slate-300">{conductor.vehiculoActual.placa}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-500">Sin asignar</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'estado',
|
||||
header: 'Estado',
|
||||
render: (conductor: Conductor) => {
|
||||
const config = {
|
||||
disponible: { variant: 'success' as const, label: 'Disponible' },
|
||||
en_viaje: { variant: 'primary' as const, label: 'En viaje' },
|
||||
descanso: { variant: 'warning' as const, label: 'Descanso' },
|
||||
inactivo: { variant: 'default' as const, label: 'Inactivo' },
|
||||
}
|
||||
const estado = config[conductor.estado]
|
||||
return <Badge variant={estado.variant}>{estado.label}</Badge>
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Conductores</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Gestiona los conductores de tu flota
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<PlusIcon className="w-5 h-5" />}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
Agregar conductor
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar por nombre, telefono..."
|
||||
className={clsx(
|
||||
'w-full pl-10 pr-4 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-slate-500">
|
||||
{filteredConductores.length} conductores
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Table */}
|
||||
<Card padding="none">
|
||||
<Table
|
||||
data={filteredConductores}
|
||||
columns={columns}
|
||||
keyExtractor={(c) => c.id}
|
||||
isLoading={isLoading}
|
||||
pagination
|
||||
pageSize={10}
|
||||
emptyMessage="No hay conductores registrados"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Create modal */}
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
title="Agregar conductor"
|
||||
size="lg"
|
||||
>
|
||||
<p className="text-slate-400">
|
||||
Formulario de creacion de conductor (por implementar)
|
||||
</p>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
421
frontend/src/pages/Configuracion.tsx
Normal file
421
frontend/src/pages/Configuracion.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import {
|
||||
UserIcon,
|
||||
BellIcon,
|
||||
MapIcon,
|
||||
PaintBrushIcon,
|
||||
ShieldCheckIcon,
|
||||
GlobeAltIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { useConfigStore } from '@/store/configStore'
|
||||
import { authApi } from '@/api'
|
||||
import Card, { CardHeader } from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Select from '@/components/ui/Select'
|
||||
import Checkbox from '@/components/ui/Checkbox'
|
||||
import { Tabs, TabPanel } from '@/components/ui/Tabs'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
|
||||
export default function Configuracion() {
|
||||
const toast = useToast()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const config = useConfigStore()
|
||||
|
||||
const [activeTab, setActiveTab] = useState('perfil')
|
||||
const [passwordForm, setPasswordForm] = useState({
|
||||
current: '',
|
||||
new: '',
|
||||
confirm: '',
|
||||
})
|
||||
|
||||
const cambiarPasswordMutation = useMutation({
|
||||
mutationFn: (data: { currentPassword: string; newPassword: string }) =>
|
||||
authApi.cambiarPassword(data),
|
||||
onSuccess: () => {
|
||||
toast.success('Contrasena actualizada')
|
||||
setPasswordForm({ current: '', new: '', confirm: '' })
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al cambiar contrasena')
|
||||
},
|
||||
})
|
||||
|
||||
const handleCambiarPassword = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (passwordForm.new !== passwordForm.confirm) {
|
||||
toast.error('Las contrasenas no coinciden')
|
||||
return
|
||||
}
|
||||
if (passwordForm.new.length < 8) {
|
||||
toast.error('La contrasena debe tener al menos 8 caracteres')
|
||||
return
|
||||
}
|
||||
cambiarPasswordMutation.mutate({
|
||||
currentPassword: passwordForm.current,
|
||||
newPassword: passwordForm.new,
|
||||
})
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'perfil', label: 'Perfil', icon: UserIcon },
|
||||
{ id: 'notificaciones', label: 'Notificaciones', icon: BellIcon },
|
||||
{ id: 'mapa', label: 'Mapa', icon: MapIcon },
|
||||
{ id: 'apariencia', label: 'Apariencia', icon: PaintBrushIcon },
|
||||
{ id: 'seguridad', label: 'Seguridad', icon: ShieldCheckIcon },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Configuracion</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Personaliza tu experiencia en la plataforma
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Sidebar tabs */}
|
||||
<div className="space-y-2">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors text-left',
|
||||
activeTab === tab.id
|
||||
? 'bg-accent-500/20 text-accent-400'
|
||||
: 'text-slate-400 hover:bg-slate-800/50 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<tab.icon className="w-5 h-5" />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="lg:col-span-3">
|
||||
{/* Perfil */}
|
||||
{activeTab === 'perfil' && (
|
||||
<Card padding="lg">
|
||||
<CardHeader
|
||||
title="Informacion del perfil"
|
||||
subtitle="Actualiza tu informacion personal"
|
||||
/>
|
||||
<div className="space-y-4 mt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-20 h-20 bg-accent-500/20 rounded-full flex items-center justify-center">
|
||||
<span className="text-2xl font-bold text-accent-400">
|
||||
{user?.nombre?.charAt(0) || 'U'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">{user?.nombre}</p>
|
||||
<p className="text-sm text-slate-500">{user?.email}</p>
|
||||
<p className="text-xs text-slate-500 capitalize">Rol: {user?.rol}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-4">
|
||||
<Input
|
||||
label="Nombre"
|
||||
value={user?.nombre || ''}
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={user?.email || ''}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500">
|
||||
Para cambiar tu nombre o email, contacta al administrador.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Notificaciones */}
|
||||
{activeTab === 'notificaciones' && (
|
||||
<Card padding="lg">
|
||||
<CardHeader
|
||||
title="Preferencias de notificaciones"
|
||||
subtitle="Configura como deseas recibir alertas"
|
||||
/>
|
||||
<div className="space-y-6 mt-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-white font-medium">Notificaciones en la app</h3>
|
||||
<Checkbox
|
||||
label="Alertas criticas"
|
||||
description="Recibir notificaciones de alertas criticas"
|
||||
checked={config.notificaciones.sonido}
|
||||
onChange={(checked) =>
|
||||
config.setNotificaciones({ sonido: checked })
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Alertas de velocidad"
|
||||
description="Excesos de velocidad y geocercas"
|
||||
checked={config.notificaciones.escritorio}
|
||||
onChange={(checked) =>
|
||||
config.setNotificaciones({ escritorio: checked })
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Alertas de mantenimiento"
|
||||
description="Recordatorios de servicios programados"
|
||||
checked={config.notificaciones.email}
|
||||
onChange={(checked) =>
|
||||
config.setNotificaciones({ email: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t border-slate-700/50">
|
||||
<h3 className="text-white font-medium">Sonidos</h3>
|
||||
<Checkbox
|
||||
label="Reproducir sonido"
|
||||
description="Sonido al recibir alertas importantes"
|
||||
checked={config.notificaciones.sonido}
|
||||
onChange={(checked) =>
|
||||
config.setNotificaciones({ sonido: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Mapa */}
|
||||
{activeTab === 'mapa' && (
|
||||
<Card padding="lg">
|
||||
<CardHeader
|
||||
title="Configuracion del mapa"
|
||||
subtitle="Personaliza la visualizacion del mapa"
|
||||
/>
|
||||
<div className="space-y-6 mt-6">
|
||||
<Select
|
||||
label="Estilo del mapa"
|
||||
value={config.mapa.estilo}
|
||||
onChange={(e) => config.setMapa({ estilo: e.target.value as any })}
|
||||
options={[
|
||||
{ value: 'dark', label: 'Oscuro' },
|
||||
{ value: 'light', label: 'Claro' },
|
||||
{ value: 'satellite', label: 'Satelite' },
|
||||
{ value: 'streets', label: 'Calles' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Latitud inicial"
|
||||
type="number"
|
||||
step="any"
|
||||
value={config.mapa.centroInicial.lat}
|
||||
onChange={(e) =>
|
||||
config.setMapa({
|
||||
centroInicial: {
|
||||
...config.mapa.centroInicial,
|
||||
lat: parseFloat(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="Longitud inicial"
|
||||
type="number"
|
||||
step="any"
|
||||
value={config.mapa.centroInicial.lng}
|
||||
onChange={(e) =>
|
||||
config.setMapa({
|
||||
centroInicial: {
|
||||
...config.mapa.centroInicial,
|
||||
lng: parseFloat(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Zoom inicial"
|
||||
type="number"
|
||||
min={1}
|
||||
max={18}
|
||||
value={config.mapa.zoomInicial}
|
||||
onChange={(e) =>
|
||||
config.setMapa({ zoomInicial: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t border-slate-700/50">
|
||||
<h3 className="text-white font-medium">Capas visibles</h3>
|
||||
<Checkbox
|
||||
label="Mostrar trafico"
|
||||
checked={config.mapa.mostrarTrafico}
|
||||
onChange={(checked) => config.setMapa({ mostrarTrafico: checked })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Mostrar geocercas"
|
||||
checked={config.mapa.mostrarGeocercas}
|
||||
onChange={(checked) => config.setMapa({ mostrarGeocercas: checked })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Mostrar POIs"
|
||||
checked={config.mapa.mostrarPOIs}
|
||||
onChange={(checked) => config.setMapa({ mostrarPOIs: checked })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Agrupar vehiculos cercanos"
|
||||
checked={config.mapa.clusterVehiculos}
|
||||
onChange={(checked) => config.setMapa({ clusterVehiculos: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Apariencia */}
|
||||
{activeTab === 'apariencia' && (
|
||||
<Card padding="lg">
|
||||
<CardHeader
|
||||
title="Apariencia"
|
||||
subtitle="Personaliza el aspecto visual"
|
||||
/>
|
||||
<div className="space-y-6 mt-6">
|
||||
<Select
|
||||
label="Tema"
|
||||
value={config.tema}
|
||||
onChange={(e) => config.setTema(e.target.value as any)}
|
||||
options={[
|
||||
{ value: 'dark', label: 'Oscuro' },
|
||||
{ value: 'light', label: 'Claro' },
|
||||
{ value: 'system', label: 'Sistema' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Idioma"
|
||||
value={config.idioma}
|
||||
onChange={(e) => config.setIdioma(e.target.value)}
|
||||
options={[
|
||||
{ value: 'es', label: 'Espanol' },
|
||||
{ value: 'en', label: 'English' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Zona horaria"
|
||||
value={config.zonaHoraria}
|
||||
onChange={(e) => config.setZonaHoraria(e.target.value)}
|
||||
options={[
|
||||
{ value: 'America/Mexico_City', label: 'Ciudad de Mexico (GMT-6)' },
|
||||
{ value: 'America/Monterrey', label: 'Monterrey (GMT-6)' },
|
||||
{ value: 'America/Tijuana', label: 'Tijuana (GMT-8)' },
|
||||
{ value: 'America/Cancun', label: 'Cancun (GMT-5)' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t border-slate-700/50">
|
||||
<h3 className="text-white font-medium">Unidades</h3>
|
||||
<Select
|
||||
label="Sistema de unidades"
|
||||
value={config.unidades.distancia}
|
||||
onChange={(e) =>
|
||||
config.setUnidades({
|
||||
distancia: e.target.value as 'km' | 'mi',
|
||||
velocidad: e.target.value === 'km' ? 'kmh' : 'mph',
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: 'km', label: 'Metrico (km, km/h)' },
|
||||
{ value: 'mi', label: 'Imperial (mi, mph)' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Seguridad */}
|
||||
{activeTab === 'seguridad' && (
|
||||
<Card padding="lg">
|
||||
<CardHeader
|
||||
title="Seguridad"
|
||||
subtitle="Gestiona la seguridad de tu cuenta"
|
||||
/>
|
||||
<div className="space-y-6 mt-6">
|
||||
<form onSubmit={handleCambiarPassword} className="space-y-4">
|
||||
<h3 className="text-white font-medium">Cambiar contrasena</h3>
|
||||
<Input
|
||||
label="Contrasena actual"
|
||||
type="password"
|
||||
value={passwordForm.current}
|
||||
onChange={(e) =>
|
||||
setPasswordForm({ ...passwordForm, current: e.target.value })
|
||||
}
|
||||
placeholder="Ingresa tu contrasena actual"
|
||||
/>
|
||||
<Input
|
||||
label="Nueva contrasena"
|
||||
type="password"
|
||||
value={passwordForm.new}
|
||||
onChange={(e) =>
|
||||
setPasswordForm({ ...passwordForm, new: e.target.value })
|
||||
}
|
||||
placeholder="Minimo 8 caracteres"
|
||||
/>
|
||||
<Input
|
||||
label="Confirmar contrasena"
|
||||
type="password"
|
||||
value={passwordForm.confirm}
|
||||
onChange={(e) =>
|
||||
setPasswordForm({ ...passwordForm, confirm: e.target.value })
|
||||
}
|
||||
placeholder="Repite la nueva contrasena"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={cambiarPasswordMutation.isPending}
|
||||
>
|
||||
Cambiar contrasena
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="pt-4 border-t border-slate-700/50 space-y-4">
|
||||
<h3 className="text-white font-medium">Sesiones activas</h3>
|
||||
<div className="p-4 bg-slate-800/50 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<GlobeAltIcon className="w-8 h-8 text-accent-400" />
|
||||
<div>
|
||||
<p className="text-white">Sesion actual</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
Este navegador - Activo ahora
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-success-400">Activo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-slate-700/50">
|
||||
<h3 className="text-white font-medium mb-4">Zona de peligro</h3>
|
||||
<Button variant="danger">Cerrar todas las sesiones</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
272
frontend/src/pages/Dashboard.tsx
Normal file
272
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
TruckIcon,
|
||||
BellAlertIcon,
|
||||
ArrowPathIcon,
|
||||
MapPinIcon,
|
||||
UserGroupIcon,
|
||||
ChartBarIcon,
|
||||
ArrowRightIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { useVehiculosStore } from '@/store/vehiculosStore'
|
||||
import { useAlertasStore } from '@/store/alertasStore'
|
||||
import { useFleetStats } from '@/hooks/useVehiculos'
|
||||
import { KPICard, MiniKPI } from '@/components/charts/KPICard'
|
||||
import { DonutChart } from '@/components/charts/PieChart'
|
||||
import LineChart from '@/components/charts/LineChart'
|
||||
import Card, { CardHeader } from '@/components/ui/Card'
|
||||
import { AlertaCard } from '@/components/alertas'
|
||||
import { VehiculoCard } from '@/components/vehiculos'
|
||||
import { SkeletonStats, SkeletonCard } from '@/components/ui/Skeleton'
|
||||
|
||||
export default function Dashboard() {
|
||||
const vehiculos = useVehiculosStore((state) => state.getVehiculosArray())
|
||||
const estadisticas = useVehiculosStore((state) => state.getEstadisticas())
|
||||
const alertasActivas = useAlertasStore((state) => state.alertasActivas)
|
||||
const { data: fleetStats, isLoading: isLoadingStats } = useFleetStats()
|
||||
|
||||
// Mock data for charts (replace with real API data)
|
||||
const activityData = [
|
||||
{ hora: '00:00', vehiculos: 2, kilometros: 15 },
|
||||
{ hora: '04:00', vehiculos: 1, kilometros: 8 },
|
||||
{ hora: '08:00', vehiculos: 8, kilometros: 120 },
|
||||
{ hora: '12:00', vehiculos: 12, kilometros: 280 },
|
||||
{ hora: '16:00', vehiculos: 15, kilometros: 350 },
|
||||
{ hora: '20:00', vehiculos: 10, kilometros: 180 },
|
||||
]
|
||||
|
||||
const vehiculosPorEstado = [
|
||||
{ name: 'En movimiento', value: estadisticas.enMovimiento, color: '#22c55e' },
|
||||
{ name: 'Detenidos', value: estadisticas.detenidos, color: '#eab308' },
|
||||
{ name: 'Sin senal', value: estadisticas.sinSenal, color: '#64748b' },
|
||||
]
|
||||
|
||||
// Get recent vehicles with activity
|
||||
const vehiculosActivos = vehiculos
|
||||
.filter((v) => v.movimiento === 'movimiento' || v.movimiento === 'detenido')
|
||||
.slice(0, 4)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Resumen general de tu flota
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full animate-pulse" />
|
||||
Actualizacion en tiempo real
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{isLoadingStats ? (
|
||||
<>
|
||||
<SkeletonStats />
|
||||
<SkeletonStats />
|
||||
<SkeletonStats />
|
||||
<SkeletonStats />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<KPICard
|
||||
title="Vehiculos Activos"
|
||||
value={estadisticas.enMovimiento}
|
||||
subtitle={`de ${estadisticas.total} total`}
|
||||
icon={<TruckIcon className="w-5 h-5" />}
|
||||
color="green"
|
||||
trend={{ value: 12, label: 'vs ayer' }}
|
||||
/>
|
||||
<KPICard
|
||||
title="Alertas Activas"
|
||||
value={alertasActivas.length}
|
||||
subtitle={alertasActivas.filter((a) => a.prioridad === 'critica').length + ' criticas'}
|
||||
icon={<BellAlertIcon className="w-5 h-5" />}
|
||||
color={alertasActivas.length > 5 ? 'red' : 'yellow'}
|
||||
/>
|
||||
<KPICard
|
||||
title="Viajes Hoy"
|
||||
value={fleetStats?.alertasActivas || 24}
|
||||
subtitle="completados"
|
||||
icon={<ArrowPathIcon className="w-5 h-5" />}
|
||||
color="blue"
|
||||
trend={{ value: 8, isPositive: true }}
|
||||
/>
|
||||
<KPICard
|
||||
title="Km Recorridos"
|
||||
value="1,245"
|
||||
subtitle="hoy"
|
||||
icon={<MapPinIcon className="w-5 h-5" />}
|
||||
trend={{ value: 5, label: 'vs promedio' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left column - Activity chart and alerts */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Activity chart */}
|
||||
<Card padding="lg">
|
||||
<CardHeader
|
||||
title="Actividad del dia"
|
||||
subtitle="Vehiculos activos y kilometros recorridos"
|
||||
action={
|
||||
<Link
|
||||
to="/reportes"
|
||||
className="text-sm text-accent-400 hover:text-accent-300 flex items-center gap-1"
|
||||
>
|
||||
Ver reportes <ArrowRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<LineChart
|
||||
data={activityData}
|
||||
xAxisKey="hora"
|
||||
lines={[
|
||||
{ dataKey: 'vehiculos', name: 'Vehiculos', color: '#3b82f6' },
|
||||
{ dataKey: 'kilometros', name: 'Km (x10)', color: '#22c55e' },
|
||||
]}
|
||||
height={250}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Recent alerts */}
|
||||
<Card padding="lg">
|
||||
<CardHeader
|
||||
title="Alertas recientes"
|
||||
subtitle={`${alertasActivas.length} alertas activas`}
|
||||
action={
|
||||
<Link
|
||||
to="/alertas"
|
||||
className="text-sm text-accent-400 hover:text-accent-300 flex items-center gap-1"
|
||||
>
|
||||
Ver todas <ArrowRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
{alertasActivas.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 rounded-full bg-success-500/20 flex items-center justify-center mx-auto mb-3">
|
||||
<BellAlertIcon className="w-6 h-6 text-success-400" />
|
||||
</div>
|
||||
<p className="text-slate-400">No hay alertas activas</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{alertasActivas.slice(0, 5).map((alerta) => (
|
||||
<AlertaCard key={alerta.id} alerta={alerta} compact />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right column - Fleet status and vehicles */}
|
||||
<div className="space-y-6">
|
||||
{/* Fleet status donut */}
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Estado de la flota" />
|
||||
<DonutChart
|
||||
data={vehiculosPorEstado}
|
||||
centerValue={estadisticas.total}
|
||||
centerLabel="vehiculos"
|
||||
height={180}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-2 mt-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<span className="w-2 h-2 rounded-full bg-success-500" />
|
||||
<span className="text-xs text-slate-500">Movimiento</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-white">{estadisticas.enMovimiento}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<span className="w-2 h-2 rounded-full bg-warning-500" />
|
||||
<span className="text-xs text-slate-500">Detenidos</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-white">{estadisticas.detenidos}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<span className="w-2 h-2 rounded-full bg-slate-500" />
|
||||
<span className="text-xs text-slate-500">Sin senal</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-white">{estadisticas.sinSenal}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Active vehicles */}
|
||||
<Card padding="lg">
|
||||
<CardHeader
|
||||
title="Vehiculos activos"
|
||||
action={
|
||||
<Link
|
||||
to="/vehiculos"
|
||||
className="text-sm text-accent-400 hover:text-accent-300 flex items-center gap-1"
|
||||
>
|
||||
Ver todos <ArrowRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
{vehiculosActivos.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<TruckIcon className="w-12 h-12 text-slate-600 mx-auto mb-3" />
|
||||
<p className="text-slate-500">No hay vehiculos activos</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{vehiculosActivos.map((vehiculo) => (
|
||||
<VehiculoCard key={vehiculo.id} vehiculo={vehiculo} compact />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Quick links */}
|
||||
<Card padding="md">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Link
|
||||
to="/mapa"
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-slate-800/50 hover:bg-slate-700/50 transition-colors"
|
||||
>
|
||||
<MapPinIcon className="w-5 h-5 text-accent-400" />
|
||||
<span className="text-sm text-slate-300">Ver mapa</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/conductores"
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-slate-800/50 hover:bg-slate-700/50 transition-colors"
|
||||
>
|
||||
<UserGroupIcon className="w-5 h-5 text-accent-400" />
|
||||
<span className="text-sm text-slate-300">Conductores</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/viajes"
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-slate-800/50 hover:bg-slate-700/50 transition-colors"
|
||||
>
|
||||
<ArrowPathIcon className="w-5 h-5 text-accent-400" />
|
||||
<span className="text-sm text-slate-300">Viajes</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/reportes"
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-slate-800/50 hover:bg-slate-700/50 transition-colors"
|
||||
>
|
||||
<ChartBarIcon className="w-5 h-5 text-accent-400" />
|
||||
<span className="text-sm text-slate-300">Reportes</span>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
413
frontend/src/pages/Geocercas.tsx
Normal file
413
frontend/src/pages/Geocercas.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
PlusIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
MapPinIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { geocercasApi } from '@/api'
|
||||
import { MapContainer } from '@/components/mapa'
|
||||
import GeocercaLayer from '@/components/mapa/GeocercaLayer'
|
||||
import DrawingTools from '@/components/mapa/DrawingTools'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Select from '@/components/ui/Select'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import { Geocerca } from '@/types'
|
||||
|
||||
type DrawingMode = 'circle' | 'polygon' | null
|
||||
|
||||
interface GeocercaForm {
|
||||
nombre: string
|
||||
tipo: 'permitida' | 'restringida' | 'velocidad'
|
||||
forma: 'circulo' | 'poligono'
|
||||
centro?: { lat: number; lng: number }
|
||||
radio?: number
|
||||
puntos?: { lat: number; lng: number }[]
|
||||
velocidadMaxima?: number
|
||||
color: string
|
||||
}
|
||||
|
||||
const defaultForm: GeocercaForm = {
|
||||
nombre: '',
|
||||
tipo: 'permitida',
|
||||
forma: 'circulo',
|
||||
color: '#3b82f6',
|
||||
}
|
||||
|
||||
export default function Geocercas() {
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
const [selectedGeocerca, setSelectedGeocerca] = useState<Geocerca | null>(null)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [drawingMode, setDrawingMode] = useState<DrawingMode>(null)
|
||||
const [form, setForm] = useState<GeocercaForm>(defaultForm)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
const { data: geocercas, isLoading } = useQuery({
|
||||
queryKey: ['geocercas'],
|
||||
queryFn: () => geocercasApi.list(),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: geocercasApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['geocercas'] })
|
||||
toast.success('Geocerca creada exitosamente')
|
||||
handleCloseModal()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al crear geocerca')
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<Geocerca> }) =>
|
||||
geocercasApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['geocercas'] })
|
||||
toast.success('Geocerca actualizada')
|
||||
handleCloseModal()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al actualizar geocerca')
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: geocercasApi.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['geocercas'] })
|
||||
toast.success('Geocerca eliminada')
|
||||
setSelectedGeocerca(null)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al eliminar geocerca')
|
||||
},
|
||||
})
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false)
|
||||
setForm(defaultForm)
|
||||
setDrawingMode(null)
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const handleNewGeocerca = () => {
|
||||
setForm(defaultForm)
|
||||
setIsEditing(false)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleEditGeocerca = (geocerca: Geocerca) => {
|
||||
setForm({
|
||||
nombre: geocerca.nombre,
|
||||
tipo: geocerca.tipo,
|
||||
forma: geocerca.forma,
|
||||
centro: geocerca.centro,
|
||||
radio: geocerca.radio,
|
||||
puntos: geocerca.puntos,
|
||||
velocidadMaxima: geocerca.velocidadMaxima,
|
||||
color: geocerca.color || '#3b82f6',
|
||||
})
|
||||
setSelectedGeocerca(geocerca)
|
||||
setIsEditing(true)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleDeleteGeocerca = (id: string) => {
|
||||
if (confirm('¿Estas seguro de eliminar esta geocerca?')) {
|
||||
deleteMutation.mutate(id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrawComplete = useCallback(
|
||||
(data: { type: 'circle'; center: { lat: number; lng: number }; radius: number } | { type: 'polygon'; points: { lat: number; lng: number }[] }) => {
|
||||
if (data.type === 'circle') {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
forma: 'circulo',
|
||||
centro: data.center,
|
||||
radio: data.radius,
|
||||
puntos: undefined,
|
||||
}))
|
||||
} else {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
forma: 'poligono',
|
||||
puntos: data.points,
|
||||
centro: undefined,
|
||||
radio: undefined,
|
||||
}))
|
||||
}
|
||||
setDrawingMode(null)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!form.nombre) {
|
||||
toast.error('Ingresa un nombre para la geocerca')
|
||||
return
|
||||
}
|
||||
|
||||
if (form.forma === 'circulo' && (!form.centro || !form.radio)) {
|
||||
toast.error('Dibuja un circulo en el mapa')
|
||||
return
|
||||
}
|
||||
|
||||
if (form.forma === 'poligono' && (!form.puntos || form.puntos.length < 3)) {
|
||||
toast.error('Dibuja un poligono en el mapa')
|
||||
return
|
||||
}
|
||||
|
||||
const geocercaData = {
|
||||
nombre: form.nombre,
|
||||
tipo: form.tipo,
|
||||
forma: form.forma,
|
||||
centro: form.centro,
|
||||
radio: form.radio,
|
||||
puntos: form.puntos,
|
||||
velocidadMaxima: form.tipo === 'velocidad' ? form.velocidadMaxima : undefined,
|
||||
color: form.color,
|
||||
activa: true,
|
||||
}
|
||||
|
||||
if (isEditing && selectedGeocerca) {
|
||||
updateMutation.mutate({ id: selectedGeocerca.id, data: geocercaData })
|
||||
} else {
|
||||
createMutation.mutate(geocercaData as Omit<Geocerca, 'id' | 'createdAt' | 'updatedAt'>)
|
||||
}
|
||||
}
|
||||
|
||||
const geocercasList = geocercas || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Geocercas</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Crea y gestiona zonas geograficas para tus vehiculos
|
||||
</p>
|
||||
</div>
|
||||
<Button leftIcon={<PlusIcon className="w-5 h-5" />} onClick={handleNewGeocerca}>
|
||||
Nueva geocerca
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Map */}
|
||||
<div className="lg:col-span-3 h-[600px] rounded-xl overflow-hidden">
|
||||
<MapContainer showControls>
|
||||
<GeocercaLayer
|
||||
geocercas={geocercasList}
|
||||
onGeocercaClick={setSelectedGeocerca}
|
||||
/>
|
||||
{drawingMode && (
|
||||
<DrawingTools mode={drawingMode} onComplete={handleDrawComplete} />
|
||||
)}
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
<Card padding="md">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Total geocercas</span>
|
||||
<span className="text-white font-medium">{geocercasList.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Permitidas</span>
|
||||
<span className="text-success-400 font-medium">
|
||||
{geocercasList.filter((g) => g.tipo === 'permitida').length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Restringidas</span>
|
||||
<span className="text-error-400 font-medium">
|
||||
{geocercasList.filter((g) => g.tipo === 'restringida').length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Velocidad</span>
|
||||
<span className="text-warning-400 font-medium">
|
||||
{geocercasList.filter((g) => g.tipo === 'velocidad').length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Geocercas list */}
|
||||
<Card padding="none">
|
||||
<div className="p-4 border-b border-slate-700/50">
|
||||
<h3 className="font-medium text-white">Lista de geocercas</h3>
|
||||
</div>
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-center text-slate-500">Cargando...</div>
|
||||
) : geocercasList.length === 0 ? (
|
||||
<div className="p-4 text-center text-slate-500">
|
||||
No hay geocercas creadas
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-700/50">
|
||||
{geocercasList.map((geocerca) => (
|
||||
<div
|
||||
key={geocerca.id}
|
||||
className={clsx(
|
||||
'p-3 hover:bg-slate-800/50 cursor-pointer transition-colors',
|
||||
selectedGeocerca?.id === geocerca.id && 'bg-slate-800/50'
|
||||
)}
|
||||
onClick={() => setSelectedGeocerca(geocerca)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: geocerca.color || '#3b82f6' }}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-white text-sm">{geocerca.nombre}</p>
|
||||
<p className="text-xs text-slate-500 capitalize">
|
||||
{geocerca.tipo} - {geocerca.forma}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEditGeocerca(geocerca)
|
||||
}}
|
||||
className="p-1 text-slate-500 hover:text-white"
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteGeocerca(geocerca.id)
|
||||
}}
|
||||
className="p-1 text-slate-500 hover:text-error-400"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
title={isEditing ? 'Editar geocerca' : 'Nueva geocerca'}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Nombre"
|
||||
value={form.nombre}
|
||||
onChange={(e) => setForm({ ...form, nombre: e.target.value })}
|
||||
placeholder="Ej: Zona de carga"
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Tipo"
|
||||
value={form.tipo}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, tipo: e.target.value as GeocercaForm['tipo'] })
|
||||
}
|
||||
options={[
|
||||
{ value: 'permitida', label: 'Zona permitida' },
|
||||
{ value: 'restringida', label: 'Zona restringida' },
|
||||
{ value: 'velocidad', label: 'Control de velocidad' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{form.tipo === 'velocidad' && (
|
||||
<Input
|
||||
label="Velocidad maxima (km/h)"
|
||||
type="number"
|
||||
value={form.velocidadMaxima || ''}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, velocidadMaxima: parseInt(e.target.value) })
|
||||
}
|
||||
placeholder="Ej: 60"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-400 mb-2">
|
||||
Color
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={form.color}
|
||||
onChange={(e) => setForm({ ...form, color: e.target.value })}
|
||||
className="w-full h-10 rounded-lg cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-400 mb-2">
|
||||
Dibujar en el mapa
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={drawingMode === 'circle' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setDrawingMode(drawingMode === 'circle' ? null : 'circle')}
|
||||
>
|
||||
Circulo
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={drawingMode === 'polygon' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setDrawingMode(drawingMode === 'polygon' ? null : 'polygon')}
|
||||
>
|
||||
Poligono
|
||||
</Button>
|
||||
</div>
|
||||
{(form.centro || form.puntos) && (
|
||||
<p className="mt-2 text-sm text-success-400">
|
||||
<MapPinIcon className="w-4 h-4 inline mr-1" />
|
||||
Forma dibujada: {form.forma}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button type="button" variant="ghost" onClick={handleCloseModal}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{isEditing ? 'Guardar cambios' : 'Crear geocerca'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
221
frontend/src/pages/Grabaciones.tsx
Normal file
221
frontend/src/pages/Grabaciones.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
CalendarIcon,
|
||||
PlayIcon,
|
||||
ArrowDownTrayIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { format } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import { videoApi } from '@/api'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Table from '@/components/ui/Table'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import { VideoPlayer } from '@/components/video'
|
||||
import { Grabacion } from '@/types'
|
||||
|
||||
export default function Grabaciones() {
|
||||
const [filtros, setFiltros] = useState({
|
||||
vehiculoId: '',
|
||||
desde: '',
|
||||
hasta: '',
|
||||
tipo: '',
|
||||
})
|
||||
const [selectedGrabacion, setSelectedGrabacion] = useState<Grabacion | null>(null)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['grabaciones', filtros],
|
||||
queryFn: () =>
|
||||
videoApi.listGrabaciones({
|
||||
...filtros,
|
||||
vehiculoId: filtros.vehiculoId || undefined,
|
||||
desde: filtros.desde || undefined,
|
||||
hasta: filtros.hasta || undefined,
|
||||
tipo: filtros.tipo || undefined,
|
||||
pageSize: 50,
|
||||
}),
|
||||
})
|
||||
|
||||
const grabaciones = data?.items || []
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'vehiculo',
|
||||
header: 'Vehiculo',
|
||||
render: (g: Grabacion) => (
|
||||
<div>
|
||||
<p className="text-white">{g.vehiculo?.nombre || 'Sin vehiculo'}</p>
|
||||
<p className="text-xs text-slate-500">{g.vehiculo?.placa}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'camara',
|
||||
header: 'Camara',
|
||||
render: (g: Grabacion) => (
|
||||
<span className="text-slate-300 capitalize">
|
||||
{g.camara?.posicion || g.camara?.nombre}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'fecha',
|
||||
header: 'Fecha',
|
||||
render: (g: Grabacion) => (
|
||||
<div>
|
||||
<p className="text-slate-300">
|
||||
{format(new Date(g.inicio), 'd MMM yyyy', { locale: es })}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{format(new Date(g.inicio), 'HH:mm')} - {format(new Date(g.fin), 'HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'duracion',
|
||||
header: 'Duracion',
|
||||
render: (g: Grabacion) => {
|
||||
const minutos = Math.floor(g.duracion / 60)
|
||||
const segundos = g.duracion % 60
|
||||
return (
|
||||
<span className="text-slate-300">
|
||||
{minutos}:{segundos.toString().padStart(2, '0')}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'tipo',
|
||||
header: 'Tipo',
|
||||
render: (g: Grabacion) => (
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs rounded capitalize',
|
||||
g.tipo === 'evento' && 'bg-warning-500/20 text-warning-400',
|
||||
g.tipo === 'manual' && 'bg-accent-500/20 text-accent-400',
|
||||
g.tipo === 'continua' && 'bg-slate-700 text-slate-300'
|
||||
)}
|
||||
>
|
||||
{g.tipo}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'acciones',
|
||||
header: '',
|
||||
sortable: false,
|
||||
render: (g: Grabacion) => (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<PlayIcon className="w-4 h-4" />}
|
||||
onClick={() => setSelectedGrabacion(g)}
|
||||
>
|
||||
Ver
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<ArrowDownTrayIcon className="w-4 h-4" />}
|
||||
>
|
||||
Descargar
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Grabaciones</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Busca y reproduce grabaciones de video
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card padding="md">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<select
|
||||
value={filtros.tipo}
|
||||
onChange={(e) => setFiltros({ ...filtros, tipo: e.target.value })}
|
||||
className={clsx(
|
||||
'px-4 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
>
|
||||
<option value="">Todos los tipos</option>
|
||||
<option value="continua">Continua</option>
|
||||
<option value="evento">Evento</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="date"
|
||||
value={filtros.desde}
|
||||
onChange={(e) => setFiltros({ ...filtros, desde: e.target.value })}
|
||||
className={clsx(
|
||||
'px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
/>
|
||||
<span className="text-slate-500">-</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filtros.hasta}
|
||||
onChange={(e) => setFiltros({ ...filtros, hasta: e.target.value })}
|
||||
className={clsx(
|
||||
'px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-slate-500 ml-auto">
|
||||
{grabaciones.length} grabaciones
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Table */}
|
||||
<Card padding="none">
|
||||
<Table
|
||||
data={grabaciones}
|
||||
columns={columns}
|
||||
keyExtractor={(g) => g.id}
|
||||
isLoading={isLoading}
|
||||
pagination
|
||||
pageSize={20}
|
||||
emptyMessage="No hay grabaciones disponibles"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Video player modal */}
|
||||
<Modal
|
||||
isOpen={!!selectedGrabacion}
|
||||
onClose={() => setSelectedGrabacion(null)}
|
||||
title={`Grabacion - ${selectedGrabacion?.vehiculo?.placa}`}
|
||||
size="xl"
|
||||
>
|
||||
{selectedGrabacion && (
|
||||
<div className="aspect-video">
|
||||
<VideoPlayer
|
||||
src={selectedGrabacion.url}
|
||||
className="w-full h-full rounded-lg"
|
||||
autoPlay
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
240
frontend/src/pages/Login.tsx
Normal file
240
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
MapPinIcon,
|
||||
EnvelopeIcon,
|
||||
LockClosedIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Input from '@/components/ui/Input'
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const { login, isLoading, error, clearError } = useAuthStore()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setFormError('')
|
||||
clearError()
|
||||
|
||||
if (!email || !password) {
|
||||
setFormError('Por favor complete todos los campos')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await login({ email, password })
|
||||
navigate('/')
|
||||
} catch {
|
||||
// Error is handled in store
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background-900 flex">
|
||||
{/* Left side - Form */}
|
||||
<div className="w-full lg:w-1/2 flex items-center justify-center p-8">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-accent-500 to-accent-700 flex items-center justify-center shadow-lg shadow-accent-500/25">
|
||||
<MapPinIcon className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Flotillas GPS</h1>
|
||||
<p className="text-sm text-slate-500">Sistema de Monitoreo</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Welcome text */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold text-white mb-2">Bienvenido</h2>
|
||||
<p className="text-slate-400">
|
||||
Ingresa tus credenciales para acceder al sistema
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{(error || formError) && (
|
||||
<div className="mb-6 p-4 bg-error-500/10 border border-error-500/20 rounded-lg">
|
||||
<p className="text-sm text-error-400">{error || formError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<Input
|
||||
label="Correo electronico"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@empresa.com"
|
||||
leftIcon={<EnvelopeIcon className="w-5 h-5" />}
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="Contrasena"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
leftIcon={<LockClosedIcon className="w-5 h-5" />}
|
||||
rightIcon={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeSlashIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<div className="mt-2 text-right">
|
||||
<a
|
||||
href="#"
|
||||
className="text-sm text-accent-400 hover:text-accent-300 transition-colors"
|
||||
>
|
||||
¿Olvidaste tu contrasena?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Iniciar sesion
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Demo credentials */}
|
||||
<div className="mt-8 p-4 bg-slate-800/50 border border-slate-700/50 rounded-lg">
|
||||
<p className="text-xs text-slate-500 mb-2">Credenciales de demo:</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
<span className="text-slate-500">Email:</span> admin@demo.com
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
<span className="text-slate-500">Password:</span> demo123
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-8 text-center text-sm text-slate-600">
|
||||
Flotillas GPS v1.0.0 | Sistema de Monitoreo de Flota
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Background */}
|
||||
<div className="hidden lg:flex lg:w-1/2 relative bg-gradient-to-br from-accent-600 to-accent-900 overflow-hidden">
|
||||
{/* Animated background */}
|
||||
<div className="absolute inset-0">
|
||||
{/* Floating elements */}
|
||||
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-white/10 rounded-full blur-3xl animate-pulse" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-accent-400/20 rounded-full blur-3xl animate-pulse animation-delay-500" />
|
||||
<div className="absolute top-1/2 right-1/3 w-48 h-48 bg-white/5 rounded-full blur-2xl animate-bounce-slow" />
|
||||
|
||||
{/* Grid pattern */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-10"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '50px 50px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Route lines */}
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full opacity-20"
|
||||
viewBox="0 0 800 800"
|
||||
>
|
||||
<path
|
||||
d="M100,400 Q200,100 400,200 T700,400"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="10,5"
|
||||
className="animate-[dash_20s_linear_infinite]"
|
||||
/>
|
||||
<path
|
||||
d="M100,500 Q300,700 500,500 T800,300"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="10,5"
|
||||
className="animate-[dash_15s_linear_infinite]"
|
||||
/>
|
||||
{/* Vehicle markers */}
|
||||
<circle cx="200" cy="300" r="8" fill="white" className="animate-pulse" />
|
||||
<circle cx="500" cy="450" r="8" fill="white" className="animate-pulse animation-delay-200" />
|
||||
<circle cx="350" cy="200" r="8" fill="white" className="animate-pulse animation-delay-300" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 flex items-center justify-center w-full p-12">
|
||||
<div className="text-center text-white">
|
||||
<div className="w-24 h-24 mx-auto mb-8 rounded-2xl bg-white/10 backdrop-blur flex items-center justify-center">
|
||||
<MapPinIcon className="w-12 h-12" />
|
||||
</div>
|
||||
<h2 className="text-4xl font-bold mb-4">
|
||||
Monitoreo en tiempo real
|
||||
</h2>
|
||||
<p className="text-xl text-white/80 max-w-md mx-auto">
|
||||
Rastrea tu flota, optimiza rutas y mejora la eficiencia operativa con nuestro sistema GPS avanzado.
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
<div className="mt-12 grid grid-cols-3 gap-6">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-white/70">GPS en vivo</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-white/70">Video streaming</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-white/70">Reportes</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
560
frontend/src/pages/Mantenimiento.tsx
Normal file
560
frontend/src/pages/Mantenimiento.tsx
Normal file
@@ -0,0 +1,560 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
PlusIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
CalendarDaysIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { format, differenceInDays, addDays } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import { mantenimientoApi, vehiculosApi } from '@/api'
|
||||
import Card, { CardHeader } from '@/components/ui/Card'
|
||||
import Table from '@/components/ui/Table'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Select from '@/components/ui/Select'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import { Mantenimiento } from '@/types'
|
||||
|
||||
interface MantenimientoForm {
|
||||
vehiculoId: string
|
||||
tipo: Mantenimiento['tipo']
|
||||
descripcion: string
|
||||
fechaProgramada: string
|
||||
kilometrajeProgramado?: number
|
||||
costo?: number
|
||||
proveedor?: string
|
||||
notas?: string
|
||||
}
|
||||
|
||||
const defaultForm: MantenimientoForm = {
|
||||
vehiculoId: '',
|
||||
tipo: 'preventivo',
|
||||
descripcion: '',
|
||||
fechaProgramada: format(addDays(new Date(), 7), 'yyyy-MM-dd'),
|
||||
}
|
||||
|
||||
export default function MantenimientoPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [form, setForm] = useState<MantenimientoForm>(defaultForm)
|
||||
const [editingMant, setEditingMant] = useState<Mantenimiento | null>(null)
|
||||
const [filtroEstado, setFiltroEstado] = useState<string>('')
|
||||
|
||||
const { data: vehiculos } = useQuery({
|
||||
queryKey: ['vehiculos'],
|
||||
queryFn: () => vehiculosApi.list(),
|
||||
})
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['mantenimientos', filtroEstado],
|
||||
queryFn: () =>
|
||||
mantenimientoApi.list({
|
||||
estado: filtroEstado || undefined,
|
||||
pageSize: 100,
|
||||
}),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: mantenimientoApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['mantenimientos'] })
|
||||
toast.success('Mantenimiento programado')
|
||||
handleCloseModal()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al programar mantenimiento')
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<Mantenimiento> }) =>
|
||||
mantenimientoApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['mantenimientos'] })
|
||||
toast.success('Mantenimiento actualizado')
|
||||
handleCloseModal()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al actualizar')
|
||||
},
|
||||
})
|
||||
|
||||
const completarMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
mantenimientoApi.update(id, {
|
||||
estado: 'completado',
|
||||
fechaRealizada: new Date().toISOString(),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['mantenimientos'] })
|
||||
toast.success('Mantenimiento marcado como completado')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al completar')
|
||||
},
|
||||
})
|
||||
|
||||
const mantenimientos = data?.items || []
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
pendientes: mantenimientos.filter((m) => m.estado === 'pendiente').length,
|
||||
enProceso: mantenimientos.filter((m) => m.estado === 'en_proceso').length,
|
||||
completados: mantenimientos.filter((m) => m.estado === 'completado').length,
|
||||
vencidos: mantenimientos.filter(
|
||||
(m) =>
|
||||
m.estado === 'pendiente' &&
|
||||
differenceInDays(new Date(), new Date(m.fechaProgramada)) > 0
|
||||
).length,
|
||||
}
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false)
|
||||
setForm(defaultForm)
|
||||
setEditingMant(null)
|
||||
}
|
||||
|
||||
const handleNewMant = () => {
|
||||
setForm(defaultForm)
|
||||
setEditingMant(null)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleEditMant = (mant: Mantenimiento) => {
|
||||
setForm({
|
||||
vehiculoId: mant.vehiculoId,
|
||||
tipo: mant.tipo,
|
||||
descripcion: mant.descripcion,
|
||||
fechaProgramada: format(new Date(mant.fechaProgramada), 'yyyy-MM-dd'),
|
||||
kilometrajeProgramado: mant.kilometrajeProgramado,
|
||||
costo: mant.costo,
|
||||
proveedor: mant.proveedor,
|
||||
notas: mant.notas,
|
||||
})
|
||||
setEditingMant(mant)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!form.vehiculoId || !form.descripcion) {
|
||||
toast.error('Completa los campos requeridos')
|
||||
return
|
||||
}
|
||||
|
||||
const mantData = {
|
||||
...form,
|
||||
estado: 'pendiente' as const,
|
||||
}
|
||||
|
||||
if (editingMant) {
|
||||
updateMutation.mutate({ id: editingMant.id, data: mantData })
|
||||
} else {
|
||||
createMutation.mutate(mantData as Omit<Mantenimiento, 'id' | 'createdAt' | 'updatedAt'>)
|
||||
}
|
||||
}
|
||||
|
||||
const getEstadoBadge = (mant: Mantenimiento) => {
|
||||
const diasRestantes = differenceInDays(new Date(mant.fechaProgramada), new Date())
|
||||
|
||||
if (mant.estado === 'completado') {
|
||||
return <Badge variant="success">Completado</Badge>
|
||||
}
|
||||
if (mant.estado === 'en_proceso') {
|
||||
return <Badge variant="warning">En proceso</Badge>
|
||||
}
|
||||
if (diasRestantes < 0) {
|
||||
return <Badge variant="error">Vencido</Badge>
|
||||
}
|
||||
if (diasRestantes <= 7) {
|
||||
return <Badge variant="warning">Proximo</Badge>
|
||||
}
|
||||
return <Badge variant="info">Programado</Badge>
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'vehiculo',
|
||||
header: 'Vehiculo',
|
||||
render: (m: Mantenimiento) => (
|
||||
<div>
|
||||
<p className="text-white">{m.vehiculo?.nombre || 'Sin vehiculo'}</p>
|
||||
<p className="text-xs text-slate-500">{m.vehiculo?.placa}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tipo',
|
||||
header: 'Tipo',
|
||||
render: (m: Mantenimiento) => (
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs rounded capitalize',
|
||||
m.tipo === 'preventivo' && 'bg-accent-500/20 text-accent-400',
|
||||
m.tipo === 'correctivo' && 'bg-error-500/20 text-error-400',
|
||||
m.tipo === 'revision' && 'bg-warning-500/20 text-warning-400'
|
||||
)}
|
||||
>
|
||||
{m.tipo}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'descripcion',
|
||||
header: 'Descripcion',
|
||||
render: (m: Mantenimiento) => (
|
||||
<div className="max-w-xs">
|
||||
<p className="text-white truncate">{m.descripcion}</p>
|
||||
{m.proveedor && (
|
||||
<p className="text-xs text-slate-500">Proveedor: {m.proveedor}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'fechaProgramada',
|
||||
header: 'Fecha programada',
|
||||
render: (m: Mantenimiento) => {
|
||||
const diasRestantes = differenceInDays(new Date(m.fechaProgramada), new Date())
|
||||
return (
|
||||
<div>
|
||||
<p className="text-slate-300">
|
||||
{format(new Date(m.fechaProgramada), 'd MMM yyyy', { locale: es })}
|
||||
</p>
|
||||
{m.estado === 'pendiente' && (
|
||||
<p
|
||||
className={clsx(
|
||||
'text-xs',
|
||||
diasRestantes < 0
|
||||
? 'text-error-400'
|
||||
: diasRestantes <= 7
|
||||
? 'text-warning-400'
|
||||
: 'text-slate-500'
|
||||
)}
|
||||
>
|
||||
{diasRestantes < 0
|
||||
? `Vencido hace ${Math.abs(diasRestantes)} dias`
|
||||
: diasRestantes === 0
|
||||
? 'Hoy'
|
||||
: `En ${diasRestantes} dias`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'costo',
|
||||
header: 'Costo',
|
||||
render: (m: Mantenimiento) => (
|
||||
<span className="text-success-400">
|
||||
{m.costo ? `$${m.costo.toFixed(0)}` : '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'estado',
|
||||
header: 'Estado',
|
||||
render: (m: Mantenimiento) => getEstadoBadge(m),
|
||||
},
|
||||
{
|
||||
key: 'acciones',
|
||||
header: '',
|
||||
sortable: false,
|
||||
render: (m: Mantenimiento) => (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
{m.estado !== 'completado' && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<CheckCircleIcon className="w-4 h-4" />}
|
||||
onClick={() => completarMutation.mutate(m.id)}
|
||||
>
|
||||
Completar
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<WrenchScrewdriverIcon className="w-4 h-4" />}
|
||||
onClick={() => handleEditMant(m)}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// Upcoming maintenance (next 30 days)
|
||||
const proximosMantenimientos = mantenimientos
|
||||
.filter(
|
||||
(m) =>
|
||||
m.estado === 'pendiente' &&
|
||||
differenceInDays(new Date(m.fechaProgramada), new Date()) <= 30 &&
|
||||
differenceInDays(new Date(m.fechaProgramada), new Date()) >= 0
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.fechaProgramada).getTime() - new Date(b.fechaProgramada).getTime()
|
||||
)
|
||||
.slice(0, 5)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Mantenimiento</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Programa y gestiona el mantenimiento de tu flota
|
||||
</p>
|
||||
</div>
|
||||
<Button leftIcon={<PlusIcon className="w-5 h-5" />} onClick={handleNewMant}>
|
||||
Programar mantenimiento
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card padding="md" className="cursor-pointer" onClick={() => setFiltroEstado('pendiente')}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-accent-500/20 rounded-lg">
|
||||
<ClockIcon className="w-6 h-6 text-accent-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{stats.pendientes}</p>
|
||||
<p className="text-sm text-slate-500">Pendientes</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md" className="cursor-pointer" onClick={() => setFiltroEstado('en_proceso')}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-warning-500/20 rounded-lg">
|
||||
<WrenchScrewdriverIcon className="w-6 h-6 text-warning-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{stats.enProceso}</p>
|
||||
<p className="text-sm text-slate-500">En proceso</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md" className="cursor-pointer" onClick={() => setFiltroEstado('completado')}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-success-500/20 rounded-lg">
|
||||
<CheckCircleIcon className="w-6 h-6 text-success-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{stats.completados}</p>
|
||||
<p className="text-sm text-slate-500">Completados</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-error-500/20 rounded-lg">
|
||||
<ExclamationTriangleIcon className="w-6 h-6 text-error-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-error-400">{stats.vencidos}</p>
|
||||
<p className="text-sm text-slate-500">Vencidos</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Upcoming */}
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Proximos mantenimientos" />
|
||||
{proximosMantenimientos.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{proximosMantenimientos.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="p-3 bg-slate-800/50 rounded-lg cursor-pointer hover:bg-slate-800"
|
||||
onClick={() => handleEditMant(m)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">{m.vehiculo?.nombre}</p>
|
||||
<p className="text-xs text-slate-500 truncate max-w-[150px]">
|
||||
{m.descripcion}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-slate-400">
|
||||
{format(new Date(m.fechaProgramada), 'd MMM', { locale: es })}
|
||||
</p>
|
||||
<p className="text-xs text-warning-400">
|
||||
{differenceInDays(new Date(m.fechaProgramada), new Date())} dias
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500 text-center py-4">
|
||||
Sin mantenimientos proximos
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Table */}
|
||||
<div className="lg:col-span-3">
|
||||
<Card padding="none">
|
||||
<div className="p-4 border-b border-slate-700/50 flex items-center justify-between">
|
||||
<h3 className="font-medium text-white">Historial de mantenimiento</h3>
|
||||
<Select
|
||||
value={filtroEstado}
|
||||
onChange={(e) => setFiltroEstado(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'Todos los estados' },
|
||||
{ value: 'pendiente', label: 'Pendiente' },
|
||||
{ value: 'en_proceso', label: 'En proceso' },
|
||||
{ value: 'completado', label: 'Completado' },
|
||||
]}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
data={mantenimientos}
|
||||
columns={columns}
|
||||
keyExtractor={(m) => m.id}
|
||||
isLoading={isLoading}
|
||||
pagination
|
||||
pageSize={15}
|
||||
emptyMessage="No hay registros de mantenimiento"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
title={editingMant ? 'Editar mantenimiento' : 'Programar mantenimiento'}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Select
|
||||
label="Vehiculo *"
|
||||
value={form.vehiculoId}
|
||||
onChange={(e) => setForm({ ...form, vehiculoId: e.target.value })}
|
||||
options={[
|
||||
{ value: '', label: 'Selecciona un vehiculo' },
|
||||
...(vehiculos?.items?.map((v) => ({
|
||||
value: v.id,
|
||||
label: `${v.nombre || v.placa} - ${v.placa}`,
|
||||
})) || []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Tipo"
|
||||
value={form.tipo}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, tipo: e.target.value as Mantenimiento['tipo'] })
|
||||
}
|
||||
options={[
|
||||
{ value: 'preventivo', label: 'Preventivo' },
|
||||
{ value: 'correctivo', label: 'Correctivo' },
|
||||
{ value: 'revision', label: 'Revision' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
label="Fecha programada *"
|
||||
type="date"
|
||||
value={form.fechaProgramada}
|
||||
onChange={(e) => setForm({ ...form, fechaProgramada: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Descripcion *"
|
||||
value={form.descripcion}
|
||||
onChange={(e) => setForm({ ...form, descripcion: e.target.value })}
|
||||
placeholder="Ej: Cambio de aceite y filtros"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Kilometraje programado"
|
||||
type="number"
|
||||
value={form.kilometrajeProgramado || ''}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
kilometrajeProgramado: e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="Ej: 50000"
|
||||
/>
|
||||
<Input
|
||||
label="Costo estimado"
|
||||
type="number"
|
||||
value={form.costo || ''}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
costo: e.target.value ? parseFloat(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
placeholder="Ej: 2500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Proveedor"
|
||||
value={form.proveedor || ''}
|
||||
onChange={(e) => setForm({ ...form, proveedor: e.target.value })}
|
||||
placeholder="Ej: Taller Mecanico XYZ"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-400 mb-2">
|
||||
Notas
|
||||
</label>
|
||||
<textarea
|
||||
value={form.notas || ''}
|
||||
onChange={(e) => setForm({ ...form, notas: e.target.value })}
|
||||
rows={3}
|
||||
className={clsx(
|
||||
'w-full px-4 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white placeholder:text-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
placeholder="Notas adicionales..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button type="button" variant="ghost" onClick={handleCloseModal}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{editingMant ? 'Guardar cambios' : 'Programar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
255
frontend/src/pages/Mapa.tsx
Normal file
255
frontend/src/pages/Mapa.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
FunnelIcon,
|
||||
ListBulletIcon,
|
||||
XMarkIcon,
|
||||
MagnifyingGlassIcon,
|
||||
MapPinIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { useVehiculosStore } from '@/store/vehiculosStore'
|
||||
import { useMapaStore } from '@/store/mapaStore'
|
||||
import { MapContainer } from '@/components/mapa'
|
||||
import { VehiculoCard } from '@/components/vehiculos'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
|
||||
export default function Mapa() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const vehiculos = useVehiculosStore((state) => state.getVehiculosArray())
|
||||
const estadisticas = useVehiculosStore((state) => state.getEstadisticas())
|
||||
const { vehiculoSeleccionado, setVehiculoSeleccionado, centrarEnVehiculo } = useMapaStore()
|
||||
|
||||
// Handle URL params
|
||||
useEffect(() => {
|
||||
const vehiculoId = searchParams.get('vehiculo')
|
||||
if (vehiculoId) {
|
||||
setVehiculoSeleccionado(vehiculoId)
|
||||
const vehiculo = vehiculos.find((v) => v.id === vehiculoId)
|
||||
if (vehiculo?.ubicacion) {
|
||||
centrarEnVehiculo(vehiculo.ubicacion.lat, vehiculo.ubicacion.lng)
|
||||
}
|
||||
}
|
||||
}, [searchParams, vehiculos])
|
||||
|
||||
// Filter vehiculos
|
||||
const filteredVehiculos = vehiculos.filter((v) => {
|
||||
if (!search) return true
|
||||
const searchLower = search.toLowerCase()
|
||||
return (
|
||||
v.nombre.toLowerCase().includes(searchLower) ||
|
||||
v.placa.toLowerCase().includes(searchLower)
|
||||
)
|
||||
})
|
||||
|
||||
const handleVehiculoSelect = (id: string) => {
|
||||
setVehiculoSeleccionado(id)
|
||||
const vehiculo = vehiculos.find((v) => v.id === id)
|
||||
if (vehiculo?.ubicacion) {
|
||||
centrarEnVehiculo(vehiculo.ubicacion.lat, vehiculo.ubicacion.lng)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] -m-6 flex">
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={clsx(
|
||||
'relative bg-background-900 border-r border-slate-800 transition-all duration-300',
|
||||
sidebarOpen ? 'w-80' : 'w-0'
|
||||
)}
|
||||
>
|
||||
{sidebarOpen && (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-slate-800">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white">Vehiculos</h2>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="p-1 text-slate-400 hover:text-white rounded"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar vehiculo..."
|
||||
className={clsx(
|
||||
'w-full pl-9 pr-4 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-sm text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-3 mt-3 text-xs">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-success-500" />
|
||||
<span className="text-slate-400">{estadisticas.enMovimiento}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-warning-500" />
|
||||
<span className="text-slate-400">{estadisticas.detenidos}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-slate-500" />
|
||||
<span className="text-slate-400">{estadisticas.sinSenal}</span>
|
||||
</span>
|
||||
<span className="text-slate-600 ml-auto">{estadisticas.total} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vehicle list */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{filteredVehiculos.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<MapPinIcon className="w-10 h-10 text-slate-600 mx-auto mb-2" />
|
||||
<p className="text-sm text-slate-500">No se encontraron vehiculos</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredVehiculos.map((vehiculo) => (
|
||||
<VehiculoCard
|
||||
key={vehiculo.id}
|
||||
vehiculo={vehiculo}
|
||||
compact
|
||||
isSelected={vehiculoSeleccionado === vehiculo.id}
|
||||
onClick={() => handleVehiculoSelect(vehiculo.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toggle button */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className={clsx(
|
||||
'absolute top-1/2 -translate-y-1/2 -right-4 z-10',
|
||||
'w-8 h-16 bg-card border border-slate-700 rounded-r-lg',
|
||||
'flex items-center justify-center',
|
||||
'text-slate-400 hover:text-white transition-colors'
|
||||
)}
|
||||
>
|
||||
{sidebarOpen ? (
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<ChevronRightIcon className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<div className="flex-1 relative">
|
||||
<MapContainer
|
||||
selectedVehiculoId={vehiculoSeleccionado}
|
||||
onVehiculoSelect={handleVehiculoSelect}
|
||||
showControls
|
||||
/>
|
||||
|
||||
{/* Selected vehicle info */}
|
||||
{vehiculoSeleccionado && (
|
||||
<SelectedVehiculoPanel
|
||||
vehiculoId={vehiculoSeleccionado}
|
||||
onClose={() => setVehiculoSeleccionado(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Selected vehicle panel
|
||||
function SelectedVehiculoPanel({
|
||||
vehiculoId,
|
||||
onClose,
|
||||
}: {
|
||||
vehiculoId: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const vehiculo = useVehiculosStore((state) => state.getVehiculoById(vehiculoId))
|
||||
|
||||
if (!vehiculo) return null
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 left-4 w-80 bg-card border border-slate-700 rounded-xl shadow-xl">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{vehiculo.nombre}</h3>
|
||||
<p className="text-sm text-slate-500">{vehiculo.placa}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-slate-400 hover:text-white rounded"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<div className="p-2 bg-slate-800/50 rounded-lg">
|
||||
<p className="text-xs text-slate-500">Velocidad</p>
|
||||
<p className="text-lg font-bold text-white">{vehiculo.velocidad || 0} km/h</p>
|
||||
</div>
|
||||
<div className="p-2 bg-slate-800/50 rounded-lg">
|
||||
<p className="text-xs text-slate-500">Estado</p>
|
||||
<Badge
|
||||
variant={
|
||||
vehiculo.movimiento === 'movimiento'
|
||||
? 'success'
|
||||
: vehiculo.movimiento === 'detenido'
|
||||
? 'warning'
|
||||
: 'default'
|
||||
}
|
||||
dot
|
||||
>
|
||||
{vehiculo.movimiento === 'movimiento'
|
||||
? 'En movimiento'
|
||||
: vehiculo.movimiento === 'detenido'
|
||||
? 'Detenido'
|
||||
: 'Sin senal'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vehiculo.conductor && (
|
||||
<div className="text-sm text-slate-400 mb-3">
|
||||
Conductor: {vehiculo.conductor.nombre} {vehiculo.conductor.apellido}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={`/vehiculos/${vehiculo.id}`}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-center text-white bg-accent-500 hover:bg-accent-600 rounded-lg transition-colors"
|
||||
>
|
||||
Ver detalles
|
||||
</a>
|
||||
<a
|
||||
href={`/viajes?vehiculo=${vehiculo.id}`}
|
||||
className="px-3 py-2 text-sm font-medium text-slate-300 hover:text-white bg-slate-700 hover:bg-slate-600 rounded-lg transition-colors"
|
||||
>
|
||||
Viajes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
frontend/src/pages/NotFound.tsx
Normal file
97
frontend/src/pages/NotFound.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { HomeIcon, ArrowLeftIcon } from '@heroicons/react/24/outline'
|
||||
import Button from '@/components/ui/Button'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
|
||||
<div className="text-center">
|
||||
{/* 404 Graphic */}
|
||||
<div className="relative">
|
||||
<h1 className="text-[200px] font-bold text-slate-800 select-none leading-none">
|
||||
404
|
||||
</h1>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-24 h-24 bg-accent-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="w-12 h-12 text-accent-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="mt-8 space-y-4">
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
Pagina no encontrada
|
||||
</h2>
|
||||
<p className="text-slate-400 max-w-md mx-auto">
|
||||
Lo sentimos, la pagina que buscas no existe o ha sido movida.
|
||||
Verifica la URL o regresa al inicio.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-8 flex items-center justify-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
leftIcon={<ArrowLeftIcon className="w-5 h-5" />}
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
Volver atras
|
||||
</Button>
|
||||
<Link to="/">
|
||||
<Button leftIcon={<HomeIcon className="w-5 h-5" />}>
|
||||
Ir al inicio
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="mt-12 pt-8 border-t border-slate-800">
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
O visita alguna de estas secciones:
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<Link
|
||||
to="/mapa"
|
||||
className="text-accent-400 hover:text-accent-300 text-sm"
|
||||
>
|
||||
Mapa
|
||||
</Link>
|
||||
<Link
|
||||
to="/vehiculos"
|
||||
className="text-accent-400 hover:text-accent-300 text-sm"
|
||||
>
|
||||
Vehiculos
|
||||
</Link>
|
||||
<Link
|
||||
to="/alertas"
|
||||
className="text-accent-400 hover:text-accent-300 text-sm"
|
||||
>
|
||||
Alertas
|
||||
</Link>
|
||||
<Link
|
||||
to="/viajes"
|
||||
className="text-accent-400 hover:text-accent-300 text-sm"
|
||||
>
|
||||
Viajes
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
447
frontend/src/pages/POIs.tsx
Normal file
447
frontend/src/pages/POIs.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
PlusIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
MapPinIcon,
|
||||
BuildingOfficeIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
ArchiveBoxIcon,
|
||||
TruckIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { poisApi } from '@/api'
|
||||
import { MapContainer } from '@/components/mapa'
|
||||
import POILayer from '@/components/mapa/POILayer'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Select from '@/components/ui/Select'
|
||||
import Table from '@/components/ui/Table'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import { POI } from '@/types'
|
||||
|
||||
interface POIForm {
|
||||
nombre: string
|
||||
tipo: POI['tipo']
|
||||
direccion: string
|
||||
lat: number | ''
|
||||
lng: number | ''
|
||||
telefono?: string
|
||||
notas?: string
|
||||
}
|
||||
|
||||
const defaultForm: POIForm = {
|
||||
nombre: '',
|
||||
tipo: 'cliente',
|
||||
direccion: '',
|
||||
lat: '',
|
||||
lng: '',
|
||||
}
|
||||
|
||||
const tipoIcons: Record<POI['tipo'], React.ReactNode> = {
|
||||
cliente: <BuildingOfficeIcon className="w-5 h-5" />,
|
||||
taller: <WrenchScrewdriverIcon className="w-5 h-5" />,
|
||||
gasolinera: <TruckIcon className="w-5 h-5" />,
|
||||
almacen: <ArchiveBoxIcon className="w-5 h-5" />,
|
||||
otro: <MapPinIcon className="w-5 h-5" />,
|
||||
}
|
||||
|
||||
const tipoColors: Record<POI['tipo'], string> = {
|
||||
cliente: 'text-accent-400',
|
||||
taller: 'text-warning-400',
|
||||
gasolinera: 'text-success-400',
|
||||
almacen: 'text-purple-400',
|
||||
otro: 'text-slate-400',
|
||||
}
|
||||
|
||||
export default function POIs() {
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [form, setForm] = useState<POIForm>(defaultForm)
|
||||
const [editingPOI, setEditingPOI] = useState<POI | null>(null)
|
||||
const [viewMode, setViewMode] = useState<'map' | 'table'>('map')
|
||||
const [filterTipo, setFilterTipo] = useState<string>('')
|
||||
|
||||
const { data: pois, isLoading } = useQuery({
|
||||
queryKey: ['pois'],
|
||||
queryFn: () => poisApi.list(),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: poisApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pois'] })
|
||||
toast.success('Punto de interes creado')
|
||||
handleCloseModal()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al crear punto de interes')
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<POI> }) =>
|
||||
poisApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pois'] })
|
||||
toast.success('Punto de interes actualizado')
|
||||
handleCloseModal()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al actualizar')
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: poisApi.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pois'] })
|
||||
toast.success('Punto de interes eliminado')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al eliminar')
|
||||
},
|
||||
})
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false)
|
||||
setForm(defaultForm)
|
||||
setEditingPOI(null)
|
||||
}
|
||||
|
||||
const handleNewPOI = () => {
|
||||
setForm(defaultForm)
|
||||
setEditingPOI(null)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleEditPOI = (poi: POI) => {
|
||||
setForm({
|
||||
nombre: poi.nombre,
|
||||
tipo: poi.tipo,
|
||||
direccion: poi.direccion,
|
||||
lat: poi.lat,
|
||||
lng: poi.lng,
|
||||
telefono: poi.telefono,
|
||||
notas: poi.notas,
|
||||
})
|
||||
setEditingPOI(poi)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleDeletePOI = (id: string) => {
|
||||
if (confirm('¿Estas seguro de eliminar este punto de interes?')) {
|
||||
deleteMutation.mutate(id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMapClick = (lat: number, lng: number) => {
|
||||
if (isModalOpen) {
|
||||
setForm({ ...form, lat, lng })
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!form.nombre || !form.direccion) {
|
||||
toast.error('Completa los campos requeridos')
|
||||
return
|
||||
}
|
||||
|
||||
if (form.lat === '' || form.lng === '') {
|
||||
toast.error('Selecciona una ubicacion en el mapa')
|
||||
return
|
||||
}
|
||||
|
||||
const poiData = {
|
||||
nombre: form.nombre,
|
||||
tipo: form.tipo,
|
||||
direccion: form.direccion,
|
||||
lat: form.lat as number,
|
||||
lng: form.lng as number,
|
||||
telefono: form.telefono,
|
||||
notas: form.notas,
|
||||
}
|
||||
|
||||
if (editingPOI) {
|
||||
updateMutation.mutate({ id: editingPOI.id, data: poiData })
|
||||
} else {
|
||||
createMutation.mutate(poiData as Omit<POI, 'id' | 'createdAt' | 'updatedAt'>)
|
||||
}
|
||||
}
|
||||
|
||||
const poisList = pois || []
|
||||
const filteredPOIs = filterTipo
|
||||
? poisList.filter((p) => p.tipo === filterTipo)
|
||||
: poisList
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'nombre',
|
||||
header: 'Nombre',
|
||||
render: (poi: POI) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={tipoColors[poi.tipo]}>{tipoIcons[poi.tipo]}</span>
|
||||
<div>
|
||||
<p className="text-white font-medium">{poi.nombre}</p>
|
||||
<p className="text-xs text-slate-500 capitalize">{poi.tipo}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'direccion',
|
||||
header: 'Direccion',
|
||||
render: (poi: POI) => <span className="text-slate-300">{poi.direccion}</span>,
|
||||
},
|
||||
{
|
||||
key: 'telefono',
|
||||
header: 'Telefono',
|
||||
render: (poi: POI) => (
|
||||
<span className="text-slate-300">{poi.telefono || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'coordenadas',
|
||||
header: 'Coordenadas',
|
||||
render: (poi: POI) => (
|
||||
<span className="text-slate-400 text-xs font-mono">
|
||||
{poi.lat.toFixed(5)}, {poi.lng.toFixed(5)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'acciones',
|
||||
header: '',
|
||||
sortable: false,
|
||||
render: (poi: POI) => (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<PencilIcon className="w-4 h-4" />}
|
||||
onClick={() => handleEditPOI(poi)}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<TrashIcon className="w-4 h-4" />}
|
||||
onClick={() => handleDeletePOI(poi.id)}
|
||||
>
|
||||
Eliminar
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// Stats by type
|
||||
const stats = {
|
||||
cliente: poisList.filter((p) => p.tipo === 'cliente').length,
|
||||
taller: poisList.filter((p) => p.tipo === 'taller').length,
|
||||
gasolinera: poisList.filter((p) => p.tipo === 'gasolinera').length,
|
||||
almacen: poisList.filter((p) => p.tipo === 'almacen').length,
|
||||
otro: poisList.filter((p) => p.tipo === 'otro').length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Puntos de Interes</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Gestiona ubicaciones importantes para tu flota
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex rounded-lg overflow-hidden border border-slate-700">
|
||||
<button
|
||||
onClick={() => setViewMode('map')}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 text-sm',
|
||||
viewMode === 'map'
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'bg-slate-800 text-slate-400 hover:text-white'
|
||||
)}
|
||||
>
|
||||
Mapa
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('table')}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 text-sm',
|
||||
viewMode === 'table'
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'bg-slate-800 text-slate-400 hover:text-white'
|
||||
)}
|
||||
>
|
||||
Tabla
|
||||
</button>
|
||||
</div>
|
||||
<Button leftIcon={<PlusIcon className="w-5 h-5" />} onClick={handleNewPOI}>
|
||||
Nuevo POI
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
||||
{Object.entries(stats).map(([tipo, count]) => (
|
||||
<Card
|
||||
key={tipo}
|
||||
padding="md"
|
||||
className={clsx(
|
||||
'cursor-pointer transition-all',
|
||||
filterTipo === tipo && 'ring-2 ring-accent-500'
|
||||
)}
|
||||
onClick={() => setFilterTipo(filterTipo === tipo ? '' : tipo)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={tipoColors[tipo as POI['tipo']]}>
|
||||
{tipoIcons[tipo as POI['tipo']]}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{count}</p>
|
||||
<p className="text-xs text-slate-500 capitalize">{tipo}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{viewMode === 'map' ? (
|
||||
<div className="h-[500px] rounded-xl overflow-hidden">
|
||||
<MapContainer showControls>
|
||||
<POILayer pois={filteredPOIs} onPOIClick={handleEditPOI} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
) : (
|
||||
<Card padding="none">
|
||||
<Table
|
||||
data={filteredPOIs}
|
||||
columns={columns}
|
||||
keyExtractor={(p) => p.id}
|
||||
isLoading={isLoading}
|
||||
pagination
|
||||
pageSize={15}
|
||||
emptyMessage="No hay puntos de interes"
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
title={editingPOI ? 'Editar punto de interes' : 'Nuevo punto de interes'}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Nombre *"
|
||||
value={form.nombre}
|
||||
onChange={(e) => setForm({ ...form, nombre: e.target.value })}
|
||||
placeholder="Ej: Cliente ABC"
|
||||
/>
|
||||
<Select
|
||||
label="Tipo"
|
||||
value={form.tipo}
|
||||
onChange={(e) => setForm({ ...form, tipo: e.target.value as POI['tipo'] })}
|
||||
options={[
|
||||
{ value: 'cliente', label: 'Cliente' },
|
||||
{ value: 'taller', label: 'Taller' },
|
||||
{ value: 'gasolinera', label: 'Gasolinera' },
|
||||
{ value: 'almacen', label: 'Almacen' },
|
||||
{ value: 'otro', label: 'Otro' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Direccion *"
|
||||
value={form.direccion}
|
||||
onChange={(e) => setForm({ ...form, direccion: e.target.value })}
|
||||
placeholder="Ej: Av. Principal 123, Ciudad"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Telefono"
|
||||
value={form.telefono || ''}
|
||||
onChange={(e) => setForm({ ...form, telefono: e.target.value })}
|
||||
placeholder="Ej: +52 555 123 4567"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-400 mb-2">
|
||||
Notas
|
||||
</label>
|
||||
<textarea
|
||||
value={form.notas || ''}
|
||||
onChange={(e) => setForm({ ...form, notas: e.target.value })}
|
||||
rows={3}
|
||||
className={clsx(
|
||||
'w-full px-4 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white placeholder:text-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
placeholder="Notas adicionales..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-400 mb-2">
|
||||
Ubicacion
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4 mb-2">
|
||||
<Input
|
||||
label="Latitud"
|
||||
type="number"
|
||||
step="any"
|
||||
value={form.lat}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, lat: e.target.value ? parseFloat(e.target.value) : '' })
|
||||
}
|
||||
placeholder="19.4326"
|
||||
/>
|
||||
<Input
|
||||
label="Longitud"
|
||||
type="number"
|
||||
step="any"
|
||||
value={form.lng}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, lng: e.target.value ? parseFloat(e.target.value) : '' })
|
||||
}
|
||||
placeholder="-99.1332"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
Ingresa las coordenadas manualmente o usa el geocodificador
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button type="button" variant="ghost" onClick={handleCloseModal}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{editingPOI ? 'Guardar cambios' : 'Crear POI'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
383
frontend/src/pages/Reportes.tsx
Normal file
383
frontend/src/pages/Reportes.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import {
|
||||
DocumentChartBarIcon,
|
||||
ArrowDownTrayIcon,
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationCircleIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { format } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import { reportesApi, vehiculosApi } from '@/api'
|
||||
import Card, { CardHeader } from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Select from '@/components/ui/Select'
|
||||
import Table from '@/components/ui/Table'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import { Reporte } from '@/types'
|
||||
|
||||
const tiposReporte = [
|
||||
{
|
||||
id: 'actividad',
|
||||
nombre: 'Reporte de Actividad',
|
||||
descripcion: 'Resumen de actividad de vehiculos incluyendo viajes, distancia y tiempo de operacion',
|
||||
icono: DocumentChartBarIcon,
|
||||
},
|
||||
{
|
||||
id: 'combustible',
|
||||
nombre: 'Reporte de Combustible',
|
||||
descripcion: 'Analisis de consumo de combustible, costos y rendimiento por vehiculo',
|
||||
icono: DocumentChartBarIcon,
|
||||
},
|
||||
{
|
||||
id: 'alertas',
|
||||
nombre: 'Reporte de Alertas',
|
||||
descripcion: 'Historial de alertas generadas, tiempos de respuesta y resolucion',
|
||||
icono: DocumentChartBarIcon,
|
||||
},
|
||||
{
|
||||
id: 'velocidad',
|
||||
nombre: 'Reporte de Velocidad',
|
||||
descripcion: 'Analisis de velocidades, excesos y patrones de conduccion',
|
||||
icono: DocumentChartBarIcon,
|
||||
},
|
||||
{
|
||||
id: 'geocercas',
|
||||
nombre: 'Reporte de Geocercas',
|
||||
descripcion: 'Entradas, salidas y tiempo de permanencia en geocercas',
|
||||
icono: DocumentChartBarIcon,
|
||||
},
|
||||
{
|
||||
id: 'mantenimiento',
|
||||
nombre: 'Reporte de Mantenimiento',
|
||||
descripcion: 'Historial de servicios, costos y programacion de mantenimiento',
|
||||
icono: DocumentChartBarIcon,
|
||||
},
|
||||
]
|
||||
|
||||
export default function Reportes() {
|
||||
const toast = useToast()
|
||||
const [selectedTipo, setSelectedTipo] = useState<string | null>(null)
|
||||
const [params, setParams] = useState({
|
||||
vehiculoId: '',
|
||||
desde: format(new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), 'yyyy-MM-dd'),
|
||||
hasta: format(new Date(), 'yyyy-MM-dd'),
|
||||
formato: 'pdf' as 'pdf' | 'excel' | 'csv',
|
||||
})
|
||||
|
||||
const { data: vehiculos } = useQuery({
|
||||
queryKey: ['vehiculos'],
|
||||
queryFn: () => vehiculosApi.list(),
|
||||
})
|
||||
|
||||
const { data: historial, isLoading: loadingHistorial } = useQuery({
|
||||
queryKey: ['reportes-historial'],
|
||||
queryFn: () => reportesApi.listHistorial({ pageSize: 20 }),
|
||||
})
|
||||
|
||||
const generarMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
reportesApi.generar({
|
||||
tipo: selectedTipo!,
|
||||
vehiculoId: params.vehiculoId || undefined,
|
||||
desde: params.desde,
|
||||
hasta: params.hasta,
|
||||
formato: params.formato,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
toast.success('Reporte generado exitosamente')
|
||||
// Download file
|
||||
if (data.url) {
|
||||
window.open(data.url, '_blank')
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al generar reporte')
|
||||
},
|
||||
})
|
||||
|
||||
const handleGenerarReporte = () => {
|
||||
if (!selectedTipo) {
|
||||
toast.error('Selecciona un tipo de reporte')
|
||||
return
|
||||
}
|
||||
generarMutation.mutate()
|
||||
}
|
||||
|
||||
const historialReportes = historial?.items || []
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'tipo',
|
||||
header: 'Tipo',
|
||||
render: (r: Reporte) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<DocumentChartBarIcon className="w-5 h-5 text-accent-400" />
|
||||
<span className="text-white capitalize">{r.tipo.replace('_', ' ')}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'periodo',
|
||||
header: 'Periodo',
|
||||
render: (r: Reporte) => (
|
||||
<span className="text-slate-300">
|
||||
{format(new Date(r.desde), 'd MMM', { locale: es })} -{' '}
|
||||
{format(new Date(r.hasta), 'd MMM yyyy', { locale: es })}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'vehiculo',
|
||||
header: 'Vehiculo',
|
||||
render: (r: Reporte) => (
|
||||
<span className="text-slate-300">
|
||||
{r.vehiculo?.nombre || 'Todos los vehiculos'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'generado',
|
||||
header: 'Generado',
|
||||
render: (r: Reporte) => (
|
||||
<div>
|
||||
<p className="text-slate-300">
|
||||
{format(new Date(r.createdAt), 'd MMM yyyy', { locale: es })}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{format(new Date(r.createdAt), 'HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'estado',
|
||||
header: 'Estado',
|
||||
render: (r: Reporte) => {
|
||||
switch (r.estado) {
|
||||
case 'completado':
|
||||
return <Badge variant="success">Completado</Badge>
|
||||
case 'procesando':
|
||||
return <Badge variant="warning">Procesando</Badge>
|
||||
case 'error':
|
||||
return <Badge variant="error">Error</Badge>
|
||||
default:
|
||||
return <Badge variant="default">{r.estado}</Badge>
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'formato',
|
||||
header: 'Formato',
|
||||
render: (r: Reporte) => (
|
||||
<span className="text-slate-400 uppercase text-sm">{r.formato}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'acciones',
|
||||
header: '',
|
||||
sortable: false,
|
||||
render: (r: Reporte) => (
|
||||
<div className="flex justify-end">
|
||||
{r.estado === 'completado' && r.url && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<ArrowDownTrayIcon className="w-4 h-4" />}
|
||||
onClick={() => window.open(r.url, '_blank')}
|
||||
>
|
||||
Descargar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Generador de Reportes</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Crea reportes personalizados de tu flota
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Report types */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-white">Tipos de reporte</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{tiposReporte.map((tipo) => (
|
||||
<Card
|
||||
key={tipo.id}
|
||||
padding="md"
|
||||
className={clsx(
|
||||
'cursor-pointer transition-all',
|
||||
selectedTipo === tipo.id
|
||||
? 'ring-2 ring-accent-500 bg-accent-500/10'
|
||||
: 'hover:bg-slate-800/50'
|
||||
)}
|
||||
onClick={() => setSelectedTipo(tipo.id)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={clsx(
|
||||
'p-2 rounded-lg',
|
||||
selectedTipo === tipo.id
|
||||
? 'bg-accent-500/20'
|
||||
: 'bg-slate-800'
|
||||
)}
|
||||
>
|
||||
<tipo.icono
|
||||
className={clsx(
|
||||
'w-6 h-6',
|
||||
selectedTipo === tipo.id
|
||||
? 'text-accent-400'
|
||||
: 'text-slate-400'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-medium">{tipo.nombre}</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">{tipo.descripcion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Report configuration */}
|
||||
<div>
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Configuracion" />
|
||||
<div className="space-y-4">
|
||||
<Select
|
||||
label="Vehiculo"
|
||||
value={params.vehiculoId}
|
||||
onChange={(e) => setParams({ ...params, vehiculoId: e.target.value })}
|
||||
options={[
|
||||
{ value: '', label: 'Todos los vehiculos' },
|
||||
...(vehiculos?.items?.map((v) => ({
|
||||
value: v.id,
|
||||
label: v.nombre || v.placa,
|
||||
})) || []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-slate-400">
|
||||
Periodo
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={params.desde}
|
||||
onChange={(e) => setParams({ ...params, desde: e.target.value })}
|
||||
className={clsx(
|
||||
'flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
/>
|
||||
<span className="text-slate-500">-</span>
|
||||
<input
|
||||
type="date"
|
||||
value={params.hasta}
|
||||
onChange={(e) => setParams({ ...params, hasta: e.target.value })}
|
||||
className={clsx(
|
||||
'flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Formato"
|
||||
value={params.formato}
|
||||
onChange={(e) =>
|
||||
setParams({ ...params, formato: e.target.value as 'pdf' | 'excel' | 'csv' })
|
||||
}
|
||||
options={[
|
||||
{ value: 'pdf', label: 'PDF' },
|
||||
{ value: 'excel', label: 'Excel' },
|
||||
{ value: 'csv', label: 'CSV' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
leftIcon={<DocumentChartBarIcon className="w-5 h-5" />}
|
||||
onClick={handleGenerarReporte}
|
||||
isLoading={generarMutation.isPending}
|
||||
disabled={!selectedTipo}
|
||||
>
|
||||
Generar reporte
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick stats */}
|
||||
<Card padding="md" className="mt-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClockIcon className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-sm text-slate-400">Ultimo generado</span>
|
||||
</div>
|
||||
<span className="text-white text-sm">
|
||||
{historialReportes[0]
|
||||
? format(new Date(historialReportes[0].createdAt), 'd MMM HH:mm', {
|
||||
locale: es,
|
||||
})
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleIcon className="w-4 h-4 text-success-400" />
|
||||
<span className="text-sm text-slate-400">Exitosos</span>
|
||||
</div>
|
||||
<span className="text-success-400 text-sm">
|
||||
{historialReportes.filter((r) => r.estado === 'completado').length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ExclamationCircleIcon className="w-4 h-4 text-error-400" />
|
||||
<span className="text-sm text-slate-400">Con errores</span>
|
||||
</div>
|
||||
<span className="text-error-400 text-sm">
|
||||
{historialReportes.filter((r) => r.estado === 'error').length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History */}
|
||||
<Card padding="none">
|
||||
<div className="p-4 border-b border-slate-700/50">
|
||||
<h3 className="font-medium text-white">Historial de reportes</h3>
|
||||
</div>
|
||||
<Table
|
||||
data={historialReportes}
|
||||
columns={columns}
|
||||
keyExtractor={(r) => r.id}
|
||||
isLoading={loadingHistorial}
|
||||
pagination
|
||||
pageSize={10}
|
||||
emptyMessage="No hay reportes generados"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
274
frontend/src/pages/VehiculoDetalle.tsx
Normal file
274
frontend/src/pages/VehiculoDetalle.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
MapPinIcon,
|
||||
UserIcon,
|
||||
CalendarIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
BellAlertIcon,
|
||||
VideoCameraIcon,
|
||||
ArrowPathIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { useVehiculo, useVehiculoStats } from '@/hooks/useVehiculos'
|
||||
import Tabs, { TabPanel } from '@/components/ui/Tabs'
|
||||
import Card, { CardHeader } from '@/components/ui/Card'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { KPICard } from '@/components/charts/KPICard'
|
||||
import { FuelGauge } from '@/components/charts/FuelGauge'
|
||||
import LineChart from '@/components/charts/LineChart'
|
||||
import { SkeletonCard, SkeletonStats } from '@/components/ui/Skeleton'
|
||||
|
||||
export default function VehiculoDetalle() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { data: vehiculo, isLoading } = useVehiculo(id!)
|
||||
const { data: stats } = useVehiculoStats(id!, 'semana')
|
||||
|
||||
if (isLoading || !vehiculo) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 bg-slate-800 rounded w-48 animate-shimmer" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<SkeletonStats />
|
||||
<SkeletonStats />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'resumen', label: 'Resumen', icon: <MapPinIcon className="w-4 h-4" /> },
|
||||
{ id: 'viajes', label: 'Viajes', icon: <ArrowPathIcon className="w-4 h-4" /> },
|
||||
{ id: 'alertas', label: 'Alertas', icon: <BellAlertIcon className="w-4 h-4" /> },
|
||||
{ id: 'video', label: 'Video', icon: <VideoCameraIcon className="w-4 h-4" /> },
|
||||
{ id: 'mantenimiento', label: 'Mantenimiento', icon: <WrenchScrewdriverIcon className="w-4 h-4" /> },
|
||||
]
|
||||
|
||||
// Mock activity data
|
||||
const activityData = [
|
||||
{ dia: 'Lun', km: 120, viajes: 5 },
|
||||
{ dia: 'Mar', km: 85, viajes: 3 },
|
||||
{ dia: 'Mie', km: 150, viajes: 6 },
|
||||
{ dia: 'Jue', km: 95, viajes: 4 },
|
||||
{ dia: 'Vie', km: 180, viajes: 7 },
|
||||
{ dia: 'Sab', km: 60, viajes: 2 },
|
||||
{ dia: 'Dom', km: 30, viajes: 1 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back button and header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/vehiculos"
|
||||
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5" />
|
||||
</Link>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-white">{vehiculo.nombre}</h1>
|
||||
<Badge
|
||||
variant={
|
||||
vehiculo.movimiento === 'movimiento'
|
||||
? 'success'
|
||||
: vehiculo.movimiento === 'detenido'
|
||||
? 'warning'
|
||||
: 'default'
|
||||
}
|
||||
dot
|
||||
pulse={vehiculo.movimiento === 'movimiento'}
|
||||
>
|
||||
{vehiculo.movimiento === 'movimiento'
|
||||
? 'En movimiento'
|
||||
: vehiculo.movimiento === 'detenido'
|
||||
? 'Detenido'
|
||||
: 'Sin senal'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-slate-500">
|
||||
{vehiculo.placa} | {vehiculo.marca} {vehiculo.modelo} {vehiculo.anio}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to={`/mapa?vehiculo=${vehiculo.id}`}>
|
||||
<Button variant="outline" leftIcon={<MapPinIcon className="w-4 h-4" />}>
|
||||
Ver en mapa
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="primary">Editar</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs tabs={tabs} defaultTab="resumen">
|
||||
<TabPanel id="resumen">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left column */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Quick stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<KPICard
|
||||
title="Velocidad"
|
||||
value={`${vehiculo.velocidad || 0} km/h`}
|
||||
color="blue"
|
||||
/>
|
||||
<KPICard
|
||||
title="Odometro"
|
||||
value={`${vehiculo.odometro?.toLocaleString() || 0} km`}
|
||||
/>
|
||||
<KPICard
|
||||
title="Horas motor"
|
||||
value={`${vehiculo.horasMotor || 0} h`}
|
||||
/>
|
||||
<KPICard
|
||||
title="Viajes hoy"
|
||||
value={vehiculo.viajesHoy || 0}
|
||||
color="green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Activity chart */}
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Actividad semanal" subtitle="Kilometros y viajes" />
|
||||
<LineChart
|
||||
data={activityData}
|
||||
xAxisKey="dia"
|
||||
lines={[
|
||||
{ dataKey: 'km', name: 'Kilometros', color: '#3b82f6' },
|
||||
{ dataKey: 'viajes', name: 'Viajes (x20)', color: '#22c55e' },
|
||||
]}
|
||||
height={250}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Location */}
|
||||
{vehiculo.ubicacion && (
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Ubicacion actual" />
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-accent-500/20 flex items-center justify-center">
|
||||
<MapPinIcon className="w-5 h-5 text-accent-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white">
|
||||
{vehiculo.ubicacion.lat.toFixed(6)}, {vehiculo.ubicacion.lng.toFixed(6)}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
Ultima actualizacion: {new Date(vehiculo.ubicacion.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
<div className="space-y-6">
|
||||
{/* Fuel gauge */}
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Combustible" />
|
||||
<FuelGauge
|
||||
value={vehiculo.combustibleNivel || 65}
|
||||
label="Nivel de tanque"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Conductor */}
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Conductor asignado" />
|
||||
{vehiculo.conductor ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-slate-700 flex items-center justify-center">
|
||||
<UserIcon className="w-6 h-6 text-slate-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-white">
|
||||
{vehiculo.conductor.nombre} {vehiculo.conductor.apellido}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">{vehiculo.conductor.telefono}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500">Sin conductor asignado</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Vehicle info */}
|
||||
<Card padding="lg">
|
||||
<CardHeader title="Informacion" />
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Tipo</span>
|
||||
<span className="text-white capitalize">{vehiculo.tipo}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Marca</span>
|
||||
<span className="text-white">{vehiculo.marca}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Modelo</span>
|
||||
<span className="text-white">{vehiculo.modelo}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Anio</span>
|
||||
<span className="text-white">{vehiculo.anio}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Color</span>
|
||||
<span className="text-white capitalize">{vehiculo.color}</span>
|
||||
</div>
|
||||
{vehiculo.vin && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">VIN</span>
|
||||
<span className="text-white font-mono text-xs">{vehiculo.vin}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="viajes">
|
||||
<Card padding="lg">
|
||||
<p className="text-slate-400 text-center py-8">
|
||||
Historial de viajes del vehiculo (por implementar)
|
||||
</p>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="alertas">
|
||||
<Card padding="lg">
|
||||
<p className="text-slate-400 text-center py-8">
|
||||
Alertas del vehiculo (por implementar)
|
||||
</p>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="video">
|
||||
<Card padding="lg">
|
||||
<p className="text-slate-400 text-center py-8">
|
||||
Camaras y grabaciones del vehiculo (por implementar)
|
||||
</p>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="mantenimiento">
|
||||
<Card padding="lg">
|
||||
<p className="text-slate-400 text-center py-8">
|
||||
Historial de mantenimiento (por implementar)
|
||||
</p>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
frontend/src/pages/Vehiculos.tsx
Normal file
53
frontend/src/pages/Vehiculos.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { PlusIcon } from '@heroicons/react/24/outline'
|
||||
import { useVehiculosStore } from '@/store/vehiculosStore'
|
||||
import { VehiculoList } from '@/components/vehiculos'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
|
||||
export default function Vehiculos() {
|
||||
const vehiculos = useVehiculosStore((state) => state.getVehiculosArray())
|
||||
const isLoading = useVehiculosStore((state) => state.isLoading)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Vehiculos</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Gestiona tu flota de vehiculos
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<PlusIcon className="w-5 h-5" />}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
Agregar vehiculo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Vehicle list */}
|
||||
<VehiculoList
|
||||
vehiculos={vehiculos}
|
||||
isLoading={isLoading}
|
||||
showFilters
|
||||
/>
|
||||
|
||||
{/* Create modal */}
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
title="Agregar vehiculo"
|
||||
size="lg"
|
||||
>
|
||||
<p className="text-slate-400">
|
||||
Formulario de creacion de vehiculo (por implementar)
|
||||
</p>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
255
frontend/src/pages/ViajeReplay.tsx
Normal file
255
frontend/src/pages/ViajeReplay.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
PlayIcon,
|
||||
PauseIcon,
|
||||
ForwardIcon,
|
||||
BackwardIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import clsx from 'clsx'
|
||||
import { viajesApi } from '@/api'
|
||||
import { MapContainer } from '@/components/mapa'
|
||||
import RutaLayer from '@/components/mapa/RutaLayer'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { SkeletonMap } from '@/components/ui/Skeleton'
|
||||
|
||||
export default function ViajeReplay() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(1)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const { data: replayData, isLoading } = useQuery({
|
||||
queryKey: ['viaje-replay', id],
|
||||
queryFn: () => viajesApi.getReplayData(id!),
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
const puntos = replayData?.puntos || []
|
||||
const viaje = replayData?.viaje
|
||||
|
||||
// Playback control
|
||||
useEffect(() => {
|
||||
if (isPlaying && puntos.length > 0) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
setCurrentIndex((prev) => {
|
||||
if (prev >= puntos.length - 1) {
|
||||
setIsPlaying(false)
|
||||
return prev
|
||||
}
|
||||
return prev + 1
|
||||
})
|
||||
}, 1000 / playbackSpeed)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [isPlaying, playbackSpeed, puntos.length])
|
||||
|
||||
const handlePlayPause = () => {
|
||||
setIsPlaying(!isPlaying)
|
||||
}
|
||||
|
||||
const handleSpeedChange = () => {
|
||||
const speeds = [1, 2, 4, 8]
|
||||
const currentSpeedIndex = speeds.indexOf(playbackSpeed)
|
||||
const nextIndex = (currentSpeedIndex + 1) % speeds.length
|
||||
setPlaybackSpeed(speeds[nextIndex])
|
||||
}
|
||||
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCurrentIndex(parseInt(e.target.value))
|
||||
}
|
||||
|
||||
const handleStepBack = () => {
|
||||
setCurrentIndex(Math.max(0, currentIndex - 10))
|
||||
}
|
||||
|
||||
const handleStepForward = () => {
|
||||
setCurrentIndex(Math.min(puntos.length - 1, currentIndex + 10))
|
||||
}
|
||||
|
||||
// Current point
|
||||
const currentPoint = puntos[currentIndex]
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 bg-slate-800 rounded w-48 animate-shimmer" />
|
||||
<SkeletonMap />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!viaje) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-500">Viaje no encontrado</p>
|
||||
<Link to="/viajes" className="text-accent-400 hover:text-accent-300 mt-2 inline-block">
|
||||
Volver a viajes
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/viajes"
|
||||
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
Replay de viaje - {viaje.vehiculo?.nombre}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
{viaje.vehiculo?.placa} | {new Date(viaje.inicio).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map and controls */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||
{/* Map */}
|
||||
<div className="lg:col-span-3 h-[500px] rounded-xl overflow-hidden">
|
||||
<MapContainer showControls={false}>
|
||||
{puntos.length > 0 && (
|
||||
<RutaLayer
|
||||
puntos={puntos.slice(0, currentIndex + 1)}
|
||||
showStartEnd
|
||||
animated={isPlaying}
|
||||
/>
|
||||
)}
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
{/* Info panel */}
|
||||
<div className="space-y-4">
|
||||
{/* Current stats */}
|
||||
<Card padding="md">
|
||||
<h3 className="text-sm font-medium text-slate-400 mb-3">Posicion actual</h3>
|
||||
{currentPoint ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Hora</span>
|
||||
<span className="text-white">
|
||||
{currentPoint.timestamp
|
||||
? new Date(currentPoint.timestamp).toLocaleTimeString()
|
||||
: '--'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Velocidad</span>
|
||||
<span className="text-white">{currentPoint.velocidad || 0} km/h</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Coordenadas</span>
|
||||
<span className="text-white text-xs font-mono">
|
||||
{currentPoint.lat.toFixed(5)}, {currentPoint.lng.toFixed(5)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500">Sin datos</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Trip summary */}
|
||||
<Card padding="md">
|
||||
<h3 className="text-sm font-medium text-slate-400 mb-3">Resumen del viaje</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Distancia</span>
|
||||
<span className="text-white">{viaje.distancia?.toFixed(1) || 0} km</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Duracion</span>
|
||||
<span className="text-white">{viaje.duracion || 0} min</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Vel. promedio</span>
|
||||
<span className="text-white">{viaje.velocidadPromedio?.toFixed(0) || 0} km/h</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Vel. maxima</span>
|
||||
<span className="text-white">{viaje.velocidadMaxima?.toFixed(0) || 0} km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playback controls */}
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Play controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleStepBack}
|
||||
disabled={currentIndex === 0}
|
||||
>
|
||||
<BackwardIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handlePlayPause}
|
||||
leftIcon={
|
||||
isPlaying ? (
|
||||
<PauseIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<PlayIcon className="w-5 h-5" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{isPlaying ? 'Pausar' : 'Reproducir'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleStepForward}
|
||||
disabled={currentIndex >= puntos.length - 1}
|
||||
>
|
||||
<ForwardIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Speed */}
|
||||
<button
|
||||
onClick={handleSpeedChange}
|
||||
className="px-3 py-1.5 text-sm font-medium text-slate-400 hover:text-white bg-slate-800 rounded-lg"
|
||||
>
|
||||
{playbackSpeed}x
|
||||
</button>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="flex-1 flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={puntos.length - 1}
|
||||
value={currentIndex}
|
||||
onChange={handleSeek}
|
||||
className="flex-1 h-2 bg-slate-700 rounded-full appearance-none cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-slate-500 whitespace-nowrap">
|
||||
{currentIndex + 1} / {puntos.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
146
frontend/src/pages/Viajes.tsx
Normal file
146
frontend/src/pages/Viajes.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
CalendarIcon,
|
||||
FunnelIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { viajesApi } from '@/api'
|
||||
import { ViajeCard } from '@/components/viajes'
|
||||
import Card from '@/components/ui/Card'
|
||||
import { SkeletonCard } from '@/components/ui/Skeleton'
|
||||
|
||||
export default function Viajes() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const vehiculoId = searchParams.get('vehiculo')
|
||||
const [filtros, setFiltros] = useState({
|
||||
estado: '',
|
||||
desde: '',
|
||||
hasta: '',
|
||||
})
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['viajes', vehiculoId, filtros],
|
||||
queryFn: () =>
|
||||
viajesApi.list({
|
||||
vehiculoId: vehiculoId || undefined,
|
||||
estado: filtros.estado || undefined,
|
||||
desde: filtros.desde || undefined,
|
||||
hasta: filtros.hasta || undefined,
|
||||
pageSize: 50,
|
||||
}),
|
||||
})
|
||||
|
||||
const viajes = data?.items || []
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
total: viajes.length,
|
||||
enCurso: viajes.filter((v) => v.estado === 'en_curso').length,
|
||||
completados: viajes.filter((v) => v.estado === 'completado').length,
|
||||
kmTotal: viajes.reduce((sum, v) => sum + (v.distancia || 0), 0),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Viajes</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Historial y seguimiento de viajes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<Card padding="md">
|
||||
<p className="text-sm text-slate-500">Total viajes</p>
|
||||
<p className="text-2xl font-bold text-white">{stats.total}</p>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<p className="text-sm text-slate-500">En curso</p>
|
||||
<p className="text-2xl font-bold text-success-400">{stats.enCurso}</p>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<p className="text-sm text-slate-500">Completados</p>
|
||||
<p className="text-2xl font-bold text-white">{stats.completados}</p>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<p className="text-sm text-slate-500">Km totales</p>
|
||||
<p className="text-2xl font-bold text-accent-400">{stats.kmTotal.toFixed(0)}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card padding="md">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<select
|
||||
value={filtros.estado}
|
||||
onChange={(e) => setFiltros({ ...filtros, estado: e.target.value })}
|
||||
className={clsx(
|
||||
'px-4 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
<option value="en_curso">En curso</option>
|
||||
<option value="completado">Completado</option>
|
||||
<option value="cancelado">Cancelado</option>
|
||||
</select>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="date"
|
||||
value={filtros.desde}
|
||||
onChange={(e) => setFiltros({ ...filtros, desde: e.target.value })}
|
||||
className={clsx(
|
||||
'px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
placeholder="Desde"
|
||||
/>
|
||||
<span className="text-slate-500">-</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filtros.hasta}
|
||||
onChange={(e) => setFiltros({ ...filtros, hasta: e.target.value })}
|
||||
className={clsx(
|
||||
'px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg',
|
||||
'text-white focus:outline-none focus:ring-2 focus:ring-accent-500'
|
||||
)}
|
||||
placeholder="Hasta"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-slate-500 ml-auto">
|
||||
{viajes.length} viajes encontrados
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Viajes list */}
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : viajes.length === 0 ? (
|
||||
<Card padding="lg">
|
||||
<div className="text-center py-8">
|
||||
<p className="text-slate-500">No hay viajes que mostrar</p>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{viajes.map((viaje) => (
|
||||
<ViajeCard key={viaje.id} viaje={viaje} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
frontend/src/pages/VideoLive.tsx
Normal file
65
frontend/src/pages/VideoLive.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { videoApi } from '@/api'
|
||||
import { VideoGrid, CamaraCard } from '@/components/video'
|
||||
import Card from '@/components/ui/Card'
|
||||
import { SkeletonCard } from '@/components/ui/Skeleton'
|
||||
|
||||
export default function VideoLive() {
|
||||
const { data: camaras, isLoading } = useQuery({
|
||||
queryKey: ['camaras'],
|
||||
queryFn: () => videoApi.listCamaras(),
|
||||
})
|
||||
|
||||
const camarasOnline = camaras?.filter((c) => c.estado === 'online' || c.estado === 'grabando') || []
|
||||
const camarasOffline = camaras?.filter((c) => c.estado === 'offline' || c.estado === 'error') || []
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 bg-slate-800 rounded w-48 animate-shimmer" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Video en vivo</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
{camarasOnline.length} camaras en linea de {camaras?.length || 0} total
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Video grid */}
|
||||
{camarasOnline.length > 0 ? (
|
||||
<VideoGrid camaras={camarasOnline} layout="2x2" />
|
||||
) : (
|
||||
<Card padding="lg">
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-500">No hay camaras en linea</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Offline cameras */}
|
||||
{camarasOffline.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">
|
||||
Camaras sin conexion ({camarasOffline.length})
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{camarasOffline.map((camara) => (
|
||||
<CamaraCard key={camara.id} camara={camara} showActions={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
frontend/src/pages/index.ts
Normal file
18
frontend/src/pages/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export { default as Login } from './Login'
|
||||
export { default as Dashboard } from './Dashboard'
|
||||
export { default as Mapa } from './Mapa'
|
||||
export { default as Vehiculos } from './Vehiculos'
|
||||
export { default as VehiculoDetalle } from './VehiculoDetalle'
|
||||
export { default as Conductores } from './Conductores'
|
||||
export { default as Alertas } from './Alertas'
|
||||
export { default as Viajes } from './Viajes'
|
||||
export { default as ViajeReplay } from './ViajeReplay'
|
||||
export { default as VideoLive } from './VideoLive'
|
||||
export { default as Grabaciones } from './Grabaciones'
|
||||
export { default as Geocercas } from './Geocercas'
|
||||
export { default as POIs } from './POIs'
|
||||
export { default as Combustible } from './Combustible'
|
||||
export { default as Mantenimiento } from './Mantenimiento'
|
||||
export { default as Reportes } from './Reportes'
|
||||
export { default as Configuracion } from './Configuracion'
|
||||
export { default as NotFound } from './NotFound'
|
||||
Reference in New Issue
Block a user