Add ability to plan operational shifts with resource assignments
Implement a new feature for operational planning that allows users to select sites, assign guards and vehicles, and create shifts with specific start and end times. This includes updates to the UI for displaying uncovered sites and resources, as well as backend logic for creating shift assignments. Replit-Commit-Author: Agent Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/sshIJbn
This commit is contained in:
parent
283b24bcb6
commit
72b7dfe74d
8
.replit
8
.replit
@ -19,6 +19,10 @@ externalPort = 80
|
|||||||
localPort = 33035
|
localPort = 33035
|
||||||
externalPort = 3001
|
externalPort = 3001
|
||||||
|
|
||||||
|
[[ports]]
|
||||||
|
localPort = 36921
|
||||||
|
externalPort = 4200
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 41343
|
localPort = 41343
|
||||||
externalPort = 3000
|
externalPort = 3000
|
||||||
@ -31,10 +35,6 @@ externalPort = 3002
|
|||||||
localPort = 43267
|
localPort = 43267
|
||||||
externalPort = 3003
|
externalPort = 3003
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 44165
|
|
||||||
externalPort = 4200
|
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
PORT = "5000"
|
PORT = "5000"
|
||||||
|
|
||||||
|
|||||||
@ -1,74 +1,212 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { queryClient, apiRequest } from "@/lib/queryClient";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Calendar, Car, Users, Clock, AlertCircle, CheckCircle2, ArrowRight } from "lucide-react";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Calendar, AlertCircle, CheckCircle2, Clock, MapPin, Users, Shield, Car as CarIcon } from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { it } from "date-fns/locale";
|
import { it } from "date-fns/locale";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
interface AssignedShift {
|
interface Shift {
|
||||||
id: string;
|
id: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
siteId: string;
|
assignedGuardsCount: number;
|
||||||
|
requiredGuards: number;
|
||||||
|
isCovered: boolean;
|
||||||
|
isPartial: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VehicleAvailability {
|
interface UncoveredSite {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
location: string;
|
||||||
|
shiftType: string;
|
||||||
|
minGuards: number;
|
||||||
|
requiresArmed: boolean;
|
||||||
|
requiresDriverLicense: boolean;
|
||||||
|
serviceStartTime: string | null;
|
||||||
|
serviceEndTime: string | null;
|
||||||
|
isCovered: boolean;
|
||||||
|
isPartiallyCovered: boolean;
|
||||||
|
totalAssignedGuards: number;
|
||||||
|
requiredGuards: number;
|
||||||
|
shiftsCount: number;
|
||||||
|
shifts: Shift[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UncoveredSitesData {
|
||||||
|
date: string;
|
||||||
|
uncoveredSites: UncoveredSite[];
|
||||||
|
totalSites: number;
|
||||||
|
totalUncovered: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Vehicle {
|
||||||
id: string;
|
id: string;
|
||||||
licensePlate: string;
|
licensePlate: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
model: string;
|
model: string;
|
||||||
vehicleType: string;
|
vehicleType: string;
|
||||||
location: string;
|
location: string;
|
||||||
status: string;
|
hasDriverLicense?: boolean;
|
||||||
isAvailable: boolean;
|
isAvailable: boolean;
|
||||||
assignedShift: AssignedShift | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GuardAvailability {
|
interface Guard {
|
||||||
id: string;
|
id: string;
|
||||||
badgeNumber: string;
|
badgeNumber: string;
|
||||||
firstName: string;
|
userId: string;
|
||||||
lastName: string;
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
location: string;
|
location: string;
|
||||||
|
isArmed: boolean;
|
||||||
|
hasDriverLicense: boolean;
|
||||||
isAvailable: boolean;
|
isAvailable: boolean;
|
||||||
assignedShift: AssignedShift | null;
|
|
||||||
availability: {
|
availability: {
|
||||||
weeklyHours: number;
|
weeklyHours: number;
|
||||||
remainingWeeklyHours: number;
|
remainingWeeklyHours: number;
|
||||||
remainingMonthlyHours: number;
|
|
||||||
consecutiveDaysWorked: number;
|
consecutiveDaysWorked: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AvailabilityData {
|
interface ResourcesData {
|
||||||
date: string;
|
date: string;
|
||||||
vehicles: VehicleAvailability[];
|
vehicles: Vehicle[];
|
||||||
guards: GuardAvailability[];
|
guards: Guard[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OperationalPlanning() {
|
export default function OperationalPlanning() {
|
||||||
|
const { toast } = useToast();
|
||||||
const [selectedDate, setSelectedDate] = useState<string>(
|
const [selectedDate, setSelectedDate] = useState<string>(
|
||||||
format(new Date(), "yyyy-MM-dd")
|
format(new Date(), "yyyy-MM-dd")
|
||||||
);
|
);
|
||||||
|
const [selectedSite, setSelectedSite] = useState<UncoveredSite | null>(null);
|
||||||
|
const [selectedGuards, setSelectedGuards] = useState<string[]>([]);
|
||||||
|
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
|
||||||
|
const [createShiftDialogOpen, setCreateShiftDialogOpen] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading, refetch } = useQuery<AvailabilityData>({
|
// Query per siti non coperti
|
||||||
queryKey: [`/api/operational-planning/availability?date=${selectedDate}`, selectedDate],
|
const { data: uncoveredData, isLoading } = useQuery<UncoveredSitesData>({
|
||||||
|
queryKey: [`/api/operational-planning/uncovered-sites?date=${selectedDate}`, selectedDate],
|
||||||
enabled: !!selectedDate,
|
enabled: !!selectedDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Query per risorse (veicoli e guardie) - solo quando c'è un sito selezionato
|
||||||
|
const { data: resourcesData, isLoading: isLoadingResources } = useQuery<ResourcesData>({
|
||||||
|
queryKey: [`/api/operational-planning/availability?date=${selectedDate}`, selectedDate],
|
||||||
|
enabled: !!selectedDate && !!selectedSite,
|
||||||
|
});
|
||||||
|
|
||||||
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setSelectedDate(e.target.value);
|
setSelectedDate(e.target.value);
|
||||||
|
setSelectedSite(null);
|
||||||
|
setSelectedGuards([]);
|
||||||
|
setSelectedVehicle(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const availableVehicles = data?.vehicles.filter((v) => v.isAvailable) || [];
|
const handleSelectSite = (site: UncoveredSite) => {
|
||||||
const unavailableVehicles = data?.vehicles.filter((v) => !v.isAvailable) || [];
|
setSelectedSite(site);
|
||||||
const availableGuards = data?.guards.filter((g) => g.isAvailable) || [];
|
setSelectedGuards([]);
|
||||||
const unavailableGuards = data?.guards.filter((g) => !g.isAvailable) || [];
|
setSelectedVehicle(null);
|
||||||
|
setCreateShiftDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleGuardSelection = (guardId: string) => {
|
||||||
|
setSelectedGuards((prev) =>
|
||||||
|
prev.includes(guardId)
|
||||||
|
? prev.filter((id) => id !== guardId)
|
||||||
|
: [...prev, guardId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtra risorse per requisiti del sito
|
||||||
|
const filteredVehicles = resourcesData?.vehicles.filter((v) => {
|
||||||
|
if (!selectedSite) return false;
|
||||||
|
// Filtra per sede e disponibilità
|
||||||
|
if (v.location !== selectedSite.location) return false;
|
||||||
|
if (!v.isAvailable) return false;
|
||||||
|
return true;
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
const filteredGuards = resourcesData?.guards.filter((g) => {
|
||||||
|
if (!selectedSite) return false;
|
||||||
|
// Filtra per sede
|
||||||
|
if (g.location !== selectedSite.location) return false;
|
||||||
|
// Filtra per disponibilità
|
||||||
|
if (!g.isAvailable) return false;
|
||||||
|
// Filtra per requisiti
|
||||||
|
if (selectedSite.requiresArmed && !g.isArmed) return false;
|
||||||
|
if (selectedSite.requiresDriverLicense && !g.hasDriverLicense) return false;
|
||||||
|
return true;
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
// Mutation per creare turno
|
||||||
|
const createShiftMutation = useMutation({
|
||||||
|
mutationFn: async (data: any) => {
|
||||||
|
return apiRequest("POST", "/api/shifts", data);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalida tutte le query che iniziano con /api/operational-planning/uncovered-sites
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => query.queryKey[0]?.toString().startsWith('/api/operational-planning/uncovered-sites') || false
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: "Turno creato",
|
||||||
|
description: "Il turno è stato creato con successo.",
|
||||||
|
});
|
||||||
|
setCreateShiftDialogOpen(false);
|
||||||
|
setSelectedSite(null);
|
||||||
|
setSelectedGuards([]);
|
||||||
|
setSelectedVehicle(null);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Errore",
|
||||||
|
description: error.message || "Impossibile creare il turno.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreateShift = () => {
|
||||||
|
if (!selectedSite) return;
|
||||||
|
|
||||||
|
// Valida che ci siano abbastanza guardie selezionate
|
||||||
|
if (selectedGuards.length < selectedSite.minGuards) {
|
||||||
|
toast({
|
||||||
|
title: "Guardie insufficienti",
|
||||||
|
description: `Seleziona almeno ${selectedSite.minGuards} guardie per questo sito.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Qui bisognerà chiedere l'orario del turno
|
||||||
|
// Per ora creiamo un turno di default basato su serviceStartTime/serviceEndTime del sito
|
||||||
|
const today = selectedDate;
|
||||||
|
const startTime = selectedSite.serviceStartTime || "08:00";
|
||||||
|
const endTime = selectedSite.serviceEndTime || "16:00";
|
||||||
|
|
||||||
|
const shiftData = {
|
||||||
|
siteId: selectedSite.id,
|
||||||
|
startTime: new Date(`${today}T${startTime}:00.000Z`),
|
||||||
|
endTime: new Date(`${today}T${endTime}:00.000Z`),
|
||||||
|
status: "planned",
|
||||||
|
vehicleId: selectedVehicle || null,
|
||||||
|
guardIds: selectedGuards,
|
||||||
|
};
|
||||||
|
|
||||||
|
createShiftMutation.mutate(shiftData);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
@ -79,7 +217,7 @@ export default function OperationalPlanning() {
|
|||||||
Pianificazione Operativa
|
Pianificazione Operativa
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
Visualizza disponibilità automezzi e agenti per data
|
Assegna turni ai siti non coperti
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -104,233 +242,290 @@ export default function OperationalPlanning() {
|
|||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
onClick={() => refetch()}
|
|
||||||
data-testid="button-refresh-availability"
|
|
||||||
>
|
|
||||||
Aggiorna Disponibilità
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{data && (
|
{uncoveredData && (
|
||||||
<p className="text-sm text-muted-foreground mt-4">
|
<div className="mt-4 flex items-center gap-4 text-sm">
|
||||||
Visualizzando disponibilità per: {format(new Date(selectedDate), "dd MMMM yyyy", { locale: it })}
|
<p className="text-muted-foreground">
|
||||||
</p>
|
{format(new Date(selectedDate), "dd MMMM yyyy", { locale: it })}
|
||||||
|
</p>
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
{uncoveredData.totalUncovered} siti da coprire
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
{/* Lista siti non coperti */}
|
||||||
{/* Veicoli */}
|
{isLoading ? (
|
||||||
<Card>
|
<div className="space-y-4">
|
||||||
<CardHeader>
|
<Skeleton className="h-32" />
|
||||||
<CardTitle className="flex items-center gap-2">
|
<Skeleton className="h-32" />
|
||||||
<Car className="h-5 w-5" />
|
</div>
|
||||||
Automezzi Disponibili
|
) : uncoveredData && uncoveredData.uncoveredSites.length > 0 ? (
|
||||||
<Badge variant="secondary" className="ml-auto">
|
<div className="space-y-4">
|
||||||
{availableVehicles.length}/{data?.vehicles.length || 0}
|
<h2 className="text-xl font-semibold">Siti Non Coperti</h2>
|
||||||
</Badge>
|
{uncoveredData.uncoveredSites.map((site) => (
|
||||||
</CardTitle>
|
<Card key={site.id} data-testid={`site-card-${site.id}`}>
|
||||||
</CardHeader>
|
<CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<div className="flex items-start justify-between">
|
||||||
{isLoading ? (
|
<div className="flex-1">
|
||||||
<>
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Skeleton className="h-20 w-full" />
|
{site.name}
|
||||||
<Skeleton className="h-20 w-full" />
|
{site.isPartiallyCovered ? (
|
||||||
<Skeleton className="h-20 w-full" />
|
<Badge variant="secondary" className="gap-1">
|
||||||
</>
|
<AlertCircle className="h-3 w-3" />
|
||||||
) : (
|
Parzialmente Coperto
|
||||||
<>
|
</Badge>
|
||||||
{/* Veicoli disponibili */}
|
) : (
|
||||||
{availableVehicles.length > 0 ? (
|
<Badge variant="destructive" className="gap-1">
|
||||||
availableVehicles.map((vehicle) => (
|
<AlertCircle className="h-3 w-3" />
|
||||||
<Card key={vehicle.id} className="bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800">
|
Non Coperto
|
||||||
<CardContent className="p-4">
|
</Badge>
|
||||||
<div className="flex items-start justify-between">
|
)}
|
||||||
<div className="flex-1">
|
</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
<CardDescription className="mt-2 space-y-1">
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
<div className="flex items-center gap-1 text-sm">
|
||||||
<h4 className="font-semibold">{vehicle.licensePlate}</h4>
|
<MapPin className="h-3 w-3" />
|
||||||
<Badge variant="outline" className="text-xs">
|
{site.address} - {site.location}
|
||||||
{vehicle.location}
|
</div>
|
||||||
</Badge>
|
{site.serviceStartTime && site.serviceEndTime && (
|
||||||
</div>
|
<div className="flex items-center gap-1 text-sm">
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<Clock className="h-3 w-3" />
|
||||||
{vehicle.brand} {vehicle.model} - {vehicle.vehicleType}
|
Orario: {site.serviceStartTime} - {site.serviceEndTime}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
data-testid={`button-assign-vehicle-${vehicle.id}`}
|
|
||||||
className="ml-2"
|
|
||||||
>
|
|
||||||
<ArrowRight className="h-4 w-4 mr-1" />
|
|
||||||
Assegna
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
</CardDescription>
|
||||||
))
|
</div>
|
||||||
) : (
|
<Button
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
onClick={() => handleSelectSite(site)}
|
||||||
Nessun veicolo disponibile
|
data-testid={`button-select-site-${site.id}`}
|
||||||
</p>
|
>
|
||||||
)}
|
Assegna Turno
|
||||||
|
</Button>
|
||||||
{/* Veicoli non disponibili */}
|
</div>
|
||||||
{unavailableVehicles.length > 0 && (
|
</CardHeader>
|
||||||
<>
|
<CardContent>
|
||||||
<div className="pt-3 border-t">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<h4 className="text-sm font-medium text-muted-foreground mb-3">Non Disponibili</h4>
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Turni Pianificati</p>
|
||||||
|
<p className="text-lg font-semibold">{site.shiftsCount}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Guardie Richieste</p>
|
||||||
|
<p className="text-lg font-semibold">{site.requiredGuards}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Guardie Assegnate</p>
|
||||||
|
<p className="text-lg font-semibold">{site.totalAssignedGuards}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Requisiti</p>
|
||||||
|
<div className="flex gap-1 mt-1">
|
||||||
|
{site.requiresArmed && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<Shield className="h-3 w-3 mr-1" />
|
||||||
|
Armato
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{site.requiresDriverLicense && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<CarIcon className="h-3 w-3 mr-1" />
|
||||||
|
Patente
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{unavailableVehicles.map((vehicle) => (
|
</div>
|
||||||
<Card key={vehicle.id} className="bg-gray-50 dark:bg-gray-900/20">
|
</div>
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<AlertCircle className="h-4 w-4 text-orange-600" />
|
|
||||||
<h4 className="font-semibold">{vehicle.licensePlate}</h4>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{vehicle.location}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
{vehicle.brand} {vehicle.model}
|
|
||||||
</p>
|
|
||||||
{vehicle.assignedShift && (
|
|
||||||
<div className="flex items-center gap-1 mt-2 text-xs text-orange-600">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
<span>
|
|
||||||
Assegnato: {format(new Date(vehicle.assignedShift.startTime), "HH:mm")} -
|
|
||||||
{format(new Date(vehicle.assignedShift.endTime), "HH:mm")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Agenti */}
|
{site.shifts.length > 0 && (
|
||||||
<Card>
|
<div className="mt-4 pt-4 border-t">
|
||||||
<CardHeader>
|
<p className="text-sm font-medium mb-2">Dettagli Turni:</p>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="space-y-2">
|
||||||
<Users className="h-5 w-5" />
|
{site.shifts.map((shift) => (
|
||||||
Agenti Disponibili
|
<div key={shift.id} className="flex items-center justify-between text-sm p-2 rounded bg-muted/50">
|
||||||
<Badge variant="secondary" className="ml-auto">
|
<div className="flex items-center gap-2">
|
||||||
{availableGuards.length}/{data?.guards.length || 0}
|
<Clock className="h-4 w-4" />
|
||||||
</Badge>
|
{format(new Date(shift.startTime), "HH:mm")} - {format(new Date(shift.endTime), "HH:mm")}
|
||||||
</CardTitle>
|
</div>
|
||||||
</CardHeader>
|
<div className="flex items-center gap-2">
|
||||||
<CardContent className="space-y-3">
|
<Users className="h-4 w-4" />
|
||||||
{isLoading ? (
|
{shift.assignedGuardsCount}/{shift.requiredGuards}
|
||||||
<>
|
{shift.isCovered ? (
|
||||||
<Skeleton className="h-20 w-full" />
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
<Skeleton className="h-20 w-full" />
|
) : shift.isPartial ? (
|
||||||
<Skeleton className="h-20 w-full" />
|
<AlertCircle className="h-4 w-4 text-orange-500" />
|
||||||
</>
|
) : (
|
||||||
) : (
|
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||||
<>
|
|
||||||
{/* Agenti disponibili */}
|
|
||||||
{availableGuards.length > 0 ? (
|
|
||||||
availableGuards.map((guard) => (
|
|
||||||
<Card key={guard.id} className="bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
|
||||||
<h4 className="font-semibold">
|
|
||||||
{guard.firstName} {guard.lastName}
|
|
||||||
</h4>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{guard.badgeNumber}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
{guard.availability.weeklyHours.toFixed(1)}h questa settimana
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Residuo: {guard.availability.remainingWeeklyHours.toFixed(1)}h
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{guard.availability.consecutiveDaysWorked > 0 && (
|
|
||||||
<p className="text-xs text-orange-600 mt-1">
|
|
||||||
{guard.availability.consecutiveDaysWorked} giorni consecutivi
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
data-testid={`button-assign-guard-${guard.id}`}
|
|
||||||
className="ml-2"
|
|
||||||
>
|
|
||||||
<ArrowRight className="h-4 w-4 mr-1" />
|
|
||||||
Assegna
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
))}
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
|
||||||
Nessun agente disponibile
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Agenti non disponibili */}
|
|
||||||
{unavailableGuards.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="pt-3 border-t">
|
|
||||||
<h4 className="text-sm font-medium text-muted-foreground mb-3">Non Disponibili</h4>
|
|
||||||
</div>
|
</div>
|
||||||
{unavailableGuards.map((guard) => (
|
</div>
|
||||||
<Card key={guard.id} className="bg-gray-50 dark:bg-gray-900/20">
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<CheckCircle2 className="h-12 w-12 mx-auto text-green-500 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Tutti i siti sono coperti!</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Non ci sono siti che richiedono assegnazioni per questa data.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialog per assegnare risorse e creare turno */}
|
||||||
|
<Dialog open={createShiftDialogOpen} onOpenChange={setCreateShiftDialogOpen}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Assegna Risorse - {selectedSite?.name}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Seleziona le guardie e il veicolo per creare il turno
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isLoadingResources ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-24" />
|
||||||
|
<Skeleton className="h-24" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Veicoli Disponibili */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<CarIcon className="h-5 w-5" />
|
||||||
|
Veicoli Disponibili ({filteredVehicles.length})
|
||||||
|
</h3>
|
||||||
|
{filteredVehicles.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{filteredVehicles.map((vehicle) => (
|
||||||
|
<Card
|
||||||
|
key={vehicle.id}
|
||||||
|
className={`cursor-pointer transition-colors ${
|
||||||
|
selectedVehicle === vehicle.id ? "border-primary bg-primary/5" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedVehicle(vehicle.id)}
|
||||||
|
data-testid={`vehicle-card-${vehicle.id}`}
|
||||||
|
>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1">
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<p className="font-medium">{vehicle.licensePlate}</p>
|
||||||
<AlertCircle className="h-4 w-4 text-orange-600" />
|
<p className="text-sm text-muted-foreground">
|
||||||
<h4 className="font-semibold">
|
{vehicle.brand} {vehicle.model}
|
||||||
{guard.firstName} {guard.lastName}
|
</p>
|
||||||
</h4>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{guard.badgeNumber}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{guard.assignedShift && (
|
|
||||||
<div className="flex items-center gap-1 mt-2 text-xs text-orange-600">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
<span>
|
|
||||||
Assegnato: {format(new Date(guard.assignedShift.startTime), "HH:mm")} -
|
|
||||||
{format(new Date(guard.assignedShift.endTime), "HH:mm")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
|
||||||
<span>{guard.availability.weeklyHours.toFixed(1)}h settimana</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedVehicle === vehicle.id}
|
||||||
|
onCheckedChange={() => setSelectedVehicle(vehicle.id)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">Nessun veicolo disponibile per questa sede</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
</CardContent>
|
{/* Guardie Disponibili */}
|
||||||
</Card>
|
<div>
|
||||||
</div>
|
<h3 className="font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Guardie Disponibili ({filteredGuards.length})
|
||||||
|
<Badge variant="outline" className="ml-auto">
|
||||||
|
Selezionate: {selectedGuards.length}/{selectedSite?.minGuards || 0}
|
||||||
|
</Badge>
|
||||||
|
</h3>
|
||||||
|
{filteredGuards.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredGuards.map((guard) => (
|
||||||
|
<Card
|
||||||
|
key={guard.id}
|
||||||
|
className={`cursor-pointer transition-colors ${
|
||||||
|
selectedGuards.includes(guard.id) ? "border-primary bg-primary/5" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleGuardSelection(guard.id)}
|
||||||
|
data-testid={`guard-card-${guard.id}`}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium">
|
||||||
|
{guard.firstName} {guard.lastName} - #{guard.badgeNumber}
|
||||||
|
</p>
|
||||||
|
{guard.isArmed && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<Shield className="h-3 w-3 mr-1" />
|
||||||
|
Armato
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{guard.hasDriverLicense && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<CarIcon className="h-3 w-3 mr-1" />
|
||||||
|
Patente
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Ore sett.: {guard.availability.weeklyHours}h | Rimaste: {guard.availability.remainingWeeklyHours}h
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedGuards.includes(guard.id)}
|
||||||
|
onCheckedChange={() => toggleGuardSelection(guard.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Nessuna guardia disponibile che soddisfa i requisiti del sito
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setCreateShiftDialogOpen(false);
|
||||||
|
setSelectedSite(null);
|
||||||
|
setSelectedGuards([]);
|
||||||
|
setSelectedVehicle(null);
|
||||||
|
}}
|
||||||
|
data-testid="button-cancel-shift"
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateShift}
|
||||||
|
disabled={
|
||||||
|
!selectedSite ||
|
||||||
|
selectedGuards.length < (selectedSite?.minGuards || 0) ||
|
||||||
|
createShiftMutation.isPending
|
||||||
|
}
|
||||||
|
data-testid="button-create-shift"
|
||||||
|
>
|
||||||
|
{createShiftMutation.isPending ? "Creazione..." : "Crea Turno"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -958,9 +958,21 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
status: req.body.status || "planned",
|
status: req.body.status || "planned",
|
||||||
|
vehicleId: req.body.vehicleId || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const shift = await storage.createShift(validatedData);
|
const shift = await storage.createShift(validatedData);
|
||||||
|
|
||||||
|
// Se ci sono guardie da assegnare, crea le assegnazioni
|
||||||
|
if (req.body.guardIds && Array.isArray(req.body.guardIds) && req.body.guardIds.length > 0) {
|
||||||
|
for (const guardId of req.body.guardIds) {
|
||||||
|
await storage.createShiftAssignment({
|
||||||
|
shiftId: shift.id,
|
||||||
|
guardId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json(shift);
|
res.json(shift);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating shift:", error);
|
console.error("Error creating shift:", error);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user