VigilanzaTurni/client/src/pages/shifts.tsx
marco370 4a1c21455b Implement contract rules for shift assignments
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
2025-10-17 07:39:19 +00:00

801 lines
31 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}