Add a dedicated section for managing customer information

Introduces a new page and routing for customer management, including UI components for viewing, creating, editing, and deleting customers, along with API integration for CRUD operations.

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/kHMnjKS
This commit is contained in:
marco370 2025-10-23 08:21:20 +00:00
parent 983adcfbe1
commit af98190e6d
4 changed files with 628 additions and 0 deletions

View File

@ -23,6 +23,10 @@ externalPort = 3001
localPort = 37831
externalPort = 5173
[[ports]]
localPort = 41295
externalPort = 6000
[[ports]]
localPort = 41343
externalPort = 3000

View File

@ -25,6 +25,7 @@ import Planning from "@/pages/planning";
import OperationalPlanning from "@/pages/operational-planning";
import GeneralPlanning from "@/pages/general-planning";
import ServicePlanning from "@/pages/service-planning";
import Customers from "@/pages/customers";
function Router() {
const { isAuthenticated, isLoading } = useAuth();
@ -39,6 +40,7 @@ function Router() {
<Route path="/" component={Dashboard} />
<Route path="/guards" component={Guards} />
<Route path="/sites" component={Sites} />
<Route path="/customers" component={Customers} />
<Route path="/services" component={Services} />
<Route path="/vehicles" component={Vehicles} />
<Route path="/shifts" component={Shifts} />

View File

@ -85,6 +85,12 @@ const menuItems = [
icon: MapPin,
roles: ["admin", "coordinator", "client"],
},
{
title: "Clienti",
url: "/customers",
icon: Briefcase,
roles: ["admin", "coordinator"],
},
{
title: "Servizi",
url: "/services",

View File

@ -0,0 +1,616 @@
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Customer, InsertCustomer, insertCustomerSchema } 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 { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Building2, Pencil, Trash2, Phone, Mail } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
export default function Customers() {
const { toast } = useToast();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [deletingCustomerId, setDeletingCustomerId] = useState<string | null>(null);
const { data: customers, isLoading } = useQuery<Customer[]>({
queryKey: ["/api/customers"],
});
const form = useForm<InsertCustomer>({
resolver: zodResolver(insertCustomerSchema),
defaultValues: {
name: "",
businessName: "",
vatNumber: "",
fiscalCode: "",
address: "",
city: "",
province: "",
zipCode: "",
phone: "",
email: "",
pec: "",
contactPerson: "",
notes: "",
isActive: true,
},
});
const createMutation = useMutation({
mutationFn: async (data: InsertCustomer) => {
return await apiRequest("POST", "/api/customers", data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/customers"] });
toast({
title: "Cliente creato",
description: "Il cliente è stato aggiunto con successo",
});
setIsCreateDialogOpen(false);
form.reset();
},
onError: (error) => {
toast({
title: "Errore",
description: error.message,
variant: "destructive",
});
},
});
const updateMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: InsertCustomer }) => {
return await apiRequest("PATCH", `/api/customers/${id}`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/customers"] });
toast({
title: "Cliente aggiornato",
description: "I dati del cliente sono stati aggiornati",
});
setEditingCustomer(null);
form.reset();
},
onError: (error) => {
toast({
title: "Errore",
description: error.message,
variant: "destructive",
});
},
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
return await apiRequest("DELETE", `/api/customers/${id}`, undefined);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/customers"] });
toast({
title: "Cliente eliminato",
description: "Il cliente è stato eliminato con successo",
});
setDeletingCustomerId(null);
},
onError: (error) => {
toast({
title: "Errore",
description: error.message,
variant: "destructive",
});
setDeletingCustomerId(null);
},
});
const onSubmit = (data: InsertCustomer) => {
if (editingCustomer) {
updateMutation.mutate({ id: editingCustomer.id, data });
} else {
createMutation.mutate(data);
}
};
const openEditDialog = (customer: Customer) => {
setEditingCustomer(customer);
form.reset({
name: customer.name || "",
businessName: customer.businessName || "",
vatNumber: customer.vatNumber || "",
fiscalCode: customer.fiscalCode || "",
address: customer.address || "",
city: customer.city || "",
province: customer.province || "",
zipCode: customer.zipCode || "",
phone: customer.phone || "",
email: customer.email || "",
pec: customer.pec || "",
contactPerson: customer.contactPerson || "",
notes: customer.notes || "",
isActive: customer.isActive ?? true,
});
setIsCreateDialogOpen(true);
};
const handleDialogOpenChange = (open: boolean) => {
setIsCreateDialogOpen(open);
if (!open) {
// Reset only on close
setEditingCustomer(null);
form.reset();
}
};
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight" data-testid="text-page-title">
Anagrafica Clienti
</h1>
<p className="text-muted-foreground">
Gestione anagrafica clienti e contratti
</p>
</div>
<Dialog open={isCreateDialogOpen} onOpenChange={handleDialogOpenChange}>
<DialogTrigger asChild>
<Button data-testid="button-create-customer">
<Plus className="mr-2 h-4 w-4" />
Nuovo Cliente
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingCustomer ? "Modifica Cliente" : "Nuovo Cliente"}
</DialogTitle>
<DialogDescription>
Inserisci i dati anagrafici del cliente
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Nome Cliente *</FormLabel>
<FormControl>
<Input
placeholder="es. Banca Centrale Roma"
data-testid="input-name"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="businessName"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Ragione Sociale</FormLabel>
<FormControl>
<Input
placeholder="es. Banca Centrale S.p.A."
data-testid="input-business-name"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="vatNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Partita IVA</FormLabel>
<FormControl>
<Input
placeholder="12345678901"
data-testid="input-vat-number"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fiscalCode"
render={({ field }) => (
<FormItem>
<FormLabel>Codice Fiscale</FormLabel>
<FormControl>
<Input
placeholder="CF cliente"
data-testid="input-fiscal-code"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="address"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Indirizzo</FormLabel>
<FormControl>
<Input
placeholder="Via, numero civico"
data-testid="input-address"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="city"
render={({ field }) => (
<FormItem>
<FormLabel>Città</FormLabel>
<FormControl>
<Input
placeholder="Roma"
data-testid="input-city"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="province"
render={({ field }) => (
<FormItem>
<FormLabel>Provincia</FormLabel>
<FormControl>
<Input
placeholder="RM"
maxLength={2}
data-testid="input-province"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="zipCode"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>CAP</FormLabel>
<FormControl>
<Input
placeholder="00100"
data-testid="input-zip-code"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Telefono</FormLabel>
<FormControl>
<Input
placeholder="+39 06 1234567"
data-testid="input-phone"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="info@cliente.it"
data-testid="input-email"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pec"
render={({ field }) => (
<FormItem>
<FormLabel>PEC</FormLabel>
<FormControl>
<Input
type="email"
placeholder="pec@cliente.it"
data-testid="input-pec"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contactPerson"
render={({ field }) => (
<FormItem>
<FormLabel>Referente</FormLabel>
<FormControl>
<Input
placeholder="Nome e cognome referente"
data-testid="input-contact-person"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Note</FormLabel>
<FormControl>
<Textarea
placeholder="Note aggiuntive sul cliente"
data-testid="input-notes"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0 col-span-2">
<FormControl>
<Switch
checked={field.value ?? true}
onCheckedChange={field.onChange}
data-testid="switch-is-active"
/>
</FormControl>
<FormLabel className="!mt-0">Cliente Attivo</FormLabel>
</FormItem>
)}
/>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => handleDialogOpenChange(false)}
data-testid="button-cancel"
>
Annulla
</Button>
<Button
type="submit"
disabled={createMutation.isPending || updateMutation.isPending}
data-testid="button-submit"
>
{editingCustomer ? "Aggiorna" : "Crea"}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
{/* Customers Table */}
<Card>
<CardHeader>
<CardTitle>Lista Clienti</CardTitle>
<CardDescription>
{customers?.length || 0} clienti registrati
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Ragione Sociale</TableHead>
<TableHead>Città</TableHead>
<TableHead>Referente</TableHead>
<TableHead>Contatti</TableHead>
<TableHead>Stato</TableHead>
<TableHead className="text-right">Azioni</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{customers?.map((customer) => (
<TableRow key={customer.id} data-testid={`row-customer-${customer.id}`}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
{customer.name}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{customer.businessName || "-"}
</TableCell>
<TableCell>
{customer.city ? `${customer.city} (${customer.province})` : "-"}
</TableCell>
<TableCell>{customer.contactPerson || "-"}</TableCell>
<TableCell>
<div className="flex flex-col gap-1 text-sm">
{customer.phone && (
<div className="flex items-center gap-1">
<Phone className="h-3 w-3" />
{customer.phone}
</div>
)}
{customer.email && (
<div className="flex items-center gap-1">
<Mail className="h-3 w-3" />
{customer.email}
</div>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={customer.isActive ? "default" : "secondary"}>
{customer.isActive ? "Attivo" : "Inattivo"}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => openEditDialog(customer)}
data-testid={`button-edit-${customer.id}`}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeletingCustomerId(customer.id)}
data-testid={`button-delete-${customer.id}`}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{(!customers || customers.length === 0) && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
Nessun cliente registrato
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deletingCustomerId} onOpenChange={() => setDeletingCustomerId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Conferma eliminazione</AlertDialogTitle>
<AlertDialogDescription>
Sei sicuro di voler eliminare questo cliente? L'operazione non può essere annullata.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel data-testid="button-cancel-delete">Annulla</AlertDialogCancel>
<AlertDialogAction
onClick={() => deletingCustomerId && deleteMutation.mutate(deletingCustomerId)}
data-testid="button-confirm-delete"
className="bg-destructive hover:bg-destructive/90"
>
Elimina
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}