From eb2ccab920cc3d6b0824705040f4e5a1f4b1ad3d Mon Sep 17 00:00:00 2001 From: marco370 <48531002-marco370@users.noreply.replit.com> Date: Fri, 17 Oct 2025 17:20:41 +0000 Subject: [PATCH] Add multi-location support for operational planning Introduce location filtering to operational planning, site management, and resource availability queries. This includes backend route modifications to handle location parameters and frontend updates for location selection and display. 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/5GnGQQ0 --- client/src/pages/operational-planning.tsx | 59 +++++++++++++++++--- client/src/pages/sites.tsx | 67 +++++++++++++++++++++-- replit.md | 27 ++++++--- server/routes.ts | 63 ++++++++++++++------- 4 files changed, 177 insertions(+), 39 deletions(-) diff --git a/client/src/pages/operational-planning.tsx b/client/src/pages/operational-planning.tsx index 7934707..5b2670c 100644 --- a/client/src/pages/operational-planning.tsx +++ b/client/src/pages/operational-planning.tsx @@ -9,7 +9,8 @@ import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Checkbox } from "@/components/ui/checkbox"; -import { Calendar, AlertCircle, CheckCircle2, Clock, MapPin, Users, Shield, Car as CarIcon } from "lucide-react"; +import { Calendar, AlertCircle, CheckCircle2, Clock, MapPin, Users, Shield, Car as CarIcon, Building2 } from "lucide-react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { format } from "date-fns"; import { it } from "date-fns/locale"; import { useToast } from "@/hooks/use-toast"; @@ -84,8 +85,15 @@ interface ResourcesData { guards: Guard[]; } +const locationLabels: Record = { + roccapiemonte: "Roccapiemonte", + milano: "Milano", + roma: "Roma", +}; + export default function OperationalPlanning() { const { toast } = useToast(); + const [selectedLocation, setSelectedLocation] = useState("roccapiemonte"); // Sede selezionata (primo step) const [selectedDate, setSelectedDate] = useState( format(new Date(), "yyyy-MM-dd") ); @@ -94,18 +102,41 @@ export default function OperationalPlanning() { const [selectedVehicle, setSelectedVehicle] = useState(null); const [createShiftDialogOpen, setCreateShiftDialogOpen] = useState(false); - // Query per siti non coperti + // Query per siti non coperti (filtrati per sede e data) const { data: uncoveredData, isLoading } = useQuery({ - queryKey: [`/api/operational-planning/uncovered-sites?date=${selectedDate}`, selectedDate], - enabled: !!selectedDate, + queryKey: ['/api/operational-planning/uncovered-sites', selectedDate, selectedLocation], + queryFn: async ({ queryKey }) => { + const [, date, location] = queryKey; + const res = await fetch(`/api/operational-planning/uncovered-sites?date=${date}&location=${location}`, { + credentials: 'include' + }); + if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`); + return res.json(); + }, + enabled: !!selectedDate && !!selectedLocation, }); // Query per risorse (veicoli e guardie) - solo quando c'è un sito selezionato const { data: resourcesData, isLoading: isLoadingResources } = useQuery({ - queryKey: [`/api/operational-planning/availability?date=${selectedDate}`, selectedDate, selectedSite?.id], - enabled: !!selectedDate && !!selectedSite, + queryKey: ['/api/operational-planning/availability', selectedDate, selectedLocation, selectedSite?.id], + queryFn: async ({ queryKey }) => { + const [, date, location] = queryKey; + const res = await fetch(`/api/operational-planning/availability?date=${date}&location=${location}`, { + credentials: 'include' + }); + if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`); + return res.json(); + }, + enabled: !!selectedDate && !!selectedLocation && !!selectedSite, }); + const handleLocationChange = (location: string) => { + setSelectedLocation(location); + setSelectedSite(null); + setSelectedGuards([]); + setSelectedVehicle(null); + }; + const handleDateChange = (e: React.ChangeEvent) => { setSelectedDate(e.target.value); setSelectedSite(null); @@ -226,11 +257,25 @@ export default function OperationalPlanning() { - Seleziona Data + Seleziona Sede e Data
+
+ + +
= { quick_response: "Pronto Intervento", }; +const locationLabels: Record = { + roccapiemonte: "Roccapiemonte", + milano: "Milano", + roma: "Roma", +}; + export default function Sites() { const { toast } = useToast(); const [isDialogOpen, setIsDialogOpen] = useState(false); @@ -129,6 +135,7 @@ export default function Sites() { editForm.reset({ name: site.name, address: site.address, + location: site.location, shiftType: site.shiftType, minGuards: site.minGuards, requiresArmed: site.requiresArmed, @@ -221,6 +228,29 @@ export default function Sites() { )} /> + ( + + Sede Gestionale + + + + )} + /> +

Dati Contrattuali

@@ -459,6 +489,29 @@ export default function Sites() { )} /> + ( + + Sede Gestionale + + + + )} + /> +

