Compare commits
No commits in common. "2b62d8ff4e5af7919910674a4bb99cdf3792128c" and "d8f22f81da46efe541935895a208813b7e9de0a5" have entirely different histories.
2b62d8ff4e
...
d8f22f81da
@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { ChevronLeft, ChevronRight, Calendar, MapPin, Users, AlertTriangle, Car, Edit, CheckCircle2, Plus, Trash2, Clock, Copy } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Calendar, MapPin, Users, AlertTriangle, Car, Edit, CheckCircle2, Plus, Trash2, Clock } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
@ -117,7 +117,6 @@ export default function GeneralPlanning() {
|
|||||||
const [consecutiveDays, setConsecutiveDays] = useState<number>(1);
|
const [consecutiveDays, setConsecutiveDays] = useState<number>(1);
|
||||||
const [showOvertimeGuards, setShowOvertimeGuards] = useState<boolean>(false);
|
const [showOvertimeGuards, setShowOvertimeGuards] = useState<boolean>(false);
|
||||||
const [ccnlConfirmation, setCcnlConfirmation] = useState<{ message: string; data: any } | null>(null);
|
const [ccnlConfirmation, setCcnlConfirmation] = useState<{ message: string; data: any } | null>(null);
|
||||||
const [showCopyWeekConfirmation, setShowCopyWeekConfirmation] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// Query per dati planning settimanale
|
// Query per dati planning settimanale
|
||||||
const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({
|
const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({
|
||||||
@ -276,54 +275,6 @@ export default function GeneralPlanning() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutation per copiare turni settimanali
|
|
||||||
const copyWeekMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
return apiRequest("POST", "/api/shift-assignments/copy-week", {
|
|
||||||
weekStart: format(weekStart, "yyyy-MM-dd"),
|
|
||||||
location: selectedLocation,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: async (response: any) => {
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Settimana copiata!",
|
|
||||||
description: `${data.copiedShifts} turni e ${data.copiedAssignments} assegnazioni copiate nella settimana successiva`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Invalida cache e naviga alla settimana successiva
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] });
|
|
||||||
setWeekStart(addWeeks(weekStart, 1)); // Naviga alla settimana copiata
|
|
||||||
setShowCopyWeekConfirmation(false);
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
let errorMessage = "Impossibile copiare la settimana";
|
|
||||||
|
|
||||||
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 Copia Settimana",
|
|
||||||
description: errorMessage,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
|
|
||||||
setShowCopyWeekConfirmation(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handler per submit form assegnazione guardia
|
// Handler per submit form assegnazione guardia
|
||||||
const handleAssignGuard = () => {
|
const handleAssignGuard = () => {
|
||||||
if (!selectedCell || !selectedGuardId) return;
|
if (!selectedCell || !selectedGuardId) return;
|
||||||
@ -407,7 +358,7 @@ export default function GeneralPlanning() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigazione settimana */}
|
{/* Navigazione settimana */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
@ -434,16 +385,6 @@ export default function GeneralPlanning() {
|
|||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={() => setShowCopyWeekConfirmation(true)}
|
|
||||||
disabled={isLoading || !planningData || copyWeekMutation.isPending}
|
|
||||||
data-testid="button-copy-week"
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4 mr-2" />
|
|
||||||
{copyWeekMutation.isPending ? "Copia in corso..." : "Copia Turno Settimanale"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info settimana */}
|
{/* Info settimana */}
|
||||||
@ -1047,60 +988,6 @@ export default function GeneralPlanning() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* Dialog conferma copia settimana */}
|
|
||||||
<AlertDialog open={showCopyWeekConfirmation} onOpenChange={setShowCopyWeekConfirmation}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle className="flex items-center gap-2">
|
|
||||||
<Copy className="h-5 w-5" />
|
|
||||||
Copia Turno Settimanale
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription asChild>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-foreground font-medium">
|
|
||||||
Vuoi copiare tutti i turni della settimana corrente nella settimana successiva?
|
|
||||||
</p>
|
|
||||||
{planningData && (
|
|
||||||
<div className="space-y-2 bg-muted/30 p-3 rounded-md">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Settimana corrente:</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{format(new Date(planningData.weekStart), "dd MMM", { locale: it })} -{" "}
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Tutti i turni e le assegnazioni guardie verranno duplicati con le stesse caratteristiche (orari, dotazioni, veicoli).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel data-testid="button-cancel-copy-week">Annulla</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => copyWeekMutation.mutate()}
|
|
||||||
data-testid="button-confirm-copy-week"
|
|
||||||
disabled={copyWeekMutation.isPending}
|
|
||||||
>
|
|
||||||
{copyWeekMutation.isPending ? "Copia in corso..." : "Conferma Copia"}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,16 +6,8 @@ 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, Copy } from "lucide-react";
|
import { Calendar, MapPin, User, Car, Clock, Navigation, ListOrdered } from "lucide-react";
|
||||||
import { format, parseISO, isValid, addDays } from "date-fns";
|
import { format, parseISO, isValid } 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';
|
||||||
@ -95,19 +87,6 @@ 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],
|
||||||
@ -152,55 +131,6 @@ 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",
|
||||||
@ -271,35 +201,6 @@ 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) {
|
||||||
@ -766,193 +667,6 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
database-backups/vigilanzaturni_v1.0.43_20251023_140453.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.43_20251023_140453.sql.gz
Normal file
Binary file not shown.
Binary file not shown.
@ -34,8 +34,8 @@ The database supports managing users, guards, certifications, sites, shifts, shi
|
|||||||
### Core Features
|
### Core Features
|
||||||
- **Multi-Sede Operational Planning**: Location-first approach for shift planning, filtering resources by selected branch.
|
- **Multi-Sede Operational Planning**: Location-first approach for shift planning, filtering resources by selected branch.
|
||||||
- **Service Type Classification**: Classifies services as "fisso" (fixed posts) or "mobile" (patrols, inspections) to route sites to appropriate planning modules.
|
- **Service Type Classification**: Classifies services as "fisso" (fixed posts) or "mobile" (patrols, inspections) to route sites to appropriate planning modules.
|
||||||
- **Planning Fissi**: Weekly planning grid for fixed posts, enabling shift creation with guard availability checks. Includes weekly shift duplication feature with confirmation dialog and automatic navigation.
|
- **Planning Fissi**: Weekly planning grid for fixed posts, enabling shift creation with guard availability checks.
|
||||||
- **Planning Mobile**: Guard-centric interface for mobile services, displaying guard availability and hours, with an interactive Leaflet map showing sites. Includes patrol sequence list view and duplication/modification dialog.
|
- **Planning Mobile**: Guard-centric interface for mobile services, displaying guard availability and hours, with an interactive Leaflet map showing sites.
|
||||||
- **Customer Management**: Full CRUD operations for customer details and customer-centric reporting with CSV export.
|
- **Customer Management**: Full CRUD operations for customer details and customer-centric reporting with CSV export.
|
||||||
- **Dashboard Operativa**: Live KPIs and real-time shift status.
|
- **Dashboard Operativa**: Live KPIs and real-time shift status.
|
||||||
- **Gestione Guardie**: Complete profiles with skill matrix, certification management, and badge numbers.
|
- **Gestione Guardie**: Complete profiles with skill matrix, certification management, and badge numbers.
|
||||||
@ -44,9 +44,6 @@ The database supports managing users, guards, certifications, sites, shifts, shi
|
|||||||
- **Advanced Planning**: Management of guard constraints, site preferences, contract parameters, training courses, holidays, and absences. Includes patrol route persistence and exclusivity constraints between fixed and mobile shifts.
|
- **Advanced Planning**: Management of guard constraints, site preferences, contract parameters, training courses, holidays, and absences. Includes patrol route persistence and exclusivity constraints between fixed and mobile shifts.
|
||||||
- **Guard Planning Views**: Dedicated views for guards to see their fixed post shifts and mobile patrol routes.
|
- **Guard Planning Views**: Dedicated views for guards to see their fixed post shifts and mobile patrol routes.
|
||||||
- **Site Planning View**: Coordinators can view all guards assigned to a specific site over a week.
|
- **Site Planning View**: Coordinators can view all guards assigned to a specific site over a week.
|
||||||
- **Shift Duplication Features**:
|
|
||||||
- **Weekly Copy (Planning Fissi)**: POST /api/shift-assignments/copy-week endpoint duplicates all shifts and assignments from selected week to next week (+7 days) with atomic transaction. Frontend includes confirmation dialog with week details and success feedback.
|
|
||||||
- **Patrol Sequence Duplication (Planning Mobili)**: POST /api/patrol-routes/duplicate endpoint with dual behavior: UPDATE when target date = source date (modifies guard), CREATE when different date (duplicates route with all stops). Frontend shows daily sequence list with duplication dialog (date picker defaulting to next day, guard selector pre-filled but changeable).
|
|
||||||
|
|
||||||
### User Roles
|
### User Roles
|
||||||
- **Admin**: Full access.
|
- **Admin**: Full access.
|
||||||
|
|||||||
215
server/routes.ts
215
server/routes.ts
@ -1337,129 +1337,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Copy weekly shift assignments to next week
|
|
||||||
app.post("/api/shift-assignments/copy-week", isAuthenticated, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { weekStart, location } = req.body;
|
|
||||||
|
|
||||||
if (!weekStart || !location) {
|
|
||||||
return res.status(400).json({ message: "Missing required fields: weekStart, location" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse week start date
|
|
||||||
const [year, month, day] = weekStart.split("-").map(Number);
|
|
||||||
if (!year || !month || !day) {
|
|
||||||
return res.status(400).json({ message: "Invalid weekStart format. Expected YYYY-MM-DD" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate week boundaries (Monday to Sunday)
|
|
||||||
const weekStartDate = new Date(year, month - 1, day, 0, 0, 0, 0);
|
|
||||||
const weekEndDate = new Date(year, month - 1, day + 6, 23, 59, 59, 999);
|
|
||||||
|
|
||||||
console.log("📋 Copying weekly shifts:", {
|
|
||||||
weekStart: weekStartDate.toISOString(),
|
|
||||||
weekEnd: weekEndDate.toISOString(),
|
|
||||||
location
|
|
||||||
});
|
|
||||||
|
|
||||||
// Transaction: copy all shifts and assignments
|
|
||||||
const result = await db.transaction(async (tx) => {
|
|
||||||
// 1. Find all shifts in the source week filtered by location
|
|
||||||
const sourceShifts = await tx
|
|
||||||
.select({
|
|
||||||
shift: shifts,
|
|
||||||
site: sites
|
|
||||||
})
|
|
||||||
.from(shifts)
|
|
||||||
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
gte(shifts.startTime, weekStartDate),
|
|
||||||
lte(shifts.startTime, weekEndDate),
|
|
||||||
eq(sites.location, location)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sourceShifts.length === 0) {
|
|
||||||
throw new Error("Nessun turno trovato nella settimana selezionata");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📋 Found ${sourceShifts.length} shifts to copy`);
|
|
||||||
|
|
||||||
let copiedShiftsCount = 0;
|
|
||||||
let copiedAssignmentsCount = 0;
|
|
||||||
|
|
||||||
// 2. For each shift, copy to next week (+7 days)
|
|
||||||
for (const { shift: sourceShift, site } of sourceShifts) {
|
|
||||||
// Calculate new dates (+7 days)
|
|
||||||
const newStartTime = new Date(sourceShift.startTime);
|
|
||||||
newStartTime.setDate(newStartTime.getDate() + 7);
|
|
||||||
|
|
||||||
const newEndTime = new Date(sourceShift.endTime);
|
|
||||||
newEndTime.setDate(newEndTime.getDate() + 7);
|
|
||||||
|
|
||||||
// Create new shift
|
|
||||||
const [newShift] = await tx
|
|
||||||
.insert(shifts)
|
|
||||||
.values({
|
|
||||||
siteId: sourceShift.siteId,
|
|
||||||
startTime: newStartTime,
|
|
||||||
endTime: newEndTime,
|
|
||||||
status: "planned",
|
|
||||||
vehicleId: sourceShift.vehicleId,
|
|
||||||
notes: sourceShift.notes,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
copiedShiftsCount++;
|
|
||||||
|
|
||||||
// 3. Copy all assignments for this shift
|
|
||||||
const sourceAssignments = await tx
|
|
||||||
.select()
|
|
||||||
.from(shiftAssignments)
|
|
||||||
.where(eq(shiftAssignments.shiftId, sourceShift.id));
|
|
||||||
|
|
||||||
for (const sourceAssignment of sourceAssignments) {
|
|
||||||
// Calculate new planned times (+7 days)
|
|
||||||
const newPlannedStart = new Date(sourceAssignment.plannedStartTime);
|
|
||||||
newPlannedStart.setDate(newPlannedStart.getDate() + 7);
|
|
||||||
|
|
||||||
const newPlannedEnd = new Date(sourceAssignment.plannedEndTime);
|
|
||||||
newPlannedEnd.setDate(newPlannedEnd.getDate() + 7);
|
|
||||||
|
|
||||||
// Create new assignment
|
|
||||||
await tx
|
|
||||||
.insert(shiftAssignments)
|
|
||||||
.values({
|
|
||||||
shiftId: newShift.id,
|
|
||||||
guardId: sourceAssignment.guardId,
|
|
||||||
plannedStartTime: newPlannedStart,
|
|
||||||
plannedEndTime: newPlannedEnd,
|
|
||||||
isArmedOnDuty: sourceAssignment.isArmedOnDuty,
|
|
||||||
assignedVehicleId: sourceAssignment.assignedVehicleId,
|
|
||||||
});
|
|
||||||
|
|
||||||
copiedAssignmentsCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { copiedShiftsCount, copiedAssignmentsCount };
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
message: `Settimana copiata con successo: ${result.copiedShiftsCount} turni, ${result.copiedAssignmentsCount} assegnazioni`,
|
|
||||||
copiedShifts: result.copiedShiftsCount,
|
|
||||||
copiedAssignments: result.copiedAssignmentsCount,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("❌ Error copying weekly shifts:", error);
|
|
||||||
res.status(500).json({
|
|
||||||
message: error.message || "Errore durante la copia dei turni settimanali",
|
|
||||||
error: String(error)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Assign guard to site/date with specific time slot (supports multi-day assignments)
|
// Assign guard to site/date with specific time slot (supports multi-day assignments)
|
||||||
app.post("/api/general-planning/assign-guard", isAuthenticated, async (req, res) => {
|
app.post("/api/general-planning/assign-guard", isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@ -4256,98 +4133,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST - Duplica o modifica patrol route
|
|
||||||
app.post("/api/patrol-routes/duplicate", isAuthenticated, async (req: any, res) => {
|
|
||||||
try {
|
|
||||||
const { sourceRouteId, targetDate, guardId } = req.body;
|
|
||||||
|
|
||||||
if (!sourceRouteId || !targetDate) {
|
|
||||||
return res.status(400).json({
|
|
||||||
message: "sourceRouteId e targetDate sono obbligatori"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Carica patrol route sorgente con tutti gli stops
|
|
||||||
const sourceRoute = await db.query.patrolRoutes.findFirst({
|
|
||||||
where: eq(patrolRoutes.id, sourceRouteId),
|
|
||||||
with: {
|
|
||||||
stops: {
|
|
||||||
orderBy: (stops, { asc }) => [asc(stops.sequenceOrder)],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!sourceRoute) {
|
|
||||||
return res.status(404).json({ message: "Sequenza pattuglia sorgente non trovata" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Controlla se targetDate è uguale a sourceRoute.scheduledDate
|
|
||||||
const sourceDate = new Date(sourceRoute.scheduledDate).toISOString().split('T')[0];
|
|
||||||
const targetDateNormalized = new Date(targetDate).toISOString().split('T')[0];
|
|
||||||
|
|
||||||
if (sourceDate === targetDateNormalized) {
|
|
||||||
// UPDATE: stessa data, modifica solo guardia se fornita
|
|
||||||
if (guardId && guardId !== sourceRoute.guardId) {
|
|
||||||
const updated = await db
|
|
||||||
.update(patrolRoutes)
|
|
||||||
.set({ guardId })
|
|
||||||
.where(eq(patrolRoutes.id, sourceRouteId))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
action: "updated",
|
|
||||||
route: updated[0],
|
|
||||||
message: "Guardia assegnata alla sequenza esistente",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return res.status(400).json({
|
|
||||||
message: "Nessuna modifica da applicare (stessa data e stessa guardia)"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// CREATE: data diversa, duplica sequenza con stops
|
|
||||||
|
|
||||||
// Crea nuova patrol route
|
|
||||||
const newRoute = await db
|
|
||||||
.insert(patrolRoutes)
|
|
||||||
.values({
|
|
||||||
guardId: guardId || sourceRoute.guardId, // Usa nuova guardia o mantieni originale
|
|
||||||
scheduledDate: new Date(targetDate),
|
|
||||||
startTime: sourceRoute.startTime,
|
|
||||||
endTime: sourceRoute.endTime,
|
|
||||||
status: "scheduled", // Nuova sequenza sempre in stato scheduled
|
|
||||||
location: sourceRoute.location,
|
|
||||||
notes: sourceRoute.notes,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
const newRouteId = newRoute[0].id;
|
|
||||||
|
|
||||||
// Duplica tutti gli stops
|
|
||||||
if (sourceRoute.stops && sourceRoute.stops.length > 0) {
|
|
||||||
const stopsData = sourceRoute.stops.map((stop) => ({
|
|
||||||
patrolRouteId: newRouteId,
|
|
||||||
siteId: stop.siteId,
|
|
||||||
sequenceOrder: stop.sequenceOrder,
|
|
||||||
estimatedArrivalTime: stop.estimatedArrivalTime,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await db.insert(patrolRouteStops).values(stopsData);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
action: "created",
|
|
||||||
route: newRoute[0],
|
|
||||||
copiedStops: sourceRoute.stops?.length || 0,
|
|
||||||
message: "Sequenza pattuglia duplicata con successo",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error duplicating patrol route:", error);
|
|
||||||
res.status(500).json({ message: "Errore durante duplicazione sequenza pattuglia" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============= GEOCODING API (Nominatim/OSM) =============
|
// ============= GEOCODING API (Nominatim/OSM) =============
|
||||||
|
|
||||||
// Rate limiter semplice per rispettare 1 req/sec di Nominatim
|
// Rate limiter semplice per rispettare 1 req/sec di Nominatim
|
||||||
|
|||||||
16
version.json
16
version.json
@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.53",
|
"version": "1.0.52",
|
||||||
"lastUpdate": "2025-10-24T16:35:13.318Z",
|
"lastUpdate": "2025-10-24T14:53:47.910Z",
|
||||||
"changelog": [
|
"changelog": [
|
||||||
{
|
|
||||||
"version": "1.0.53",
|
|
||||||
"date": "2025-10-24",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.53"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"version": "1.0.52",
|
"version": "1.0.52",
|
||||||
"date": "2025-10-24",
|
"date": "2025-10-24",
|
||||||
@ -301,6 +295,12 @@
|
|||||||
"date": "2025-10-17",
|
"date": "2025-10-17",
|
||||||
"type": "patch",
|
"type": "patch",
|
||||||
"description": "Deployment automatico v1.0.4"
|
"description": "Deployment automatico v1.0.4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.3",
|
||||||
|
"date": "2025-10-17",
|
||||||
|
"type": "patch",
|
||||||
|
"description": "Deployment automatico v1.0.3"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user