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.
384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|