diff --git a/.replit b/.replit index 75d108a..4a9a396 100644 --- a/.replit +++ b/.replit @@ -19,6 +19,10 @@ externalPort = 80 localPort = 33035 externalPort = 3001 +[[ports]] +localPort = 36851 +externalPort = 6000 + [[ports]] localPort = 41295 externalPort = 5173 diff --git a/client/src/App.tsx b/client/src/App.tsx index 029844d..8ac0aa0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -27,6 +27,9 @@ import GeneralPlanning from "@/pages/general-planning"; import ServicePlanning from "@/pages/service-planning"; import Customers from "@/pages/customers"; import PlanningMobile from "@/pages/planning-mobile"; +import MyShiftsFixed from "@/pages/my-shifts-fixed"; +import MyShiftsMobile from "@/pages/my-shifts-mobile"; +import SitePlanningView from "@/pages/site-planning-view"; function Router() { const { isAuthenticated, isLoading } = useAuth(); @@ -51,6 +54,9 @@ function Router() { + + + diff --git a/client/src/pages/my-shifts-fixed.tsx b/client/src/pages/my-shifts-fixed.tsx new file mode 100644 index 0000000..a55758a --- /dev/null +++ b/client/src/pages/my-shifts-fixed.tsx @@ -0,0 +1,234 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Calendar, MapPin, Clock, Shield, Car, ChevronLeft, ChevronRight } from "lucide-react"; +import { format, addDays, startOfWeek, parseISO, isValid } from "date-fns"; +import { it } from "date-fns/locale"; + +interface ShiftAssignment { + id: string; + shiftId: string; + plannedStartTime: string; + plannedEndTime: string; + armed: boolean; + vehicleId: string | null; + vehiclePlate: string | null; + site: { + id: string; + name: string; + address: string; + location: string; + }; + shift: { + shiftDate: string; + startTime: string; + endTime: string; + }; +} + +export default function MyShiftsFixed() { + // Data iniziale: inizio settimana corrente + const [currentWeekStart, setCurrentWeekStart] = useState(() => { + const today = new Date(); + return startOfWeek(today, { weekStartsOn: 1 }); + }); + + // Query per recuperare i turni fissi della guardia loggata + const { data: user } = useQuery({ + queryKey: ["/api/auth/user"], + }); + + const { data: myShifts, isLoading } = useQuery({ + queryKey: ["/api/my-shifts/fixed", currentWeekStart.toISOString()], + queryFn: async () => { + const weekEnd = addDays(currentWeekStart, 6); + const response = await fetch( + `/api/my-shifts/fixed?startDate=${currentWeekStart.toISOString()}&endDate=${weekEnd.toISOString()}` + ); + if (!response.ok) throw new Error("Failed to fetch shifts"); + return response.json(); + }, + enabled: !!user, + }); + + // Naviga settimana precedente + const handlePreviousWeek = () => { + setCurrentWeekStart(addDays(currentWeekStart, -7)); + }; + + // Naviga settimana successiva + const handleNextWeek = () => { + setCurrentWeekStart(addDays(currentWeekStart, 7)); + }; + + // Raggruppa i turni per giorno + const shiftsByDay = myShifts?.reduce((acc, shift) => { + const date = shift.shift.shiftDate; + if (!acc[date]) acc[date] = []; + acc[date].push(shift); + return acc; + }, {} as Record) || {}; + + // Genera array di 7 giorni della settimana + const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeekStart, i)); + + const locationLabels: Record = { + roccapiemonte: "Roccapiemonte", + milano: "Milano", + roma: "Roma", + }; + + return ( +
+ {/* Header */} +
+
+

+ I Miei Turni Fissi +

+

+ Visualizza i tuoi turni con orari e dotazioni operative +

+
+
+ + {/* Navigazione settimana */} + + +
+ + + {format(currentWeekStart, "dd MMMM", { locale: it })} -{" "} + {format(addDays(currentWeekStart, 6), "dd MMMM yyyy", { locale: it })} + + +
+
+
+ + {/* Loading state */} + {isLoading && ( + + + Caricamento turni... + + + )} + + {/* Griglia giorni settimana */} + {!isLoading && ( +
+ {weekDays.map((day) => { + const dateStr = format(day, "yyyy-MM-dd"); + const dayShifts = shiftsByDay[dateStr] || []; + + return ( + + + + {format(day, "EEEE dd/MM", { locale: it })} + + + {dayShifts.length === 0 + ? "Nessun turno" + : `${dayShifts.length} turno${dayShifts.length > 1 ? "i" : ""}`} + + + + {dayShifts.length === 0 ? ( +
+ Riposo +
+ ) : ( + dayShifts.map((shift) => { + // Parsing sicuro orari + let startTime = "N/A"; + let endTime = "N/A"; + + if (shift.plannedStartTime) { + const parsedStart = parseISO(shift.plannedStartTime); + if (isValid(parsedStart)) { + startTime = format(parsedStart, "HH:mm"); + } + } + + if (shift.plannedEndTime) { + const parsedEnd = parseISO(shift.plannedEndTime); + if (isValid(parsedEnd)) { + endTime = format(parsedEnd, "HH:mm"); + } + } + + return ( +
+
+
+

{shift.site.name}

+
+ + {locationLabels[shift.site.location] || shift.site.location} +
+
+
+ +
+ + + {startTime} - {endTime} + +
+ + {/* Dotazioni */} +
+ {shift.armed && ( + + + Armato + + )} + {shift.vehicleId && ( + + + {shift.vehiclePlate || "Automezzo"} + + )} +
+ +
+ {shift.site.address} +
+
+ ); + }) + )} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/client/src/pages/my-shifts-mobile.tsx b/client/src/pages/my-shifts-mobile.tsx new file mode 100644 index 0000000..e181d59 --- /dev/null +++ b/client/src/pages/my-shifts-mobile.tsx @@ -0,0 +1,247 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Calendar, MapPin, Navigation, ChevronLeft, ChevronRight } from "lucide-react"; +import { format, addDays, startOfWeek, parseISO, isValid } from "date-fns"; +import { it } from "date-fns/locale"; + +interface PatrolRouteStop { + siteId: string; + siteName: string; + siteAddress: string; + sequenceOrder: number; + latitude: string | null; + longitude: string | null; +} + +interface PatrolRoute { + id: string; + shiftDate: string; + startTime: string; + endTime: string; + location: string; + status: string; + vehicleId: string | null; + vehiclePlate: string | null; + stops: PatrolRouteStop[]; +} + +export default function MyShiftsMobile() { + // Data iniziale: inizio settimana corrente + const [currentWeekStart, setCurrentWeekStart] = useState(() => { + const today = new Date(); + return startOfWeek(today, { weekStartsOn: 1 }); + }); + + // Query per recuperare i turni mobile della guardia loggata + const { data: user } = useQuery({ + queryKey: ["/api/auth/user"], + }); + + const { data: myRoutes, isLoading } = useQuery({ + queryKey: ["/api/my-shifts/mobile", currentWeekStart.toISOString()], + queryFn: async () => { + const weekEnd = addDays(currentWeekStart, 6); + const response = await fetch( + `/api/my-shifts/mobile?startDate=${currentWeekStart.toISOString()}&endDate=${weekEnd.toISOString()}` + ); + if (!response.ok) throw new Error("Failed to fetch patrol routes"); + return response.json(); + }, + enabled: !!user, + }); + + // Naviga settimana precedente + const handlePreviousWeek = () => { + setCurrentWeekStart(addDays(currentWeekStart, -7)); + }; + + // Naviga settimana successiva + const handleNextWeek = () => { + setCurrentWeekStart(addDays(currentWeekStart, 7)); + }; + + // Raggruppa i patrol routes per giorno + const routesByDay = myRoutes?.reduce((acc, route) => { + const date = route.shiftDate; + if (!acc[date]) acc[date] = []; + acc[date].push(route); + return acc; + }, {} as Record) || {}; + + // Genera array di 7 giorni della settimana + const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeekStart, i)); + + const locationLabels: Record = { + roccapiemonte: "Roccapiemonte", + milano: "Milano", + roma: "Roma", + }; + + const statusLabels: Record = { + planned: "Pianificato", + in_progress: "In Corso", + completed: "Completato", + cancelled: "Annullato", + }; + + const statusColors: Record = { + planned: "bg-blue-500/10 text-blue-500 border-blue-500/20", + in_progress: "bg-green-500/10 text-green-500 border-green-500/20", + completed: "bg-gray-500/10 text-gray-500 border-gray-500/20", + cancelled: "bg-red-500/10 text-red-500 border-red-500/20", + }; + + return ( +
+ {/* Header */} +
+
+

+ I Miei Turni Pattuglia +

+

+ Visualizza i tuoi percorsi di pattuglia con sequenza tappe +

+
+
+ + {/* Navigazione settimana */} + + +
+ + + {format(currentWeekStart, "dd MMMM", { locale: it })} -{" "} + {format(addDays(currentWeekStart, 6), "dd MMMM yyyy", { locale: it })} + + +
+
+
+ + {/* Loading state */} + {isLoading && ( + + + Caricamento turni pattuglia... + + + )} + + {/* Griglia giorni settimana */} + {!isLoading && ( +
+ {weekDays.map((day) => { + const dateStr = format(day, "yyyy-MM-dd"); + const dayRoutes = routesByDay[dateStr] || []; + + return ( + + + + {format(day, "EEEE dd/MM", { locale: it })} + + + {dayRoutes.length === 0 + ? "Nessuna pattuglia" + : `${dayRoutes.length} pattuglia${dayRoutes.length > 1 ? "e" : ""}`} + + + + {dayRoutes.length === 0 ? ( +
+ Riposo +
+ ) : ( + dayRoutes.map((route) => ( +
+ {/* Header pattuglia */} +
+
+
+ + + Pattuglia {locationLabels[route.location]} + +
+
+ + {route.stops.length} tappe +
+
+ + {statusLabels[route.status] || route.status} + +
+ + {/* Sequenza tappe */} +
+ {route.stops + .sort((a, b) => a.sequenceOrder - b.sequenceOrder) + .map((stop, index) => ( +
+
+ + {stop.sequenceOrder} + +
+

+ {stop.siteName} +

+

+ {stop.siteAddress} +

+
+
+
+ ))} +
+ + {/* Info veicolo */} + {route.vehiclePlate && ( +
+ Automezzo: {route.vehiclePlate} +
+ )} +
+ )) + )} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/client/src/pages/site-planning-view.tsx b/client/src/pages/site-planning-view.tsx new file mode 100644 index 0000000..d93ba15 --- /dev/null +++ b/client/src/pages/site-planning-view.tsx @@ -0,0 +1,274 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { MapPin, Shield, Car, Clock, User, ChevronLeft, ChevronRight } from "lucide-react"; +import { format, addDays, startOfWeek, parseISO, isValid } from "date-fns"; +import { it } from "date-fns/locale"; + +interface GuardAssignment { + guardId: string; + guardName: string; + badgeNumber: string; + plannedStartTime: string; + plannedEndTime: string; + armed: boolean; + vehicleId: string | null; + vehiclePlate: string | null; +} + +interface SiteDayPlan { + date: string; + guards: GuardAssignment[]; +} + +interface Site { + id: string; + name: string; + address: string; + location: string; +} + +export default function SitePlanningView() { + const [selectedSiteId, setSelectedSiteId] = useState(""); + const [currentWeekStart, setCurrentWeekStart] = useState(() => { + const today = new Date(); + return startOfWeek(today, { weekStartsOn: 1 }); + }); + + // Query sites + const { data: sites } = useQuery({ + queryKey: ["/api/sites"], + }); + + // Query site planning + const { data: sitePlanning, isLoading } = useQuery({ + queryKey: ["/api/site-planning", selectedSiteId, currentWeekStart.toISOString()], + queryFn: async () => { + if (!selectedSiteId) return []; + const weekEnd = addDays(currentWeekStart, 6); + const response = await fetch( + `/api/site-planning/${selectedSiteId}?startDate=${currentWeekStart.toISOString()}&endDate=${weekEnd.toISOString()}` + ); + if (!response.ok) throw new Error("Failed to fetch site planning"); + return response.json(); + }, + enabled: !!selectedSiteId, + }); + + // Naviga settimana precedente + const handlePreviousWeek = () => { + setCurrentWeekStart(addDays(currentWeekStart, -7)); + }; + + // Naviga settimana successiva + const handleNextWeek = () => { + setCurrentWeekStart(addDays(currentWeekStart, 7)); + }; + + // Raggruppa per giorno + const planningByDay = sitePlanning?.reduce((acc, day) => { + acc[day.date] = day.guards; + return acc; + }, {} as Record) || {}; + + // Genera array di 7 giorni della settimana + const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeekStart, i)); + + const selectedSite = sites?.find(s => s.id === selectedSiteId); + + const locationLabels: Record = { + roccapiemonte: "Roccapiemonte", + milano: "Milano", + roma: "Roma", + }; + + return ( +
+ {/* Header */} +
+
+

+ Planning per Sito +

+

+ Visualizza tutti gli agenti assegnati a un sito con dotazioni +

+
+
+ + {/* Selettore sito */} + + + Seleziona Sito + + +
+ + + {selectedSite && ( +
+

{selectedSite.name}

+

{selectedSite.address}

+
+ )} +
+
+
+ + {/* Navigazione settimana */} + {selectedSiteId && ( + + +
+ + + {format(currentWeekStart, "dd MMMM", { locale: it })} -{" "} + {format(addDays(currentWeekStart, 6), "dd MMMM yyyy", { locale: it })} + + +
+
+
+ )} + + {/* Loading state */} + {isLoading && ( + + + Caricamento planning sito... + + + )} + + {/* Griglia giorni settimana */} + {selectedSiteId && !isLoading && ( +
+ {weekDays.map((day) => { + const dateStr = format(day, "yyyy-MM-dd"); + const dayGuards = planningByDay[dateStr] || []; + + return ( + + + + {format(day, "EEEE dd/MM", { locale: it })} + + + {dayGuards.length === 0 + ? "Nessun agente" + : `${dayGuards.length} agente${dayGuards.length > 1 ? "i" : ""}`} + + + + {dayGuards.length === 0 ? ( +
+ Nessuna copertura +
+ ) : ( + dayGuards.map((guard, index) => { + // Parsing sicuro orari + let startTime = "N/A"; + let endTime = "N/A"; + + if (guard.plannedStartTime) { + const parsedStart = parseISO(guard.plannedStartTime); + if (isValid(parsedStart)) { + startTime = format(parsedStart, "HH:mm"); + } + } + + if (guard.plannedEndTime) { + const parsedEnd = parseISO(guard.plannedEndTime); + if (isValid(parsedEnd)) { + endTime = format(parsedEnd, "HH:mm"); + } + } + + return ( +
+
+
+
+ + {guard.guardName} +
+
+ Matricola: {guard.badgeNumber} +
+
+
+ +
+ + + {startTime} - {endTime} + +
+ + {/* Dotazioni */} +
+ {guard.armed && ( + + + Armato + + )} + {guard.vehicleId && ( + + + {guard.vehiclePlate || "Automezzo"} + + )} +
+
+ ); + }) + )} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/replit.md b/replit.md index cf41ac5..467b9da 100644 --- a/replit.md +++ b/replit.md @@ -120,6 +120,28 @@ To prevent timezone-related bugs, especially when assigning shifts, dates should - Card display now shows service type label from database instead of hardcoded labels - **Impact**: Sites now correctly reference service types configured in "Tipologie Servizi" page, ensuring consistency across the system +### Advanced Planning System Complete (October 23, 2025) +- **Implementation**: Full planning system with guard views, exclusivity constraints, and database persistence +- **Features**: + - **Patrol Route Database Persistence**: + - Backend endpoints: GET/POST/PUT/DELETE `/api/patrol-routes` + - Database schema: `patrol_routes` table with `patrol_route_stops` for sequence + - Planning Mobile loads existing routes when guard selected, saves to DB + - Green markers on map for sites in current patrol route + - **Exclusivity Constraint (fisso/mobile)**: + - Validation in 3 backend endpoints: POST `/api/patrol-routes`, POST `/api/shift-assignments`, POST `/api/shifts/:shiftId/assignments` + - Guards cannot be assigned to both fixed posts and mobile patrols on same date + - Clear error messages when constraint violated + - **Guard Planning Views**: + - `/my-shifts-fixed`: Guards view their fixed post shifts with orari, dotazioni (armato, automezzo), location, sito + - `/my-shifts-mobile`: Guards view patrol routes with sequenced site list, addresses, vehicle assignment + - Backend endpoints: GET `/api/my-shifts/fixed`, GET `/api/my-shifts/mobile` with date range filters + - **Site Planning View**: + - `/site-planning-view`: Coordinators view all guards assigned to a site across a week + - Shows guard name, badge, orari, dotazioni for each assignment + - Backend endpoint: GET `/api/site-planning/:siteId` with date range filters +- **Impact**: Complete end-to-end planning system supporting both coordinator and guard roles with database-backed route planning and operational equipment tracking + ## External Dependencies - **Replit Auth**: For OpenID Connect (OIDC) based authentication. - **Neon**: Managed PostgreSQL database service. diff --git a/server/routes.ts b/server/routes.ts index 84ad193..1e0b999 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -2756,6 +2756,250 @@ export async function registerRoutes(app: Express): Promise { } }); + // ============= MY SHIFTS (GUARD VIEW) ROUTES ============= + // GET - Turni fissi della guardia loggata + app.get("/api/my-shifts/fixed", isAuthenticated, async (req: any, res) => { + try { + const userId = getUserId(req); + const currentUser = await storage.getUser(userId); + + if (!currentUser) { + return res.status(401).json({ message: "User not authenticated" }); + } + + // Trova la guardia associata all'utente + const [guard] = await db + .select() + .from(guards) + .where(eq(guards.userId, userId)) + .limit(1); + + if (!guard) { + return res.status(404).json({ message: "Guardia non trovata per questo utente" }); + } + + // Estrai filtri data (opzionali) + const { startDate, endDate } = req.query; + + // Query per recuperare i turni fissi assegnati alla guardia + let query = db + .select({ + id: shiftAssignments.id, + shiftId: shiftAssignments.shiftId, + plannedStartTime: shiftAssignments.plannedStartTime, + plannedEndTime: shiftAssignments.plannedEndTime, + armed: shiftAssignments.armed, + vehicleId: shiftAssignments.vehicleId, + vehiclePlate: vehicles.licensePlate, + site: { + id: sites.id, + name: sites.name, + address: sites.address, + location: sites.location, + }, + shift: { + shiftDate: shifts.shiftDate, + startTime: shifts.startTime, + endTime: shifts.endTime, + }, + }) + .from(shiftAssignments) + .innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id)) + .innerJoin(sites, eq(shifts.siteId, sites.id)) + .leftJoin(vehicles, eq(shiftAssignments.vehicleId, vehicles.id)) + .where(eq(shiftAssignments.guardId, guard.id)); + + // Applica filtri data se presenti + if (startDate && endDate) { + const start = new Date(startDate as string); + const end = new Date(endDate as string); + + if (isValid(start) && isValid(end)) { + query = query.where( + and( + eq(shiftAssignments.guardId, guard.id), + gte(shifts.shiftDate, format(start, "yyyy-MM-dd")), + lte(shifts.shiftDate, format(end, "yyyy-MM-dd")) + ) + ); + } + } + + const myShifts = await query.orderBy(asc(shifts.shiftDate), asc(shiftAssignments.plannedStartTime)); + + res.json(myShifts); + } catch (error) { + console.error("Error fetching guard's fixed shifts:", error); + res.status(500).json({ message: "Errore caricamento turni fissi" }); + } + }); + + // GET - Turni pattuglia mobile della guardia loggata + app.get("/api/my-shifts/mobile", isAuthenticated, async (req: any, res) => { + try { + const userId = getUserId(req); + const currentUser = await storage.getUser(userId); + + if (!currentUser) { + return res.status(401).json({ message: "User not authenticated" }); + } + + // Trova la guardia associata all'utente + const [guard] = await db + .select() + .from(guards) + .where(eq(guards.userId, userId)) + .limit(1); + + if (!guard) { + return res.status(404).json({ message: "Guardia non trovata per questo utente" }); + } + + // Estrai filtri data (opzionali) + const { startDate, endDate } = req.query; + + // Query per recuperare i patrol routes assegnati alla guardia + let query = db + .select({ + id: patrolRoutes.id, + shiftDate: patrolRoutes.shiftDate, + startTime: patrolRoutes.startTime, + endTime: patrolRoutes.endTime, + location: patrolRoutes.location, + status: patrolRoutes.status, + vehicleId: patrolRoutes.vehicleId, + vehiclePlate: vehicles.licensePlate, + }) + .from(patrolRoutes) + .leftJoin(vehicles, eq(patrolRoutes.vehicleId, vehicles.id)) + .where(eq(patrolRoutes.guardId, guard.id)); + + // Applica filtri data se presenti + if (startDate && endDate) { + const start = new Date(startDate as string); + const end = new Date(endDate as string); + + if (isValid(start) && isValid(end)) { + query = query.where( + and( + eq(patrolRoutes.guardId, guard.id), + gte(patrolRoutes.shiftDate, format(start, "yyyy-MM-dd")), + lte(patrolRoutes.shiftDate, format(end, "yyyy-MM-dd")) + ) + ); + } + } + + const routes = await query.orderBy(asc(patrolRoutes.shiftDate), asc(patrolRoutes.startTime)); + + // Per ogni route, recupera gli stops + const routesWithStops = await Promise.all( + routes.map(async (route) => { + const stops = await db + .select({ + siteId: patrolRouteStops.siteId, + siteName: sites.name, + siteAddress: sites.address, + sequenceOrder: patrolRouteStops.sequenceOrder, + latitude: sites.latitude, + longitude: sites.longitude, + }) + .from(patrolRouteStops) + .leftJoin(sites, eq(patrolRouteStops.siteId, sites.id)) + .where(eq(patrolRouteStops.patrolRouteId, route.id)) + .orderBy(asc(patrolRouteStops.sequenceOrder)); + + return { + ...route, + stops, + }; + }) + ); + + res.json(routesWithStops); + } catch (error) { + console.error("Error fetching guard's patrol routes:", error); + res.status(500).json({ message: "Errore caricamento turni pattuglia" }); + } + }); + + // GET - Planning per un sito specifico (tutti gli agenti assegnati) + app.get("/api/site-planning/:siteId", isAuthenticated, async (req: any, res) => { + try { + const { siteId } = req.params; + const { startDate, endDate } = req.query; + + if (!startDate || !endDate) { + return res.status(400).json({ + message: "Missing required parameters: startDate, endDate" + }); + } + + const start = new Date(startDate as string); + const end = new Date(endDate as string); + + if (!isValid(start) || !isValid(end)) { + return res.status(400).json({ message: "Invalid date format" }); + } + + // Query per recuperare tutti i turni del sito nel range di date + const assignments = await db + .select({ + guardId: guards.id, + guardName: sql`${guards.firstName} || ' ' || ${guards.lastName}`, + badgeNumber: guards.badgeNumber, + shiftDate: shifts.shiftDate, + plannedStartTime: shiftAssignments.plannedStartTime, + plannedEndTime: shiftAssignments.plannedEndTime, + armed: shiftAssignments.armed, + vehicleId: shiftAssignments.vehicleId, + vehiclePlate: vehicles.licensePlate, + }) + .from(shiftAssignments) + .innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id)) + .innerJoin(guards, eq(shiftAssignments.guardId, guards.id)) + .leftJoin(vehicles, eq(shiftAssignments.vehicleId, vehicles.id)) + .where( + and( + eq(shifts.siteId, siteId), + gte(shifts.shiftDate, format(start, "yyyy-MM-dd")), + lte(shifts.shiftDate, format(end, "yyyy-MM-dd")) + ) + ) + .orderBy(asc(shifts.shiftDate), asc(shiftAssignments.plannedStartTime)); + + // Raggruppa per data + const byDay = assignments.reduce((acc, assignment) => { + const date = assignment.shiftDate; + if (!acc[date]) { + acc[date] = []; + } + acc[date].push({ + guardId: assignment.guardId, + guardName: assignment.guardName, + badgeNumber: assignment.badgeNumber, + plannedStartTime: assignment.plannedStartTime, + plannedEndTime: assignment.plannedEndTime, + armed: assignment.armed, + vehicleId: assignment.vehicleId, + vehiclePlate: assignment.vehiclePlate, + }); + return acc; + }, {} as Record); + + // Converti in array + const result = Object.entries(byDay).map(([date, guards]) => ({ + date, + guards, + })); + + res.json(result); + } catch (error) { + console.error("Error fetching site planning:", error); + res.status(500).json({ message: "Errore caricamento planning sito" }); + } + }); + // ============= NOTIFICATION ROUTES ============= app.get("/api/notifications", isAuthenticated, async (req: any, res) => { try {