VigilanzaTurni/client/src/pages/shifts.tsx
marco370 a300d18489 Add editing capabilities for guards, sites, and shifts
Implement PATCH endpoints for updating guards, sites, and shifts, along with UI elements and form handling for editing existing records across the application.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 99f0fce6-9386-489a-9632-1d81223cab44
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/99f0fce6-9386-489a-9632-1d81223cab44/Iga2bds
2025-10-11 15:58:34 +00:00

692 lines
26 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 { 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 } 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 { 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"],
});
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);
};
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 handleAssignGuard = (guardId: string) => {
if (selectedShift) {
assignGuardMutation.mutate({ shiftId: selectedShift.id, guardId });
}
};
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>
<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>
{sites?.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>
{shiftsLoading ? (
<div className="space-y-4">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
) : shifts && shifts.length > 0 ? (
<div className="space-y-4">
{shifts.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">
{guards.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>
{sites?.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>
</div>
);
}