import { useState, useMemo, useRef, useEffect } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Calendar, MapPin, User, Car, Clock, Navigation, ListOrdered, Copy } from "lucide-react"; import { format, parseISO, isValid, addDays } from "date-fns"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { it } from "date-fns/locale"; import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'; import L from 'leaflet'; import { useToast } from "@/hooks/use-toast"; import { apiRequest } from "@/lib/queryClient"; import { queryClient } from "@/lib/queryClient"; // Fix Leaflet default icon issue with Webpack delete (L.Icon.Default.prototype as any)._getIconUrl; // Icona blu standard per siti non in route const blueIcon = new L.Icon({ iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png', iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png', shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41] }); // Icona verde per marker selezionati nella patrol route const greenIcon = new L.Icon({ iconRetinaUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png', iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-green.png', shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41] }); type Location = "roccapiemonte" | "milano" | "roma"; type MobileSite = { id: string; name: string; address: string; serviceTypeId: string | null; serviceTypeName: string | null; location: Location; latitude: string | null; longitude: string | null; }; type AvailableGuard = { id: string; firstName: string; lastName: string; badgeNumber: string; location: Location; weeklyHours: number; availableHours: number; }; // Componente per controllare la mappa da fuori function MapController({ center, zoom }: { center: [number, number] | null; zoom: number }) { const map = useMap(); useEffect(() => { if (center) { map.flyTo(center, zoom, { duration: 1.5, }); } }, [center, zoom, map]); return null; } export default function PlanningMobile() { const { toast } = useToast(); const [selectedDate, setSelectedDate] = useState(format(new Date(), "yyyy-MM-dd")); const [selectedLocation, setSelectedLocation] = useState("roccapiemonte"); const [selectedGuardId, setSelectedGuardId] = useState("all"); const [mapCenter, setMapCenter] = useState<[number, number] | null>(null); const [mapZoom, setMapZoom] = useState(12); const [patrolRoute, setPatrolRoute] = useState([]); // State per dialog duplicazione sequenza const [duplicateDialog, setDuplicateDialog] = useState<{ isOpen: boolean; sourceRoute: any | null; targetDate: string; selectedDuplicateGuardId: string; }>({ isOpen: false, sourceRoute: null, targetDate: "", selectedDuplicateGuardId: "", }); // Query siti mobile per location const { data: mobileSites, isLoading: sitesLoading } = useQuery({ queryKey: ["/api/planning-mobile/sites", selectedLocation], queryFn: async () => { const response = await fetch(`/api/planning-mobile/sites?location=${selectedLocation}`); if (!response.ok) { throw new Error("Failed to fetch mobile sites"); } return response.json(); }, enabled: !!selectedLocation, }); // Query guardie disponibili per location e data const { data: availableGuards, isLoading: guardsLoading } = useQuery({ queryKey: ["/api/planning-mobile/guards", selectedLocation, selectedDate], queryFn: async () => { const response = await fetch(`/api/planning-mobile/guards?location=${selectedLocation}&date=${selectedDate}`); if (!response.ok) { throw new Error("Failed to fetch available guards"); } return response.json(); }, enabled: !!selectedLocation && !!selectedDate, }); // Query patrol routes esistenti per guardia selezionata e data const { data: existingPatrolRoutes } = useQuery({ queryKey: ["/api/patrol-routes", selectedGuardId, selectedDate, selectedLocation], queryFn: async () => { const params = new URLSearchParams(); if (selectedGuardId && selectedGuardId !== "all") { params.set("guardId", selectedGuardId); } params.set("date", selectedDate); params.set("location", selectedLocation); const response = await fetch(`/api/patrol-routes?${params.toString()}`); if (!response.ok) throw new Error("Failed to fetch patrol routes"); return response.json(); }, enabled: !!selectedDate && !!selectedLocation, }); // Mutation per duplicare sequenza pattuglia const duplicatePatrolRouteMutation = useMutation({ mutationFn: async (data: { sourceRouteId: string; targetDate: string; guardId: string }) => { return apiRequest("POST", "/api/patrol-routes/duplicate", data); }, onSuccess: async (response: any) => { const data = await response.json(); const actionLabel = data.action === "updated" ? "modificata" : "duplicata"; toast({ title: `Sequenza ${actionLabel}!`, description: data.message, }); // Invalida cache e chiudi dialog await queryClient.invalidateQueries({ queryKey: ["/api/patrol-routes"] }); setDuplicateDialog({ isOpen: false, sourceRoute: null, targetDate: "", selectedDuplicateGuardId: "", }); }, onError: (error: any) => { let errorMessage = "Impossibile duplicare la sequenza"; if (error.message) { const match = error.message.match(/^(\d+):\s*(.+)$/); if (match) { try { const parsed = JSON.parse(match[2]); errorMessage = parsed.message || errorMessage; } catch { errorMessage = match[2]; } } else { errorMessage = error.message; } } toast({ title: "Errore Duplicazione", description: errorMessage, variant: "destructive", }); }, }); const locationLabels: Record = { roccapiemonte: "Roccapiemonte", milano: "Milano", roma: "Roma", }; const locationColors: Record = { roccapiemonte: "bg-blue-500", milano: "bg-green-500", roma: "bg-purple-500", }; // Coordinate di default per centrare la mappa sulla location selezionata const locationCenters: Record = { roccapiemonte: [40.8167, 14.6167], // Roccapiemonte, Salerno milano: [45.4642, 9.1900], // Milano roma: [41.9028, 12.4964], // Roma }; // Filtra siti con coordinate valide per la mappa const sitesWithCoordinates = useMemo(() => { return mobileSites?.filter((site) => site.latitude !== null && site.longitude !== null && !isNaN(parseFloat(site.latitude)) && !isNaN(parseFloat(site.longitude)) ) || []; }, [mobileSites]); // Trova guardia selezionata const selectedGuard = useMemo(() => { if (selectedGuardId === "all") return null; return availableGuards?.find(g => g.id === selectedGuardId) || null; }, [selectedGuardId, availableGuards]); // Funzione per zoomare su un sito const handleZoomToSite = (site: MobileSite) => { if (site.latitude && site.longitude) { setMapCenter([parseFloat(site.latitude), parseFloat(site.longitude)]); setMapZoom(16); // Zoom ravvicinato toast({ title: "Mappa centrata", description: `Visualizzazione di ${site.name}`, }); } else { toast({ title: "Coordinate mancanti", description: "Questo sito non ha coordinate GPS", variant: "destructive", }); } }; // Funzione per assegnare guardia a un sito const handleAssignGuard = (site: MobileSite) => { if (!selectedGuard) { toast({ title: "Guardia non selezionata", description: "Seleziona una guardia dal filtro in alto", variant: "destructive", }); return; } toast({ title: "Guardia assegnata", description: `${site.name} assegnato a ${selectedGuard.firstName} ${selectedGuard.lastName}`, }); }; // Funzione per aprire dialog duplicazione sequenza const handleOpenDuplicateDialog = (route: any) => { const nextDay = format(addDays(parseISO(selectedDate), 1), "yyyy-MM-dd"); setDuplicateDialog({ isOpen: true, sourceRoute: route, targetDate: nextDay, // Default = giorno successivo selectedDuplicateGuardId: route.guardId, // Pre-compilato con guardia attuale }); }; // Handler submit dialog duplicazione const handleSubmitDuplicate = () => { if (!duplicateDialog.sourceRoute || !duplicateDialog.targetDate || !duplicateDialog.selectedDuplicateGuardId) { toast({ title: "Campi mancanti", description: "Compila tutti i campi obbligatori", variant: "destructive", }); return; } duplicatePatrolRouteMutation.mutate({ sourceRouteId: duplicateDialog.sourceRoute.id, targetDate: duplicateDialog.targetDate, guardId: duplicateDialog.selectedDuplicateGuardId, }); }; // Funzione per aggiungere sito alla patrol route const handleAddToRoute = (site: MobileSite) => { if (!selectedGuard) { toast({ title: "Seleziona una guardia", description: "Prima seleziona una guardia per creare il turno pattuglia", variant: "destructive", }); return; } // Controlla se il sito è già nella route const alreadyInRoute = patrolRoute.some(s => s.id === site.id); if (alreadyInRoute) { toast({ title: "Sito già in sequenza", description: `${site.name} è già nella sequenza di pattuglia`, variant: "destructive", }); return; } setPatrolRoute([...patrolRoute, site]); toast({ title: "Sito aggiunto alla sequenza", description: `${site.name} - Tappa ${patrolRoute.length + 1} di ${patrolRoute.length + 1}`, }); }; // Funzione per rimuovere sito dalla route const handleRemoveFromRoute = (siteId: string) => { setPatrolRoute(patrolRoute.filter(s => s.id !== siteId)); toast({ title: "Sito rimosso", description: "Sito rimosso dalla sequenza di pattuglia", }); }; // Mutation per salvare patrol route const savePatrolRouteMutation = useMutation({ mutationFn: async ({ data, existingRouteId }: { data: any; existingRouteId?: string }) => { if (existingRouteId) { // UPDATE: usa PUT se esiste già un patrol route const response = await apiRequest("PUT", `/api/patrol-routes/${existingRouteId}`, data); return response.json(); } else { // CREATE: usa POST se è un nuovo patrol route const response = await apiRequest("POST", "/api/patrol-routes", data); return response.json(); } }, onSuccess: () => { toast({ title: "Turno pattuglia salvato", description: `${patrolRoute.length} tappe assegnate a ${selectedGuard?.firstName} ${selectedGuard?.lastName}`, }); setPatrolRoute([]); queryClient.invalidateQueries({ queryKey: ["/api/patrol-routes"] }); }, onError: (error: any) => { toast({ title: "Errore salvataggio", description: error.message || "Impossibile salvare il turno pattuglia", variant: "destructive", }); }, }); // Funzione per salvare il turno pattuglia const handleSavePatrolRoute = () => { if (!selectedGuard) { toast({ title: "Guardia non selezionata", description: "Seleziona una guardia per salvare il turno", variant: "destructive", }); return; } if (patrolRoute.length === 0) { toast({ title: "Nessun sito nella sequenza", description: "Aggiungi almeno un sito alla sequenza di pattuglia", variant: "destructive", }); return; } // Prepara i dati per il salvataggio const patrolRouteData = { guardId: selectedGuard.id, shiftDate: selectedDate, startTime: "08:00", // TODO: permettere all'utente di configurare endTime: "20:00", location: selectedLocation, status: "planned", stops: patrolRoute.map((site) => ({ siteId: site.id, })), }; // Controlla se esiste già un patrol route per questa guardia/data const existingRoute = existingPatrolRoutes?.find( (route: any) => route.guardId === selectedGuard.id ); savePatrolRouteMutation.mutate({ data: patrolRouteData, existingRouteId: existingRoute?.id, }); }; // Carica patrol route esistente quando si seleziona una guardia useEffect(() => { // Reset route quando cambia guardia o location setPatrolRoute([]); // Carica route esistente se c'è una guardia selezionata if (selectedGuardId && selectedGuardId !== "all" && existingPatrolRoutes && existingPatrolRoutes.length > 0) { const guardRoute = existingPatrolRoutes.find(r => r.guardId === selectedGuardId); if (guardRoute && guardRoute.stops && guardRoute.stops.length > 0) { // Ricostruisci il patrol route dai stops esistenti const loadedRoute = guardRoute.stops .sort((a: any, b: any) => a.sequenceOrder - b.sequenceOrder) .map((stop: any) => mobileSites?.find(s => s.id === stop.siteId)) .filter((site: any) => site !== undefined) as MobileSite[]; setPatrolRoute(loadedRoute); toast({ title: "Turno pattuglia caricato", description: `${loadedRoute.length} tappe caricate per ${selectedGuard?.firstName} ${selectedGuard?.lastName}`, }); } } }, [selectedGuardId, selectedLocation, existingPatrolRoutes, mobileSites]); return (
{/* Header */}

