From 00ac8c8415251cdaa4abdceae9be817f81469ea1 Mon Sep 17 00:00:00 2001 From: marco370 <48531002-marco370@users.noreply.replit.com> Date: Thu, 23 Oct 2025 16:57:03 +0000 Subject: [PATCH] Add mobile patrol routes and distinguish between fixed and mobile guard duties Introduce new data structures and API endpoints for mobile patrol routes, differentiating them from fixed guard shifts in the service planning interface. 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/rjLU1aT --- client/src/pages/service-planning.tsx | 251 +++++++++++++++++++++----- server/routes.ts | 247 ++++++++++++++++++++++++- 2 files changed, 452 insertions(+), 46 deletions(-) diff --git a/client/src/pages/service-planning.tsx b/client/src/pages/service-planning.tsx index b1f268c..05efa08 100644 --- a/client/src/pages/service-planning.tsx +++ b/client/src/pages/service-planning.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { format, addWeeks, addDays, startOfWeek } from "date-fns"; import { it } from "date-fns/locale"; -import { ChevronLeft, ChevronRight, Users, Building2 } from "lucide-react"; +import { ChevronLeft, ChevronRight, Users, Building2, Navigation, Shield, Car as CarIcon, MapPin } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -12,13 +12,15 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; type Location = "roccapiemonte" | "milano" | "roma"; -interface ShiftDetail { +interface FixedShiftDetail { shiftId: string; date: string; from: string; to: string; siteName: string; + siteAddress: string; siteId: string; + isArmed: boolean; vehicle?: { licensePlate: string; brand: string; @@ -27,14 +29,42 @@ interface ShiftDetail { hours: number; } -interface GuardSchedule { +interface FixedGuardSchedule { guardId: string; guardName: string; badgeNumber: string; - shifts: ShiftDetail[]; + shifts: FixedShiftDetail[]; totalHours: number; } +interface PatrolRoute { + routeId: string; + guardId: string; + shiftDate: string; + startTime: string; + endTime: string; + isArmedRoute: boolean; + vehicle?: { + licensePlate: string; + brand: string; + model: string; + }; + stops: { + siteId: string; + siteName: string; + siteAddress: string; + sequenceOrder: number; + }[]; +} + +interface MobileGuardSchedule { + guardId: string; + guardName: string; + badgeNumber: string; + routes: PatrolRoute[]; + totalRoutes: number; +} + interface SiteSchedule { siteId: string; siteName: string; @@ -48,6 +78,7 @@ interface SiteSchedule { guardName: string; badgeNumber: string; hours: number; + isArmed: boolean; }[]; vehicle?: { licensePlate: string; @@ -64,20 +95,30 @@ interface SiteSchedule { export default function ServicePlanning() { const [selectedLocation, setSelectedLocation] = useState("roccapiemonte"); const [weekStart, setWeekStart] = useState(startOfWeek(new Date(), { weekStartsOn: 1 })); - const [viewMode, setViewMode] = useState<"guard" | "site">("guard"); + const [viewMode, setViewMode] = useState<"guard-fixed" | "guard-mobile" | "site">("guard-fixed"); const weekStartStr = format(weekStart, "yyyy-MM-dd"); - const weekEndStr = format(addDays(weekStart, 6), "yyyy-MM-dd"); - // Query per vista Guardie - const { data: guardSchedules, isLoading: isLoadingGuards } = useQuery({ - queryKey: ["/api/service-planning/by-guard", weekStartStr, selectedLocation], + // Query per vista Agenti Fissi + const { data: fixedGuardSchedules, isLoading: isLoadingFixedGuards } = useQuery({ + queryKey: ["/api/service-planning/guards-fixed", weekStartStr, selectedLocation], queryFn: async () => { - const response = await fetch(`/api/service-planning/by-guard?weekStart=${weekStartStr}&location=${selectedLocation}`); - if (!response.ok) throw new Error("Failed to fetch guard schedules"); + const response = await fetch(`/api/service-planning/guards-fixed?weekStart=${weekStartStr}&location=${selectedLocation}`); + if (!response.ok) throw new Error("Failed to fetch fixed guard schedules"); return response.json(); }, - enabled: viewMode === "guard", + enabled: viewMode === "guard-fixed", + }); + + // Query per vista Agenti Mobili + const { data: mobileGuardSchedules, isLoading: isLoadingMobileGuards } = useQuery({ + queryKey: ["/api/service-planning/guards-mobile", weekStartStr, selectedLocation], + queryFn: async () => { + const response = await fetch(`/api/service-planning/guards-mobile?weekStart=${weekStartStr}&location=${selectedLocation}`); + if (!response.ok) throw new Error("Failed to fetch mobile guard schedules"); + return response.json(); + }, + enabled: viewMode === "guard-mobile", }); // Query per vista Siti @@ -99,9 +140,9 @@ export default function ServicePlanning() { {/* Header */}
-

Planning di Servizio

+

Visione Servizi

- Visualizza orari e dotazioni per guardia o sito + Visualizza orari e dotazioni per agente fisso, agente mobile o per sito

@@ -145,11 +186,15 @@ export default function ServicePlanning() { {/* Tabs per vista */} - setViewMode(v as "guard" | "site")}> - - + setViewMode(v as "guard-fixed" | "guard-mobile" | "site")}> + + - Vista Agente + Agenti Fissi + + + + Agenti Mobili @@ -157,18 +202,18 @@ export default function ServicePlanning() { - {/* Vista Agente */} - - {isLoadingGuards ? ( + {/* Vista Agenti Fissi */} + + {isLoadingFixedGuards ? (
{[1, 2, 3].map((i) => ( ))}
- ) : guardSchedules && guardSchedules.length > 0 ? ( + ) : fixedGuardSchedules && fixedGuardSchedules.length > 0 ? (
- {guardSchedules.map((guard) => ( - + {fixedGuardSchedules.map((guard) => ( +
@@ -179,25 +224,40 @@ export default function ServicePlanning() { {guard.shifts.length === 0 ? ( -

Nessun turno assegnato

+

Nessun turno fisso assegnato

) : (
{guard.shifts.map((shift) => (
-
-
{shift.siteName}
-
- {format(new Date(shift.date), "EEEE d MMM", { locale: it })} • {shift.from} - {shift.to} ({shift.hours}h) -
- {shift.vehicle && ( -
- 🚗 {shift.vehicle.licensePlate} ({shift.vehicle.brand} {shift.vehicle.model}) +
+
+
{shift.siteName}
+
+ + {shift.siteAddress}
- )} +
+ {format(new Date(shift.date), "EEEE d MMM", { locale: it })} • {shift.from} - {shift.to} ({shift.hours}h) +
+
+
+ {shift.isArmed && ( + + + Armato + + )} + {shift.vehicle && ( + + + {shift.vehicle.licensePlate} + + )} +
))} @@ -210,7 +270,101 @@ export default function ServicePlanning() { ) : ( -

Nessuna guardia con turni assegnati

+

Nessun agente con turni fissi assegnati

+
+
+ )} + + + {/* Vista Agenti Mobili */} + + {isLoadingMobileGuards ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : mobileGuardSchedules && mobileGuardSchedules.length > 0 ? ( +
+ {mobileGuardSchedules.map((guard) => ( + + +
+ + {guard.guardName} {guard.badgeNumber} + + {guard.totalRoutes} {guard.totalRoutes === 1 ? 'percorso' : 'percorsi'} +
+
+ + {guard.routes.length === 0 ? ( +

Nessun percorso pattuglia assegnato

+ ) : ( +
+ {guard.routes.map((route) => ( +
+
+
+ {format(new Date(route.shiftDate), "EEEE d MMM yyyy", { locale: it })} +
+
+ {route.startTime} - {route.endTime} +
+
+ +
+ {route.isArmedRoute && ( + + + Armato + + )} + {route.vehicle && ( + + + {route.vehicle.licensePlate} + + )} +
+ +
+
+ + Percorso ({route.stops.length} {route.stops.length === 1 ? 'tappa' : 'tappe'}): +
+
+ {route.stops.map((stop) => ( +
+ + {stop.sequenceOrder} + +
+
{stop.siteName}
+
+ + {stop.siteAddress} +
+
+
+ ))} +
+
+
+ ))} +
+ )} +
+
+ ))} +
+ ) : ( + + +

Nessun agente con percorsi pattuglia assegnati

)} @@ -252,20 +406,29 @@ export default function ServicePlanning() {
{format(new Date(shift.date), "EEEE d MMM", { locale: it })} • {shift.from} - {shift.to}
- {shift.totalGuards} {shift.totalGuards === 1 ? "guardia" : "guardie"} +
+ {shift.totalGuards} {shift.totalGuards === 1 ? "guardia" : "guardie"} + {shift.vehicle && ( + + + {shift.vehicle.licensePlate} + + )} +
{shift.guards.map((guard, idx) => ( -
- 👤 {guard.guardName} ({guard.badgeNumber}) - {guard.hours}h +
+ {guard.guardName} ({guard.badgeNumber}) - {guard.hours}h + {guard.isArmed && ( + + + Armato + + )}
))}
- {shift.vehicle && ( -
- 🚗 {shift.vehicle.licensePlate} ({shift.vehicle.brand} {shift.vehicle.model}) -
- )}
))}
diff --git a/server/routes.ts b/server/routes.ts index e563a53..1545548 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -679,6 +679,17 @@ export async function registerRoutes(app: Express): Promise { ) ); + // Ottieni patrol routes del giorno SOLO della sede selezionata + const dayPatrolRoutes = await db + .select() + .from(patrolRoutes) + .where( + and( + eq(patrolRoutes.shiftDate, dateStr), + eq(patrolRoutes.location, location as any) + ) + ); + // Calcola disponibilità agenti con report CCNL const guardsWithAvailability = await Promise.all( locationGuards.map(async (guard) => { @@ -686,6 +697,10 @@ export async function registerRoutes(app: Express): Promise { (assignment: any) => assignment.shift_assignments.guardId === guard.id ); + const assignedPatrolRoute = dayPatrolRoutes.find( + (route: any) => route.guardId === guard.id + ); + // Calcola report disponibilità CCNL const availabilityReport = await getGuardAvailabilityReport( guard.id, @@ -695,13 +710,18 @@ export async function registerRoutes(app: Express): Promise { return { ...guard, - isAvailable: !assignedShift, + isAvailable: !assignedShift && !assignedPatrolRoute, assignedShift: assignedShift ? { id: assignedShift.shifts.id, startTime: assignedShift.shifts.startTime, endTime: assignedShift.shifts.endTime, siteId: assignedShift.shifts.siteId } : null, + assignedPatrolRoute: assignedPatrolRoute ? { + id: assignedPatrolRoute.id, + startTime: assignedPatrolRoute.startTime, + endTime: assignedPatrolRoute.endTime, + } : null, availability: { weeklyHours: availabilityReport.weeklyHours.current, remainingWeeklyHours: availabilityReport.remainingWeeklyHours, @@ -1455,7 +1475,229 @@ export async function registerRoutes(app: Express): Promise { // ============= SERVICE PLANNING ROUTES ============= - // Vista per Guardia - mostra orari e dotazioni per ogni guardia + // Vista per Agente Fisso - mostra orari e dotazioni operative per turni fissi + app.get("/api/service-planning/guards-fixed", isAuthenticated, async (req, res) => { + try { + const rawWeekStart = req.query.weekStart as string || format(new Date(), "yyyy-MM-dd"); + const normalizedWeekStart = rawWeekStart.split("/")[0]; + + const parsedWeekStart = parseISO(normalizedWeekStart); + if (!isValid(parsedWeekStart)) { + return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" }); + } + + const weekStartDate = format(parsedWeekStart, "yyyy-MM-dd"); + const location = req.query.location as string || "roccapiemonte"; + const weekEndDate = format(addDays(parsedWeekStart, 6), "yyyy-MM-dd"); + + const weekStartTimestamp = new Date(weekStartDate); + weekStartTimestamp.setHours(0, 0, 0, 0); + + const weekEndTimestamp = new Date(weekEndDate); + weekEndTimestamp.setHours(23, 59, 59, 999); + + // Ottieni tutte le guardie della sede + const allGuards = await db + .select() + .from(guards) + .where(eq(guards.location, location as any)) + .orderBy(guards.fullName); + + // Ottieni tutti i turni della settimana + const allWeekShifts = await db + .select({ + shift: shifts, + site: sites, + vehicle: vehicles, + serviceType: serviceTypes, + }) + .from(shifts) + .innerJoin(sites, eq(shifts.siteId, sites.id)) + .leftJoin(vehicles, eq(shifts.vehicleId, vehicles.id)) + .leftJoin(serviceTypes, eq(sites.serviceTypeId, serviceTypes.id)) + .where( + and( + gte(shifts.startTime, weekStartTimestamp), + lte(shifts.startTime, weekEndTimestamp), + ne(shifts.status, "cancelled"), + eq(sites.location, location as any) + ) + ); + + // Filtra solo turni FISSI in base alla classificazione del serviceType + const weekShifts = allWeekShifts.filter((s: any) => + s.serviceType && s.serviceType.classification === "fisso" + ); + + // Ottieni tutte le assegnazioni per i turni della settimana + const shiftIds = weekShifts.map((s: any) => s.shift.id); + const assignments = shiftIds.length > 0 ? await db + .select({ + assignment: shiftAssignments, + guard: guards, + }) + .from(shiftAssignments) + .innerJoin(guards, eq(shiftAssignments.guardId, guards.id)) + .where( + sql`${shiftAssignments.shiftId} IN (${sql.join(shiftIds.map((id: string) => sql`${id}`), sql`, `)})` + ) : []; + + // Costruisci dati per ogni guardia + const guardSchedules = allGuards.map((guard: any) => { + // Trova assegnazioni della guardia + const guardAssignments = assignments.filter((a: any) => a.guard.id === guard.id); + + // Costruisci lista turni con dettagli + const shifts = guardAssignments.map((a: any) => { + const shiftData = weekShifts.find((s: any) => s.shift.id === a.assignment.shiftId); + if (!shiftData) return null; + + const plannedStart = new Date(a.assignment.plannedStartTime); + const plannedEnd = new Date(a.assignment.plannedEndTime); + const minutes = differenceInMinutes(plannedEnd, plannedStart); + const hours = Math.round((minutes / 60) * 10) / 10; + + return { + shiftId: shiftData.shift.id, + date: format(plannedStart, "yyyy-MM-dd"), + from: format(plannedStart, "HH:mm"), + to: format(plannedEnd, "HH:mm"), + siteName: shiftData.site.name, + siteAddress: shiftData.site.address, + siteId: shiftData.site.id, + isArmed: guard.isArmed, + vehicle: shiftData.vehicle ? { + licensePlate: shiftData.vehicle.licensePlate, + brand: shiftData.vehicle.brand, + model: shiftData.vehicle.model, + } : undefined, + hours, + }; + }).filter(Boolean); + + const totalHours = Math.round(shifts.reduce((sum: number, s: any) => sum + s.hours, 0) * 10) / 10; + + return { + guardId: guard.id, + guardName: guard.fullName, + badgeNumber: guard.badgeNumber, + shifts, + totalHours, + }; + }); + + // Filtra solo guardie con turni assegnati + const guardsWithShifts = guardSchedules.filter((g: any) => g.shifts.length > 0); + + res.json(guardsWithShifts); + } catch (error) { + console.error("Error fetching fixed guard schedules:", error); + res.status(500).json({ message: "Failed to fetch fixed guard schedules", error: String(error) }); + } + }); + + // Vista per Agente Mobile - mostra percorsi pattuglia con siti e indirizzi + app.get("/api/service-planning/guards-mobile", isAuthenticated, async (req, res) => { + try { + const rawWeekStart = req.query.weekStart as string || format(new Date(), "yyyy-MM-dd"); + const normalizedWeekStart = rawWeekStart.split("/")[0]; + + const parsedWeekStart = parseISO(normalizedWeekStart); + if (!isValid(parsedWeekStart)) { + return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" }); + } + + const weekStartDate = format(parsedWeekStart, "yyyy-MM-dd"); + const location = req.query.location as string || "roccapiemonte"; + const weekEndDate = format(addDays(parsedWeekStart, 6), "yyyy-MM-dd"); + + // Ottieni tutte le guardie della sede + const allGuards = await db + .select() + .from(guards) + .where(eq(guards.location, location as any)) + .orderBy(guards.fullName); + + // Ottieni tutte le patrol routes della settimana per la sede + const weekRoutes = await db + .select({ + route: patrolRoutes, + guard: guards, + vehicle: vehicles, + }) + .from(patrolRoutes) + .innerJoin(guards, eq(patrolRoutes.guardId, guards.id)) + .leftJoin(vehicles, eq(patrolRoutes.vehicleId, vehicles.id)) + .where( + and( + gte(sql`${patrolRoutes.shiftDate}::date`, weekStartDate), + lte(sql`${patrolRoutes.shiftDate}::date`, weekEndDate), + eq(patrolRoutes.location, location as any) + ) + ); + + // Per ogni route, ottieni le stops + const routesWithStops = await Promise.all( + weekRoutes.map(async (routeData: any) => { + const stops = await db + .select({ + stop: patrolRouteStops, + site: sites, + }) + .from(patrolRouteStops) + .innerJoin(sites, eq(patrolRouteStops.siteId, sites.id)) + .where(eq(patrolRouteStops.routeId, routeData.route.id)) + .orderBy(asc(patrolRouteStops.sequenceOrder)); + + return { + routeId: routeData.route.id, + guardId: routeData.guard.id, + shiftDate: routeData.route.shiftDate, + startTime: routeData.route.startTime, + endTime: routeData.route.endTime, + isArmedRoute: routeData.route.isArmedRoute, + vehicle: routeData.vehicle ? { + licensePlate: routeData.vehicle.licensePlate, + brand: routeData.vehicle.brand, + model: routeData.vehicle.model, + } : undefined, + stops: stops.map((s: any) => ({ + siteId: s.site.id, + siteName: s.site.name, + siteAddress: s.site.address, + sequenceOrder: s.stop.sequenceOrder, + })), + }; + }) + ); + + // Costruisci dati per ogni guardia + const guardSchedules = allGuards.map((guard: any) => { + // Trova routes della guardia + const guardRoutes = routesWithStops.filter((r: any) => r.guardId === guard.id); + + const totalRoutes = guardRoutes.length; + + return { + guardId: guard.id, + guardName: guard.fullName, + badgeNumber: guard.badgeNumber, + routes: guardRoutes, + totalRoutes, + }; + }); + + // Filtra solo guardie con routes assegnate + const guardsWithRoutes = guardSchedules.filter((g: any) => g.routes.length > 0); + + res.json(guardsWithRoutes); + } catch (error) { + console.error("Error fetching mobile guard schedules:", error); + res.status(500).json({ message: "Failed to fetch mobile guard schedules", error: String(error) }); + } + }); + + // Vista per Guardia - mostra orari e dotazioni per ogni guardia (LEGACY - manteniamo per compatibilità) app.get("/api/service-planning/by-guard", isAuthenticated, async (req, res) => { try { const rawWeekStart = req.query.weekStart as string || format(new Date(), "yyyy-MM-dd"); @@ -1651,6 +1893,7 @@ export async function registerRoutes(app: Express): Promise { guardName: a.guard.fullName, badgeNumber: a.guard.badgeNumber, hours, + isArmed: a.guard.isArmed, }; });