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, }; });