Compare commits
6 Commits
fecfe44542
...
76af862a6b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76af862a6b | ||
|
|
8ed55e05cc | ||
|
|
72b7dfe74d | ||
|
|
283b24bcb6 | ||
|
|
144a281657 | ||
|
|
a6c3ba293b |
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
BIN
database-backups/vigilanzaturni_v1.0.15_20251017_140916.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.15_20251017_140916.sql.gz
Normal file
Binary file not shown.
Binary file not shown.
10
replit.md
10
replit.md
@ -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.
|
||||||
|
|||||||
105
server/routes.ts
105
server/routes.ts
@ -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);
|
||||||
|
|||||||
@ -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"),
|
||||||
|
|||||||
10
version.json
10
version.json
@ -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",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user