From 72b7dfe74d40369201a3a4c09452de7789f06c53 Mon Sep 17 00:00:00 2001 From: marco370 <48531002-marco370@users.noreply.replit.com> Date: Fri, 17 Oct 2025 13:57:54 +0000 Subject: [PATCH] Add ability to plan operational shifts with resource assignments Implement a new feature for operational planning that allows users to select sites, assign guards and vehicles, and create shifts with specific start and end times. This includes updates to the UI for displaying uncovered sites and resources, as well as backend logic for creating shift assignments. 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/sshIJbn --- .replit | 8 +- client/src/pages/operational-planning.tsx | 657 ++++++++++++++-------- server/routes.ts | 12 + 3 files changed, 442 insertions(+), 235 deletions(-) diff --git a/.replit b/.replit index 0e6ab99..c595a3f 100644 --- a/.replit +++ b/.replit @@ -19,6 +19,10 @@ externalPort = 80 localPort = 33035 externalPort = 3001 +[[ports]] +localPort = 36921 +externalPort = 4200 + [[ports]] localPort = 41343 externalPort = 3000 @@ -31,10 +35,6 @@ externalPort = 3002 localPort = 43267 externalPort = 3003 -[[ports]] -localPort = 44165 -externalPort = 4200 - [env] PORT = "5000" diff --git a/client/src/pages/operational-planning.tsx b/client/src/pages/operational-planning.tsx index 5215b4e..b13c141 100644 --- a/client/src/pages/operational-planning.tsx +++ b/client/src/pages/operational-planning.tsx @@ -1,74 +1,212 @@ import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { queryClient, apiRequest } from "@/lib/queryClient"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } 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 { Skeleton } from "@/components/ui/skeleton"; -import { Calendar, Car, Users, Clock, AlertCircle, CheckCircle2, ArrowRight } from "lucide-react"; +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 { format } from "date-fns"; import { it } from "date-fns/locale"; +import { useToast } from "@/hooks/use-toast"; -interface AssignedShift { +interface Shift { id: string; startTime: string; endTime: string; - siteId: string; + assignedGuardsCount: number; + requiredGuards: number; + isCovered: boolean; + isPartial: boolean; } -interface VehicleAvailability { +interface UncoveredSite { + id: string; + name: string; + address: string; + location: string; + shiftType: string; + minGuards: number; + requiresArmed: boolean; + requiresDriverLicense: boolean; + serviceStartTime: string | null; + serviceEndTime: string | null; + isCovered: boolean; + isPartiallyCovered: boolean; + totalAssignedGuards: number; + requiredGuards: number; + shiftsCount: number; + shifts: Shift[]; +} + +interface UncoveredSitesData { + date: string; + uncoveredSites: UncoveredSite[]; + totalSites: number; + totalUncovered: number; +} + +interface Vehicle { id: string; licensePlate: string; brand: string; model: string; vehicleType: string; location: string; - status: string; + hasDriverLicense?: boolean; isAvailable: boolean; - assignedShift: AssignedShift | null; } -interface GuardAvailability { +interface Guard { id: string; badgeNumber: string; - firstName: string; - lastName: string; + userId: string; + firstName?: string; + lastName?: string; location: string; + isArmed: boolean; + hasDriverLicense: boolean; isAvailable: boolean; - assignedShift: AssignedShift | null; availability: { weeklyHours: number; remainingWeeklyHours: number; - remainingMonthlyHours: number; consecutiveDaysWorked: number; }; } -interface AvailabilityData { +interface ResourcesData { date: string; - vehicles: VehicleAvailability[]; - guards: GuardAvailability[]; + vehicles: Vehicle[]; + guards: Guard[]; } export default function OperationalPlanning() { + const { toast } = useToast(); const [selectedDate, setSelectedDate] = useState( format(new Date(), "yyyy-MM-dd") ); + const [selectedSite, setSelectedSite] = useState(null); + const [selectedGuards, setSelectedGuards] = useState([]); + const [selectedVehicle, setSelectedVehicle] = useState(null); + const [createShiftDialogOpen, setCreateShiftDialogOpen] = useState(false); - const { data, isLoading, refetch } = useQuery({ - queryKey: [`/api/operational-planning/availability?date=${selectedDate}`, selectedDate], + // Query per siti non coperti + const { data: uncoveredData, isLoading } = useQuery({ + queryKey: [`/api/operational-planning/uncovered-sites?date=${selectedDate}`, selectedDate], enabled: !!selectedDate, }); + // 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], + enabled: !!selectedDate && !!selectedSite, + }); + const handleDateChange = (e: React.ChangeEvent) => { setSelectedDate(e.target.value); + setSelectedSite(null); + setSelectedGuards([]); + setSelectedVehicle(null); }; - const availableVehicles = data?.vehicles.filter((v) => v.isAvailable) || []; - const unavailableVehicles = data?.vehicles.filter((v) => !v.isAvailable) || []; - const availableGuards = data?.guards.filter((g) => g.isAvailable) || []; - const unavailableGuards = data?.guards.filter((g) => !g.isAvailable) || []; + const handleSelectSite = (site: UncoveredSite) => { + setSelectedSite(site); + setSelectedGuards([]); + setSelectedVehicle(null); + setCreateShiftDialogOpen(true); + }; + + const toggleGuardSelection = (guardId: string) => { + setSelectedGuards((prev) => + prev.includes(guardId) + ? prev.filter((id) => id !== guardId) + : [...prev, guardId] + ); + }; + + // Filtra risorse per requisiti del sito + const filteredVehicles = resourcesData?.vehicles.filter((v) => { + if (!selectedSite) return false; + // Filtra per sede e disponibilità + if (v.location !== selectedSite.location) return false; + if (!v.isAvailable) return false; + return true; + }) || []; + + const filteredGuards = resourcesData?.guards.filter((g) => { + if (!selectedSite) return false; + // Filtra per sede + if (g.location !== selectedSite.location) return false; + // Filtra per disponibilità + if (!g.isAvailable) return false; + // Filtra per requisiti + if (selectedSite.requiresArmed && !g.isArmed) return false; + if (selectedSite.requiresDriverLicense && !g.hasDriverLicense) return false; + return true; + }) || []; + + // Mutation per creare turno + const createShiftMutation = useMutation({ + mutationFn: async (data: any) => { + return apiRequest("POST", "/api/shifts", data); + }, + onSuccess: () => { + // Invalida tutte le query che iniziano con /api/operational-planning/uncovered-sites + queryClient.invalidateQueries({ + predicate: (query) => query.queryKey[0]?.toString().startsWith('/api/operational-planning/uncovered-sites') || false + }); + toast({ + title: "Turno creato", + description: "Il turno è stato creato con successo.", + }); + setCreateShiftDialogOpen(false); + setSelectedSite(null); + setSelectedGuards([]); + setSelectedVehicle(null); + }, + onError: (error: any) => { + toast({ + title: "Errore", + description: error.message || "Impossibile creare il turno.", + variant: "destructive", + }); + }, + }); + + const handleCreateShift = () => { + if (!selectedSite) return; + + // Valida che ci siano abbastanza guardie selezionate + if (selectedGuards.length < selectedSite.minGuards) { + toast({ + title: "Guardie insufficienti", + description: `Seleziona almeno ${selectedSite.minGuards} guardie per questo sito.`, + variant: "destructive", + }); + return; + } + + // TODO: Qui bisognerà chiedere l'orario del turno + // Per ora creiamo un turno di default basato su serviceStartTime/serviceEndTime del sito + const today = selectedDate; + const startTime = selectedSite.serviceStartTime || "08:00"; + const endTime = selectedSite.serviceEndTime || "16:00"; + + const shiftData = { + siteId: selectedSite.id, + startTime: new Date(`${today}T${startTime}:00.000Z`), + endTime: new Date(`${today}T${endTime}:00.000Z`), + status: "planned", + vehicleId: selectedVehicle || null, + guardIds: selectedGuards, + }; + + createShiftMutation.mutate(shiftData); + }; return (
@@ -79,7 +217,7 @@ export default function OperationalPlanning() { Pianificazione Operativa

- Visualizza disponibilità automezzi e agenti per data + Assegna turni ai siti non coperti

@@ -104,233 +242,290 @@ export default function OperationalPlanning() { className="mt-1" /> - - {data && ( -

- Visualizzando disponibilità per: {format(new Date(selectedDate), "dd MMMM yyyy", { locale: it })} -

+ {uncoveredData && ( +
+

+ {format(new Date(selectedDate), "dd MMMM yyyy", { locale: it })} +

+ + + {uncoveredData.totalUncovered} siti da coprire + +
)} -
- {/* Veicoli */} - - - - - Automezzi Disponibili - - {availableVehicles.length}/{data?.vehicles.length || 0} - - - - - {isLoading ? ( - <> - - - - - ) : ( - <> - {/* Veicoli disponibili */} - {availableVehicles.length > 0 ? ( - availableVehicles.map((vehicle) => ( - - -
-
-
- -

{vehicle.licensePlate}

- - {vehicle.location} - -
-

- {vehicle.brand} {vehicle.model} - {vehicle.vehicleType} -

-
- + {/* Lista siti non coperti */} + {isLoading ? ( +
+ + +
+ ) : uncoveredData && uncoveredData.uncoveredSites.length > 0 ? ( +
+

Siti Non Coperti

+ {uncoveredData.uncoveredSites.map((site) => ( + + +
+
+ + {site.name} + {site.isPartiallyCovered ? ( + + + Parzialmente Coperto + + ) : ( + + + Non Coperto + + )} + + +
+ + {site.address} - {site.location} +
+ {site.serviceStartTime && site.serviceEndTime && ( +
+ + Orario: {site.serviceStartTime} - {site.serviceEndTime}
- - - )) - ) : ( -

- Nessun veicolo disponibile -

- )} - - {/* Veicoli non disponibili */} - {unavailableVehicles.length > 0 && ( - <> -
-

Non Disponibili

+ )} + +
+ +
+ + +
+
+

Turni Pianificati

+

{site.shiftsCount}

+
+
+

Guardie Richieste

+

{site.requiredGuards}

+
+
+

Guardie Assegnate

+

{site.totalAssignedGuards}

+
+
+

Requisiti

+
+ {site.requiresArmed && ( + + + Armato + + )} + {site.requiresDriverLicense && ( + + + Patente + + )}
- {unavailableVehicles.map((vehicle) => ( - - -
-
-
- -

{vehicle.licensePlate}

- - {vehicle.location} - -
-

- {vehicle.brand} {vehicle.model} -

- {vehicle.assignedShift && ( -
- - - Assegnato: {format(new Date(vehicle.assignedShift.startTime), "HH:mm")} - - {format(new Date(vehicle.assignedShift.endTime), "HH:mm")} - -
- )} -
-
-
-
- ))} - - )} - - )} - - +
+
- {/* Agenti */} - - - - - Agenti Disponibili - - {availableGuards.length}/{data?.guards.length || 0} - - - - - {isLoading ? ( - <> - - - - - ) : ( - <> - {/* Agenti disponibili */} - {availableGuards.length > 0 ? ( - availableGuards.map((guard) => ( - - -
-
-
- -

- {guard.firstName} {guard.lastName} -

- - {guard.badgeNumber} - -
-
- - - {guard.availability.weeklyHours.toFixed(1)}h questa settimana - - - Residuo: {guard.availability.remainingWeeklyHours.toFixed(1)}h - -
- {guard.availability.consecutiveDaysWorked > 0 && ( -

- {guard.availability.consecutiveDaysWorked} giorni consecutivi -

+ {site.shifts.length > 0 && ( +
+

Dettagli Turni:

+
+ {site.shifts.map((shift) => ( +
+
+ + {format(new Date(shift.startTime), "HH:mm")} - {format(new Date(shift.endTime), "HH:mm")} +
+
+ + {shift.assignedGuardsCount}/{shift.requiredGuards} + {shift.isCovered ? ( + + ) : shift.isPartial ? ( + + ) : ( + )}
-
- - - )) - ) : ( -

- Nessun agente disponibile -

- )} - - {/* Agenti non disponibili */} - {unavailableGuards.length > 0 && ( - <> -
-

Non Disponibili

+ ))}
- {unavailableGuards.map((guard) => ( - +
+ )} + + + ))} +
+ ) : ( + + + +

Tutti i siti sono coperti!

+

+ Non ci sono siti che richiedono assegnazioni per questa data. +

+
+
+ )} + + {/* Dialog per assegnare risorse e creare turno */} + + + + Assegna Risorse - {selectedSite?.name} + + Seleziona le guardie e il veicolo per creare il turno + + + + {isLoadingResources ? ( +
+ + +
+ ) : ( +
+ {/* Veicoli Disponibili */} +
+

+ + Veicoli Disponibili ({filteredVehicles.length}) +

+ {filteredVehicles.length > 0 ? ( +
+ {filteredVehicles.map((vehicle) => ( + setSelectedVehicle(vehicle.id)} + data-testid={`vehicle-card-${vehicle.id}`} + > -
-
-
- -

- {guard.firstName} {guard.lastName} -

- - {guard.badgeNumber} - -
- {guard.assignedShift && ( -
- - - Assegnato: {format(new Date(guard.assignedShift.startTime), "HH:mm")} - - {format(new Date(guard.assignedShift.endTime), "HH:mm")} - -
- )} -
- {guard.availability.weeklyHours.toFixed(1)}h settimana -
+
+
+

{vehicle.licensePlate}

+

+ {vehicle.brand} {vehicle.model} +

+ setSelectedVehicle(vehicle.id)} + />
))} - +
+ ) : ( +

Nessun veicolo disponibile per questa sede

)} - - )} - - -
+
+ + {/* Guardie Disponibili */} +
+

+ + Guardie Disponibili ({filteredGuards.length}) + + Selezionate: {selectedGuards.length}/{selectedSite?.minGuards || 0} + +

+ {filteredGuards.length > 0 ? ( +
+ {filteredGuards.map((guard) => ( + toggleGuardSelection(guard.id)} + data-testid={`guard-card-${guard.id}`} + > + +
+
+
+

+ {guard.firstName} {guard.lastName} - #{guard.badgeNumber} +

+ {guard.isArmed && ( + + + Armato + + )} + {guard.hasDriverLicense && ( + + + Patente + + )} +
+

+ Ore sett.: {guard.availability.weeklyHours}h | Rimaste: {guard.availability.remainingWeeklyHours}h +

+
+ toggleGuardSelection(guard.id)} + /> +
+
+
+ ))} +
+ ) : ( +

+ Nessuna guardia disponibile che soddisfa i requisiti del sito +

+ )} +
+
+ )} + + + + + + +
); } diff --git a/server/routes.ts b/server/routes.ts index 762d16f..7e45a80 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -958,9 +958,21 @@ export async function registerRoutes(app: Express): Promise { startTime, endTime, status: req.body.status || "planned", + vehicleId: req.body.vehicleId || null, }); const shift = await storage.createShift(validatedData); + + // Se ci sono guardie da assegnare, crea le assegnazioni + if (req.body.guardIds && Array.isArray(req.body.guardIds) && req.body.guardIds.length > 0) { + for (const guardId of req.body.guardIds) { + await storage.createShiftAssignment({ + shiftId: shift.id, + guardId, + }); + } + } + res.json(shift); } catch (error) { console.error("Error creating shift:", error);