FlotillasGPS - Sistema completo de monitoreo de flotillas GPS

Sistema completo para monitoreo y gestion de flotas de vehiculos con:
- Backend FastAPI con PostgreSQL/TimescaleDB
- Frontend React con TypeScript y TailwindCSS
- App movil React Native con Expo
- Soporte para dispositivos GPS, Meshtastic y celulares
- Video streaming en vivo con MediaMTX
- Geocercas, alertas, viajes y reportes
- Autenticacion JWT y WebSockets en tiempo real

Documentacion completa y guias de usuario incluidas.
This commit is contained in:
FlotillasGPS Developer
2026-01-21 08:18:00 +00:00
commit 51d78bacf4
248 changed files with 50171 additions and 0 deletions

View File

@@ -0,0 +1,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>
)
}