VigilanzaTurni/client/src/pages/sites.tsx
marco370 eb2ccab920 Add multi-location support for operational planning
Introduce location filtering to operational planning, site management, and resource availability queries. This includes backend route modifications to handle location parameters and frontend updates for location selection and display.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/5GnGQQ0
2025-10-17 17:20:41 +00:00

819 lines
32 KiB
TypeScript

import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Site, InsertSite } 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 { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { insertSiteSchema } from "@shared/schema";
import { Plus, MapPin, Shield, Users, Pencil, Building2 } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { StatusBadge } from "@/components/status-badge";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
const shiftTypeLabels: Record<string, string> = {
fixed_post: "Presidio Fisso",
patrol: "Pattugliamento",
night_inspection: "Ispettorato Notturno",
quick_response: "Pronto Intervento",
};
const locationLabels: Record<string, string> = {
roccapiemonte: "Roccapiemonte",
milano: "Milano",
roma: "Roma",
};
export default function Sites() {
const { toast } = useToast();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingSite, setEditingSite] = useState<Site | null>(null);
const { data: sites, isLoading } = useQuery<Site[]>({
queryKey: ["/api/sites"],
});
const form = useForm<InsertSite>({
resolver: zodResolver(insertSiteSchema),
defaultValues: {
name: "",
address: "",
shiftType: "fixed_post",
minGuards: 1,
requiresArmed: false,
requiresDriverLicense: false,
contractReference: "",
contractStartDate: undefined,
contractEndDate: undefined,
serviceStartTime: "",
serviceEndTime: "",
isActive: true,
},
});
const editForm = useForm<InsertSite>({
resolver: zodResolver(insertSiteSchema),
defaultValues: {
name: "",
address: "",
shiftType: "fixed_post",
minGuards: 1,
requiresArmed: false,
requiresDriverLicense: false,
contractReference: "",
contractStartDate: undefined,
contractEndDate: undefined,
serviceStartTime: "",
serviceEndTime: "",
isActive: true,
},
});
const createMutation = useMutation({
mutationFn: async (data: InsertSite) => {
return await apiRequest("POST", "/api/sites", data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/sites"] });
toast({
title: "Sito creato",
description: "Il sito è stato aggiunto con successo",
});
setIsDialogOpen(false);
form.reset();
},
onError: (error) => {
toast({
title: "Errore",
description: error.message,
variant: "destructive",
});
},
});
const updateMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: InsertSite }) => {
return await apiRequest("PATCH", `/api/sites/${id}`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/sites"] });
toast({
title: "Sito aggiornato",
description: "I dati del sito sono stati aggiornati",
});
setEditingSite(null);
editForm.reset();
},
onError: (error) => {
toast({
title: "Errore",
description: error.message,
variant: "destructive",
});
},
});
const onSubmit = (data: InsertSite) => {
createMutation.mutate(data);
};
const onEditSubmit = (data: InsertSite) => {
if (editingSite) {
updateMutation.mutate({ id: editingSite.id, data });
}
};
const openEditDialog = (site: Site) => {
setEditingSite(site);
editForm.reset({
name: site.name,
address: site.address,
location: site.location,
shiftType: site.shiftType,
minGuards: site.minGuards,
requiresArmed: site.requiresArmed,
requiresDriverLicense: site.requiresDriverLicense,
contractReference: site.contractReference || "",
contractStartDate: site.contractStartDate || undefined,
contractEndDate: site.contractEndDate || undefined,
serviceStartTime: site.serviceStartTime || "",
serviceEndTime: site.serviceEndTime || "",
isActive: site.isActive,
});
};
// Funzione per determinare lo stato del contratto
const getContractStatus = (site: Site): "active" | "expiring" | "expired" | "none" => {
if (!site.contractStartDate || !site.contractEndDate) return "none";
const today = new Date();
const startDate = new Date(site.contractStartDate);
const endDate = new Date(site.contractEndDate);
if (today < startDate) return "none"; // Contratto non ancora iniziato
if (today > endDate) return "expired";
// Calcola i giorni rimanenti
const daysLeft = Math.ceil((endDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (daysLeft <= 30) return "expiring"; // In scadenza se mancano 30 giorni o meno
return "active";
};
const contractStatusLabels = {
active: { label: "Contratto Attivo", variant: "default" as const },
expiring: { label: "In Scadenza", variant: "outline" as const },
expired: { label: "Scaduto", variant: "destructive" as const },
none: { label: "Nessun Contratto", variant: "secondary" as const },
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-semibold mb-2">Gestione Siti</h1>
<p className="text-muted-foreground">
Siti e commesse con tipologie servizio
</p>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button data-testid="button-add-site">
<Plus className="h-4 w-4 mr-2" />
Aggiungi Sito
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Nuovo Sito</DialogTitle>
<DialogDescription>
Inserisci i dati del nuovo sito da presidiare
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Nome Sito</FormLabel>
<FormControl>
<Input placeholder="Centro Commerciale Nord" {...field} data-testid="input-site-name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="address"
render={({ field }) => (
<FormItem>
<FormLabel>Indirizzo</FormLabel>
<FormControl>
<Input placeholder="Via Roma 123, Milano" {...field} data-testid="input-address" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Sede Gestionale</FormLabel>
<Select onValueChange={field.onChange} value={field.value || "roccapiemonte"}>
<FormControl>
<SelectTrigger data-testid="select-location">
<SelectValue placeholder="Seleziona sede" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="border-t pt-4 space-y-4">
<p className="text-sm font-medium">Dati Contrattuali</p>
<FormField
control={form.control}
name="contractReference"
render={({ field }) => (
<FormItem>
<FormLabel>Riferimento Contratto</FormLabel>
<FormControl>
<Input placeholder="CT-2025-001" {...field} value={field.value || ""} data-testid="input-contract-reference" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="contractStartDate"
render={({ field }) => (
<FormItem>
<FormLabel>Data Inizio Contratto</FormLabel>
<FormControl>
<Input
type="date"
{...field}
value={field.value || ""}
data-testid="input-contract-start-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contractEndDate"
render={({ field }) => (
<FormItem>
<FormLabel>Data Fine Contratto</FormLabel>
<FormControl>
<Input
type="date"
{...field}
value={field.value || ""}
data-testid="input-contract-end-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<FormField
control={form.control}
name="shiftType"
render={({ field }) => (
<FormItem>
<FormLabel>Tipologia Servizio</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger data-testid="select-shift-type">
<SelectValue placeholder="Seleziona tipo servizio" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="fixed_post">Presidio Fisso</SelectItem>
<SelectItem value="patrol">Pattugliamento</SelectItem>
<SelectItem value="night_inspection">Ispettorato Notturno</SelectItem>
<SelectItem value="quick_response">Pronto Intervento</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="minGuards"
render={({ field }) => (
<FormItem>
<FormLabel>Numero Minimo Guardie</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value))}
data-testid="input-min-guards"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-3">
<p className="text-sm font-medium">Requisiti</p>
<FormField
control={form.control}
name="requiresArmed"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-md border p-3">
<FormLabel className="mb-0">Richiede Guardia Armata</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} data-testid="switch-requires-armed" />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="requiresDriverLicense"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-md border p-3">
<FormLabel className="mb-0">Richiede Patente</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} data-testid="switch-requires-driver" />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="border-t pt-4 space-y-4">
<p className="text-sm font-medium">Orari Servizio</p>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="serviceStartTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Inizio</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-service-start-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="serviceEndTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Fine</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-service-end-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</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-site"
>
{createMutation.isPending ? "Creazione..." : "Crea Sito"}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
{/* Edit Site Dialog */}
<Dialog open={!!editingSite} onOpenChange={(open) => !open && setEditingSite(null)}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Modifica Sito</DialogTitle>
<DialogDescription>
Modifica i dati del sito {editingSite?.name}
</DialogDescription>
</DialogHeader>
<Form {...editForm}>
<form onSubmit={editForm.handleSubmit(onEditSubmit)} className="space-y-4">
<FormField
control={editForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Nome Sito</FormLabel>
<FormControl>
<Input placeholder="Centro Commerciale Nord" {...field} data-testid="input-edit-site-name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="address"
render={({ field }) => (
<FormItem>
<FormLabel>Indirizzo</FormLabel>
<FormControl>
<Input placeholder="Via Roma 123, Milano" {...field} data-testid="input-edit-address" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Sede Gestionale</FormLabel>
<Select onValueChange={field.onChange} value={field.value || "roccapiemonte"}>
<FormControl>
<SelectTrigger data-testid="select-edit-location">
<SelectValue placeholder="Seleziona sede" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="border-t pt-4 space-y-4">
<p className="text-sm font-medium">Dati Contrattuali</p>
<FormField
control={editForm.control}
name="contractReference"
render={({ field }) => (
<FormItem>
<FormLabel>Riferimento Contratto</FormLabel>
<FormControl>
<Input placeholder="CT-2025-001" {...field} value={field.value || ""} data-testid="input-edit-contract-reference" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={editForm.control}
name="contractStartDate"
render={({ field }) => (
<FormItem>
<FormLabel>Data Inizio Contratto</FormLabel>
<FormControl>
<Input
type="date"
{...field}
value={field.value || ""}
data-testid="input-edit-contract-start-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="contractEndDate"
render={({ field }) => (
<FormItem>
<FormLabel>Data Fine Contratto</FormLabel>
<FormControl>
<Input
type="date"
{...field}
value={field.value || ""}
data-testid="input-edit-contract-end-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<FormField
control={editForm.control}
name="shiftType"
render={({ field }) => (
<FormItem>
<FormLabel>Tipologia Servizio</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger data-testid="select-edit-shift-type">
<SelectValue placeholder="Seleziona tipo servizio" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="fixed_post">Presidio Fisso</SelectItem>
<SelectItem value="patrol">Pattugliamento</SelectItem>
<SelectItem value="night_inspection">Ispettorato Notturno</SelectItem>
<SelectItem value="quick_response">Pronto Intervento</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="minGuards"
render={({ field }) => (
<FormItem>
<FormLabel>Numero Minimo Guardie</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value))}
data-testid="input-edit-min-guards"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-3">
<p className="text-sm font-medium">Requisiti</p>
<FormField
control={editForm.control}
name="requiresArmed"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-md border p-3">
<FormLabel className="mb-0">Richiede Guardia Armata</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} data-testid="switch-edit-requires-armed" />
</FormControl>
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="requiresDriverLicense"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-md border p-3">
<FormLabel className="mb-0">Richiede Patente</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} data-testid="switch-edit-requires-driver" />
</FormControl>
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="isActive"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-md border p-3">
<FormLabel className="mb-0">Sito Attivo</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} data-testid="switch-edit-is-active" />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="border-t pt-4 space-y-4">
<p className="text-sm font-medium">Orari Servizio</p>
<div className="grid grid-cols-2 gap-4">
<FormField
control={editForm.control}
name="serviceStartTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Inizio</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-edit-service-start-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="serviceEndTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Fine</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-edit-service-end-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => setEditingSite(null)}
className="flex-1"
data-testid="button-edit-site-cancel"
>
Annulla
</Button>
<Button
type="submit"
className="flex-1"
disabled={updateMutation.isPending}
data-testid="button-submit-edit-site"
>
{updateMutation.isPending ? "Aggiornamento..." : "Salva Modifiche"}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-48" />
<Skeleton className="h-48" />
<Skeleton className="h-48" />
</div>
) : sites && sites.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sites.map((site) => (
<Card key={site.id} className="hover-elevate" data-testid={`card-site-${site.id}`}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<CardTitle className="text-lg truncate">{site.name}</CardTitle>
<CardDescription className="text-xs mt-1 space-y-0.5">
<div>
<MapPin className="h-3 w-3 inline mr-1" />
{site.address}
</div>
<div>
<Building2 className="h-3 w-3 inline mr-1" />
Sede: {locationLabels[site.location]}
</div>
</CardDescription>
</div>
<div className="flex items-center gap-2">
<StatusBadge status={site.isActive ? "active" : "inactive"}>
{site.isActive ? "Attivo" : "Inattivo"}
</StatusBadge>
<Button
size="icon"
variant="ghost"
onClick={() => openEditDialog(site)}
data-testid={`button-edit-site-${site.id}`}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2">
<Badge variant="outline">
{shiftTypeLabels[site.shiftType]}
</Badge>
{(() => {
const status = getContractStatus(site);
const statusInfo = contractStatusLabels[status];
return (
<Badge variant={statusInfo.variant} data-testid={`badge-contract-status-${site.id}`}>
{statusInfo.label}
</Badge>
);
})()}
</div>
{site.contractReference && (
<div className="text-xs text-muted-foreground">
Contratto: {site.contractReference}
{site.contractEndDate && ` • Scade: ${new Date(site.contractEndDate).toLocaleDateString('it-IT')}`}
</div>
)}
<div className="space-y-1 text-sm">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Min. {site.minGuards} guardie</span>
</div>
{site.requiresArmed && (
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Servizio armato</span>
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16">
<MapPin className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-lg font-medium mb-2">Nessun sito presente</p>
<p className="text-sm text-muted-foreground mb-4">
Inizia aggiungendo il primo sito da presidiare
</p>
</CardContent>
</Card>
)}
</div>
);
}