Introduces a dialog to copy weekly schedules to the next week and duplicates patrol routes with specified guards and dates, updating the client-side UI and API interactions. Replit-Commit-Author: Agent Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88 Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/EDxr1e6
959 lines
36 KiB
TypeScript
959 lines
36 KiB
TypeScript
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<Location>("roccapiemonte");
|
|
const [selectedGuardId, setSelectedGuardId] = useState<string>("all");
|
|
const [mapCenter, setMapCenter] = useState<[number, number] | null>(null);
|
|
const [mapZoom, setMapZoom] = useState(12);
|
|
const [patrolRoute, setPatrolRoute] = useState<MobileSite[]>([]);
|
|
|
|
// 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<MobileSite[]>({
|
|
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<AvailableGuard[]>({
|
|
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<any[]>({
|
|
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<Location, string> = {
|
|
roccapiemonte: "Roccapiemonte",
|
|
milano: "Milano",
|
|
roma: "Roma",
|
|
};
|
|
|
|
const locationColors: Record<Location, string> = {
|
|
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<Location, [number, number]> = {
|
|
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 (
|
|
<div className="h-full flex flex-col p-6 space-y-6">
|
|
{/* Header */}
|
|
<div className="space-y-2">
|
|
<h1 className="text-3xl font-bold">Planning Mobile</h1>
|
|
<p className="text-muted-foreground">
|
|
Pianificazione ronde, ispezioni e interventi notturni per servizi mobili
|
|
</p>
|
|
</div>
|
|
|
|
{/* Filtri */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<MapPin className="h-5 w-5" />
|
|
Filtri Pianificazione
|
|
</CardTitle>
|
|
<CardDescription>Seleziona sede, data e guardia per iniziare</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="location-select">Sede*</Label>
|
|
<Select value={selectedLocation} onValueChange={(v) => setSelectedLocation(v as Location)}>
|
|
<SelectTrigger id="location-select" data-testid="select-mobile-location">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
|
|
<SelectItem value="milano">Milano</SelectItem>
|
|
<SelectItem value="roma">Roma</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="date-select">Data*</Label>
|
|
<Input
|
|
id="date-select"
|
|
type="date"
|
|
value={selectedDate}
|
|
onChange={(e) => setSelectedDate(e.target.value)}
|
|
data-testid="input-mobile-date"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="guard-select">Guardia (opzionale)</Label>
|
|
<Select value={selectedGuardId} onValueChange={setSelectedGuardId}>
|
|
<SelectTrigger id="guard-select" data-testid="select-mobile-guard">
|
|
<SelectValue placeholder="Tutte le guardie" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Tutte le guardie</SelectItem>
|
|
{availableGuards?.map((guard) => (
|
|
<SelectItem key={guard.id} value={guard.id}>
|
|
{guard.firstName} {guard.lastName} - #{guard.badgeNumber} ({guard.availableHours}h disponibili)
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Sequenza Pattuglia */}
|
|
{selectedGuard && patrolRoute.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<ListOrdered className="h-5 w-5" />
|
|
Sequenza Pattuglia - {selectedGuard.firstName} {selectedGuard.lastName}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{patrolRoute.length} tappe programmate per il turno del {format(parseISO(selectedDate), "dd MMMM yyyy", { locale: it })}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="flex gap-2 flex-wrap">
|
|
{patrolRoute.map((site, index) => (
|
|
<div
|
|
key={site.id}
|
|
className="flex items-center gap-2 p-2 border rounded-lg bg-muted/20"
|
|
data-testid={`route-stop-${index}`}
|
|
>
|
|
<Badge className="bg-green-600">
|
|
{index + 1}
|
|
</Badge>
|
|
<span className="text-sm font-medium">{site.name}</span>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => handleRemoveFromRoute(site.id)}
|
|
className="h-6 w-6 p-0"
|
|
data-testid={`button-remove-stop-${index}`}
|
|
>
|
|
✕
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="flex gap-2 pt-2">
|
|
<Button
|
|
onClick={handleSavePatrolRoute}
|
|
data-testid="button-save-patrol-route"
|
|
>
|
|
Salva Turno Pattuglia
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setPatrolRoute([])}
|
|
data-testid="button-clear-patrol-route"
|
|
>
|
|
Cancella Sequenza
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Grid: Mappa + Siti */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 flex-1">
|
|
{/* Mappa Siti */}
|
|
<Card className="flex flex-col">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<MapPin className="h-5 w-5" />
|
|
Mappa Siti Mobile
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{mobileSites?.length || 0} siti con servizi mobili in {locationLabels[selectedLocation]}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex-1 p-0 overflow-hidden">
|
|
{sitesWithCoordinates.length > 0 ? (
|
|
<MapContainer
|
|
key={selectedLocation}
|
|
center={locationCenters[selectedLocation]}
|
|
zoom={12}
|
|
className="h-full w-full min-h-[400px]"
|
|
data-testid="map-container"
|
|
>
|
|
<MapController center={mapCenter} zoom={mapZoom} />
|
|
<TileLayer
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
/>
|
|
{sitesWithCoordinates.map((site) => {
|
|
const isInRoute = patrolRoute.some(s => s.id === site.id);
|
|
const routeIndex = patrolRoute.findIndex(s => s.id === site.id);
|
|
|
|
return (
|
|
<Marker
|
|
key={site.id}
|
|
position={[parseFloat(site.latitude!), parseFloat(site.longitude!)]}
|
|
icon={isInRoute ? greenIcon : blueIcon}
|
|
eventHandlers={{
|
|
click: () => handleAddToRoute(site),
|
|
}}
|
|
>
|
|
<Popup>
|
|
<div className="space-y-2">
|
|
<h4 className="font-semibold text-sm">{site.name}</h4>
|
|
<p className="text-xs text-muted-foreground">{site.address}</p>
|
|
{site.serviceTypeName && (
|
|
<Badge variant="outline" className="text-xs">
|
|
{site.serviceTypeName}
|
|
</Badge>
|
|
)}
|
|
{isInRoute && (
|
|
<Badge className="text-xs bg-green-600">
|
|
Tappa {routeIndex + 1}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</Popup>
|
|
</Marker>
|
|
);
|
|
})}
|
|
</MapContainer>
|
|
) : (
|
|
<div className="h-full flex items-center justify-center bg-muted/20">
|
|
<div className="text-center space-y-2 p-6">
|
|
<MapPin className="h-12 w-12 mx-auto text-muted-foreground" />
|
|
<p className="text-sm text-muted-foreground">
|
|
Nessun sito con coordinate GPS disponibile
|
|
<br />
|
|
<span className="text-xs">Aggiungi latitudine e longitudine ai siti per visualizzarli sulla mappa</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Lista Siti Mobile */}
|
|
<Card className="flex flex-col">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Clock className="h-5 w-5" />
|
|
Siti con Servizi Mobili
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Ronde notturne, ispezioni, interventi programmati
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3 overflow-y-auto">
|
|
{sitesLoading ? (
|
|
<p className="text-sm text-muted-foreground">Caricamento...</p>
|
|
) : 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 (
|
|
<div
|
|
key={site.id}
|
|
className="p-4 border rounded-lg space-y-2"
|
|
data-testid={`site-card-${site.id}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="space-y-1 flex-1">
|
|
<h4 className="font-semibold">{site.name}</h4>
|
|
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
|
<MapPin className="h-3 w-3" />
|
|
{site.address}
|
|
</p>
|
|
</div>
|
|
<Badge className={locationColors[site.location]} data-testid={`badge-location-${site.id}`}>
|
|
{locationLabels[site.location]}
|
|
</Badge>
|
|
</div>
|
|
{site.serviceTypeName && (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Badge variant="outline" data-testid={`badge-service-${site.id}`}>
|
|
{site.serviceTypeName}
|
|
</Badge>
|
|
{isInRoute && (
|
|
<Badge className="bg-green-600">
|
|
Tappa {routeIndex + 1}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2 pt-2">
|
|
<Button
|
|
size="sm"
|
|
variant="default"
|
|
onClick={() => handleAssignGuard(site)}
|
|
data-testid={`button-assign-${site.id}`}
|
|
>
|
|
Assegna Guardia
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleZoomToSite(site)}
|
|
data-testid={`button-zoom-${site.id}`}
|
|
>
|
|
<Navigation className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
) : (
|
|
<div className="text-center py-8 space-y-2">
|
|
<MapPin className="h-12 w-12 mx-auto text-muted-foreground" />
|
|
<p className="text-sm text-muted-foreground">
|
|
Nessun sito con servizi mobili in {locationLabels[selectedLocation]}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Guardie Disponibili */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<User className="h-5 w-5" />
|
|
Guardie Disponibili ({availableGuards?.length || 0})
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Guardie con ore disponibili per {format(parseISO(selectedDate), "dd MMMM yyyy", { locale: it })}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{guardsLoading ? (
|
|
<p className="text-sm text-muted-foreground col-span-full">Caricamento...</p>
|
|
) : availableGuards && availableGuards.length > 0 ? (
|
|
availableGuards.map((guard) => (
|
|
<div
|
|
key={guard.id}
|
|
className="p-3 border rounded-lg space-y-2"
|
|
data-testid={`guard-card-${guard.id}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
<h5 className="font-semibold text-sm">
|
|
{guard.firstName} {guard.lastName}
|
|
</h5>
|
|
<p className="text-xs text-muted-foreground">#{guard.badgeNumber}</p>
|
|
</div>
|
|
<Badge className={locationColors[guard.location]}>
|
|
{locationLabels[guard.location]}
|
|
</Badge>
|
|
</div>
|
|
<div className="text-xs space-y-1">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Ore settimanali:</span>
|
|
<span className="font-medium">{guard.weeklyHours}h / 45h</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Disponibili:</span>
|
|
<span className="font-medium text-green-600">{guard.availableHours}h</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<p className="text-sm text-muted-foreground col-span-full text-center py-4">
|
|
Nessuna guardia disponibile per la data selezionata
|
|
</p>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Sequenze Pattuglia del Giorno */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<ListOrdered className="h-5 w-5" />
|
|
Sequenze Pattuglia - {format(parseISO(selectedDate), "dd MMMM yyyy", { locale: it })}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Sequenze programmate per la data selezionata
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{existingPatrolRoutes && existingPatrolRoutes.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{existingPatrolRoutes.map((route) => {
|
|
const guard = availableGuards?.find(g => g.id === route.guardId);
|
|
return (
|
|
<div
|
|
key={route.id}
|
|
className="p-4 border rounded-lg space-y-3 hover-elevate"
|
|
data-testid={`patrol-route-${route.id}`}
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="space-y-2 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<User className="h-4 w-4 text-muted-foreground" />
|
|
<span className="font-semibold">
|
|
{guard ? `${guard.firstName} ${guard.lastName}` : "Guardia sconosciuta"}
|
|
</span>
|
|
{guard && (
|
|
<Badge variant="outline" className="text-xs">
|
|
#{guard.badgeNumber}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
|
<div className="flex items-center gap-1">
|
|
<Clock className="h-3 w-3" />
|
|
{route.startTime} - {route.endTime}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<MapPin className="h-3 w-3" />
|
|
{route.stops?.length || 0} {route.stops?.length === 1 ? "tappa" : "tappe"}
|
|
</div>
|
|
</div>
|
|
{route.stops && route.stops.length > 0 && (
|
|
<div className="text-xs text-muted-foreground mt-2">
|
|
<span className="font-medium">Sequenza: </span>
|
|
{route.stops.map((stop: any, idx: number) => (
|
|
<span key={stop.id}>
|
|
{stop.siteName}{idx < route.stops.length - 1 ? " → " : ""}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleOpenDuplicateDialog(route)}
|
|
data-testid={`button-duplicate-route-${route.id}`}
|
|
>
|
|
<Copy className="h-4 w-4 mr-2" />
|
|
Duplica
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8 space-y-2">
|
|
<ListOrdered className="h-12 w-12 mx-auto text-muted-foreground opacity-50" />
|
|
<p className="text-sm text-muted-foreground">
|
|
Nessuna sequenza pattuglia pianificata per {format(parseISO(selectedDate), "dd MMMM yyyy", { locale: it })}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Dialog Duplica Sequenza */}
|
|
<Dialog open={duplicateDialog.isOpen} onOpenChange={(open) => {
|
|
if (!open) {
|
|
setDuplicateDialog({
|
|
isOpen: false,
|
|
sourceRoute: null,
|
|
targetDate: "",
|
|
selectedDuplicateGuardId: "",
|
|
});
|
|
}
|
|
}}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Copy className="h-5 w-5" />
|
|
Duplica/Modifica Sequenza Pattuglia
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Copia la sequenza in un'altra data o modifica la guardia assegnata
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 py-4">
|
|
{/* Data Sorgente (readonly) */}
|
|
{duplicateDialog.sourceRoute && (
|
|
<div className="space-y-2">
|
|
<Label>Sequenza Sorgente</Label>
|
|
<div className="p-3 bg-muted/30 rounded-md space-y-1 text-sm">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-muted-foreground">Data:</span>
|
|
<span className="font-medium">
|
|
{format(parseISO(duplicateDialog.sourceRoute.scheduledDate), "dd/MM/yyyy", { locale: it })}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-muted-foreground">Tappe:</span>
|
|
<span className="font-medium">{duplicateDialog.sourceRoute.stops?.length || 0}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Data Target */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="target-date">Data di Destinazione *</Label>
|
|
<Input
|
|
id="target-date"
|
|
type="date"
|
|
value={duplicateDialog.targetDate}
|
|
onChange={(e) => setDuplicateDialog({ ...duplicateDialog, targetDate: e.target.value })}
|
|
data-testid="input-target-date"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{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"
|
|
}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Selezione Guardia */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="guard-select">Guardia Assegnata *</Label>
|
|
<Select
|
|
value={duplicateDialog.selectedDuplicateGuardId}
|
|
onValueChange={(value) => setDuplicateDialog({ ...duplicateDialog, selectedDuplicateGuardId: value })}
|
|
>
|
|
<SelectTrigger id="guard-select" data-testid="select-duplicate-guard">
|
|
<SelectValue placeholder="Seleziona guardia" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableGuards?.map((guard) => (
|
|
<SelectItem key={guard.id} value={guard.id}>
|
|
{guard.firstName} {guard.lastName} (#{guard.badgeNumber})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setDuplicateDialog({
|
|
isOpen: false,
|
|
sourceRoute: null,
|
|
targetDate: "",
|
|
selectedDuplicateGuardId: "",
|
|
})}
|
|
data-testid="button-cancel-duplicate"
|
|
>
|
|
Annulla
|
|
</Button>
|
|
<Button
|
|
onClick={handleSubmitDuplicate}
|
|
disabled={duplicatePatrolRouteMutation.isPending}
|
|
data-testid="button-confirm-duplicate"
|
|
>
|
|
{duplicatePatrolRouteMutation.isPending ? "Elaborazione..." : "Conferma"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|