Dati Contrattuali

@@ -684,9 +737,15 @@ export default function Sites() {
{site.name} - - - {site.address} + +
+ + {site.address} +
+
+ + Sede: {locationLabels[site.location]} +
diff --git a/replit.md b/replit.md index 665e637..83e2b03 100644 --- a/replit.md +++ b/replit.md @@ -35,11 +35,23 @@ The database includes core tables for `users`, `guards`, `certifications`, `site - Sites include service schedule fields: `serviceStartTime` and `serviceEndTime` (formato HH:MM) - **Contract Management**: Sites now include contract fields: `contractReference` (codice contratto), `contractStartDate`, `contractEndDate` (date validità contratto in formato YYYY-MM-DD) - Sites now reference service types via `serviceTypeId` foreign key; `shiftType` is optional and can be derived from service type +- **Multi-Location Support**: Added `location` field (enum: roccapiemonte, milano, roma) to `sites`, `guards`, and `vehicles` tables for complete multi-sede resource isolation + +**Recent Features (October 17, 2025)**: +- **Multi-Sede Operational Planning**: Redesigned operational planning workflow with location-first approach: + 1. Select sede (Roccapiemonte/Milano/Roma) - first step with default value + 2. Select date + 3. View uncovered sites filtered by selected sede + 4. Select site → view available resources (guards and vehicles) filtered by sede + 5. Assign resources and create shift +- **Location-Based Filtering**: Backend endpoints use INNER JOIN with sites table to ensure complete resource isolation between locations - guards/vehicles in one sede remain available even when assigned to shifts in other sedi +- **Site Management**: Added sede selection in site creation/editing forms with visual badges showing location in site listings **Recent Bug Fixes (October 17, 2025)**: - **Operational Planning Date Handling**: Fixed date sanitization in `/api/operational-planning/uncovered-sites` and `/api/operational-planning/availability` endpoints to handle malformed date inputs (e.g., "2025-10-17/2025-10-17"). Both endpoints now validate dates using `parseISO`/`isValid` and return 400 for invalid formats. - **Checkbox Event Propagation**: Fixed double-toggle bug in operational planning resource selection by wrapping vehicle and guard checkboxes in `
e.stopPropagation()}>` to prevent Card onClick from firing when clicking checkboxes. -- **Resource Query Key**: Added `selectedSite?.id` to TanStack Query queryKey for availability endpoint to ensure resources re-fetch when operator selects a different site. +- **Multi-Sede Resource Isolation**: Fixed critical bug where resources from different sedi were incorrectly marked as unavailable due to global shift queries. Now both availability and uncovered-sites endpoints filter shifts by location using JOIN with sites table. +- **QueryKey Cache Invalidation**: Fixed queryKey structure from single-string to hierarchical array with custom queryFn to enable targeted cache invalidation by location and date while preventing URL concatenation errors. ### API Endpoints Comprehensive RESTful API endpoints are provided for Authentication, Users, Guards, Sites, Shifts, and Notifications, supporting full CRUD operations with role-based access control. @@ -56,11 +68,12 @@ Key frontend routes include `/`, `/guards`, `/sites`, `/shifts`, `/reports`, `/n ### Key Features - **Dashboard Operativa**: Live KPIs (active shifts, total guards, active sites, expiring certifications) and real-time shift status. - **Gestione Guardie**: Complete profiles with skill matrix (armed, fire safety, first aid, driver's license), certification management with automatic expiry, and unique badge numbers. -- **Gestione Siti/Commesse**: Service types with specialized parameters (fixed post hours, patrol passages, inspection frequency, response time) and minimum requirements (guard count, armed, driver's license). Sites include service schedule (start/end time) and contract management (reference code, validity period with start/end dates). Contract status is visualized with badges (active/expiring/expired) and enforces shift creation only within active contract periods. -- **Pianificazione Operativa Interattiva**: Three-step workflow for shift assignment: - 1. Select date → view uncovered sites with coverage status - 2. Select site → view filtered resources (guards and vehicles matching requirements) - 3. Assign resources → create shift with atomic guard assignments and vehicle allocation +- **Gestione Siti/Commesse**: Service types with specialized parameters (fixed post hours, patrol passages, inspection frequency, response time) and minimum requirements (guard count, armed, driver's license). Sites include service schedule (start/end time), contract management (reference code, validity period with start/end dates), and location/sede assignment. Contract status is visualized with badges (active/expiring/expired) and enforces shift creation only within active contract periods. +- **Pianificazione Operativa Multi-Sede**: Location-aware workflow for shift assignment: + 1. Select sede (Roccapiemonte/Milano/Roma) → filters all subsequent data by location + 2. Select date → view uncovered sites with coverage status (sede-filtered) + 3. Select site → view available resources (guards and vehicles matching sede and requirements) + 4. Assign resources → create shift with atomic guard assignments and vehicle allocation - **Pianificazione Turni**: 24/7 calendar, manual guard assignment, basic constraints, and shift statuses (planned, active, completed, cancelled). - **Reportistica**: Total hours worked, monthly hours per guard, shift statistics, and data export capabilities. - **Advanced Planning**: Management of guard constraints (preferences, max hours, rest days), site preferences (preferred/blacklisted guards), contract parameters, training courses, holidays, and absences with substitution system. @@ -77,4 +90,4 @@ Key frontend routes include `/`, `/guards`, `/sites`, `/shifts`, `/reports`, `/n - **PM2**: Production process manager for Node.js applications. - **Nginx**: As a reverse proxy for the production environment. - **Let's Encrypt**: For SSL/TLS certificates. -- **GitLab CI/CD**: For continuous integration and deployment. \ No newline at end of file +- **GitLab CI/CD**: For continuous integration and deployment. diff --git a/server/routes.ts b/server/routes.ts index c5c43df..a89edb8 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -551,60 +551,71 @@ export async function registerRoutes(app: Express): Promise { const dateStr = format(parsedDate, "yyyy-MM-dd"); + // Ottieni location dalla query (default: roccapiemonte) + const location = req.query.location as string || "roccapiemonte"; + // Imposta inizio e fine giornata in UTC const startOfDay = new Date(dateStr + "T00:00:00.000Z"); const endOfDay = new Date(dateStr + "T23:59:59.999Z"); - // Ottieni tutti i veicoli + // Ottieni tutti i veicoli della sede selezionata const allVehicles = await storage.getAllVehicles(); + const locationVehicles = allVehicles.filter(v => v.location === location); - // Ottieni turni del giorno per trovare veicoli assegnati + // Ottieni turni del giorno SOLO della sede selezionata (join con sites per filtrare per location) const dayShifts = await db - .select() + .select({ + shift: shifts + }) .from(shifts) + .innerJoin(sites, eq(shifts.siteId, sites.id)) .where( and( gte(shifts.startTime, startOfDay), - lte(shifts.startTime, endOfDay) + lte(shifts.startTime, endOfDay), + eq(sites.location, location) ) ); // Mappa veicoli con disponibilità const vehiclesWithAvailability = await Promise.all( - allVehicles.map(async (vehicle) => { - const assignedShift = dayShifts.find((shift: any) => shift.vehicleId === vehicle.id); + locationVehicles.map(async (vehicle) => { + const assignedShiftRecord = dayShifts.find((s: any) => s.shift.vehicleId === vehicle.id); return { ...vehicle, - isAvailable: !assignedShift, - assignedShift: assignedShift ? { - id: assignedShift.id, - startTime: assignedShift.startTime, - endTime: assignedShift.endTime, - siteId: assignedShift.siteId + isAvailable: !assignedShiftRecord, + assignedShift: assignedShiftRecord ? { + id: assignedShiftRecord.shift.id, + startTime: assignedShiftRecord.shift.startTime, + endTime: assignedShiftRecord.shift.endTime, + siteId: assignedShiftRecord.shift.siteId } : null }; }) ); - // Ottieni tutte le guardie + // Ottieni tutte le guardie della sede selezionata const allGuards = await storage.getAllGuards(); + const locationGuards = allGuards.filter(g => g.location === location); - // Ottieni assegnazioni turni del giorno + // Ottieni assegnazioni turni del giorno SOLO della sede selezionata (join con sites per filtrare per location) const dayShiftAssignments = await db .select() .from(shiftAssignments) .innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id)) + .innerJoin(sites, eq(shifts.siteId, sites.id)) .where( and( gte(shifts.startTime, startOfDay), - lte(shifts.startTime, endOfDay) + lte(shifts.startTime, endOfDay), + eq(sites.location, location) ) ); // Calcola disponibilità agenti con report CCNL const guardsWithAvailability = await Promise.all( - allGuards.map(async (guard) => { + locationGuards.map(async (guard) => { const assignedShift = dayShiftAssignments.find( (assignment: any) => assignment.shift_assignments.guardId === guard.id ); @@ -664,7 +675,7 @@ export async function registerRoutes(app: Express): Promise { } }); - // Endpoint per ottenere siti non completamente coperti per una data + // Endpoint per ottenere siti non completamente coperti per una data e sede app.get("/api/operational-planning/uncovered-sites", isAuthenticated, async (req, res) => { try { // Sanitizza input: gestisce sia "2025-10-17" che "2025-10-17/2025-10-17" @@ -679,11 +690,19 @@ export async function registerRoutes(app: Express): Promise { const dateStr = format(parsedDate, "yyyy-MM-dd"); - // Ottieni tutti i siti attivi + // Ottieni location dalla query (default: roccapiemonte) + const location = req.query.location as string || "roccapiemonte"; + + // Ottieni tutti i siti attivi della sede selezionata const allSites = await db .select() .from(sites) - .where(eq(sites.isActive, true)); + .where( + and( + eq(sites.isActive, true), + eq(sites.location, location) + ) + ); // Filtra siti con contratto valido per la data selezionata const sitesWithValidContract = allSites.filter((site: any) => { @@ -706,7 +725,7 @@ export async function registerRoutes(app: Express): Promise { return selectedDate >= contractStart && selectedDate <= contractEnd; }); - // Ottieni turni del giorno con assegnazioni + // Ottieni turni del giorno con assegnazioni SOLO della sede selezionata const startOfDayDate = new Date(dateStr); startOfDayDate.setHours(0, 0, 0, 0); @@ -719,12 +738,14 @@ export async function registerRoutes(app: Express): Promise { assignmentCount: sql`count(${shiftAssignments.id})::int` }) .from(shifts) + .innerJoin(sites, eq(shifts.siteId, sites.id)) .leftJoin(shiftAssignments, eq(shifts.id, shiftAssignments.shiftId)) .where( and( gte(shifts.startTime, startOfDayDate), lte(shifts.startTime, endOfDayDate), - ne(shifts.status, "cancelled") + ne(shifts.status, "cancelled"), + eq(sites.location, location) ) ) .groupBy(shifts.id);