Files
ATLAS/frontend/src/pages/POIs.tsx
FlotillasGPS Developer 51d78bacf4 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.
2026-01-21 08:18:00 +00:00

448 lines
13 KiB
TypeScript

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