From 758a6974470856f5438115dc17142b198486c79d Mon Sep 17 00:00:00 2001 From: marco370 <48531002-marco370@users.noreply.replit.com> Date: Wed, 29 Oct 2025 09:24:38 +0000 Subject: [PATCH] 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 --- client/src/pages/planning-mobile.tsx | 365 +++++++++++++++++++++++++-- replit.md | 8 +- server/routes.ts | 184 ++++++++++++++ 3 files changed, 541 insertions(+), 16 deletions(-) diff --git a/client/src/pages/planning-mobile.tsx b/client/src/pages/planning-mobile.tsx index 46ce4dc..7251d2a 100644 --- a/client/src/pages/planning-mobile.tsx +++ b/client/src/pages/planning-mobile.tsx @@ -155,6 +155,8 @@ export default function PlanningMobile() { const [mapCenter, setMapCenter] = useState<[number, number] | null>(null); const [mapZoom, setMapZoom] = useState(12); const [patrolRoute, setPatrolRoute] = useState([]); + const [shiftStartTime, setShiftStartTime] = useState("08:00"); + const [shiftDuration, setShiftDuration] = useState("8"); // State per dialog duplicazione sequenza const [duplicateDialog, setDuplicateDialog] = useState<{ @@ -162,11 +164,13 @@ export default function PlanningMobile() { sourceRoute: any | null; targetDate: string; selectedDuplicateGuardId: string; + numDays: string; }>({ isOpen: false, sourceRoute: null, targetDate: "", selectedDuplicateGuardId: "", + numDays: "1", }); // State per dialog risultati ottimizzazione @@ -180,6 +184,19 @@ export default function PlanningMobile() { 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 const patrolSequencesRef = useRef(null); @@ -270,6 +287,7 @@ export default function PlanningMobile() { sourceRoute: null, targetDate: "", selectedDuplicateGuardId: "", + numDays: "1", }); }, onError: (error: any) => { @@ -415,6 +433,7 @@ export default function PlanningMobile() { sourceRoute: route, targetDate: nextDay, // Default = giorno successivo selectedDuplicateGuardId: route.guardId || "", // Pre-compilato con guardia attuale + numDays: "1", }); } catch (error) { toast({ @@ -426,8 +445,8 @@ export default function PlanningMobile() { } }; - // Handler submit dialog duplicazione - const handleSubmitDuplicate = () => { + // Handler submit dialog duplicazione con supporto giorni multipli + const handleSubmitDuplicate = async () => { if (!duplicateDialog.sourceRoute || !duplicateDialog.targetDate || !duplicateDialog.selectedDuplicateGuardId) { toast({ title: "Campi mancanti", @@ -437,11 +456,116 @@ export default function PlanningMobile() { return; } - duplicatePatrolRouteMutation.mutate({ - sourceRouteId: duplicateDialog.sourceRoute.id, - targetDate: duplicateDialog.targetDate, - guardId: duplicateDialog.selectedDuplicateGuardId, - }); + const numDays = parseInt(duplicateDialog.numDays) || 1; + + // Se numDays === 1, comportamento standard + if (numDays === 1) { + duplicatePatrolRouteMutation.mutate({ + sourceRouteId: duplicateDialog.sourceRoute.id, + targetDate: duplicateDialog.targetDate, + 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 @@ -581,8 +705,20 @@ export default function PlanningMobile() { optimizeRouteMutation.mutate(coordinates); }; - // Funzione per salvare il turno pattuglia - const handleSavePatrolRoute = () => { + // Helper per calcolare endTime da startTime + durata + 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) { toast({ title: "Guardia non selezionata", @@ -601,12 +737,15 @@ export default function PlanningMobile() { return; } + // Calcola endTime + const endTime = calculateEndTime(shiftStartTime, shiftDuration); + // Prepara i dati per il salvataggio const patrolRouteData = { guardId: selectedGuard.id, shiftDate: selectedDate, - startTime: "08:00", // TODO: permettere all'utente di configurare - endTime: "20:00", + startTime: shiftStartTime, + endTime: endTime, location: selectedLocation, status: "planned", stops: patrolRoute.map((site) => ({ @@ -619,9 +758,69 @@ export default function PlanningMobile() { (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({ + data: patrolRouteData, + 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: patrolRouteData, - existingRouteId: existingRoute?.id, + 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, }); }; @@ -729,6 +928,34 @@ export default function PlanningMobile() { + {/* Campi Orario Turno */} +
+
+ + setShiftStartTime(e.target.value)} + data-testid="input-shift-start-time" + /> +
+
+ + setShiftDuration(e.target.value)} + data-testid="input-shift-duration" + /> +
+
+ + {/* Lista Tappe Draggable */} + {/* Dialog Conferma Forzatura Turno */} + { + if (!open) { + setForceDialog({ + isOpen: false, + conflicts: [], + weeklyHours: null, + patrolRouteData: null, + }); + } + }}> + + + + Attenzione: Sovrapposizione Turni + + + La guardia ha già turni assegnati nelle ore indicate o supererebbe i limiti contrattuali. + + + +
+ {/* Conflitti */} + {forceDialog.conflicts && forceDialog.conflicts.length > 0 && ( +
+

Turni in Conflitto:

+
+ {forceDialog.conflicts.map((conflict: any, idx: number) => ( +
+
+ {conflict.type === 'fisso' ? `Turno Fisso - ${conflict.siteName}` : `Turno Mobile`} +
+
+ {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} + + )} +
+
+ ))} +
+
+ )} + + {/* Ore Settimanali */} + {forceDialog.weeklyHours && ( +
+

Ore Settimanali:

+
+
+ Ore attuali: + {forceDialog.weeklyHours.current}h +
+
+ Ore nuovo turno: + {forceDialog.weeklyHours.newShiftHours}h +
+
+ Totale con nuovo turno: + + {forceDialog.weeklyHours.withNewShift}h / {forceDialog.weeklyHours.maxTotal}h + +
+ {forceDialog.weeklyHours.exceedsLimit && ( +

+ Superamento limite contrattuale di {Math.round((forceDialog.weeklyHours.withNewShift - forceDialog.weeklyHours.maxTotal) * 100) / 100}h +

+ )} +
+
+ )} +
+ + + + + +
+
+ {/* Dialog Duplica Sequenza */} { if (!open) { @@ -1087,6 +1403,7 @@ export default function PlanningMobile() { sourceRoute: null, targetDate: "", selectedDuplicateGuardId: "", + numDays: "1", }); } }}> @@ -1125,7 +1442,7 @@ export default function PlanningMobile() { {/* Data Target */}
- +
+ {/* Numero Giorni */} +
+ + setDuplicateDialog({ ...duplicateDialog, numDays: e.target.value })} + data-testid="input-num-days" + /> +

+ Duplica la sequenza per {duplicateDialog.numDays} {parseInt(duplicateDialog.numDays) === 1 ? 'giorno' : 'giorni'} consecutivi a partire dalla data indicata +

+
+ {/* Selezione Guardia */}
@@ -1171,6 +1505,7 @@ export default function PlanningMobile() { sourceRoute: null, targetDate: "", selectedDuplicateGuardId: "", + numDays: "1", })} data-testid="button-cancel-duplicate" > diff --git a/replit.md b/replit.md index 2c40442..d8202b9 100644 --- a/replit.md +++ b/replit.md @@ -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 - **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 - - **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. - **Dashboard Operativa**: Live KPIs and real-time shift status. - **Gestione Guardie**: Complete profiles with skill matrix, certification management, and badge numbers. diff --git a/server/routes.ts b/server/routes.ts index 7b83b22..04d0727 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -4348,6 +4348,190 @@ export async function registerRoutes(app: Express): Promise { } }); + // 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) ============= // Rate limiter semplice per rispettare 1 req/sec di Nominatim