Compare commits
No commits in common. "76af862a6b9d88e434ccb5cf630db2cb578c10f4" and "fecfe44542fdb64491289de06b08479e1f62e1c1" have entirely different histories.
76af862a6b
...
fecfe44542
@ -1,212 +1,74 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { queryClient, apiRequest } from "@/lib/queryClient";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { 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 { Calendar, Car, Users, Clock, AlertCircle, CheckCircle2, ArrowRight } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { it } from "date-fns/locale";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface Shift {
|
||||
interface AssignedShift {
|
||||
id: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
assignedGuardsCount: number;
|
||||
requiredGuards: number;
|
||||
isCovered: boolean;
|
||||
isPartial: boolean;
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
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 {
|
||||
interface VehicleAvailability {
|
||||
id: string;
|
||||
licensePlate: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
vehicleType: string;
|
||||
location: string;
|
||||
hasDriverLicense?: boolean;
|
||||
status: string;
|
||||
isAvailable: boolean;
|
||||
assignedShift: AssignedShift | null;
|
||||
}
|
||||
|
||||
interface Guard {
|
||||
interface GuardAvailability {
|
||||
id: string;
|
||||
badgeNumber: string;
|
||||
userId: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
location: string;
|
||||
isArmed: boolean;
|
||||
hasDriverLicense: boolean;
|
||||
isAvailable: boolean;
|
||||
assignedShift: AssignedShift | null;
|
||||
availability: {
|
||||
weeklyHours: number;
|
||||
remainingWeeklyHours: number;
|
||||
remainingMonthlyHours: number;
|
||||
consecutiveDaysWorked: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ResourcesData {
|
||||
interface AvailabilityData {
|
||||
date: string;
|
||||
vehicles: Vehicle[];
|
||||
guards: Guard[];
|
||||
vehicles: VehicleAvailability[];
|
||||
guards: GuardAvailability[];
|
||||
}
|
||||
|
||||
export default function OperationalPlanning() {
|
||||
const { toast } = useToast();
|
||||
const [selectedDate, setSelectedDate] = useState<string>(
|
||||
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: 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>({
|
||||
const { data, isLoading, refetch } = useQuery<AvailabilityData>({
|
||||
queryKey: [`/api/operational-planning/availability?date=${selectedDate}`, selectedDate],
|
||||
enabled: !!selectedDate && !!selectedSite,
|
||||
enabled: !!selectedDate,
|
||||
});
|
||||
|
||||
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSelectedDate(e.target.value);
|
||||
setSelectedSite(null);
|
||||
setSelectedGuards([]);
|
||||
setSelectedVehicle(null);
|
||||
};
|
||||
|
||||
const handleSelectSite = (site: UncoveredSite) => {
|
||||
setSelectedSite(site);
|
||||
setSelectedGuards([]);
|
||||
setSelectedVehicle(null);
|
||||
setCreateShiftDialogOpen(true);
|
||||
};
|
||||
|
||||
const toggleGuardSelection = (guardId: string) => {
|
||||
setSelectedGuards((prev) =>
|
||||
prev.includes(guardId)
|
||||
? prev.filter((id) => id !== guardId)
|
||||
: [...prev, guardId]
|
||||
);
|
||||
};
|
||||
|
||||
// Filtra risorse per requisiti del sito
|
||||
const filteredVehicles = resourcesData?.vehicles.filter((v) => {
|
||||
if (!selectedSite) return false;
|
||||
// Filtra per sede e disponibilità
|
||||
if (v.location !== selectedSite.location) return false;
|
||||
if (!v.isAvailable) return false;
|
||||
return true;
|
||||
}) || [];
|
||||
|
||||
const filteredGuards = resourcesData?.guards.filter((g) => {
|
||||
if (!selectedSite) return false;
|
||||
// Filtra per sede
|
||||
if (g.location !== selectedSite.location) return false;
|
||||
// Filtra per disponibilità
|
||||
if (!g.isAvailable) return false;
|
||||
// Filtra per requisiti
|
||||
if (selectedSite.requiresArmed && !g.isArmed) return false;
|
||||
if (selectedSite.requiresDriverLicense && !g.hasDriverLicense) return false;
|
||||
return true;
|
||||
}) || [];
|
||||
|
||||
// Mutation per creare turno
|
||||
const createShiftMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
return apiRequest("POST", "/api/shifts", data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalida tutte le query che iniziano con /api/operational-planning/uncovered-sites
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => query.queryKey[0]?.toString().startsWith('/api/operational-planning/uncovered-sites') || false
|
||||
});
|
||||
toast({
|
||||
title: "Turno creato",
|
||||
description: "Il turno è stato creato con successo.",
|
||||
});
|
||||
setCreateShiftDialogOpen(false);
|
||||
setSelectedSite(null);
|
||||
setSelectedGuards([]);
|
||||
setSelectedVehicle(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Errore",
|
||||
description: error.message || "Impossibile creare il turno.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateShift = () => {
|
||||
if (!selectedSite) return;
|
||||
|
||||
// Valida che ci siano abbastanza guardie selezionate
|
||||
if (selectedGuards.length < selectedSite.minGuards) {
|
||||
toast({
|
||||
title: "Guardie insufficienti",
|
||||
description: `Seleziona almeno ${selectedSite.minGuards} guardie per questo sito.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Qui bisognerà chiedere l'orario del turno
|
||||
// Per ora creiamo un turno di default basato su serviceStartTime/serviceEndTime del sito
|
||||
const today = selectedDate;
|
||||
const startTime = selectedSite.serviceStartTime || "08:00";
|
||||
const endTime = selectedSite.serviceEndTime || "16:00";
|
||||
|
||||
const shiftData = {
|
||||
siteId: selectedSite.id,
|
||||
startTime: new Date(`${today}T${startTime}:00.000Z`),
|
||||
endTime: new Date(`${today}T${endTime}:00.000Z`),
|
||||
status: "planned",
|
||||
vehicleId: selectedVehicle || null,
|
||||
guardIds: selectedGuards,
|
||||
};
|
||||
|
||||
createShiftMutation.mutate(shiftData);
|
||||
};
|
||||
const availableVehicles = data?.vehicles.filter((v) => v.isAvailable) || [];
|
||||
const unavailableVehicles = data?.vehicles.filter((v) => !v.isAvailable) || [];
|
||||
const availableGuards = data?.guards.filter((g) => g.isAvailable) || [];
|
||||
const unavailableGuards = data?.guards.filter((g) => !g.isAvailable) || [];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
@ -217,7 +79,7 @@ export default function OperationalPlanning() {
|
||||
Pianificazione Operativa
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Assegna turni ai siti non coperti
|
||||
Visualizza disponibilità automezzi e agenti per data
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -242,290 +104,233 @@ export default function OperationalPlanning() {
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => refetch()}
|
||||
data-testid="button-refresh-availability"
|
||||
>
|
||||
Aggiorna Disponibilità
|
||||
</Button>
|
||||
</div>
|
||||
{uncoveredData && (
|
||||
<div className="mt-4 flex items-center gap-4 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
{format(new Date(selectedDate), "dd MMMM yyyy", { locale: it })}
|
||||
{data && (
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
Visualizzando disponibilità per: {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>
|
||||
</Card>
|
||||
|
||||
{/* Lista siti non coperti */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<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}`}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Veicoli */}
|
||||
<Card>
|
||||
<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-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">
|
||||
<Clock className="h-4 w-4" />
|
||||
{format(new Date(shift.startTime), "HH:mm")} - {format(new Date(shift.endTime), "HH:mm")}
|
||||
</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 && (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
<h4 className="font-semibold">{vehicle.licensePlate}</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Armato
|
||||
{vehicle.location}
|
||||
</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
|
||||
{vehicle.brand} {vehicle.model} - {vehicle.vehicleType}
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={selectedGuards.includes(guard.id)}
|
||||
onCheckedChange={() => toggleGuardSelection(guard.id)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
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>
|
||||
</CardContent>
|
||||
</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>
|
||||
)}
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Nessun agente disponibile
|
||||
</p>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{/* 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>
|
||||
{unavailableGuards.map((guard) => (
|
||||
<Card key={guard.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">
|
||||
{guard.firstName} {guard.lastName}
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -235,10 +235,6 @@ export default function Services() {
|
||||
description: type.description,
|
||||
icon: type.icon,
|
||||
color: type.color,
|
||||
fixedPostHours: type.fixedPostHours || null,
|
||||
patrolPassages: type.patrolPassages || null,
|
||||
inspectionFrequency: type.inspectionFrequency || null,
|
||||
responseTimeMinutes: type.responseTimeMinutes || null,
|
||||
isActive: type.isActive,
|
||||
});
|
||||
setEditTypeDialogOpen(true);
|
||||
@ -270,8 +266,6 @@ export default function Services() {
|
||||
minGuards: site.minGuards,
|
||||
requiresArmed: site.requiresArmed || false,
|
||||
requiresDriverLicense: site.requiresDriverLicense || false,
|
||||
serviceStartTime: site.serviceStartTime || "",
|
||||
serviceEndTime: site.serviceEndTime || "",
|
||||
isActive: site.isActive,
|
||||
});
|
||||
setEditDialogOpen(true);
|
||||
@ -553,45 +547,6 @@ export default function Services() {
|
||||
/>
|
||||
</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">
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
@ -778,45 +733,6 @@ export default function Services() {
|
||||
/>
|
||||
</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">
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
@ -1071,92 +987,6 @@ export default function Services() {
|
||||
/>
|
||||
</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
|
||||
control={createTypeForm.control}
|
||||
name="isActive"
|
||||
@ -1309,92 +1139,6 @@ export default function Services() {
|
||||
/>
|
||||
</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
|
||||
control={editTypeForm.control}
|
||||
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
|
||||
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
|
||||
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
|
||||
- **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 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
|
||||
- **Gestione Siti/Commesse**: Service types (fixed post, patrol, night inspection, quick response) and minimum requirements (guard count, armed, driver's license).
|
||||
- **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.
|
||||
- **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 { db } from "./db";
|
||||
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";
|
||||
|
||||
// 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 =============
|
||||
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
@ -958,21 +867,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
startTime,
|
||||
endTime,
|
||||
status: req.body.status || "planned",
|
||||
vehicleId: req.body.vehicleId || null,
|
||||
});
|
||||
|
||||
const shift = await storage.createShift(validatedData);
|
||||
|
||||
// Se ci sono guardie da assegnare, crea le assegnazioni
|
||||
if (req.body.guardIds && Array.isArray(req.body.guardIds) && req.body.guardIds.length > 0) {
|
||||
for (const guardId of req.body.guardIds) {
|
||||
await storage.createShiftAssignment({
|
||||
shiftId: shift.id,
|
||||
guardId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json(shift);
|
||||
} catch (error) {
|
||||
console.error("Error creating shift:", error);
|
||||
|
||||
@ -183,13 +183,6 @@ export const serviceTypes = pgTable("service_types", {
|
||||
description: text("description"), // Descrizione dettagliata
|
||||
icon: varchar("icon").notNull().default("Building2"), // Nome icona Lucide
|
||||
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),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
updatedAt: timestamp("updated_at").defaultNow(),
|
||||
@ -210,10 +203,6 @@ export const sites = pgTable("sites", {
|
||||
requiresArmed: boolean("requires_armed").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)
|
||||
latitude: varchar("latitude"),
|
||||
longitude: varchar("longitude"),
|
||||
|
||||
10
version.json
10
version.json
@ -1,13 +1,7 @@
|
||||
{
|
||||
"version": "1.0.15",
|
||||
"lastUpdate": "2025-10-17T14:09:32.389Z",
|
||||
"version": "1.0.14",
|
||||
"lastUpdate": "2025-10-17T13:25:44.910Z",
|
||||
"changelog": [
|
||||
{
|
||||
"version": "1.0.15",
|
||||
"date": "2025-10-17",
|
||||
"type": "patch",
|
||||
"description": "Deployment automatico v1.0.15"
|
||||
},
|
||||
{
|
||||
"version": "1.0.14",
|
||||
"date": "2025-10-17",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user