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