Planning Mobile

Pianificazione ronde, ispezioni e interventi notturni per servizi mobili

{/* Filtri */} Filtri Pianificazione Seleziona sede, data e guardia per iniziare
setSelectedDate(e.target.value)} data-testid="input-mobile-date" />
{/* Sequenza Pattuglia */} {selectedGuard && patrolRoute.length > 0 && ( Sequenza Pattuglia - {selectedGuard.firstName} {selectedGuard.lastName} {patrolRoute.length} tappe programmate per il turno del {format(parseISO(selectedDate), "dd MMMM yyyy", { locale: it })}
{patrolRoute.map((site, index) => (
{index + 1} {site.name}
))}
)} {/* Grid: Mappa + Siti */}
{/* Mappa Siti */} Mappa Siti Mobile {mobileSites?.length || 0} siti con servizi mobili in {locationLabels[selectedLocation]} {sitesWithCoordinates.length > 0 ? ( {sitesWithCoordinates.map((site) => { const isInRoute = patrolRoute.some(s => s.id === site.id); const routeIndex = patrolRoute.findIndex(s => s.id === site.id); return ( handleAddToRoute(site), }} >

{site.name}

{site.address}

{site.serviceTypeName && ( {site.serviceTypeName} )} {isInRoute && ( Tappa {routeIndex + 1} )}
); })}
) : (

Nessun sito con coordinate GPS disponibile
Aggiungi latitudine e longitudine ai siti per visualizzarli sulla mappa

)}
{/* Lista Siti Mobile */} Siti con Servizi Mobili Ronde notturne, ispezioni, interventi programmati {sitesLoading ? (

Caricamento...

) : mobileSites && mobileSites.length > 0 ? ( mobileSites.map((site) => { const isInRoute = patrolRoute.some(s => s.id === site.id); const routeIndex = patrolRoute.findIndex(s => s.id === site.id); return (

{site.name}

{site.address}

{locationLabels[site.location]}
{site.serviceTypeName && (
{site.serviceTypeName} {isInRoute && ( Tappa {routeIndex + 1} )}
)}
); }) ) : (

Nessun sito con servizi mobili in {locationLabels[selectedLocation]}

)}
{/* Guardie Disponibili */} Guardie Disponibili ({availableGuards?.length || 0}) Guardie con ore disponibili per {format(parseISO(selectedDate), "dd MMMM yyyy", { locale: it })}
{guardsLoading ? (

Caricamento...

) : availableGuards && availableGuards.length > 0 ? ( availableGuards.map((guard) => (
{guard.firstName} {guard.lastName}

#{guard.badgeNumber}

{locationLabels[guard.location]}
Ore settimanali: {guard.weeklyHours}h / 45h
Disponibili: {guard.availableHours}h
)) ) : (

Nessuna guardia disponibile per la data selezionata

)}
{/* Sequenze Pattuglia del Giorno */} Sequenze Pattuglia - {format(parseISO(selectedDate), "dd MMMM yyyy", { locale: it })} Sequenze programmate per la data selezionata {existingPatrolRoutes && existingPatrolRoutes.length > 0 ? (
{existingPatrolRoutes.map((route) => { const guard = availableGuards?.find(g => g.id === route.guardId); return (
{guard ? `${guard.firstName} ${guard.lastName}` : "Guardia sconosciuta"} {guard && ( #{guard.badgeNumber} )}
{route.startTime} - {route.endTime}
{route.stops?.length || 0} {route.stops?.length === 1 ? "tappa" : "tappe"}
{route.stops && route.stops.length > 0 && (
Sequenza: {route.stops.map((stop: any, idx: number) => ( {stop.siteName}{idx < route.stops.length - 1 ? " → " : ""} ))}
)}
); })}
) : (

Nessuna sequenza pattuglia pianificata per {format(parseISO(selectedDate), "dd MMMM yyyy", { locale: it })}

)}
{/* Dialog Duplica Sequenza */} { if (!open) { setDuplicateDialog({ isOpen: false, sourceRoute: null, targetDate: "", selectedDuplicateGuardId: "", }); } }}> Duplica/Modifica Sequenza Pattuglia Copia la sequenza in un'altra data o modifica la guardia assegnata
{/* Data Sorgente (readonly) */} {duplicateDialog.sourceRoute && (
Data: {format(parseISO(duplicateDialog.sourceRoute.scheduledDate), "dd/MM/yyyy", { locale: it })}
Tappe: {duplicateDialog.sourceRoute.stops?.length || 0}
)} {/* Data Target */}
setDuplicateDialog({ ...duplicateDialog, targetDate: e.target.value })} data-testid="input-target-date" />

{duplicateDialog.sourceRoute && duplicateDialog.targetDate && format(parseISO(duplicateDialog.sourceRoute.scheduledDate), "yyyy-MM-dd") === duplicateDialog.targetDate ? "⚠️ Stessa data: verrà modificata la guardia della sequenza esistente" : "✓ Data diversa: verrà creata una nuova sequenza con tutte le tappe" }

{/* Selezione Guardia */}
); }