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