Compare commits

...

6 Commits

Author SHA1 Message Date
Marco Lanzara
76af862a6b 🚀 Release v1.0.15
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.15_20251017_140916.sql.gz
- Data: 2025-10-17 14:09:32
2025-10-17 14:09:32 +00:00
marco370
8ed55e05cc Enhance site management with specialized service parameters and schedules
Update database schema to include `serviceStartTime`, `serviceEndTime` for sites and specialized parameters like `fixedPostHours`, `patrolPassages`, `inspectionFrequency`, `responseTimeMinutes` for service types.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/UBH5igx
2025-10-17 14:04:22 +00:00
marco370
72b7dfe74d 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
2025-10-17 13:57:54 +00:00
marco370
283b24bcb6 Add operational planning view for uncovered sites
Introduces a new API endpoint `/api/operational-planning/uncovered-sites` that queries for sites not fully covered by assigned guards on a given date, returning sites with partial or no coverage.

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
2025-10-17 13:52:54 +00:00
marco370
144a281657 Add service time parameters for fixed post and patrol services
Introduces new fields for `serviceStartTime`, `serviceEndTime`, `fixedPostHours`, `patrolPassages`, and `inspectionFrequency` in the Services section of the client-side application.

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
2025-10-17 13:49:30 +00:00
marco370
a6c3ba293b Add service-specific parameters and site scheduling details
Update schema definitions in `shared/schema.ts` to include service type parameters (e.g., `fixedPostHours`, `patrolPassages`) and site-specific service times (`serviceStartTime`, `serviceEndTime`) to support more detailed scheduling requirements.

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
2025-10-17 13:44:37 +00:00
8 changed files with 814 additions and 235 deletions

View File

