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
This commit is contained in:
marco370 2025-10-24 15:46:11 +00:00
parent 1bad21cf9e
commit 0a72b413fa
3 changed files with 318 additions and 34 deletions

View File

@ -39,10 +39,6 @@ externalPort = 3002
localPort = 42187 localPort = 42187
externalPort = 6800 externalPort = 6800
[[ports]]
localPort = 42277
externalPort = 6000
[[ports]] [[ports]]
localPort = 43169 localPort = 43169
externalPort = 5000 externalPort = 5000

View File

@ -1056,7 +1056,8 @@ export default function GeneralPlanning() {
<Copy className="h-5 w-5" /> <Copy className="h-5 w-5" />
Copia Turno Settimanale Copia Turno Settimanale
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription className="space-y-3"> <AlertDialogDescription asChild>
<div className="space-y-3">
<p className="text-foreground font-medium"> <p className="text-foreground font-medium">
Vuoi copiare tutti i turni della settimana corrente nella settimana successiva? Vuoi copiare tutti i turni della settimana corrente nella settimana successiva?
</p> </p>
@ -1085,6 +1086,7 @@ export default function GeneralPlanning() {
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Tutti i turni e le assegnazioni guardie verranno duplicati con le stesse caratteristiche (orari, dotazioni, veicoli). Tutti i turni e le assegnazioni guardie verranno duplicati con le stesse caratteristiche (orari, dotazioni, veicoli).
</p> </p>
</div>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>

View File

@ -6,8 +6,16 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Calendar, MapPin, User, Car, Clock, Navigation, ListOrdered } from "lucide-react"; import { Calendar, MapPin, User, Car, Clock, Navigation, ListOrdered, Copy } from "lucide-react";
import { format, parseISO, isValid } from "date-fns"; 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 { it } from "date-fns/locale";
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'; import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
import L from 'leaflet'; import L from 'leaflet';
@ -87,6 +95,19 @@ export default function PlanningMobile() {
const [mapZoom, setMapZoom] = useState(12); const [mapZoom, setMapZoom] = useState(12);
const [patrolRoute, setPatrolRoute] = useState<MobileSite[]>([]); 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 // Query siti mobile per location
const { data: mobileSites, isLoading: sitesLoading } = useQuery<MobileSite[]>({ const { data: mobileSites, isLoading: sitesLoading } = useQuery<MobileSite[]>({
queryKey: ["/api/planning-mobile/sites", selectedLocation], queryKey: ["/api/planning-mobile/sites", selectedLocation],
@ -131,6 +152,55 @@ export default function PlanningMobile() {
enabled: !!selectedDate && !!selectedLocation, 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> = { const locationLabels: Record<Location, string> = {
roccapiemonte: "Roccapiemonte", roccapiemonte: "Roccapiemonte",
milano: "Milano", milano: "Milano",
@ -201,6 +271,35 @@ export default function PlanningMobile() {
}); });
}; };
// 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 // Funzione per aggiungere sito alla patrol route
const handleAddToRoute = (site: MobileSite) => { const handleAddToRoute = (site: MobileSite) => {
if (!selectedGuard) { if (!selectedGuard) {
@ -667,6 +766,193 @@ export default function PlanningMobile() {
</div> </div>
</CardContent> </CardContent>
</Card> </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> </div>
); );
} }