VigilanzaTurni/client/src/pages/shifts.tsx
marco370 b3d0441306 Add functionality to assign and remove guards from shifts
Implement API endpoints and UI components for managing shift assignments, including guard certifications and a new assignment removal mutation.

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/9lvKVew
2025-10-11 11:03:43 +00:00

502 lines
20 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 } 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 { 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 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 handleAssignGuard = (guardId: string) => {
if (selectedShift) {
assignGuardMutation.mutate({ shiftId: selectedShift.id, guardId });
}
};
const handleRemoveAssignment = (assignmentId: string) => {
removeAssignmentMutation.mutate(assignmentId);
};
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>
<StatusBadge status={getStatusVariant(shift.status)}>
{getStatusLabel(shift.status)}
</StatusBadge>
</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>
</div>
);
}