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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user