VigilanzaTurni/client/src/pages/planning-mobile.tsx
marco370 0a72b413fa Add functionality to duplicate weekly schedules and patrol routes
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
2025-10-24 15:46:11 +00:00

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='&copy; <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>
);
}