Add detailed validation and conflict resolution for patrol shifts
Implement new API endpoint `/api/patrol-routes/check-overlaps` for shift conflict detection, including fixed posts, other mobile routes, and weekly hour compliance. Introduce a new "force-save" dialog for manual conflict overrides and enhance the patrol route duplication feature to support multi-day operations with overlap validation. Replit-Commit-Author: Agent Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/p38S9Gi
This commit is contained in:
parent
40cde1634b
commit
758a697447
@ -155,6 +155,8 @@ 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[]>([]);
|
||||||
|
const [shiftStartTime, setShiftStartTime] = useState("08:00");
|
||||||
|
const [shiftDuration, setShiftDuration] = useState("8");
|
||||||
|
|
||||||
// State per dialog duplicazione sequenza
|
// State per dialog duplicazione sequenza
|
||||||
const [duplicateDialog, setDuplicateDialog] = useState<{
|
const [duplicateDialog, setDuplicateDialog] = useState<{
|
||||||
@ -162,11 +164,13 @@ export default function PlanningMobile() {
|
|||||||
sourceRoute: any | null;
|
sourceRoute: any | null;
|
||||||
targetDate: string;
|
targetDate: string;
|
||||||
selectedDuplicateGuardId: string;
|
selectedDuplicateGuardId: string;
|
||||||
|
numDays: string;
|
||||||
}>({
|
}>({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
sourceRoute: null,
|
sourceRoute: null,
|
||||||
targetDate: "",
|
targetDate: "",
|
||||||
selectedDuplicateGuardId: "",
|
selectedDuplicateGuardId: "",
|
||||||
|
numDays: "1",
|
||||||
});
|
});
|
||||||
|
|
||||||
// State per dialog risultati ottimizzazione
|
// State per dialog risultati ottimizzazione
|
||||||
@ -180,6 +184,19 @@ export default function PlanningMobile() {
|
|||||||
estimatedTime: "",
|
estimatedTime: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// State per dialog conferma forzatura turno
|
||||||
|
const [forceDialog, setForceDialog] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
conflicts: any[];
|
||||||
|
weeklyHours: any;
|
||||||
|
patrolRouteData: any;
|
||||||
|
}>({
|
||||||
|
isOpen: false,
|
||||||
|
conflicts: [],
|
||||||
|
weeklyHours: null,
|
||||||
|
patrolRouteData: null,
|
||||||
|
});
|
||||||
|
|
||||||
// Ref per scroll alla sezione sequenze pattuglia
|
// Ref per scroll alla sezione sequenze pattuglia
|
||||||
const patrolSequencesRef = useRef<HTMLDivElement>(null);
|
const patrolSequencesRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -270,6 +287,7 @@ export default function PlanningMobile() {
|
|||||||
sourceRoute: null,
|
sourceRoute: null,
|
||||||
targetDate: "",
|
targetDate: "",
|
||||||
selectedDuplicateGuardId: "",
|
selectedDuplicateGuardId: "",
|
||||||
|
numDays: "1",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
@ -415,6 +433,7 @@ export default function PlanningMobile() {
|
|||||||
sourceRoute: route,
|
sourceRoute: route,
|
||||||
targetDate: nextDay, // Default = giorno successivo
|
targetDate: nextDay, // Default = giorno successivo
|
||||||
selectedDuplicateGuardId: route.guardId || "", // Pre-compilato con guardia attuale
|
selectedDuplicateGuardId: route.guardId || "", // Pre-compilato con guardia attuale
|
||||||
|
numDays: "1",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
@ -426,8 +445,8 @@ export default function PlanningMobile() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handler submit dialog duplicazione
|
// Handler submit dialog duplicazione con supporto giorni multipli
|
||||||
const handleSubmitDuplicate = () => {
|
const handleSubmitDuplicate = async () => {
|
||||||
if (!duplicateDialog.sourceRoute || !duplicateDialog.targetDate || !duplicateDialog.selectedDuplicateGuardId) {
|
if (!duplicateDialog.sourceRoute || !duplicateDialog.targetDate || !duplicateDialog.selectedDuplicateGuardId) {
|
||||||
toast({
|
toast({
|
||||||
title: "Campi mancanti",
|
title: "Campi mancanti",
|
||||||
@ -437,11 +456,116 @@ export default function PlanningMobile() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const numDays = parseInt(duplicateDialog.numDays) || 1;
|
||||||
|
|
||||||
|
// Se numDays === 1, comportamento standard
|
||||||
|
if (numDays === 1) {
|
||||||
duplicatePatrolRouteMutation.mutate({
|
duplicatePatrolRouteMutation.mutate({
|
||||||
sourceRouteId: duplicateDialog.sourceRoute.id,
|
sourceRouteId: duplicateDialog.sourceRoute.id,
|
||||||
targetDate: duplicateDialog.targetDate,
|
targetDate: duplicateDialog.targetDate,
|
||||||
guardId: duplicateDialog.selectedDuplicateGuardId,
|
guardId: duplicateDialog.selectedDuplicateGuardId,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicazione multipla: verifica sovrapposizioni per tutti i giorni
|
||||||
|
try {
|
||||||
|
const startDate = parseISO(duplicateDialog.targetDate);
|
||||||
|
|
||||||
|
// Verifica sovrapposizioni per ogni giorno usando timing reale della route sorgente
|
||||||
|
let hasAnyConflict = false;
|
||||||
|
const sourceStartTime = duplicateDialog.sourceRoute.startTime || "08:00";
|
||||||
|
|
||||||
|
// Usa endTime se presente, altrimenti calcola da duration (se presente), altrimenti fallback a 8h
|
||||||
|
let sourceEndTime: string;
|
||||||
|
if (duplicateDialog.sourceRoute.endTime) {
|
||||||
|
sourceEndTime = duplicateDialog.sourceRoute.endTime;
|
||||||
|
} else {
|
||||||
|
const durationStr = duplicateDialog.sourceRoute.duration?.toString() || "8";
|
||||||
|
sourceEndTime = calculateEndTime(sourceStartTime, durationStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < numDays; i++) {
|
||||||
|
const targetDate = format(addDays(startDate, i), "yyyy-MM-dd");
|
||||||
|
|
||||||
|
const checkResponse = await apiRequest("POST", "/api/patrol-routes/check-overlaps", {
|
||||||
|
guardId: duplicateDialog.selectedDuplicateGuardId,
|
||||||
|
shiftDate: targetDate,
|
||||||
|
startTime: sourceStartTime,
|
||||||
|
endTime: sourceEndTime,
|
||||||
|
excludeRouteId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkData = await checkResponse.json();
|
||||||
|
|
||||||
|
if (checkData.hasConflicts || checkData.weeklyHours.exceedsLimit) {
|
||||||
|
hasAnyConflict = true;
|
||||||
|
break; // Ferma al primo conflitto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se ci sono conflitti, avvisa l'utente e blocca
|
||||||
|
if (hasAnyConflict) {
|
||||||
|
toast({
|
||||||
|
title: "Conflitto rilevato",
|
||||||
|
description: "Almeno un giorno ha conflitti di sovrapposizione turni. La duplicazione multipla è stata bloccata per sicurezza. Duplica singolarmente i giorni per gestire i conflitti.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nessun conflitto, procedi con duplicazione
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < numDays; i++) {
|
||||||
|
const targetDate = format(addDays(startDate, i), "yyyy-MM-dd");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiRequest("POST", "/api/patrol-routes/duplicate", {
|
||||||
|
sourceRouteId: duplicateDialog.sourceRoute.id,
|
||||||
|
targetDate: targetDate,
|
||||||
|
guardId: duplicateDialog.selectedDuplicateGuardId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await response.json();
|
||||||
|
successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
errorCount++;
|
||||||
|
console.error(`Error duplicating for ${targetDate}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mostra risultato
|
||||||
|
if (successCount > 0) {
|
||||||
|
toast({
|
||||||
|
title: "Duplicazione completata!",
|
||||||
|
description: `${successCount} sequenze create con successo${errorCount > 0 ? ` (${errorCount} errori)` : ''}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Errore",
|
||||||
|
description: "Nessuna sequenza è stata duplicata",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chiudi dialog e invalida cache
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["/api/patrol-routes"] });
|
||||||
|
setDuplicateDialog({
|
||||||
|
isOpen: false,
|
||||||
|
sourceRoute: null,
|
||||||
|
targetDate: "",
|
||||||
|
selectedDuplicateGuardId: "",
|
||||||
|
numDays: "1",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Errore duplicazione multipla",
|
||||||
|
description: "Errore durante la duplicazione delle sequenze",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Funzione per aggiungere sito alla patrol route
|
// Funzione per aggiungere sito alla patrol route
|
||||||
@ -581,8 +705,20 @@ export default function PlanningMobile() {
|
|||||||
optimizeRouteMutation.mutate(coordinates);
|
optimizeRouteMutation.mutate(coordinates);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Funzione per salvare il turno pattuglia
|
// Helper per calcolare endTime da startTime + durata
|
||||||
const handleSavePatrolRoute = () => {
|
const calculateEndTime = (startTime: string, durationHours: string): string => {
|
||||||
|
const [hours, minutes] = startTime.split(':').map(Number);
|
||||||
|
const duration = parseFloat(durationHours);
|
||||||
|
|
||||||
|
const totalMinutes = hours * 60 + minutes + duration * 60;
|
||||||
|
const endHours = Math.floor(totalMinutes / 60) % 24;
|
||||||
|
const endMinutes = totalMinutes % 60;
|
||||||
|
|
||||||
|
return `${String(endHours).padStart(2, '0')}:${String(endMinutes).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Funzione per salvare il turno pattuglia con controllo sovrapposizioni
|
||||||
|
const handleSavePatrolRoute = async () => {
|
||||||
if (!selectedGuard) {
|
if (!selectedGuard) {
|
||||||
toast({
|
toast({
|
||||||
title: "Guardia non selezionata",
|
title: "Guardia non selezionata",
|
||||||
@ -601,12 +737,15 @@ export default function PlanningMobile() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calcola endTime
|
||||||
|
const endTime = calculateEndTime(shiftStartTime, shiftDuration);
|
||||||
|
|
||||||
// Prepara i dati per il salvataggio
|
// Prepara i dati per il salvataggio
|
||||||
const patrolRouteData = {
|
const patrolRouteData = {
|
||||||
guardId: selectedGuard.id,
|
guardId: selectedGuard.id,
|
||||||
shiftDate: selectedDate,
|
shiftDate: selectedDate,
|
||||||
startTime: "08:00", // TODO: permettere all'utente di configurare
|
startTime: shiftStartTime,
|
||||||
endTime: "20:00",
|
endTime: endTime,
|
||||||
location: selectedLocation,
|
location: selectedLocation,
|
||||||
status: "planned",
|
status: "planned",
|
||||||
stops: patrolRoute.map((site) => ({
|
stops: patrolRoute.map((site) => ({
|
||||||
@ -619,10 +758,70 @@ export default function PlanningMobile() {
|
|||||||
(route: any) => route.guardId === selectedGuard.id
|
(route: any) => route.guardId === selectedGuard.id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Verifica sovrapposizioni con endpoint
|
||||||
|
try {
|
||||||
|
const checkResponse = await apiRequest("POST", "/api/patrol-routes/check-overlaps", {
|
||||||
|
guardId: selectedGuard.id,
|
||||||
|
shiftDate: selectedDate,
|
||||||
|
startTime: shiftStartTime,
|
||||||
|
endTime: endTime,
|
||||||
|
excludeRouteId: existingRoute?.id || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkData = await checkResponse.json();
|
||||||
|
|
||||||
|
// Se ci sono conflitti o si superano ore contrattuali, mostra dialog
|
||||||
|
if (checkData.hasConflicts || checkData.weeklyHours.exceedsLimit) {
|
||||||
|
setForceDialog({
|
||||||
|
isOpen: true,
|
||||||
|
conflicts: checkData.conflicts || [],
|
||||||
|
weeklyHours: checkData.weeklyHours,
|
||||||
|
patrolRouteData: {
|
||||||
|
...patrolRouteData,
|
||||||
|
existingRouteId: existingRoute?.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nessun problema, salva direttamente
|
||||||
savePatrolRouteMutation.mutate({
|
savePatrolRouteMutation.mutate({
|
||||||
data: patrolRouteData,
|
data: patrolRouteData,
|
||||||
existingRouteId: existingRoute?.id,
|
existingRouteId: existingRoute?.id,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Errore verifica sovrapposizioni",
|
||||||
|
description: "Impossibile verificare conflitti turni. Riprova.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Funzione per forzare il salvataggio ignorando i conflitti
|
||||||
|
const handleForceSave = () => {
|
||||||
|
const { patrolRouteData } = forceDialog;
|
||||||
|
if (!patrolRouteData) return;
|
||||||
|
|
||||||
|
savePatrolRouteMutation.mutate({
|
||||||
|
data: {
|
||||||
|
guardId: patrolRouteData.guardId,
|
||||||
|
shiftDate: patrolRouteData.shiftDate,
|
||||||
|
startTime: patrolRouteData.startTime,
|
||||||
|
endTime: patrolRouteData.endTime,
|
||||||
|
location: patrolRouteData.location,
|
||||||
|
status: patrolRouteData.status,
|
||||||
|
stops: patrolRouteData.stops,
|
||||||
|
},
|
||||||
|
existingRouteId: patrolRouteData.existingRouteId,
|
||||||
|
});
|
||||||
|
|
||||||
|
setForceDialog({
|
||||||
|
isOpen: false,
|
||||||
|
conflicts: [],
|
||||||
|
weeklyHours: null,
|
||||||
|
patrolRouteData: null,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Carica patrol route esistente quando si seleziona una guardia
|
// Carica patrol route esistente quando si seleziona una guardia
|
||||||
@ -729,6 +928,34 @@ export default function PlanningMobile() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
|
{/* Campi Orario Turno */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-muted/30 rounded-md">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="shift-start-time">Ora Inizio</Label>
|
||||||
|
<Input
|
||||||
|
id="shift-start-time"
|
||||||
|
type="time"
|
||||||
|
value={shiftStartTime}
|
||||||
|
onChange={(e) => setShiftStartTime(e.target.value)}
|
||||||
|
data-testid="input-shift-start-time"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="shift-duration">Durata (ore)</Label>
|
||||||
|
<Input
|
||||||
|
id="shift-duration"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="24"
|
||||||
|
step="0.5"
|
||||||
|
value={shiftDuration}
|
||||||
|
onChange={(e) => setShiftDuration(e.target.value)}
|
||||||
|
data-testid="input-shift-duration"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista Tappe Draggable */}
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
@ -1079,6 +1306,95 @@ export default function PlanningMobile() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Dialog Conferma Forzatura Turno */}
|
||||||
|
<Dialog open={forceDialog.isOpen} onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setForceDialog({
|
||||||
|
isOpen: false,
|
||||||
|
conflicts: [],
|
||||||
|
weeklyHours: null,
|
||||||
|
patrolRouteData: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-orange-600">
|
||||||
|
Attenzione: Sovrapposizione Turni
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
La guardia ha già turni assegnati nelle ore indicate o supererebbe i limiti contrattuali.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Conflitti */}
|
||||||
|
{forceDialog.conflicts && forceDialog.conflicts.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-semibold text-sm">Turni in Conflitto:</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{forceDialog.conflicts.map((conflict: any, idx: number) => (
|
||||||
|
<div key={idx} className="p-3 bg-destructive/10 border border-destructive/20 rounded-md text-sm">
|
||||||
|
<div className="font-medium">
|
||||||
|
{conflict.type === 'fisso' ? `Turno Fisso - ${conflict.siteName}` : `Turno Mobile`}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{conflict.type === 'fisso' ? (
|
||||||
|
<>
|
||||||
|
{format(new Date(conflict.startTime), "dd/MM/yyyy HH:mm")} - {format(new Date(conflict.endTime), "HH:mm")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{conflict.shiftDate} {conflict.startTime} - {conflict.endTime}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ore Settimanali */}
|
||||||
|
{forceDialog.weeklyHours && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-semibold text-sm">Ore Settimanali:</h4>
|
||||||
|
<div className="p-3 bg-muted/30 rounded-md space-y-1 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Ore attuali:</span>
|
||||||
|
<span className="font-medium">{forceDialog.weeklyHours.current}h</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Ore nuovo turno:</span>
|
||||||
|
<span className="font-medium text-blue-600">{forceDialog.weeklyHours.newShiftHours}h</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-t pt-1">
|
||||||
|
<span>Totale con nuovo turno:</span>
|
||||||
|
<span className={`font-bold ${forceDialog.weeklyHours.exceedsLimit ? 'text-destructive' : ''}`}>
|
||||||
|
{forceDialog.weeklyHours.withNewShift}h / {forceDialog.weeklyHours.maxTotal}h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{forceDialog.weeklyHours.exceedsLimit && (
|
||||||
|
<p className="text-xs text-destructive pt-2">
|
||||||
|
Superamento limite contrattuale di {Math.round((forceDialog.weeklyHours.withNewShift - forceDialog.weeklyHours.maxTotal) * 100) / 100}h
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setForceDialog({ isOpen: false, conflicts: [], weeklyHours: null, patrolRouteData: null })}>
|
||||||
|
Annulla
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleForceSave} data-testid="button-force-save">
|
||||||
|
Forza Salvataggio
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Dialog Duplica Sequenza */}
|
{/* Dialog Duplica Sequenza */}
|
||||||
<Dialog open={duplicateDialog.isOpen} onOpenChange={(open) => {
|
<Dialog open={duplicateDialog.isOpen} onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@ -1087,6 +1403,7 @@ export default function PlanningMobile() {
|
|||||||
sourceRoute: null,
|
sourceRoute: null,
|
||||||
targetDate: "",
|
targetDate: "",
|
||||||
selectedDuplicateGuardId: "",
|
selectedDuplicateGuardId: "",
|
||||||
|
numDays: "1",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
@ -1125,7 +1442,7 @@ export default function PlanningMobile() {
|
|||||||
|
|
||||||
{/* Data Target */}
|
{/* Data Target */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="target-date">Data di Destinazione *</Label>
|
<Label htmlFor="target-date">Data di Partenza *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="target-date"
|
id="target-date"
|
||||||
type="date"
|
type="date"
|
||||||
@ -1137,11 +1454,28 @@ export default function PlanningMobile() {
|
|||||||
{duplicateDialog.sourceRoute && duplicateDialog.targetDate && duplicateDialog.sourceRoute.shiftDate && isValid(parseISO(duplicateDialog.sourceRoute.shiftDate)) &&
|
{duplicateDialog.sourceRoute && duplicateDialog.targetDate && duplicateDialog.sourceRoute.shiftDate && isValid(parseISO(duplicateDialog.sourceRoute.shiftDate)) &&
|
||||||
format(parseISO(duplicateDialog.sourceRoute.shiftDate), "yyyy-MM-dd") === duplicateDialog.targetDate
|
format(parseISO(duplicateDialog.sourceRoute.shiftDate), "yyyy-MM-dd") === duplicateDialog.targetDate
|
||||||
? "⚠️ Stessa data: verrà modificata la guardia della sequenza esistente"
|
? "⚠️ Stessa data: verrà modificata la guardia della sequenza esistente"
|
||||||
: "✓ Data diversa: verrà creata una nuova sequenza con tutte le tappe"
|
: "✓ Data diversa: verranno create nuove sequenze consecutive"
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Numero Giorni */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="num-days">Numero Giorni Consecutivi</Label>
|
||||||
|
<Input
|
||||||
|
id="num-days"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="30"
|
||||||
|
value={duplicateDialog.numDays}
|
||||||
|
onChange={(e) => setDuplicateDialog({ ...duplicateDialog, numDays: e.target.value })}
|
||||||
|
data-testid="input-num-days"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Duplica la sequenza per {duplicateDialog.numDays} {parseInt(duplicateDialog.numDays) === 1 ? 'giorno' : 'giorni'} consecutivi a partire dalla data indicata
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Selezione Guardia */}
|
{/* Selezione Guardia */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="guard-select">Guardia Assegnata *</Label>
|
<Label htmlFor="guard-select">Guardia Assegnata *</Label>
|
||||||
@ -1171,6 +1505,7 @@ export default function PlanningMobile() {
|
|||||||
sourceRoute: null,
|
sourceRoute: null,
|
||||||
targetDate: "",
|
targetDate: "",
|
||||||
selectedDuplicateGuardId: "",
|
selectedDuplicateGuardId: "",
|
||||||
|
numDays: "1",
|
||||||
})}
|
})}
|
||||||
data-testid="button-cancel-duplicate"
|
data-testid="button-cancel-duplicate"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -40,7 +40,13 @@ The database supports managing users, guards, certifications, sites, shifts, shi
|
|||||||
- **Drag-and-Drop Reordering**: Interactive drag-and-drop using @dnd-kit library for patrol route stops with visual feedback and automatic sequenceOrder persistence
|
- **Drag-and-Drop Reordering**: Interactive drag-and-drop using @dnd-kit library for patrol route stops with visual feedback and automatic sequenceOrder persistence
|
||||||
- **Route Optimization**: OSRM API integration with TSP (Traveling Salesman Problem) nearest neighbor algorithm; displays total distance (km) and estimated travel time in dedicated dialog
|
- **Route Optimization**: OSRM API integration with TSP (Traveling Salesman Problem) nearest neighbor algorithm; displays total distance (km) and estimated travel time in dedicated dialog
|
||||||
- **Patrol Sequence List View**: Daily view of planned patrol routes with stops visualization
|
- **Patrol Sequence List View**: Daily view of planned patrol routes with stops visualization
|
||||||
- **Duplication/Modification Dialog**: Copy routes to different dates or modify assigned guard
|
- **Custom Shift Timing**: Configurable start time and duration for each patrol route (replaces hardcoded 08:00-20:00)
|
||||||
|
- **Shift Overlap Validation**: POST /api/patrol-routes/check-overlaps endpoint verifies:
|
||||||
|
- No conflicts with existing fixed post shifts (shift_assignments)
|
||||||
|
- No conflicts with other mobile patrol routes
|
||||||
|
- Weekly hours compliance with contract parameters (maxHoursPerWeek + maxOvertimePerWeek)
|
||||||
|
- **Force-Save Dialog**: Interactive conflict resolution when saving patrol routes with overlaps or contractual limit violations; shows detailed conflict information and allows coordinator override
|
||||||
|
- **Multi-Day Duplication**: Duplication dialog supports "numero giorni consecutivi" field to create patrol sequences across N consecutive days; includes overlap validation (conservative approach: blocks entire operation if any day has conflicts)
|
||||||
- **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.
|
||||||
|
|||||||
184
server/routes.ts
184
server/routes.ts
@ -4348,6 +4348,190 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST - Verifica sovrapposizioni turni e calcola ore settimanali
|
||||||
|
app.post("/api/patrol-routes/check-overlaps", isAuthenticated, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { guardId, shiftDate, startTime, endTime, excludeRouteId } = req.body;
|
||||||
|
|
||||||
|
if (!guardId || !shiftDate || !startTime || !endTime) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: "guardId, shiftDate, startTime e endTime sono obbligatori"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converte orari in timestamp per confronto
|
||||||
|
const shiftDateObj = new Date(shiftDate);
|
||||||
|
const [startHour, startMin] = startTime.split(':').map(Number);
|
||||||
|
const [endHour, endMin] = endTime.split(':').map(Number);
|
||||||
|
|
||||||
|
const startTimestamp = new Date(shiftDateObj);
|
||||||
|
startTimestamp.setHours(startHour, startMin, 0, 0);
|
||||||
|
|
||||||
|
const endTimestamp = new Date(shiftDateObj);
|
||||||
|
endTimestamp.setHours(endHour, endMin, 0, 0);
|
||||||
|
|
||||||
|
// Se endTime è minore di startTime, il turno attraversa la mezzanotte
|
||||||
|
if (endTimestamp <= startTimestamp) {
|
||||||
|
endTimestamp.setDate(endTimestamp.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflicts = [];
|
||||||
|
|
||||||
|
// 1. Controlla sovrapposizioni con shift_assignments (turni fissi)
|
||||||
|
const fixedShifts = await db
|
||||||
|
.select({
|
||||||
|
id: shiftAssignments.id,
|
||||||
|
siteName: sites.name,
|
||||||
|
plannedStartTime: shiftAssignments.plannedStartTime,
|
||||||
|
plannedEndTime: shiftAssignments.plannedEndTime,
|
||||||
|
})
|
||||||
|
.from(shiftAssignments)
|
||||||
|
.leftJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
|
||||||
|
.leftJoin(sites, eq(shifts.siteId, sites.id))
|
||||||
|
.where(eq(shiftAssignments.guardId, guardId));
|
||||||
|
|
||||||
|
for (const shift of fixedShifts) {
|
||||||
|
const shiftStart = new Date(shift.plannedStartTime);
|
||||||
|
const shiftEnd = new Date(shift.plannedEndTime);
|
||||||
|
|
||||||
|
// Controlla sovrapposizione
|
||||||
|
if (startTimestamp < shiftEnd && endTimestamp > shiftStart) {
|
||||||
|
conflicts.push({
|
||||||
|
type: 'fisso',
|
||||||
|
siteName: shift.siteName,
|
||||||
|
startTime: shift.plannedStartTime,
|
||||||
|
endTime: shift.plannedEndTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Controlla sovrapposizioni con patrol_routes (turni mobili)
|
||||||
|
const mobileShifts = await db
|
||||||
|
.select()
|
||||||
|
.from(patrolRoutes)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(patrolRoutes.guardId, guardId),
|
||||||
|
excludeRouteId ? ne(patrolRoutes.id, excludeRouteId) : undefined
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const route of mobileShifts) {
|
||||||
|
const routeDateObj = new Date(route.shiftDate);
|
||||||
|
const [rStartHour, rStartMin] = route.startTime.split(':').map(Number);
|
||||||
|
const [rEndHour, rEndMin] = route.endTime.split(':').map(Number);
|
||||||
|
|
||||||
|
const routeStart = new Date(routeDateObj);
|
||||||
|
routeStart.setHours(rStartHour, rStartMin, 0, 0);
|
||||||
|
|
||||||
|
const routeEnd = new Date(routeDateObj);
|
||||||
|
routeEnd.setHours(rEndHour, rEndMin, 0, 0);
|
||||||
|
|
||||||
|
if (routeEnd <= routeStart) {
|
||||||
|
routeEnd.setDate(routeEnd.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controlla sovrapposizione
|
||||||
|
if (startTimestamp < routeEnd && endTimestamp > routeStart) {
|
||||||
|
conflicts.push({
|
||||||
|
type: 'mobile',
|
||||||
|
shiftDate: route.shiftDate,
|
||||||
|
startTime: route.startTime,
|
||||||
|
endTime: route.endTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Calcola ore settimanali
|
||||||
|
const weekStart = new Date(shiftDateObj);
|
||||||
|
weekStart.setDate(weekStart.getDate() - weekStart.getDay() + (weekStart.getDay() === 0 ? -6 : 1));
|
||||||
|
weekStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const weekEnd = new Date(weekStart);
|
||||||
|
weekEnd.setDate(weekEnd.getDate() + 7);
|
||||||
|
|
||||||
|
let totalWeeklyHours = 0;
|
||||||
|
|
||||||
|
// Ore da turni fissi
|
||||||
|
const weeklyFixedShifts = await db
|
||||||
|
.select({
|
||||||
|
plannedStartTime: shiftAssignments.plannedStartTime,
|
||||||
|
plannedEndTime: shiftAssignments.plannedEndTime,
|
||||||
|
})
|
||||||
|
.from(shiftAssignments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(shiftAssignments.guardId, guardId),
|
||||||
|
gte(shiftAssignments.plannedStartTime, weekStart),
|
||||||
|
lt(shiftAssignments.plannedStartTime, weekEnd)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const shift of weeklyFixedShifts) {
|
||||||
|
const start = new Date(shift.plannedStartTime);
|
||||||
|
const end = new Date(shift.plannedEndTime);
|
||||||
|
const hours = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
|
||||||
|
totalWeeklyHours += hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ore da turni mobili
|
||||||
|
const weeklyMobileShifts = await db
|
||||||
|
.select()
|
||||||
|
.from(patrolRoutes)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(patrolRoutes.guardId, guardId),
|
||||||
|
gte(patrolRoutes.shiftDate, weekStart.toISOString().split('T')[0]),
|
||||||
|
lt(patrolRoutes.shiftDate, weekEnd.toISOString().split('T')[0]),
|
||||||
|
excludeRouteId ? ne(patrolRoutes.id, excludeRouteId) : undefined
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const route of weeklyMobileShifts) {
|
||||||
|
const [rStartHour, rStartMin] = route.startTime.split(':').map(Number);
|
||||||
|
const [rEndHour, rEndMin] = route.endTime.split(':').map(Number);
|
||||||
|
|
||||||
|
let hours = rEndHour - rStartHour + (rEndMin - rStartMin) / 60;
|
||||||
|
if (hours < 0) hours += 24; // Turno attraversa mezzanotte
|
||||||
|
|
||||||
|
totalWeeklyHours += hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggiungi ore del nuovo turno
|
||||||
|
const newShiftHours = (endTimestamp.getTime() - startTimestamp.getTime()) / (1000 * 60 * 60);
|
||||||
|
const totalHoursWithNew = totalWeeklyHours + newShiftHours;
|
||||||
|
|
||||||
|
// 4. Recupera limiti contrattuali
|
||||||
|
const contractParams = await db
|
||||||
|
.select()
|
||||||
|
.from(contractParameters)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const maxHours = contractParams[0]?.maxHoursPerWeek || 40;
|
||||||
|
const maxOvertime = contractParams[0]?.maxOvertimePerWeek || 8;
|
||||||
|
const totalMaxHours = maxHours + maxOvertime;
|
||||||
|
|
||||||
|
const exceedsContractLimit = totalHoursWithNew > totalMaxHours;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
hasConflicts: conflicts.length > 0,
|
||||||
|
conflicts,
|
||||||
|
weeklyHours: {
|
||||||
|
current: Math.round(totalWeeklyHours * 100) / 100,
|
||||||
|
withNewShift: Math.round(totalHoursWithNew * 100) / 100,
|
||||||
|
newShiftHours: Math.round(newShiftHours * 100) / 100,
|
||||||
|
maxRegular: maxHours,
|
||||||
|
maxOvertime: maxOvertime,
|
||||||
|
maxTotal: totalMaxHours,
|
||||||
|
exceedsLimit: exceedsContractLimit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking overlaps:", error);
|
||||||
|
res.status(500).json({ message: "Errore verifica sovrapposizioni" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============= 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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user