From bd4a55e001ce3804fd572f59e03ac6db66ae52c2 Mon Sep 17 00:00:00 2001 From: marco370 <48531002-marco370@users.noreply.replit.com> Date: Thu, 23 Oct 2025 08:52:16 +0000 Subject: [PATCH] Add mobile planning interface and backend endpoints Introduce a new "Planning Mobile" section to the application, including a frontend page (client/src/pages/planning-mobile.tsx) for managing mobile services (patrols, inspections, interventions) and backend API routes (server/routes.ts) to fetch relevant sites and guard availability based on location and date. This also includes updates to the app sidebar (client/src/components/app-sidebar.tsx) and router (client/src/App.tsx) to integrate the new functionality. 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/1nTItRR --- .replit | 4 + client/src/App.tsx | 2 + client/src/components/app-sidebar.tsx | 7 + client/src/pages/planning-mobile.tsx | 284 ++++++++++++++++++++++++++ server/routes.ts | 129 ++++++++++++ 5 files changed, 426 insertions(+) create mode 100644 client/src/pages/planning-mobile.tsx diff --git a/.replit b/.replit index aa839ef..3b53bcf 100644 --- a/.replit +++ b/.replit @@ -19,6 +19,10 @@ externalPort = 80 localPort = 33035 externalPort = 3001 +[[ports]] +localPort = 38781 +externalPort = 5173 + [[ports]] localPort = 41295 externalPort = 6000 diff --git a/client/src/App.tsx b/client/src/App.tsx index 215fea0..029844d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -26,6 +26,7 @@ import OperationalPlanning from "@/pages/operational-planning"; import GeneralPlanning from "@/pages/general-planning"; import ServicePlanning from "@/pages/service-planning"; import Customers from "@/pages/customers"; +import PlanningMobile from "@/pages/planning-mobile"; function Router() { const { isAuthenticated, isLoading } = useAuth(); @@ -48,6 +49,7 @@ function Router() { + diff --git a/client/src/components/app-sidebar.tsx b/client/src/components/app-sidebar.tsx index bae3694..561e184 100644 --- a/client/src/components/app-sidebar.tsx +++ b/client/src/components/app-sidebar.tsx @@ -11,6 +11,7 @@ import { ClipboardList, Car, Briefcase, + Navigation, } from "lucide-react"; import { Link, useLocation } from "wouter"; import { @@ -61,6 +62,12 @@ const menuItems = [ icon: BarChart3, roles: ["admin", "coordinator"], }, + { + title: "Planning Mobile", + url: "/planning-mobile", + icon: Navigation, + roles: ["admin", "coordinator"], + }, { title: "Planning di Servizio", url: "/service-planning", diff --git a/client/src/pages/planning-mobile.tsx b/client/src/pages/planning-mobile.tsx new file mode 100644 index 0000000..0037be7 --- /dev/null +++ b/client/src/pages/planning-mobile.tsx @@ -0,0 +1,284 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Calendar, MapPin, User, Car, Clock } from "lucide-react"; +import { format, parseISO, isValid } from "date-fns"; +import { it } from "date-fns/locale"; + +type Location = "roccapiemonte" | "milano" | "roma"; + +type MobileSite = { + id: string; + name: string; + address: string; + city: string; + serviceTypeId: string; + serviceTypeName: string; + location: Location; + latitude: number | null; + longitude: number | null; +}; + +type AvailableGuard = { + id: string; + firstName: string; + lastName: string; + badgeNumber: string; + location: Location; + weeklyHours: number; + availableHours: number; +}; + +export default function PlanningMobile() { + const [selectedDate, setSelectedDate] = useState(format(new Date(), "yyyy-MM-dd")); + const [selectedLocation, setSelectedLocation] = useState("roccapiemonte"); + const [selectedGuardId, setSelectedGuardId] = useState(""); + + // Query siti mobile per location + const { data: mobileSites, isLoading: sitesLoading } = useQuery({ + queryKey: ["/api/planning-mobile/sites", selectedLocation], + queryFn: async () => { + const response = await fetch(`/api/planning-mobile/sites?location=${selectedLocation}`); + if (!response.ok) { + throw new Error("Failed to fetch mobile sites"); + } + return response.json(); + }, + enabled: !!selectedLocation, + }); + + // Query guardie disponibili per location e data + const { data: availableGuards, isLoading: guardsLoading } = useQuery({ + queryKey: ["/api/planning-mobile/guards", selectedLocation, selectedDate], + queryFn: async () => { + const response = await fetch(`/api/planning-mobile/guards?location=${selectedLocation}&date=${selectedDate}`); + if (!response.ok) { + throw new Error("Failed to fetch available guards"); + } + return response.json(); + }, + enabled: !!selectedLocation && !!selectedDate, + }); + + const locationLabels: Record = { + roccapiemonte: "Roccapiemonte", + milano: "Milano", + roma: "Roma", + }; + + const locationColors: Record = { + roccapiemonte: "bg-blue-500", + milano: "bg-green-500", + roma: "bg-purple-500", + }; + + return ( +
+ {/* Header */} +
+

Planning Mobile

+

+ Pianificazione ronde, ispezioni e interventi notturni per servizi mobili +

+
+ + {/* Filtri */} + + + + + Filtri Pianificazione + + Seleziona sede, data e guardia per iniziare + + +
+ + +
+ +
+ + setSelectedDate(e.target.value)} + data-testid="input-mobile-date" + /> +
+ +
+ + +
+
+
+ + {/* Grid: Mappa + Siti */} +
+ {/* Mappa Siti */} + + + + + Mappa Siti Mobile + + + {mobileSites?.length || 0} siti con servizi mobili in {locationLabels[selectedLocation]} + + + +
+ +

+ Integrazione mappa in sviluppo +
+ (Leaflet/Google Maps) +

+
+
+
+ + {/* Lista Siti Mobile */} + + + + + Siti con Servizi Mobili + + + Ronde notturne, ispezioni, interventi programmati + + + + {sitesLoading ? ( +

Caricamento...

+ ) : mobileSites && mobileSites.length > 0 ? ( + mobileSites.map((site) => ( +
+
+
+

{site.name}

+

+ + {site.address}, {site.city} +

+
+ + {locationLabels[site.location]} + +
+
+ + {site.serviceTypeName} + +
+
+ + +
+
+ )) + ) : ( +
+ +

+ Nessun sito con servizi mobili in {locationLabels[selectedLocation]} +

+
+ )} +
+
+
+ + {/* Guardie Disponibili */} + + + + + Guardie Disponibili ({availableGuards?.length || 0}) + + + Guardie con ore disponibili per {format(parseISO(selectedDate), "dd MMMM yyyy", { locale: it })} + + + +
+ {guardsLoading ? ( +

Caricamento...

+ ) : availableGuards && availableGuards.length > 0 ? ( + availableGuards.map((guard) => ( +
+
+
+
+ {guard.firstName} {guard.lastName} +
+

#{guard.badgeNumber}

+
+ + {locationLabels[guard.location]} + +
+
+
+ Ore settimanali: + {guard.weeklyHours}h / 45h +
+
+ Disponibili: + {guard.availableHours}h +
+
+
+ )) + ) : ( +

+ Nessuna guardia disponibile per la data selezionata +

+ )} +
+
+
+
+ ); +} diff --git a/server/routes.ts b/server/routes.ts index 9ae7de5..4967841 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -3175,6 +3175,135 @@ export async function registerRoutes(app: Express): Promise { } }); + // ============= PLANNING MOBILE ROUTES ============= + // GET /api/planning-mobile/sites?location=X - Siti con servizi mobili (ronde/ispezioni/interventi) + app.get("/api/planning-mobile/sites", isAuthenticated, async (req, res) => { + try { + const { location } = req.query; + + if (!location || !["roccapiemonte", "milano", "roma"].includes(location as string)) { + return res.status(400).json({ message: "Location parameter required (roccapiemonte|milano|roma)" }); + } + + // Query siti con serviceType.classification = 'mobile' e location matching + const mobileSites = await db + .select({ + id: sites.id, + name: sites.name, + address: sites.address, + city: sites.city, + serviceTypeId: sites.serviceTypeId, + serviceTypeName: serviceTypes.label, + location: sites.location, + latitude: sites.latitude, + longitude: sites.longitude, + }) + .from(sites) + .innerJoin(serviceTypes, eq(sites.serviceTypeId, serviceTypes.id)) + .where( + and( + eq(sites.location, location as "roccapiemonte" | "milano" | "roma"), + eq(serviceTypes.classification, "mobile"), + eq(sites.isActive, true) + ) + ) + .orderBy(sites.name); + + res.json(mobileSites); + } catch (error) { + console.error("Error fetching mobile sites:", error); + res.status(500).json({ message: "Errore caricamento siti mobili" }); + } + }); + + // GET /api/planning-mobile/guards?location=X&date=YYYY-MM-DD - Guardie disponibili per location e data + app.get("/api/planning-mobile/guards", isAuthenticated, async (req, res) => { + try { + const { location, date } = req.query; + + if (!location || !["roccapiemonte", "milano", "roma"].includes(location as string)) { + return res.status(400).json({ message: "Location parameter required" }); + } + + if (!date || typeof date !== "string") { + return res.status(400).json({ message: "Date parameter required (YYYY-MM-DD)" }); + } + + // Valida formato data + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(date)) { + return res.status(400).json({ message: "Invalid date format (use YYYY-MM-DD)" }); + } + + // Ottieni tutte le guardie per location + const allGuards = await db + .select() + .from(guards) + .where( + and( + eq(guards.location, location as "roccapiemonte" | "milano" | "roma"), + eq(guards.isActive, true) + ) + ) + .orderBy(guards.lastName, guards.firstName); + + // Calcola settimana corrente per calcolare ore settimanali + const [year, month, day] = date.split("-").map(Number); + const targetDate = new Date(year, month - 1, day); + const weekStart = startOfWeek(targetDate, { weekStartsOn: 1 }); // lunedì + const weekEnd = endOfWeek(targetDate, { weekStartsOn: 1 }); + + // Per ogni guardia, calcola ore già assegnate nella settimana + const guardsWithAvailability = await Promise.all( + allGuards.map(async (guard) => { + // Query shifts assegnati alla guardia nella settimana + const weekShifts = await db + .select({ + shiftId: shifts.id, + startTime: shifts.startTime, + endTime: shifts.endTime, + }) + .from(shiftAssignments) + .innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id)) + .where( + and( + eq(shiftAssignments.guardId, guard.id), + gte(shifts.startTime, weekStart), + lte(shifts.startTime, weekEnd) + ) + ); + + // Calcola ore totali nella settimana + const weeklyHours = weekShifts.reduce((total, shift) => { + const hours = differenceInHours(new Date(shift.endTime), new Date(shift.startTime)); + return total + hours; + }, 0); + + const maxWeeklyHours = 45; // CCNL limit + const availableHours = Math.max(0, maxWeeklyHours - weeklyHours); + + return { + id: guard.id, + firstName: guard.firstName, + lastName: guard.lastName, + badgeNumber: guard.badgeNumber, + location: guard.location, + weeklyHours, + availableHours, + }; + }) + ); + + // Filtra solo guardie con ore disponibili + const availableGuards = guardsWithAvailability.filter(g => g.availableHours > 0); + + res.json(availableGuards); + } catch (error) { + console.error("Error fetching available guards:", error); + res.status(500).json({ message: "Errore caricamento guardie disponibili" }); + } + }); + const httpServer = createServer(app); return httpServer; }