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