VigilanzaTurni/client/src/pages/shifts.tsx
marco370 177ad892f0 Update shift creation to use a new form schema and improve validation
Refactor shift creation endpoint to use `insertShiftFormSchema` for validation and data transformation, and update client-side components to handle datetime strings.

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/cpTvSfP
2025-10-11 10:31:19 +00:00

272 lines
10 KiB
TypeScript

import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { ShiftWithDetails, InsertShift, Site, Guard } 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 } 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";
export default function Shifts() {
const { toast } = useToast();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({
queryKey: ["/api/shifts"],
});
const { data: sites } = useQuery<Site[]>({
queryKey: ["/api/sites"],
});
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 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 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.assignments.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{shift.assignments.map((assignment) => (
<div
key={assignment.id}
className="text-xs bg-secondary text-secondary-foreground px-2 py-1 rounded-md"
>
{assignment.guard.user?.firstName} {assignment.guard.user?.lastName}
</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>
)}
</div>
);
}