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.
448 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|