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:
parent
1bad21cf9e
commit
0a72b413fa
4
.replit
4
.replit
@ -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
|
||||||
|
|||||||
@ -1056,35 +1056,37 @@ 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>
|
||||||
<p className="text-foreground font-medium">
|
<div className="space-y-3">
|
||||||
Vuoi copiare tutti i turni della settimana corrente nella settimana successiva?
|
<p className="text-foreground font-medium">
|
||||||
</p>
|
Vuoi copiare tutti i turni della settimana corrente nella settimana successiva?
|
||||||
{planningData && (
|
</p>
|
||||||
<div className="space-y-2 bg-muted/30 p-3 rounded-md">
|
{planningData && (
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="space-y-2 bg-muted/30 p-3 rounded-md">
|
||||||
<span className="text-muted-foreground">Settimana corrente:</span>
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="font-medium">
|
<span className="text-muted-foreground">Settimana corrente:</span>
|
||||||
{format(new Date(planningData.weekStart), "dd MMM", { locale: it })} -{" "}
|
<span className="font-medium">
|
||||||
{format(new Date(planningData.weekEnd), "dd MMM yyyy", { locale: it })}
|
{format(new Date(planningData.weekStart), "dd MMM", { locale: it })} -{" "}
|
||||||
</span>
|
{format(new Date(planningData.weekEnd), "dd MMM yyyy", { locale: it })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Verrà copiata in:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{format(addWeeks(new Date(planningData.weekStart), 1), "dd MMM", { locale: it })} -{" "}
|
||||||
|
{format(addWeeks(new Date(planningData.weekEnd), 1), "dd MMM yyyy", { locale: it })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Sede:</span>
|
||||||
|
<span className="font-medium">{formatLocation(selectedLocation)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
)}
|
||||||
<span className="text-muted-foreground">Verrà copiata in:</span>
|
<p className="text-sm text-muted-foreground">
|
||||||
<span className="font-medium">
|
Tutti i turni e le assegnazioni guardie verranno duplicati con le stesse caratteristiche (orari, dotazioni, veicoli).
|
||||||
{format(addWeeks(new Date(planningData.weekStart), 1), "dd MMM", { locale: it })} -{" "}
|
</p>
|
||||||
{format(addWeeks(new Date(planningData.weekEnd), 1), "dd MMM yyyy", { locale: it })}
|
</div>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Sede:</span>
|
|
||||||
<span className="font-medium">{formatLocation(selectedLocation)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Tutti i turni e le assegnazioni guardie verranno duplicati con le stesse caratteristiche (orari, dotazioni, veicoli).
|
|
||||||
</p>
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|||||||
@ -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';
|
||||||
@ -86,6 +94,19 @@ export default function PlanningMobile() {
|
|||||||
const [mapCenter, setMapCenter] = useState<[number, number] | null>(null);
|
const [mapCenter, setMapCenter] = useState<[number, number] | null>(null);
|
||||||
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[]>({
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user