Add CCNL (Contratto Collettivo Nazionale di Lavoro) validation logic on the server-side to check shift durations against defined contract parameters. This includes updating the client to handle potential violations and display them to the user via an alert dialog. The server now exposes a new API endpoint (/api/shifts/validate-ccnl) for this validation. Additionally, seeding script has been updated to include default contract parameters. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 42d8028a-fa71-4ec2-938c-e43eedf7df01 Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/42d8028a-fa71-4ec2-938c-e43eedf7df01/IdDfihe
801 lines
31 KiB
TypeScript
801 lines
31 KiB
TypeScript
import { useState } from "react";
|
||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||
import { ShiftWithDetails, InsertShift, Site, GuardWithCertifications } from "@shared/schema";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { useForm } from "react-hook-form";
|
||
import { zodResolver } from "@hookform/resolvers/zod";
|
||
import { insertShiftFormSchema } from "@shared/schema";
|
||
import { Plus, Calendar, MapPin, Users, Clock, UserPlus, X, Shield, Car, Heart, Flame, Pencil, Building2 } from "lucide-react";
|
||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||
import { useToast } from "@/hooks/use-toast";
|
||
import { StatusBadge } from "@/components/status-badge";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { format } from "date-fns";
|
||
import { it } from "date-fns/locale";
|
||
import { Badge } from "@/components/ui/badge";
|
||
|
||
export default function Shifts() {
|
||
const { toast } = useToast();
|
||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||
const [selectedShift, setSelectedShift] = useState<ShiftWithDetails | null>(null);
|
||
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
|
||
const [editingShift, setEditingShift] = useState<ShiftWithDetails | null>(null);
|
||
const [selectedLocation, setSelectedLocation] = useState<string>("all");
|
||
const [ccnlViolations, setCcnlViolations] = useState<Array<{type: string, message: string}>>([]);
|
||
const [pendingAssignment, setPendingAssignment] = useState<{shiftId: string, guardId: string} | null>(null);
|
||
|
||
const { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({
|
||
queryKey: ["/api/shifts"],
|
||
});
|
||
|
||
const { data: sites } = useQuery<Site[]>({
|
||
queryKey: ["/api/sites"],
|
||
});
|
||
|
||
const { data: guards } = useQuery<GuardWithCertifications[]>({
|
||
queryKey: ["/api/guards"],
|
||
});
|
||
|
||
// Filter data by location
|
||
const filteredShifts = selectedLocation === "all"
|
||
? shifts
|
||
: shifts?.filter(s => {
|
||
const site = sites?.find(site => site.id === s.siteId);
|
||
return site?.location === selectedLocation;
|
||
});
|
||
|
||
const filteredSites = selectedLocation === "all"
|
||
? sites
|
||
: sites?.filter(s => s.location === selectedLocation);
|
||
|
||
const filteredGuards = selectedLocation === "all"
|
||
? guards
|
||
: guards?.filter(g => g.location === selectedLocation);
|
||
|
||
const form = useForm({
|
||
resolver: zodResolver(insertShiftFormSchema),
|
||
defaultValues: {
|
||
siteId: "",
|
||
startTime: "",
|
||
endTime: "",
|
||
status: "planned" as const,
|
||
},
|
||
});
|
||
|
||
const editForm = useForm({
|
||
resolver: zodResolver(insertShiftFormSchema),
|
||
defaultValues: {
|
||
siteId: "",
|
||
startTime: "",
|
||
endTime: "",
|
||
status: "planned" as const,
|
||
},
|
||
});
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: async (data: InsertShift) => {
|
||
return await apiRequest("POST", "/api/shifts", data);
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["/api/shifts"] });
|
||
toast({
|
||
title: "Turno creato",
|
||
description: "Il turno è stato pianificato con successo",
|
||
});
|
||
setIsDialogOpen(false);
|
||
form.reset();
|
||
},
|
||
onError: (error) => {
|
||
toast({
|
||
title: "Errore",
|
||
description: error.message,
|
||
variant: "destructive",
|
||
});
|
||
},
|
||
});
|
||
|
||
const onSubmit = (data: InsertShift) => {
|
||
createMutation.mutate(data);
|
||
};
|
||
|
||
// Validate CCNL before assignment
|
||
const validateCcnl = async (shiftId: string, guardId: string) => {
|
||
const shift = shifts?.find(s => s.id === shiftId);
|
||
if (!shift) return { violations: [] };
|
||
|
||
try {
|
||
const response = await apiRequest("POST", "/api/shifts/validate-ccnl", {
|
||
guardId,
|
||
shiftStartTime: shift.startTime,
|
||
shiftEndTime: shift.endTime
|
||
});
|
||
return response as { violations: Array<{type: string, message: string}> };
|
||
} catch (error) {
|
||
console.error("Errore validazione CCNL:", error);
|
||
return { violations: [] };
|
||
}
|
||
};
|
||
|
||
const handleAssignGuard = async (guardId: string) => {
|
||
if (!selectedShift) return;
|
||
|
||
// Validate CCNL
|
||
const { violations } = await validateCcnl(selectedShift.id, guardId);
|
||
|
||
if (violations.length > 0) {
|
||
setCcnlViolations(violations);
|
||
setPendingAssignment({ shiftId: selectedShift.id, guardId });
|
||
} else {
|
||
// No violations, assign directly
|
||
assignGuardMutation.mutate({ shiftId: selectedShift.id, guardId });
|
||
}
|
||
};
|
||
|
||
const confirmAssignment = () => {
|
||
if (pendingAssignment) {
|
||
assignGuardMutation.mutate(pendingAssignment);
|
||
setPendingAssignment(null);
|
||
setCcnlViolations([]);
|
||
}
|
||
};
|
||
|
||
const assignGuardMutation = useMutation({
|
||
mutationFn: async ({ shiftId, guardId }: { shiftId: string; guardId: string }) => {
|
||
return await apiRequest("POST", "/api/shift-assignments", { shiftId, guardId });
|
||
},
|
||
onSuccess: async () => {
|
||
// Invalidate and wait for refetch to complete
|
||
await queryClient.refetchQueries({ queryKey: ["/api/shifts"] });
|
||
// Now get the fresh data
|
||
const updatedShifts = queryClient.getQueryData<ShiftWithDetails[]>(["/api/shifts"]);
|
||
if (selectedShift && updatedShifts) {
|
||
const updatedShift = updatedShifts.find(s => s.id === selectedShift.id);
|
||
if (updatedShift) {
|
||
setSelectedShift(updatedShift);
|
||
}
|
||
}
|
||
toast({
|
||
title: "Guardia assegnata",
|
||
description: "La guardia è stata assegnata al turno con successo",
|
||
});
|
||
},
|
||
onError: (error) => {
|
||
toast({
|
||
title: "Errore",
|
||
description: error.message,
|
||
variant: "destructive",
|
||
});
|
||
},
|
||
});
|
||
|
||
const removeAssignmentMutation = useMutation({
|
||
mutationFn: async (assignmentId: string) => {
|
||
return await apiRequest("DELETE", `/api/shift-assignments/${assignmentId}`);
|
||
},
|
||
onSuccess: async () => {
|
||
// Invalidate and wait for refetch to complete
|
||
await queryClient.refetchQueries({ queryKey: ["/api/shifts"] });
|
||
// Now get the fresh data
|
||
const updatedShifts = queryClient.getQueryData<ShiftWithDetails[]>(["/api/shifts"]);
|
||
if (selectedShift && updatedShifts) {
|
||
const updatedShift = updatedShifts.find(s => s.id === selectedShift.id);
|
||
if (updatedShift) {
|
||
setSelectedShift(updatedShift);
|
||
}
|
||
}
|
||
toast({
|
||
title: "Assegnazione rimossa",
|
||
description: "La guardia è stata rimossa dal turno",
|
||
});
|
||
},
|
||
onError: (error) => {
|
||
toast({
|
||
title: "Errore",
|
||
description: error.message,
|
||
variant: "destructive",
|
||
});
|
||
},
|
||
});
|
||
|
||
const updateMutation = useMutation({
|
||
mutationFn: async ({ id, data }: { id: string; data: InsertShift }) => {
|
||
return await apiRequest("PATCH", `/api/shifts/${id}`, data);
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["/api/shifts"] });
|
||
toast({
|
||
title: "Turno aggiornato",
|
||
description: "Il turno è stato aggiornato con successo",
|
||
});
|
||
setEditingShift(null);
|
||
editForm.reset();
|
||
},
|
||
onError: (error) => {
|
||
toast({
|
||
title: "Errore",
|
||
description: error.message,
|
||
variant: "destructive",
|
||
});
|
||
},
|
||
});
|
||
|
||
const handleRemoveAssignment = (assignmentId: string) => {
|
||
removeAssignmentMutation.mutate(assignmentId);
|
||
};
|
||
|
||
const onEditSubmit = (data: InsertShift) => {
|
||
if (editingShift) {
|
||
updateMutation.mutate({ id: editingShift.id, data });
|
||
}
|
||
};
|
||
|
||
const openEditDialog = (shift: ShiftWithDetails) => {
|
||
const formatForInput = (date: Date | string) => {
|
||
const d = new Date(date);
|
||
return d.toISOString().slice(0, 16);
|
||
};
|
||
|
||
setEditingShift(shift);
|
||
editForm.reset({
|
||
siteId: shift.siteId,
|
||
startTime: formatForInput(shift.startTime),
|
||
endTime: formatForInput(shift.endTime),
|
||
status: shift.status,
|
||
});
|
||
};
|
||
|
||
const isGuardAssigned = (guardId: string) => {
|
||
return selectedShift?.assignments.some(a => a.guardId === guardId) || false;
|
||
};
|
||
|
||
const canGuardBeAssigned = (guard: GuardWithCertifications) => {
|
||
if (!selectedShift) return false;
|
||
const site = sites?.find(s => s.id === selectedShift.siteId);
|
||
if (!site) return true;
|
||
|
||
if (site.requiresArmed && !guard.isArmed) return false;
|
||
if (site.requiresDriverLicense && !guard.hasDriverLicense) return false;
|
||
|
||
return true;
|
||
};
|
||
|
||
const getStatusLabel = (status: string) => {
|
||
const labels: Record<string, string> = {
|
||
planned: "Pianificato",
|
||
active: "Attivo",
|
||
completed: "Completato",
|
||
cancelled: "Annullato",
|
||
};
|
||
return labels[status] || status;
|
||
};
|
||
|
||
const getStatusVariant = (status: string): "active" | "inactive" | "pending" | "completed" => {
|
||
const variants: Record<string, "active" | "inactive" | "pending" | "completed"> = {
|
||
planned: "pending",
|
||
active: "active",
|
||
completed: "completed",
|
||
cancelled: "inactive",
|
||
};
|
||
return variants[status] || "inactive";
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-3xl font-semibold mb-2">Pianificazione Turni</h1>
|
||
<p className="text-muted-foreground">
|
||
Calendario 24/7 con assegnazione guardie
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||
<Select value={selectedLocation} onValueChange={setSelectedLocation}>
|
||
<SelectTrigger className="w-[180px]" data-testid="select-location-shifts">
|
||
<SelectValue placeholder="Seleziona sede" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">Tutte le Sedi</SelectItem>
|
||
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
|
||
<SelectItem value="milano">Milano</SelectItem>
|
||
<SelectItem value="roma">Roma</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||
<DialogTrigger asChild>
|
||
<Button data-testid="button-add-shift">
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
Nuovo Turno
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>Nuovo Turno</DialogTitle>
|
||
<DialogDescription>
|
||
Pianifica un nuovo turno di servizio
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<Form {...form}>
|
||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||
<FormField
|
||
control={form.control}
|
||
name="siteId"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Sito</FormLabel>
|
||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||
<FormControl>
|
||
<SelectTrigger data-testid="select-site">
|
||
<SelectValue placeholder="Seleziona sito" />
|
||
</SelectTrigger>
|
||
</FormControl>
|
||
<SelectContent>
|
||
{filteredSites?.map((site) => (
|
||
<SelectItem key={site.id} value={site.id}>
|
||
{site.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<FormField
|
||
control={form.control}
|
||
name="startTime"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Inizio</FormLabel>
|
||
<FormControl>
|
||
<input
|
||
type="datetime-local"
|
||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||
value={field.value}
|
||
onChange={(e) => field.onChange(e.target.value)}
|
||
data-testid="input-start-time"
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="endTime"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Fine</FormLabel>
|
||
<FormControl>
|
||
<input
|
||
type="datetime-local"
|
||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||
value={field.value}
|
||
onChange={(e) => field.onChange(e.target.value)}
|
||
data-testid="input-end-time"
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => setIsDialogOpen(false)}
|
||
className="flex-1"
|
||
data-testid="button-cancel"
|
||
>
|
||
Annulla
|
||
</Button>
|
||
<Button
|
||
type="submit"
|
||
className="flex-1"
|
||
disabled={createMutation.isPending}
|
||
data-testid="button-submit-shift"
|
||
>
|
||
{createMutation.isPending ? "Creazione..." : "Crea Turno"}
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
</div>
|
||
|
||
{shiftsLoading ? (
|
||
<div className="space-y-4">
|
||
<Skeleton className="h-32" />
|
||
<Skeleton className="h-32" />
|
||
<Skeleton className="h-32" />
|
||
</div>
|
||
) : filteredShifts && filteredShifts.length > 0 ? (
|
||
<div className="space-y-4">
|
||
{filteredShifts.map((shift) => (
|
||
<Card key={shift.id} className="hover-elevate" data-testid={`card-shift-${shift.id}`}>
|
||
<CardHeader>
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="flex-1 min-w-0">
|
||
<CardTitle className="text-lg flex items-center gap-2">
|
||
<MapPin className="h-5 w-5 text-muted-foreground" />
|
||
{shift.site.name}
|
||
</CardTitle>
|
||
<CardDescription className="mt-2 flex items-center gap-2">
|
||
<Clock className="h-4 w-4" />
|
||
{format(new Date(shift.startTime), "dd/MM/yyyy HH:mm", { locale: it })} -{" "}
|
||
{format(new Date(shift.endTime), "HH:mm", { locale: it })}
|
||
</CardDescription>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<StatusBadge status={getStatusVariant(shift.status)}>
|
||
{getStatusLabel(shift.status)}
|
||
</StatusBadge>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
onClick={() => openEditDialog(shift)}
|
||
data-testid={`button-edit-shift-${shift.id}`}
|
||
>
|
||
<Pencil className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
<Users className="h-4 w-4" />
|
||
<span>
|
||
{shift.assignments.length > 0
|
||
? `${shift.assignments.length} guardie assegnate`
|
||
: "Nessuna guardia assegnata"}
|
||
</span>
|
||
</div>
|
||
{shift.status === "planned" && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => {
|
||
setSelectedShift(shift);
|
||
setIsAssignDialogOpen(true);
|
||
}}
|
||
data-testid={`button-assign-guards-${shift.id}`}
|
||
>
|
||
<UserPlus className="h-4 w-4 mr-2" />
|
||
Assegna Guardie
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{shift.assignments.length > 0 && (
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{shift.assignments.map((assignment) => (
|
||
<div
|
||
key={assignment.id}
|
||
className="flex items-center gap-1 text-xs bg-secondary text-secondary-foreground px-2 py-1 rounded-md"
|
||
data-testid={`assignment-${assignment.id}`}
|
||
>
|
||
<span>
|
||
{assignment.guard.user?.firstName} {assignment.guard.user?.lastName}
|
||
</span>
|
||
{shift.status === "planned" && (
|
||
<button
|
||
onClick={() => handleRemoveAssignment(assignment.id)}
|
||
className="ml-1 hover:text-destructive"
|
||
data-testid={`button-remove-assignment-${assignment.id}`}
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<Card>
|
||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||
<Calendar className="h-12 w-12 text-muted-foreground mb-4" />
|
||
<p className="text-lg font-medium mb-2">Nessun turno pianificato</p>
|
||
<p className="text-sm text-muted-foreground mb-4">
|
||
Inizia pianificando il primo turno di servizio
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Assignment Dialog */}
|
||
<Dialog open={isAssignDialogOpen} onOpenChange={setIsAssignDialogOpen}>
|
||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle>Assegna Guardie al Turno</DialogTitle>
|
||
<DialogDescription>
|
||
{selectedShift && (
|
||
<>
|
||
{selectedShift.site.name} -{" "}
|
||
{format(new Date(selectedShift.startTime), "dd/MM/yyyy HH:mm", { locale: it })}
|
||
</>
|
||
)}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{selectedShift && (
|
||
<div className="space-y-4">
|
||
{/* Site Requirements */}
|
||
<div className="p-4 bg-muted rounded-lg">
|
||
<h3 className="font-medium mb-2">Requisiti Sito</h3>
|
||
<div className="flex flex-wrap gap-2">
|
||
{selectedShift.site.requiresArmed && (
|
||
<Badge variant="outline">
|
||
<Shield className="h-3 w-3 mr-1" />
|
||
Armato richiesto
|
||
</Badge>
|
||
)}
|
||
{selectedShift.site.requiresDriverLicense && (
|
||
<Badge variant="outline">
|
||
<Car className="h-3 w-3 mr-1" />
|
||
Patente richiesta
|
||
</Badge>
|
||
)}
|
||
<Badge variant="outline">
|
||
Min. {selectedShift.site.minGuards} guardie
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Guards List */}
|
||
<div className="space-y-2">
|
||
<h3 className="font-medium">Guardie Disponibili</h3>
|
||
{guards && guards.length > 0 ? (
|
||
<div className="space-y-2">
|
||
{filteredGuards?.map((guard) => {
|
||
const assigned = isGuardAssigned(guard.id);
|
||
const canAssign = canGuardBeAssigned(guard);
|
||
|
||
return (
|
||
<div
|
||
key={guard.id}
|
||
className="flex items-center justify-between p-3 border rounded-lg"
|
||
data-testid={`guard-option-${guard.id}`}
|
||
>
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<p className="font-medium">
|
||
{guard.user?.firstName} {guard.user?.lastName}
|
||
</p>
|
||
<span className="text-xs text-muted-foreground font-mono">
|
||
#{guard.badgeNumber}
|
||
</span>
|
||
</div>
|
||
<div className="flex gap-1 mt-1">
|
||
{guard.isArmed && (
|
||
<Badge variant="secondary" className="text-xs">
|
||
<Shield className="h-3 w-3 mr-1" />
|
||
Armato
|
||
</Badge>
|
||
)}
|
||
{guard.hasDriverLicense && (
|
||
<Badge variant="secondary" className="text-xs">
|
||
<Car className="h-3 w-3 mr-1" />
|
||
Patente
|
||
</Badge>
|
||
)}
|
||
{guard.hasFirstAid && (
|
||
<Badge variant="secondary" className="text-xs">
|
||
<Heart className="h-3 w-3 mr-1" />
|
||
Primo Soccorso
|
||
</Badge>
|
||
)}
|
||
{guard.hasFireSafety && (
|
||
<Badge variant="secondary" className="text-xs">
|
||
<Flame className="h-3 w-3 mr-1" />
|
||
Antincendio
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
variant={assigned ? "secondary" : "default"}
|
||
onClick={() => handleAssignGuard(guard.id)}
|
||
disabled={assigned || !canAssign}
|
||
data-testid={`button-assign-guard-${guard.id}`}
|
||
>
|
||
{assigned ? "Assegnato" : canAssign ? "Assegna" : "Non idoneo"}
|
||
</Button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">Nessuna guardia disponibile</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Edit Shift Dialog */}
|
||
<Dialog open={!!editingShift} onOpenChange={(open) => !open && setEditingShift(null)}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>Modifica Turno</DialogTitle>
|
||
<DialogDescription>
|
||
Modifica i dati del turno presso {editingShift?.site.name}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<Form {...editForm}>
|
||
<form onSubmit={editForm.handleSubmit(onEditSubmit)} className="space-y-4">
|
||
<FormField
|
||
control={editForm.control}
|
||
name="siteId"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Sito</FormLabel>
|
||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||
<FormControl>
|
||
<SelectTrigger data-testid="select-edit-site">
|
||
<SelectValue placeholder="Seleziona sito" />
|
||
</SelectTrigger>
|
||
</FormControl>
|
||
<SelectContent>
|
||
{filteredSites?.map((site) => (
|
||
<SelectItem key={site.id} value={site.id}>
|
||
{site.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<FormField
|
||
control={editForm.control}
|
||
name="startTime"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Inizio</FormLabel>
|
||
<FormControl>
|
||
<input
|
||
type="datetime-local"
|
||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||
value={field.value}
|
||
onChange={(e) => field.onChange(e.target.value)}
|
||
data-testid="input-edit-start-time"
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={editForm.control}
|
||
name="endTime"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Fine</FormLabel>
|
||
<FormControl>
|
||
<input
|
||
type="datetime-local"
|
||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||
value={field.value}
|
||
onChange={(e) => field.onChange(e.target.value)}
|
||
data-testid="input-edit-end-time"
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
<FormField
|
||
control={editForm.control}
|
||
name="status"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Stato Turno</FormLabel>
|
||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||
<FormControl>
|
||
<SelectTrigger data-testid="select-edit-status">
|
||
<SelectValue placeholder="Seleziona stato" />
|
||
</SelectTrigger>
|
||
</FormControl>
|
||
<SelectContent>
|
||
<SelectItem value="planned">Pianificato</SelectItem>
|
||
<SelectItem value="active">Attivo</SelectItem>
|
||
<SelectItem value="completed">Completato</SelectItem>
|
||
<SelectItem value="cancelled">Annullato</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => setEditingShift(null)}
|
||
className="flex-1"
|
||
data-testid="button-edit-shift-cancel"
|
||
>
|
||
Annulla
|
||
</Button>
|
||
<Button
|
||
type="submit"
|
||
className="flex-1"
|
||
disabled={updateMutation.isPending}
|
||
data-testid="button-submit-edit-shift"
|
||
>
|
||
{updateMutation.isPending ? "Aggiornamento..." : "Salva Modifiche"}
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* CCNL Violations Alert Dialog */}
|
||
<AlertDialog open={pendingAssignment !== null} onOpenChange={(open) => !open && setPendingAssignment(null)}>
|
||
<AlertDialogContent data-testid="dialog-ccnl-violations">
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>⚠️ Violazioni CCNL Rilevate</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
L'assegnazione di questa guardia al turno viola i seguenti limiti contrattuali:
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<div className="space-y-2">
|
||
{ccnlViolations.map((violation, index) => (
|
||
<Alert key={index} variant="destructive" data-testid={`alert-violation-${index}`}>
|
||
<AlertDescription>{violation.message}</AlertDescription>
|
||
</Alert>
|
||
))}
|
||
</div>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel
|
||
data-testid="button-cancel-assignment"
|
||
onClick={() => {
|
||
setPendingAssignment(null);
|
||
setCcnlViolations([]);
|
||
}}
|
||
>
|
||
Annulla
|
||
</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
data-testid="button-confirm-assignment"
|
||
onClick={confirmAssignment}
|
||
className="bg-destructive hover:bg-destructive/90"
|
||
>
|
||
Procedi Comunque
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</div>
|
||
);
|
||
}
|