@ -1,74 +1,212 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { queryClient, apiRequest } from "@/lib/queryClient";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; 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 { format } from "date-fns";
import { it } from "date-fns/locale"; import { it } from "date-fns/locale";
import { useToast } from "@/hooks/use-toast";
interface AssignedShift { interface Shift {
id: string; id: string;
startTime: string; startTime: string;
endTime: 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; id: string;
licensePlate: string; licensePlate: string;
brand: string; brand: string;
model: string; model: string;
vehicleType: string; vehicleType: string;
location: string; location: string;
status: string; hasDriverLicense?: boolean;
isAvailable: boolean; isAvailable: boolean;
assignedShift: AssignedShift | null;
} }
interface GuardAvailability { interface Guard {
id: string; id: string;
badgeNumber: string; badgeNumber: string;
firstName: string; userId: string;
lastName: string; firstName?: string;
lastName?: string;
location: string; location: string;
isArmed: boolean;
hasDriverLicense: boolean;
isAvailable: boolean; isAvailable: boolean;
assignedShift: AssignedShift | null;
availability: { availability: {
weeklyHours: number; weeklyHours: number;
remainingWeeklyHours: number; remainingWeeklyHours: number;
remainingMonthlyHours: number;
consecutiveDaysWorked: number; consecutiveDaysWorked: number;
}; };
} }
interface AvailabilityData { interface ResourcesData {
date: string; date: string;
vehicles: VehicleAvailability[]; vehicles: Vehicle[];
guards: GuardAvailability[]; guards: Guard[];
} }
export default function OperationalPlanning() { export default function OperationalPlanning() {
const { toast } = useToast();
const [selectedDate, setSelectedDate] = useState<string>( const [selectedDate, setSelectedDate] = useState<string>(
format(new Date(), "yyyy-MM-dd") format(new Date(), "yyyy-MM-dd")
); );
const [selectedSite, setSelectedSite] = useState<UncoveredSite | null>(null);
const [selectedGuards, setSelectedGuards] = useState<string[]>([]);
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
const [createShiftDialogOpen, setCreateShiftDialogOpen] = useState(false);
const { data, isLoading, refetch } = useQuery<AvailabilityData>({ // Query per siti non coperti
queryKey: [`/api/operational-planning/availability?date=${selectedDate}`, selectedDate], const { data: uncoveredData, isLoading } = useQuery<UncoveredSitesData>({
queryKey: [`/api/operational-planning/uncovered-sites?date=${selectedDate}`, selectedDate],
enabled: !!selectedDate, enabled: !!selectedDate,
}); });
// Query per risorse (veicoli e guardie) - solo quando c'è un sito selezionato
const { data: resourcesData, isLoading: isLoadingResources } = useQuery<ResourcesData>({
queryKey: [`/api/operational-planning/availability?date=${selectedDate}`, selectedDate],
enabled: !!selectedDate && !!selectedSite,
});
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSelectedDate(e.target.value); setSelectedDate(e.target.value);
setSelectedSite(null);
setSelectedGuards([]);
setSelectedVehicle(null);
}; };
const availableVehicles = data?.vehicles.filter((v) => v.isAvailable) || []; const handleSelectSite = (site: UncoveredSite) => {
const unavailableVehicles = data?.vehicles.filter((v) => !v.isAvailable) || []; setSelectedSite(site);
const availableGuards = data?.guards.filter((g) => g.isAvailable) || []; setSelectedGuards([]);
const unavailableGuards = data?.guards.filter((g) => !g.isAvailable) || []; 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 ( return (
<div className="container mx-auto p-6 space-y-6"> <div className="container mx-auto p-6 space-y-6">
@ -79,7 +217,7 @@ export default function OperationalPlanning() {
Pianificazione Operativa Pianificazione Operativa
</h1> </h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">
Visualizza disponibilità automezzi e agenti per data Assegna turni ai siti non coperti
</p> </p>
</div> </div>
</div> </div>
@ -104,233 +242,290 @@ export default function OperationalPlanning() {
className="mt-1" className="mt-1"
/> />
</div> </div>
<Button
onClick={() => refetch()}
data-testid="button-refresh-availability"
>
Aggiorna Disponibilità
</Button>
</div> </div>
{data && ( {uncoveredData && (
<p className="text-sm text-muted-foreground mt-4"> <div className="mt-4 flex items-center gap-4 text-sm">
Visualizzando disponibilità per: {format(new Date(selectedDate), "dd MMMM yyyy", { locale: it })} <p className="text-muted-foreground">
</p> {format(new Date(selectedDate), "dd MMMM yyyy", { locale: it })}
</p>
<Badge variant="outline" className="gap-1">
<AlertCircle className="h-3 w-3" />
{uncoveredData.totalUncovered} siti da coprire
</Badge>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> {/* Lista siti non coperti */}
{/* Veicoli */} {isLoading ? (
<Card> <div className="space-y-4">
<CardHeader> <Skeleton className="h-32" />
<CardTitle className="flex items-center gap-2"> <Skeleton className="h-32" />
<Car className="h-5 w-5" /> </div>
Automezzi Disponibili ) : uncoveredData && uncoveredData.uncoveredSites.length > 0 ? (
<Badge variant="secondary" className="ml-auto"> <div className="space-y-4">
{availableVehicles.length}/{data?.vehicles.length || 0} <h2 className="text-xl font-semibold">Siti Non Coperti</h2>
</Badge> {uncoveredData.uncoveredSites.map((site) => (
</CardTitle> <Card key={site.id} data-testid={`site-card-${site.id}`}>
</CardHeader> <CardHeader>
<CardContent className="space-y-3"> <div className="flex items-start justify-between">
{isLoading ? ( <div className="flex-1">
<> <CardTitle className="flex items-center gap-2">
<Skeleton className="h-20 w-full" /> {site.name}
<Skeleton className="h-20 w-full" /> {site.isPartiallyCovered ? (
<Skeleton className="h-20 w-full" /> <Badge variant="secondary" className="gap-1">
</> <AlertCircle className="h-3 w-3" />
) : ( Parzialmente Coperto
<> </Badge>
{/* Veicoli disponibili */} ) : (
{availableVehicles.length > 0 ? ( <Badge variant="destructive" className="gap-1">
availableVehicles.map((vehicle) => ( <AlertCircle className="h-3 w-3" />
<Card key={vehicle.id} className="bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800"> Non Coperto
<CardContent className="p-4"> </Badge>
<div className="flex items-start justify-between"> )}
<div className="flex-1"> </CardTitle>
<div className="flex items-center gap-2"> <CardDescription className="mt-2 space-y-1">
<CheckCircle2 className="h-4 w-4 text-green-600" /> <div className="flex items-center gap-1 text-sm">
<h4 className="font-semibold">{vehicle.licensePlate}</h4> <MapPin className="h-3 w-3" />
<Badge variant="outline" className="text-xs"> {site.address} - {site.location}
{vehicle.location} </div>
</Badge> {site.serviceStartTime && site.serviceEndTime && (
</div> <div className="flex items-center gap-1 text-sm">
<p className="text-sm text-muted-foreground mt-1"> <Clock className="h-3 w-3" />
{vehicle.brand} {vehicle.model} - {vehicle.vehicleType} Orario: {site.serviceStartTime} - {site.serviceEndTime}
</p>
</div>
<Button
size="sm"
data-testid={`button-assign-vehicle-${vehicle.id}`}
className="ml-2"
>
<ArrowRight className="h-4 w-4 mr-1" />
Assegna
</Button>
</div> </div>
</CardContent> )}
</Card> </CardDescription>
)) </div>
) : ( <Button
<p className="text-sm text-muted-foreground text-center py-4"> onClick={() => handleSelectSite(site)}
Nessun veicolo disponibile data-testid={`button-select-site-${site.id}`}
</p> >
)} Assegna Turno
</Button>
{/* Veicoli non disponibili */} </div>
{unavailableVehicles.length > 0 && ( </CardHeader>
<> <CardContent>
<div className="pt-3 border-t"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<h4 className="text-sm font-medium text-muted-foreground mb-3">Non Disponibili</h4> <div>
<p className="text-sm text-muted-foreground">Turni Pianificati</p>
<p className="text-lg font-semibold">{site.shiftsCount}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Guardie Richieste</p>
<p className="text-lg font-semibold">{site.requiredGuards}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Guardie Assegnate</p>
<p className="text-lg font-semibold">{site.totalAssignedGuards}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Requisiti</p>
<div className="flex gap-1 mt-1">
{site.requiresArmed && (
<Badge variant="outline" className="text-xs">
<Shield className="h-3 w-3 mr-1" />
Armato
</Badge>
)}
{site.requiresDriverLicense && (
<Badge variant="outline" className="text-xs">
<CarIcon className="h-3 w-3 mr-1" />
Patente
</Badge>
)}
</div> </div>
{unavailableVehicles.map((vehicle) => ( </div>
<Card key={vehicle.id} className="bg-gray-50 dark:bg-gray-900/20"> </div>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-orange-600" />
<h4 className="font-semibold">{vehicle.licensePlate}</h4>
<Badge variant="outline" className="text-xs">
{vehicle.location}
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
{vehicle.brand} {vehicle.model}
</p>
{vehicle.assignedShift && (
<div className="flex items-center gap-1 mt-2 text-xs text-orange-600">
<Clock className="h-3 w-3" />
<span>
Assegnato: {format(new Date(vehicle.assignedShift.startTime), "HH:mm")} -
{format(new Date(vehicle.assignedShift.endTime), "HH:mm")}
</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
))}
</>
)}
</>
)}
</CardContent>
</Card>
{/* Agenti */} {site.shifts.length > 0 && (
<Card> <div className="mt-4 pt-4 border-t">
<CardHeader> <p className="text-sm font-medium mb-2">Dettagli Turni:</p>
<CardTitle className="flex items-center gap-2"> <div className="space-y-2">
<Users className="h-5 w-5" /> {site.shifts.map((shift) => (
Agenti Disponibili <div key={shift.id} className="flex items-center justify-between text-sm p-2 rounded bg-muted/50">
<Badge variant="secondary" className="ml-auto"> <div className="flex items-center gap-2">
{availableGuards.length}/{data?.guards.length || 0} <Clock className="h-4 w-4" />
</Badge> {format(new Date(shift.startTime), "HH:mm")} - {format(new Date(shift.endTime), "HH:mm")}
</CardTitle> </div>
</CardHeader> <div className="flex items-center gap-2">
<CardContent className="space-y-3"> <Users className="h-4 w-4" />
{isLoading ? ( {shift.assignedGuardsCount}/{shift.requiredGuards}
<> {shift.isCovered ? (
<Skeleton className="h-20 w-full" /> <CheckCircle2 className="h-4 w-4 text-green-500" />
<Skeleton className="h-20 w-full" /> ) : shift.isPartial ? (
<Skeleton className="h-20 w-full" /> <AlertCircle className="h-4 w-4 text-orange-500" />
</> ) : (
) : ( <AlertCircle className="h-4 w-4 text-red-500" />
<>
{/* Agenti disponibili */}
{availableGuards.length > 0 ? (
availableGuards.map((guard) => (
<Card key={guard.id} className="bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
<h4 className="font-semibold">
{guard.firstName} {guard.lastName}
</h4>
<Badge variant="outline" className="text-xs">
{guard.badgeNumber}
</Badge>
</div>
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{guard.availability.weeklyHours.toFixed(1)}h questa settimana
</span>
<span>
Residuo: {guard.availability.remainingWeeklyHours.toFixed(1)}h
</span>
</div>
{guard.availability.consecutiveDaysWorked > 0 && (
<p className="text-xs text-orange-600 mt-1">
{guard.availability.consecutiveDaysWorked} giorni consecutivi
</p>
)} )}
</div> </div>
<Button
size="sm"
data-testid={`button-assign-guard-${guard.id}`}
className="ml-2"
>
<ArrowRight className="h-4 w-4 mr-1" />
Assegna
</Button>
</div> </div>
</CardContent> ))}
</Card>
))
) : (
<p className="text-sm text-muted-foreground text-center py-4">
Nessun agente disponibile
</p>
)}
{/* Agenti non disponibili */}
{unavailableGuards.length > 0 && (
<>
<div className="pt-3 border-t">
<h4 className="text-sm font-medium text-muted-foreground mb-3">Non Disponibili</h4>
</div> </div>
{unavailableGuards.map((guard) => ( </div>
<Card key={guard.id} className="bg-gray-50 dark:bg-gray-900/20"> )}
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="py-12 text-center">
<CheckCircle2 className="h-12 w-12 mx-auto text-green-500 mb-4" />
<h3 className="text-lg font-semibold mb-2">Tutti i siti sono coperti!</h3>
<p className="text-muted-foreground">
Non ci sono siti che richiedono assegnazioni per questa data.
</p>
</CardContent>
</Card>
)}
{/* Dialog per assegnare risorse e creare turno */}
<Dialog open={createShiftDialogOpen} onOpenChange={setCreateShiftDialogOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Assegna Risorse - {selectedSite?.name}</DialogTitle>
<DialogDescription>
Seleziona le guardie e il veicolo per creare il turno
</DialogDescription>
</DialogHeader>
{isLoadingResources ? (
<div className="space-y-4">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
) : (
<div className="space-y-6">
{/* Veicoli Disponibili */}
<div>
<h3 className="font-semibold mb-3 flex items-center gap-2">
<CarIcon className="h-5 w-5" />
Veicoli Disponibili ({filteredVehicles.length})
</h3>
{filteredVehicles.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{filteredVehicles.map((vehicle) => (
<Card
key={vehicle.id}
className={`cursor-pointer transition-colors ${
selectedVehicle === vehicle.id ? "border-primary bg-primary/5" : ""
}`}
onClick={() => setSelectedVehicle(vehicle.id)}
data-testid={`vehicle-card-${vehicle.id}`}
>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start justify-between"> <div className="flex items-center justify-between">
<div className="flex-1"> <div>
<div className="flex items-center gap-2"> <p className="font-medium">{vehicle.licensePlate}</p>
<AlertCircle className="h-4 w-4 text-orange-600" /> <p className="text-sm text-muted-foreground">
<h4 className="font-semibold"> {vehicle.brand} {vehicle.model}
{guard.firstName} {guard.lastName} </p>
</h4>
<Badge variant="outline" className="text-xs">
{guard.badgeNumber}
</Badge>
</div>
{guard.assignedShift && (
<div className="flex items-center gap-1 mt-2 text-xs text-orange-600">
<Clock className="h-3 w-3" />
<span>
Assegnato: {format(new Date(guard.assignedShift.startTime), "HH:mm")} -
{format(new Date(guard.assignedShift.endTime), "HH:mm")}
</span>
</div>
)}
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<span>{guard.availability.weeklyHours.toFixed(1)}h settimana</span>
</div>
</div> </div>
<Checkbox
checked={selectedVehicle === vehicle.id}
onCheckedChange={() => setSelectedVehicle(vehicle.id)}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</> </div>
) : (
<p className="text-sm text-muted-foreground">Nessun veicolo disponibile per questa sede</p>
)} )}
</> </div>
)}
</CardContent> {/* Guardie Disponibili */}
</Card> <div>
</div> <h3 className="font-semibold mb-3 flex items-center gap-2">
<Users className="h-5 w-5" />
Guardie Disponibili ({filteredGuards.length})
<Badge variant="outline" className="ml-auto">
Selezionate: {selectedGuards.length}/{selectedSite?.minGuards || 0}
</Badge>
</h3>
{filteredGuards.length > 0 ? (
<div className="space-y-2">
{filteredGuards.map((guard) => (
<Card
key={guard.id}
className={`cursor-pointer transition-colors ${
selectedGuards.includes(guard.id) ? "border-primary bg-primary/5" : ""
}`}
onClick={() => toggleGuardSelection(guard.id)}
data-testid={`guard-card-${guard.id}`}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium">
{guard.firstName} {guard.lastName} - #{guard.badgeNumber}
</p>
{guard.isArmed && (
<Badge variant="outline" className="text-xs">
<Shield className="h-3 w-3 mr-1" />
Armato
</Badge>
)}
{guard.hasDriverLicense && (
<Badge variant="outline" className="text-xs">
<CarIcon className="h-3 w-3 mr-1" />
Patente
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
Ore sett.: {guard.availability.weeklyHours}h | Rimaste: {guard.availability.remainingWeeklyHours}h
</p>
</div>
<Checkbox
checked={selectedGuards.includes(guard.id)}
onCheckedChange={() => toggleGuardSelection(guard.id)}
/>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
Nessuna guardia disponibile che soddisfa i requisiti del sito
</p>
)}
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setCreateShiftDialogOpen(false);
setSelectedSite(null);
setSelectedGuards([]);
setSelectedVehicle(null);
}}
data-testid="button-cancel-shift"
>
Annulla
</Button>
<Button
onClick={handleCreateShift}
disabled={
!selectedSite ||
selectedGuards.length < (selectedSite?.minGuards || 0) ||
createShiftMutation.isPending
}
data-testid="button-create-shift"
>
{createShiftMutation.isPending ? "Creazione..." : "Crea Turno"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@ -235,6 +235,10 @@ export default function Services() {
description: type.description, description: type.description,
icon: type.icon, icon: type.icon,
color: type.color, color: type.color,
fixedPostHours: type.fixedPostHours || null,
patrolPassages: type.patrolPassages || null,
inspectionFrequency: type.inspectionFrequency || null,
responseTimeMinutes: type.responseTimeMinutes || null,
isActive: type.isActive, isActive: type.isActive,
}); });
setEditTypeDialogOpen(true); setEditTypeDialogOpen(true);
@ -266,6 +270,8 @@ export default function Services() {
minGuards: site.minGuards, minGuards: site.minGuards,
requiresArmed: site.requiresArmed || false, requiresArmed: site.requiresArmed || false,
requiresDriverLicense: site.requiresDriverLicense || false, requiresDriverLicense: site.requiresDriverLicense || false,
serviceStartTime: site.serviceStartTime || "",
serviceEndTime: site.serviceEndTime || "",
isActive: site.isActive, isActive: site.isActive,
}); });
setEditDialogOpen(true); setEditDialogOpen(true);
@ -547,6 +553,45 @@ export default function Services() {
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={createForm.control}
name="serviceStartTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Inizio Servizio</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-service-start-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="serviceEndTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Fine Servizio</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-service-end-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<FormField <FormField
control={createForm.control} control={createForm.control}
@ -733,6 +778,45 @@ export default function Services() {
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={editForm.control}
name="serviceStartTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Inizio Servizio</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-edit-service-start-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="serviceEndTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Fine Servizio</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-edit-service-end-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<FormField <FormField
control={editForm.control} control={editForm.control}
@ -987,6 +1071,92 @@ export default function Services() {
/> />
</div> </div>
<div className="space-y-4 p-4 border rounded-lg">
<h4 className="font-semibold text-sm">Parametri Specifici</h4>
<div className="grid grid-cols-2 gap-4">
<FormField
control={createTypeForm.control}
name="fixedPostHours"
render={({ field }) => (
<FormItem>
<FormLabel>Ore Presidio Fisso</FormLabel>
<FormControl>
<Input
type="number"
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
placeholder="es: 8, 12"
data-testid="input-fixed-post-hours"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createTypeForm.control}
name="patrolPassages"
render={({ field }) => (
<FormItem>
<FormLabel>Passaggi Pattugliamento</FormLabel>
<FormControl>
<Input
type="number"
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
placeholder="es: 3, 5"
data-testid="input-patrol-passages"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createTypeForm.control}
name="inspectionFrequency"
render={({ field }) => (
<FormItem>
<FormLabel>Frequenza Ispezioni (min)</FormLabel>
<FormControl>
<Input
type="number"
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
placeholder="es: 60, 120"
data-testid="input-inspection-frequency"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createTypeForm.control}
name="responseTimeMinutes"
render={({ field }) => (
<FormItem>
<FormLabel>Tempo Risposta (min)</FormLabel>
<FormControl>
<Input
type="number"
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
placeholder="es: 15, 30"
data-testid="input-response-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<FormField <FormField
control={createTypeForm.control} control={createTypeForm.control}
name="isActive" name="isActive"
@ -1139,6 +1309,92 @@ export default function Services() {
/> />
</div> </div>
<div className="space-y-4 p-4 border rounded-lg">
<h4 className="font-semibold text-sm">Parametri Specifici</h4>
<div className="grid grid-cols-2 gap-4">
<FormField
control={editTypeForm.control}
name="fixedPostHours"
render={({ field }) => (
<FormItem>
<FormLabel>Ore Presidio Fisso</FormLabel>
<FormControl>
<Input
type="number"
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
placeholder="es: 8, 12"
data-testid="input-edit-fixed-post-hours"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editTypeForm.control}
name="patrolPassages"
render={({ field }) => (
<FormItem>
<FormLabel>Passaggi Pattugliamento</FormLabel>
<FormControl>
<Input
type="number"
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
placeholder="es: 3, 5"
data-testid="input-edit-patrol-passages"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editTypeForm.control}
name="inspectionFrequency"
render={({ field }) => (
<FormItem>
<FormLabel>Frequenza Ispezioni (min)</FormLabel>
<FormControl>
<Input
type="number"
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
placeholder="es: 60, 120"
data-testid="input-edit-inspection-frequency"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editTypeForm.control}
name="responseTimeMinutes"
render={({ field }) => (
<FormItem>
<FormLabel>Tempo Risposta (min)</FormLabel>
<FormControl>
<Input
type="number"
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
placeholder="es: 15, 30"
data-testid="input-edit-response-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<FormField <FormField
control={editTypeForm.control} control={editTypeForm.control}
name="isActive" name="isActive"

View File

@ -30,6 +30,10 @@ VigilanzaTurni is a professional 24/7 shift management system designed for secur
### Database Schema ### Database Schema
The database includes core tables for `users`, `guards`, `certifications`, `sites`, `shifts`, `shift_assignments`, and `notifications`. Advanced scheduling and constraints are managed via `guard_constraints`, `site_preferences`, `contract_parameters`, `training_courses`, `holidays`, `holiday_assignments`, `absences`, and `absence_affected_shifts`. All tables include appropriate foreign keys and unique constraints to maintain data integrity. The database includes core tables for `users`, `guards`, `certifications`, `sites`, `shifts`, `shift_assignments`, and `notifications`. Advanced scheduling and constraints are managed via `guard_constraints`, `site_preferences`, `contract_parameters`, `training_courses`, `holidays`, `holiday_assignments`, `absences`, and `absence_affected_shifts`. All tables include appropriate foreign keys and unique constraints to maintain data integrity.
**Recent Schema Updates (October 2025)**:
- Service types now include specialized parameters: `fixedPostHours` (ore presidio fisso), `patrolPassages` (numero passaggi pattuglia), `inspectionFrequency` (frequenza ispezioni), `responseTimeMinutes` (tempo risposta pronto intervento)
- Sites include service schedule fields: `serviceStartTime` and `serviceEndTime` (formato HH:MM)
### API Endpoints ### 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. Comprehensive RESTful API endpoints are provided for Authentication, Users, Guards, Sites, Shifts, and Notifications, supporting full CRUD operations with role-based access control.
@ -45,7 +49,11 @@ Key frontend routes include `/`, `/guards`, `/sites`, `/shifts`, `/reports`, `/n
### Key Features ### Key Features
- **Dashboard Operativa**: Live KPIs (active shifts, total guards, active sites, expiring certifications) and real-time shift status. - **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 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 (fixed post, patrol, night inspection, quick response) and minimum requirements (guard count, armed, driver's license). - **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).
- **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
- **Pianificazione Turni**: 24/7 calendar, manual guard assignment, basic constraints, and shift statuses (planned, active, completed, cancelled). - **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. - **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. - **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.

View File

@ -5,7 +5,7 @@ import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth"; import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
import { db } from "./db"; import { db } from "./db";
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters } from "@shared/schema"; import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters } from "@shared/schema";
import { eq, and, gte, lte, desc, asc } from "drizzle-orm"; import { eq, and, gte, lte, desc, asc, ne, sql } from "drizzle-orm";
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format } from "date-fns"; import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format } from "date-fns";
// Determina quale sistema auth usare basandosi sull'ambiente // Determina quale sistema auth usare basandosi sull'ambiente
@ -654,6 +654,97 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
}); });
// Endpoint per ottenere siti non completamente coperti per una data
app.get("/api/operational-planning/uncovered-sites", isAuthenticated, async (req, res) => {
try {
const dateStr = req.query.date as string || format(new Date(), "yyyy-MM-dd");
// 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 siti attivi
const allSites = await db
.select()
.from(sites)
.where(eq(sites.isActive, true));
// Ottieni turni del giorno con assegnazioni
const dayShifts = await db
.select({
shift: shifts,
assignmentCount: sql<number>`count(${shiftAssignments.id})::int`
})
.from(shifts)
.leftJoin(shiftAssignments, eq(shifts.id, shiftAssignments.shiftId))
.where(
and(
gte(shifts.startTime, startOfDay),
lte(shifts.startTime, endOfDay),
ne(shifts.status, "cancelled")
)
)
.groupBy(shifts.id);
// Calcola copertura per ogni sito
const sitesWithCoverage = allSites.map((site: any) => {
const siteShifts = dayShifts.filter((s: any) => s.shift.siteId === site.id);
// Verifica copertura per ogni turno
const shiftsWithCoverage = siteShifts.map((s: any) => ({
id: s.shift.id,
startTime: s.shift.startTime,
endTime: s.shift.endTime,
assignedGuardsCount: s.assignmentCount,
requiredGuards: site.minGuards,
isCovered: s.assignmentCount >= site.minGuards,
isPartial: s.assignmentCount > 0 && s.assignmentCount < site.minGuards
}));
// Un sito è completamente coperto solo se TUTTI i turni hanno il numero minimo di guardie
const allShiftsCovered = siteShifts.length > 0 && shiftsWithCoverage.every((s: any) => s.isCovered);
// Un sito è parzialmente coperto se ha turni ma non tutti sono completamente coperti
const hasPartialCoverage = siteShifts.length > 0 && !allShiftsCovered && shiftsWithCoverage.some((s: any) => s.assignedGuardsCount > 0);
// Calcola totale guardie assegnate per info
const totalAssignedGuards = siteShifts.reduce((sum: number, s: any) => sum + s.assignmentCount, 0);
return {
...site,
isCovered: allShiftsCovered,
isPartiallyCovered: hasPartialCoverage,
totalAssignedGuards,
requiredGuards: site.minGuards,
shiftsCount: siteShifts.length,
shifts: shiftsWithCoverage
};
});
// Filtra solo siti non completamente coperti
const uncoveredSites = sitesWithCoverage.filter(
(site: any) => !site.isCovered
);
// Ordina: parzialmente coperti prima, poi non coperti
const sortedUncoveredSites = uncoveredSites.sort((a: any, b: any) => {
if (a.isPartiallyCovered && !b.isPartiallyCovered) return -1;
if (!a.isPartiallyCovered && b.isPartiallyCovered) return 1;
return a.name.localeCompare(b.name);
});
res.json({
date: dateStr,
uncoveredSites: sortedUncoveredSites,
totalSites: allSites.length,
totalUncovered: uncoveredSites.length
});
} catch (error) {
console.error("Error fetching uncovered sites:", error);
res.status(500).json({ message: "Failed to fetch uncovered sites", error: String(error) });
}
});
// ============= CERTIFICATION ROUTES ============= // ============= CERTIFICATION ROUTES =============
app.post("/api/certifications", isAuthenticated, async (req, res) => { app.post("/api/certifications", isAuthenticated, async (req, res) => {
try { try {
@ -867,9 +958,21 @@ export async function registerRoutes(app: Express): Promise<Server> {
startTime, startTime,
endTime, endTime,
status: req.body.status || "planned", status: req.body.status || "planned",
vehicleId: req.body.vehicleId || null,
}); });
const shift = await storage.createShift(validatedData); 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); res.json(shift);
} catch (error) { } catch (error) {
console.error("Error creating shift:", error); console.error("Error creating shift:", error);

View File

@ -183,6 +183,13 @@ export const serviceTypes = pgTable("service_types", {
description: text("description"), // Descrizione dettagliata description: text("description"), // Descrizione dettagliata
icon: varchar("icon").notNull().default("Building2"), // Nome icona Lucide icon: varchar("icon").notNull().default("Building2"), // Nome icona Lucide
color: varchar("color").notNull().default("blue"), // blue, green, purple, orange color: varchar("color").notNull().default("blue"), // blue, green, purple, orange
// Parametri specifici per tipo servizio
fixedPostHours: integer("fixed_post_hours"), // Ore presidio fisso (es. 8, 12)
patrolPassages: integer("patrol_passages"), // Numero passaggi pattugliamento (es. 3, 5)
inspectionFrequency: integer("inspection_frequency"), // Frequenza ispezioni in minuti
responseTimeMinutes: integer("response_time_minutes"), // Tempo risposta pronto intervento
isActive: boolean("is_active").default(true), isActive: boolean("is_active").default(true),
createdAt: timestamp("created_at").defaultNow(), createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(),
@ -203,6 +210,10 @@ export const sites = pgTable("sites", {
requiresArmed: boolean("requires_armed").default(false), requiresArmed: boolean("requires_armed").default(false),
requiresDriverLicense: boolean("requires_driver_license").default(false), requiresDriverLicense: boolean("requires_driver_license").default(false),
// Orari servizio (formato HH:MM, es. "08:00", "20:00")
serviceStartTime: varchar("service_start_time"), // Orario inizio servizio
serviceEndTime: varchar("service_end_time"), // Orario fine servizio
// Coordinates for geofencing (future use) // Coordinates for geofencing (future use)
latitude: varchar("latitude"), latitude: varchar("latitude"),
longitude: varchar("longitude"), longitude: varchar("longitude"),

View File

@ -1,7 +1,13 @@
{ {
"version": "1.0.14", "version": "1.0.15",
"lastUpdate": "2025-10-17T13:25:44.910Z", "lastUpdate": "2025-10-17T14:09:32.389Z",
"changelog": [ "changelog": [
{
"version": "1.0.15",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.15"
},
{ {
"version": "1.0.14", "version": "1.0.14",
"date": "2025-10-17", "date": "2025-10-17",