From efcaca356a736698c7aa0b31d41ca45ff690e5bd Mon Sep 17 00:00:00 2001 From: marco370 <48531002-marco370@users.noreply.replit.com> Date: Wed, 22 Oct 2025 08:08:00 +0000 Subject: [PATCH] Add a new section for viewing and managing service planning details Implement the "Service Planning" page with backend API routes and frontend components for displaying guard and site schedules. Replit-Commit-Author: Agent Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/KiuJzNf --- client/src/App.tsx | 2 + client/src/components/app-sidebar.tsx | 6 + client/src/pages/service-planning.tsx | 288 ++++++++++++++++++++++++++ server/routes.ts | 244 ++++++++++++++++++++++ 4 files changed, 540 insertions(+) create mode 100644 client/src/pages/service-planning.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 84f2c7e..cb19b8f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -24,6 +24,7 @@ import Services from "@/pages/services"; import Planning from "@/pages/planning"; import OperationalPlanning from "@/pages/operational-planning"; import GeneralPlanning from "@/pages/general-planning"; +import ServicePlanning from "@/pages/service-planning"; function Router() { const { isAuthenticated, isLoading } = useAuth(); @@ -44,6 +45,7 @@ function Router() { + diff --git a/client/src/components/app-sidebar.tsx b/client/src/components/app-sidebar.tsx index a766f41..cdcf329 100644 --- a/client/src/components/app-sidebar.tsx +++ b/client/src/components/app-sidebar.tsx @@ -61,6 +61,12 @@ const menuItems = [ icon: BarChart3, roles: ["admin", "coordinator"], }, + { + title: "Planning di Servizio", + url: "/service-planning", + icon: ClipboardList, + roles: ["admin", "coordinator"], + }, { title: "Gestione Pianificazioni", url: "/advanced-planning", diff --git a/client/src/pages/service-planning.tsx b/client/src/pages/service-planning.tsx new file mode 100644 index 0000000..b1f268c --- /dev/null +++ b/client/src/pages/service-planning.tsx @@ -0,0 +1,288 @@ +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 { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +type Location = "roccapiemonte" | "milano" | "roma"; + +interface ShiftDetail { + shiftId: string; + date: string; + from: string; + to: string; + siteName: string; + siteId: string; + vehicle?: { + licensePlate: string; + brand: string; + model: string; + }; + hours: number; +} + +interface GuardSchedule { + guardId: string; + guardName: string; + badgeNumber: string; + shifts: ShiftDetail[]; + totalHours: number; +} + +interface SiteSchedule { + siteId: string; + siteName: string; + location: string; + shifts: { + shiftId: string; + date: string; + from: string; + to: string; + guards: { + guardName: string; + badgeNumber: string; + hours: number; + }[]; + vehicle?: { + licensePlate: string; + brand: string; + model: string; + }; + totalGuards: number; + totalHours: number; + }[]; + totalShifts: number; + totalHours: number; +} + +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 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], + 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"); + return response.json(); + }, + enabled: viewMode === "guard", + }); + + // Query per vista Siti + const { data: siteSchedules, isLoading: isLoadingSites } = useQuery({ + queryKey: ["/api/service-planning/by-site", weekStartStr, selectedLocation], + queryFn: async () => { + const response = await fetch(`/api/service-planning/by-site?weekStart=${weekStartStr}&location=${selectedLocation}`); + if (!response.ok) throw new Error("Failed to fetch site schedules"); + return response.json(); + }, + enabled: viewMode === "site", + }); + + const goToPreviousWeek = () => setWeekStart(addWeeks(weekStart, -1)); + const goToNextWeek = () => setWeekStart(addWeeks(weekStart, 1)); + + return ( +
+ {/* Header */} +
+
+

Planning di Servizio

+

+ Visualizza orari e dotazioni per guardia o sito +

+
+
+ + {/* Controlli */} + + +
+ {/* Selezione sede */} +
+ + +
+ + {/* Navigazione settimana */} +
+ +
+ +
+ {format(weekStart, "d MMM", { locale: it })} - {format(addDays(weekStart, 6), "d MMM yyyy", { locale: it })} +
+ +
+
+
+
+
+ + {/* Tabs per vista */} + setViewMode(v as "guard" | "site")}> + + + + Vista Agente + + + + Vista Sito + + + + {/* Vista Agente */} + + {isLoadingGuards ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : guardSchedules && guardSchedules.length > 0 ? ( +
+ {guardSchedules.map((guard) => ( + + +
+ + {guard.guardName} {guard.badgeNumber} + + {guard.totalHours}h totali +
+
+ + {guard.shifts.length === 0 ? ( +

Nessun turno 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}) +
+ )} +
+
+ ))} +
+ )} +
+
+ ))} +
+ ) : ( + + +

Nessuna guardia con turni assegnati

+
+
+ )} +
+ + {/* Vista Sito */} + + {isLoadingSites ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : siteSchedules && siteSchedules.length > 0 ? ( +
+ {siteSchedules.map((site) => ( + + +
+ {site.siteName} +
+ {site.totalShifts} turni + {site.totalHours}h totali +
+
+
+ + {site.shifts.length === 0 ? ( +

Nessun turno programmato

+ ) : ( +
+ {site.shifts.map((shift) => ( +
+
+
+ {format(new Date(shift.date), "EEEE d MMM", { locale: it })} • {shift.from} - {shift.to} +
+ {shift.totalGuards} {shift.totalGuards === 1 ? "guardia" : "guardie"} +
+
+ {shift.guards.map((guard, idx) => ( +
+ 👤 {guard.guardName} ({guard.badgeNumber}) - {guard.hours}h +
+ ))} +
+ {shift.vehicle && ( +
+ 🚗 {shift.vehicle.licensePlate} ({shift.vehicle.brand} {shift.vehicle.model}) +
+ )} +
+ ))} +
+ )} +
+
+ ))} +
+ ) : ( + + +

Nessun sito con turni programmati

+
+
+ )} +
+
+
+ ); +} diff --git a/server/routes.ts b/server/routes.ts index 963b6fa..ba47639 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -1429,6 +1429,250 @@ export async function registerRoutes(app: Express): Promise { } }); + // ============= SERVICE PLANNING ROUTES ============= + + // Vista per Guardia - mostra orari e dotazioni per ogni guardia + 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"); + 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 per la sede (con JOIN su sites per filtrare location) + const weekShifts = await db + .select({ + shift: shifts, + site: sites, + vehicle: vehicles, + }) + .from(shifts) + .innerJoin(sites, eq(shifts.siteId, sites.id)) + .leftJoin(vehicles, eq(shifts.vehicleId, vehicles.id)) + .where( + and( + gte(shifts.startTime, weekStartTimestamp), + lte(shifts.startTime, weekEndTimestamp), + ne(shifts.status, "cancelled"), + eq(sites.location, location as any) + ) + ); + + // 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; // Arrotonda a 1 decimale + + 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, + siteId: shiftData.site.id, + 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 guard schedules:", error); + res.status(500).json({ message: "Failed to fetch guard schedules", error: String(error) }); + } + }); + + // Vista per Sito - mostra agenti e dotazioni per ogni sito + app.get("/api/service-planning/by-site", 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 tutti i siti attivi della sede + const activeSites = await db + .select() + .from(sites) + .where( + and( + eq(sites.isActive, true), + eq(sites.location, location as any) + ) + ) + .orderBy(sites.name); + + // Ottieni tutti i turni della settimana per la sede + const weekShifts = await db + .select({ + shift: shifts, + site: sites, + vehicle: vehicles, + }) + .from(shifts) + .innerJoin(sites, eq(shifts.siteId, sites.id)) + .leftJoin(vehicles, eq(shifts.vehicleId, vehicles.id)) + .where( + and( + gte(shifts.startTime, weekStartTimestamp), + lte(shifts.startTime, weekEndTimestamp), + ne(shifts.status, "cancelled"), + eq(sites.location, location as any) + ) + ); + + // 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 sito + const siteSchedules = activeSites.map((site: any) => { + // Trova turni del sito + const siteShifts = weekShifts.filter((s: any) => s.site.id === site.id); + + // Costruisci lista turni con guardie e veicoli + const shifts = siteShifts.map((shiftData: any) => { + const shiftAssignments = assignments.filter((a: any) => a.assignment.shiftId === shiftData.shift.id); + + const guards = shiftAssignments.map((a: any) => { + 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; // Arrotonda a 1 decimale + + return { + guardName: a.guard.fullName, + badgeNumber: a.guard.badgeNumber, + hours, + }; + }); + + const shiftStart = new Date(shiftData.shift.startTime); + const shiftEnd = new Date(shiftData.shift.endTime); + const minutes = differenceInMinutes(shiftEnd, shiftStart); + const totalHours = Math.round((minutes / 60) * 10) / 10; + + return { + shiftId: shiftData.shift.id, + date: format(shiftStart, "yyyy-MM-dd"), + from: format(shiftStart, "HH:mm"), + to: format(shiftEnd, "HH:mm"), + guards, + vehicle: shiftData.vehicle ? { + licensePlate: shiftData.vehicle.licensePlate, + brand: shiftData.vehicle.brand, + model: shiftData.vehicle.model, + } : undefined, + totalGuards: guards.length, + totalHours, + }; + }); + + const totalHours = Math.round(shifts.reduce((sum: number, s: any) => sum + s.totalHours, 0) * 10) / 10; + + return { + siteId: site.id, + siteName: site.name, + location: site.location, + shifts, + totalShifts: shifts.length, + totalHours, + }; + }); + + // Filtra solo siti con turni programmati + const sitesWithShifts = siteSchedules.filter((s: any) => s.shifts.length > 0); + + res.json(sitesWithShifts); + } catch (error) { + console.error("Error fetching site schedules:", error); + res.status(500).json({ message: "Failed to fetch site schedules", error: String(error) }); + } + }); + // ============= CERTIFICATION ROUTES ============= app.post("/api/certifications", isAuthenticated, async (req, res) => { try {