Compare commits
No commits in common. "76af862a6b9d88e434ccb5cf630db2cb578c10f4" and "fecfe44542fdb64491289de06b08479e1f62e1c1" have entirely different histories.
76af862a6b
...
fecfe44542
@ -1,212 +1,74 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { queryClient, apiRequest } from "@/lib/queryClient";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Calendar, Car, Users, Clock, AlertCircle, CheckCircle2, ArrowRight } from "lucide-react";
|
||||||
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 Shift {
|
interface AssignedShift {
|
||||||
id: string;
|
id: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
assignedGuardsCount: number;
|
siteId: string;
|
||||||
requiredGuards: number;
|
|
||||||
isCovered: boolean;
|
|
||||||
isPartial: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UncoveredSite {
|
interface VehicleAvailability {
|
||||||
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;
|
||||||
hasDriverLicense?: boolean;
|
status: string;
|
||||||
isAvailable: boolean;
|
isAvailable: boolean;
|
||||||
|
assignedShift: AssignedShift | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Guard {
|
interface GuardAvailability {
|
||||||
id: string;
|
id: string;
|
||||||
badgeNumber: string;
|
badgeNumber: string;
|
||||||
userId: string;
|
firstName: string;
|
||||||
firstName?: string;
|
lastName: 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 ResourcesData {
|
interface AvailabilityData {
|
||||||
date: string;
|
date: string;
|
||||||
vehicles: Vehicle[];
|
vehicles: VehicleAvailability[];
|
||||||
guards: Guard[];
|
guards: GuardAvailability[];
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Query per siti non coperti
|
const { data, isLoading, refetch } = useQuery<AvailabilityData>({
|
||||||
const { data: uncoveredData, isLoading } = useQuery<UncoveredSitesData>({
|
|
||||||
queryKey: [`/api/operational-planning/uncovered-sites?date=${selectedDate}`, selectedDate],
|
|
||||||
enabled: !!selectedDate,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Query per risorse (veicoli e guardie) - solo quando c'è un sito selezionato
|
|
||||||
const { data: resourcesData, isLoading: isLoadingResources } = useQuery<ResourcesData>({
|
|
||||||
queryKey: [`/api/operational-planning/availability?date=${selectedDate}`, selectedDate],
|
queryKey: [`/api/operational-planning/availability?date=${selectedDate}`, selectedDate],
|
||||||
enabled: !!selectedDate && !!selectedSite,
|
enabled: !!selectedDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
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 handleSelectSite = (site: UncoveredSite) => {
|
const availableVehicles = data?.vehicles.filter((v) => v.isAvailable) || [];
|
||||||
setSelectedSite(site);
|
const unavailableVehicles = data?.vehicles.filter((v) => !v.isAvailable) || [];
|
||||||
setSelectedGuards([]);
|
const availableGuards = data?.guards.filter((g) => g.isAvailable) || [];
|
||||||
setSelectedVehicle(null);
|
const unavailableGuards = data?.guards.filter((g) => !g.isAvailable) || [];
|
||||||
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">
|
||||||
@ -217,7 +79,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">
|
||||||
Assegna turni ai siti non coperti
|
Visualizza disponibilità automezzi e agenti per data
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -242,290 +104,233 @@ 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>
|
||||||
{uncoveredData && (
|
{data && (
|
||||||
<div className="mt-4 flex items-center gap-4 text-sm">
|
<p className="text-sm text-muted-foreground mt-4">
|
||||||
<p className="text-muted-foreground">
|
Visualizzando disponibilità per: {format(new Date(selectedDate), "dd MMMM yyyy", { locale: it })}
|
||||||
{format(new Date(selectedDate), "dd MMMM yyyy", { locale: it })}
|
|
||||||
</p>
|
</p>
|
||||||
<Badge variant="outline" className="gap-1">
|
|
||||||
<AlertCircle className="h-3 w-3" />
|
|
||||||
{uncoveredData.totalUncovered} siti da coprire
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Lista siti non coperti */}
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{isLoading ? (
|
{/* Veicoli */}
|
||||||
<div className="space-y-4">
|
<Card>
|
||||||
<Skeleton className="h-32" />
|
|
||||||
<Skeleton className="h-32" />
|
|
||||||
</div>
|
|
||||||
) : uncoveredData && uncoveredData.uncoveredSites.length > 0 ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h2 className="text-xl font-semibold">Siti Non Coperti</h2>
|
|
||||||
{uncoveredData.uncoveredSites.map((site) => (
|
|
||||||
<Card key={site.id} data-testid={`site-card-${site.id}`}>
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Car className="h-5 w-5" />
|
||||||
|
Automezzi Disponibili
|
||||||
|
<Badge variant="secondary" className="ml-auto">
|
||||||
|
{availableVehicles.length}/{data?.vehicles.length || 0}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Veicoli disponibili */}
|
||||||
|
{availableVehicles.length > 0 ? (
|
||||||
|
availableVehicles.map((vehicle) => (
|
||||||
|
<Card key={vehicle.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 items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
{site.name}
|
|
||||||
{site.isPartiallyCovered ? (
|
|
||||||
<Badge variant="secondary" className="gap-1">
|
|
||||||
<AlertCircle className="h-3 w-3" />
|
|
||||||
Parzialmente Coperto
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="destructive" className="gap-1">
|
|
||||||
<AlertCircle className="h-3 w-3" />
|
|
||||||
Non Coperto
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="mt-2 space-y-1">
|
|
||||||
<div className="flex items-center gap-1 text-sm">
|
|
||||||
<MapPin className="h-3 w-3" />
|
|
||||||
{site.address} - {site.location}
|
|
||||||
</div>
|
|
||||||
{site.serviceStartTime && site.serviceEndTime && (
|
|
||||||
<div className="flex items-center gap-1 text-sm">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
Orario: {site.serviceStartTime} - {site.serviceEndTime}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleSelectSite(site)}
|
|
||||||
data-testid={`button-select-site-${site.id}`}
|
|
||||||
>
|
|
||||||
Assegna Turno
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{site.shifts.length > 0 && (
|
|
||||||
<div className="mt-4 pt-4 border-t">
|
|
||||||
<p className="text-sm font-medium mb-2">Dettagli Turni:</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{site.shifts.map((shift) => (
|
|
||||||
<div key={shift.id} className="flex items-center justify-between text-sm p-2 rounded bg-muted/50">
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Clock className="h-4 w-4" />
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
{format(new Date(shift.startTime), "HH:mm")} - {format(new Date(shift.endTime), "HH:mm")}
|
<h4 className="font-semibold">{vehicle.licensePlate}</h4>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Users className="h-4 w-4" />
|
|
||||||
{shift.assignedGuardsCount}/{shift.requiredGuards}
|
|
||||||
{shift.isCovered ? (
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
|
||||||
) : shift.isPartial ? (
|
|
||||||
<AlertCircle className="h-4 w-4 text-orange-500" />
|
|
||||||
) : (
|
|
||||||
<AlertCircle className="h-4 w-4 text-red-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{vehicle.licensePlate}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{vehicle.brand} {vehicle.model}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedVehicle === vehicle.id}
|
|
||||||
onCheckedChange={() => setSelectedVehicle(vehicle.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">Nessun veicolo disponibile per questa sede</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Guardie Disponibili */}
|
|
||||||
<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">
|
<Badge variant="outline" className="text-xs">
|
||||||
<Shield className="h-3 w-3 mr-1" />
|
{vehicle.location}
|
||||||
Armato
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
|
||||||
{guard.hasDriverLicense && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
<CarIcon className="h-3 w-3 mr-1" />
|
|
||||||
Patente
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Ore sett.: {guard.availability.weeklyHours}h | Rimaste: {guard.availability.remainingWeeklyHours}h
|
{vehicle.brand} {vehicle.model} - {vehicle.vehicleType}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Checkbox
|
<Button
|
||||||
checked={selectedGuards.includes(guard.id)}
|
size="sm"
|
||||||
onCheckedChange={() => toggleGuardSelection(guard.id)}
|
data-testid={`button-assign-vehicle-${vehicle.id}`}
|
||||||
/>
|
className="ml-2"
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-4 w-4 mr-1" />
|
||||||
|
Assegna
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
Nessun veicolo disponibile
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Veicoli non disponibili */}
|
||||||
|
{unavailableVehicles.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="pt-3 border-t">
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground mb-3">Non Disponibili</h4>
|
||||||
|
</div>
|
||||||
|
{unavailableVehicles.map((vehicle) => (
|
||||||
|
<Card key={vehicle.id} className="bg-gray-50 dark:bg-gray-900/20">
|
||||||
|
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Agenti */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Agenti Disponibili
|
||||||
|
<Badge variant="secondary" className="ml-auto">
|
||||||
|
{availableGuards.length}/{data?.guards.length || 0}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<>
|
||||||
Nessuna guardia disponibile che soddisfa i requisiti del sito
|
{/* 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>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
{/* Agenti non disponibili */}
|
||||||
<Button
|
{unavailableGuards.length > 0 && (
|
||||||
variant="outline"
|
<>
|
||||||
onClick={() => {
|
<div className="pt-3 border-t">
|
||||||
setCreateShiftDialogOpen(false);
|
<h4 className="text-sm font-medium text-muted-foreground mb-3">Non Disponibili</h4>
|
||||||
setSelectedSite(null);
|
</div>
|
||||||
setSelectedGuards([]);
|
{unavailableGuards.map((guard) => (
|
||||||
setSelectedVehicle(null);
|
<Card key={guard.id} className="bg-gray-50 dark:bg-gray-900/20">
|
||||||
}}
|
<CardContent className="p-4">
|
||||||
data-testid="button-cancel-shift"
|
<div className="flex items-start justify-between">
|
||||||
>
|
<div className="flex-1">
|
||||||
Annulla
|
<div className="flex items-center gap-2">
|
||||||
</Button>
|
<AlertCircle className="h-4 w-4 text-orange-600" />
|
||||||
<Button
|
<h4 className="font-semibold">
|
||||||
onClick={handleCreateShift}
|
{guard.firstName} {guard.lastName}
|
||||||
disabled={
|
</h4>
|
||||||
!selectedSite ||
|
<Badge variant="outline" className="text-xs">
|
||||||
selectedGuards.length < (selectedSite?.minGuards || 0) ||
|
{guard.badgeNumber}
|
||||||
createShiftMutation.isPending
|
</Badge>
|
||||||
}
|
</div>
|
||||||
data-testid="button-create-shift"
|
{guard.assignedShift && (
|
||||||
>
|
<div className="flex items-center gap-1 mt-2 text-xs text-orange-600">
|
||||||
{createShiftMutation.isPending ? "Creazione..." : "Crea Turno"}
|
<Clock className="h-3 w-3" />
|
||||||
</Button>
|
<span>
|
||||||
</DialogFooter>
|
Assegnato: {format(new Date(guard.assignedShift.startTime), "HH:mm")} -
|
||||||
</DialogContent>
|
{format(new Date(guard.assignedShift.endTime), "HH:mm")}
|
||||||
</Dialog>
|
</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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -235,10 +235,6 @@ 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);
|
||||||
@ -270,8 +266,6 @@ 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);
|
||||||
@ -553,45 +547,6 @@ 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}
|
||||||
@ -778,45 +733,6 @@ 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}
|
||||||
@ -1071,92 +987,6 @@ 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"
|
||||||
@ -1309,92 +1139,6 @@ 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"
|
||||||
|
|||||||
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.5_20251017_065422.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.5_20251017_065422.sql.gz
Normal file
Binary file not shown.
10
replit.md
10
replit.md
@ -30,10 +30,6 @@ 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.
|
||||||
|
|
||||||
@ -49,11 +45,7 @@ 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 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).
|
- **Gestione Siti/Commesse**: Service types (fixed post, patrol, night inspection, quick response) and minimum requirements (guard count, armed, driver's license).
|
||||||
- **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, ne, sql } from "drizzle-orm";
|
import { eq, and, gte, lte, desc, asc } 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,97 +654,6 @@ 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 {
|
||||||
@ -958,21 +867,9 @@ 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,13 +183,6 @@ 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(),
|
||||||
@ -210,10 +203,6 @@ 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,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.15",
|
"version": "1.0.14",
|
||||||
"lastUpdate": "2025-10-17T14:09:32.389Z",
|
"lastUpdate": "2025-10-17T13:25:44.910Z",
|
||||||
"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