Compare commits
No commits in common. "f34e8f9136c375839c2aa14462e483fd1198d666" and "1598eb208bdc9c70174c3d99328c2b71e057dfcc" have entirely different histories.
f34e8f9136
...
1598eb208b
4
.replit
4
.replit
@ -19,10 +19,6 @@ externalPort = 80
|
|||||||
localPort = 33035
|
localPort = 33035
|
||||||
externalPort = 3001
|
externalPort = 3001
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 41295
|
|
||||||
externalPort = 6000
|
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 41343
|
localPort = 41343
|
||||||
externalPort = 3000
|
externalPort = 3000
|
||||||
|
|||||||
@ -25,7 +25,6 @@ import Planning from "@/pages/planning";
|
|||||||
import OperationalPlanning from "@/pages/operational-planning";
|
import OperationalPlanning from "@/pages/operational-planning";
|
||||||
import GeneralPlanning from "@/pages/general-planning";
|
import GeneralPlanning from "@/pages/general-planning";
|
||||||
import ServicePlanning from "@/pages/service-planning";
|
import ServicePlanning from "@/pages/service-planning";
|
||||||
import Customers from "@/pages/customers";
|
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
@ -40,7 +39,6 @@ function Router() {
|
|||||||
<Route path="/" component={Dashboard} />
|
<Route path="/" component={Dashboard} />
|
||||||
<Route path="/guards" component={Guards} />
|
<Route path="/guards" component={Guards} />
|
||||||
<Route path="/sites" component={Sites} />
|
<Route path="/sites" component={Sites} />
|
||||||
<Route path="/customers" component={Customers} />
|
|
||||||
<Route path="/services" component={Services} />
|
<Route path="/services" component={Services} />
|
||||||
<Route path="/vehicles" component={Vehicles} />
|
<Route path="/vehicles" component={Vehicles} />
|
||||||
<Route path="/shifts" component={Shifts} />
|
<Route path="/shifts" component={Shifts} />
|
||||||
|
|||||||
@ -85,12 +85,6 @@ const menuItems = [
|
|||||||
icon: MapPin,
|
icon: MapPin,
|
||||||
roles: ["admin", "coordinator", "client"],
|
roles: ["admin", "coordinator", "client"],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Clienti",
|
|
||||||
url: "/customers",
|
|
||||||
icon: Briefcase,
|
|
||||||
roles: ["admin", "coordinator"],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Servizi",
|
title: "Servizi",
|
||||||
url: "/services",
|
url: "/services",
|
||||||
|
|||||||
@ -1,616 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -35,30 +35,6 @@ interface SiteReport {
|
|||||||
totalShifts: number;
|
totalShifts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CustomerReport {
|
|
||||||
customerId: string;
|
|
||||||
customerName: string;
|
|
||||||
sites: {
|
|
||||||
siteId: string;
|
|
||||||
siteName: string;
|
|
||||||
serviceTypes: {
|
|
||||||
name: string;
|
|
||||||
hours: number;
|
|
||||||
shifts: number;
|
|
||||||
passages: number;
|
|
||||||
inspections: number;
|
|
||||||
interventions: number;
|
|
||||||
}[];
|
|
||||||
totalHours: number;
|
|
||||||
totalShifts: number;
|
|
||||||
}[];
|
|
||||||
totalHours: number;
|
|
||||||
totalShifts: number;
|
|
||||||
totalPatrolPassages: number;
|
|
||||||
totalInspections: number;
|
|
||||||
totalInterventions: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Reports() {
|
export default function Reports() {
|
||||||
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
|
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
|
||||||
const [selectedMonth, setSelectedMonth] = useState<string>(format(new Date(), "yyyy-MM"));
|
const [selectedMonth, setSelectedMonth] = useState<string>(format(new Date(), "yyyy-MM"));
|
||||||
@ -103,28 +79,6 @@ export default function Reports() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Query per report clienti
|
|
||||||
const { data: customerReport, isLoading: isLoadingCustomers } = useQuery<{
|
|
||||||
month: string;
|
|
||||||
location: string;
|
|
||||||
customers: CustomerReport[];
|
|
||||||
summary: {
|
|
||||||
totalCustomers: number;
|
|
||||||
totalHours: number;
|
|
||||||
totalShifts: number;
|
|
||||||
totalPatrolPassages: number;
|
|
||||||
totalInspections: number;
|
|
||||||
totalInterventions: number;
|
|
||||||
};
|
|
||||||
}>({
|
|
||||||
queryKey: ["/api/reports/customer-billing", selectedMonth, selectedLocation],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await fetch(`/api/reports/customer-billing?month=${selectedMonth}&location=${selectedLocation}`);
|
|
||||||
if (!response.ok) throw new Error("Failed to fetch customer report");
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Genera mesi disponibili (ultimi 12 mesi)
|
// Genera mesi disponibili (ultimi 12 mesi)
|
||||||
const availableMonths = Array.from({ length: 12 }, (_, i) => {
|
const availableMonths = Array.from({ length: 12 }, (_, i) => {
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
@ -170,28 +124,6 @@ export default function Reports() {
|
|||||||
a.click();
|
a.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export CSV clienti
|
|
||||||
const exportCustomersCSV = () => {
|
|
||||||
if (!customerReport?.customers) return;
|
|
||||||
|
|
||||||
const headers = "Cliente,Sito,Tipologia Servizio,Ore,Turni,Passaggi,Ispezioni,Interventi\n";
|
|
||||||
const rows = customerReport.customers.flatMap(c =>
|
|
||||||
c.sites.flatMap(s =>
|
|
||||||
s.serviceTypes.map(st =>
|
|
||||||
`"${c.customerName}","${s.siteName}","${st.name}",${st.hours},${st.shifts},${st.passages},${st.inspections},${st.interventions}`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).join("\n");
|
|
||||||
|
|
||||||
const csv = headers + rows;
|
|
||||||
const blob = new Blob([csv], { type: "text/csv" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `fatturazione_clienti_${selectedMonth}_${selectedLocation}.csv`;
|
|
||||||
a.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto p-6 space-y-6">
|
<div className="h-full overflow-auto p-6 space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -245,7 +177,7 @@ export default function Reports() {
|
|||||||
|
|
||||||
{/* Tabs Report */}
|
{/* Tabs Report */}
|
||||||
<Tabs defaultValue="guards" className="space-y-6">
|
<Tabs defaultValue="guards" className="space-y-6">
|
||||||
<TabsList className="grid w-full max-w-[600px] grid-cols-3">
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
<TabsTrigger value="guards" data-testid="tab-guard-report">
|
<TabsTrigger value="guards" data-testid="tab-guard-report">
|
||||||
<Users className="h-4 w-4 mr-2" />
|
<Users className="h-4 w-4 mr-2" />
|
||||||
Report Guardie
|
Report Guardie
|
||||||
@ -254,10 +186,6 @@ export default function Reports() {
|
|||||||
<Building2 className="h-4 w-4 mr-2" />
|
<Building2 className="h-4 w-4 mr-2" />
|
||||||
Report Siti
|
Report Siti
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="customers" data-testid="tab-customer-report">
|
|
||||||
<Building2 className="h-4 w-4 mr-2" />
|
|
||||||
Report Clienti
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Tab Report Guardie */}
|
{/* Tab Report Guardie */}
|
||||||
@ -465,154 +393,6 @@ export default function Reports() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Tab Report Clienti */}
|
|
||||||
<TabsContent value="customers" className="space-y-4">
|
|
||||||
{isLoadingCustomers ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Skeleton className="h-32 w-full" />
|
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
</div>
|
|
||||||
) : customerReport ? (
|
|
||||||
<>
|
|
||||||
{/* Summary cards */}
|
|
||||||
<div className="grid gap-4 md:grid-cols-5">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardDescription className="flex items-center gap-2">
|
|
||||||
<Building2 className="h-4 w-4" />
|
|
||||||
Clienti
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-2xl font-semibold">{customerReport.summary.totalCustomers}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardDescription className="flex items-center gap-2">
|
|
||||||
<Clock className="h-4 w-4" />
|
|
||||||
Ore Totali
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-2xl font-semibold">{customerReport.summary.totalHours}h</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardDescription>Passaggi</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-2xl font-semibold">{customerReport.summary.totalPatrolPassages}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardDescription>Ispezioni</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-2xl font-semibold">{customerReport.summary.totalInspections}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardDescription>Interventi</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-2xl font-semibold">{customerReport.summary.totalInterventions}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabella clienti */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Fatturazione per Cliente</CardTitle>
|
|
||||||
<CardDescription>Dettaglio siti e servizi erogati</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Button onClick={exportCustomersCSV} data-testid="button-export-customers">
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
|
||||||
Export CSV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{customerReport.customers.length > 0 ? (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{customerReport.customers.map((customer) => (
|
|
||||||
<div key={customer.customerId} className="border-2 rounded-lg p-4" data-testid={`customer-report-${customer.customerId}`}>
|
|
||||||
{/* Header Cliente */}
|
|
||||||
<div className="flex items-center justify-between mb-4 pb-3 border-b">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-xl">{customer.customerName}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">{customer.sites.length} siti attivi</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="default" className="text-base px-3 py-1">{customer.totalHours}h totali</Badge>
|
|
||||||
<Badge variant="outline">{customer.totalShifts} turni</Badge>
|
|
||||||
{customer.totalPatrolPassages > 0 && (
|
|
||||||
<Badge variant="secondary">{customer.totalPatrolPassages} passaggi</Badge>
|
|
||||||
)}
|
|
||||||
{customer.totalInspections > 0 && (
|
|
||||||
<Badge variant="secondary">{customer.totalInspections} ispezioni</Badge>
|
|
||||||
)}
|
|
||||||
{customer.totalInterventions > 0 && (
|
|
||||||
<Badge variant="secondary">{customer.totalInterventions} interventi</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lista Siti */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{customer.sites.map((site) => (
|
|
||||||
<div key={site.siteId} className="bg-muted/30 rounded-md p-3">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h4 className="font-medium">{site.siteName}</h4>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="outline" className="text-xs">{site.totalHours}h</Badge>
|
|
||||||
<Badge variant="outline" className="text-xs">{site.totalShifts} turni</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{site.serviceTypes.map((st, idx) => (
|
|
||||||
<div key={idx} className="flex items-center justify-between text-sm p-2 rounded bg-background">
|
|
||||||
<span className="text-muted-foreground">{st.name}</span>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{st.hours > 0 && <span className="font-mono">{st.hours}h</span>}
|
|
||||||
{st.passages > 0 && (
|
|
||||||
<Badge variant="secondary" className="text-xs">{st.passages} passaggi</Badge>
|
|
||||||
)}
|
|
||||||
{st.inspections > 0 && (
|
|
||||||
<Badge variant="secondary" className="text-xs">{st.inspections} ispezioni</Badge>
|
|
||||||
)}
|
|
||||||
{st.interventions > 0 && (
|
|
||||||
<Badge variant="secondary" className="text-xs">{st.interventions} interventi</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-center text-muted-foreground py-8">Nessun cliente con servizi fatturabili</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
BIN
database-backups/vigilanzaturni_v1.0.25_20251021_141055.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.25_20251021_141055.sql.gz
Normal file
Binary file not shown.
Binary file not shown.
57
replit.md
57
replit.md
@ -10,43 +10,6 @@ VigilanzaTurni is a professional 24/7 shift management system designed for secur
|
|||||||
- Focus su efficienza e densità informativa
|
- Focus su efficienza e densità informativa
|
||||||
- **Testing**: Tutti i test vengono eseguiti ESCLUSIVAMENTE sul server esterno (vt.alfacom.it) con autenticazione locale (non Replit Auth)
|
- **Testing**: Tutti i test vengono eseguiti ESCLUSIVAMENTE sul server esterno (vt.alfacom.it) con autenticazione locale (non Replit Auth)
|
||||||
|
|
||||||
## ⚠️ CRITICAL: Date/Timezone Handling Rules
|
|
||||||
**PROBLEMA RICORRENTE**: Quando si assegna una guardia per il giorno X, appare assegnata al giorno X±1 a causa di conversioni timezone.
|
|
||||||
|
|
||||||
**REGOLE OBBLIGATORIE** per evitare questo bug:
|
|
||||||
|
|
||||||
1. **MAI usare `parseISO()` su date YYYY-MM-DD**
|
|
||||||
- ❌ SBAGLIATO: `const date = parseISO("2025-10-20")` → converte in UTC causando shift
|
|
||||||
- ✅ CORRETTO: `const [y, m, d] = "2025-10-20".split("-").map(Number); const date = new Date(y, m-1, d)`
|
|
||||||
|
|
||||||
2. **Costruire Date da componenti, NON da stringhe ISO**
|
|
||||||
```typescript
|
|
||||||
// ✅ CORRETTO - date components (no timezone conversion)
|
|
||||||
const [year, month, day] = startDate.split("-").map(Number);
|
|
||||||
const shiftDate = new Date(year, month - 1, day);
|
|
||||||
const shiftStart = new Date(year, month - 1, day, startHour, startMin, 0, 0);
|
|
||||||
|
|
||||||
// ❌ SBAGLIATO - parseISO o new Date(string ISO)
|
|
||||||
const date = parseISO(startDate); // converte in UTC!
|
|
||||||
const date = new Date("2025-10-20"); // timezone-dependent!
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Validazione date: usare regex, NON parseISO**
|
|
||||||
```typescript
|
|
||||||
// ✅ CORRETTO
|
|
||||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
|
||||||
if (!dateRegex.test(dateStr)) { /* invalid */ }
|
|
||||||
|
|
||||||
// ❌ SBAGLIATO
|
|
||||||
const parsed = parseISO(dateStr);
|
|
||||||
if (!isValid(parsed)) { /* invalid */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **File da verificare sempre**: `server/routes.ts` - tutte le route che ricevono date dal frontend
|
|
||||||
5. **Testare sempre**: Assegnare guardia giorno X → verificare appaia nel giorno X (non X±1)
|
|
||||||
|
|
||||||
**RIFERIMENTI FIX**: Vedere commit "Fix timezone bug in shift creation" - linee 1148-1184, 615-621, 753-759 in server/routes.ts
|
|
||||||
|
|
||||||
## System Architecture
|
## System Architecture
|
||||||
|
|
||||||
### Stack Tecnologico
|
### Stack Tecnologico
|
||||||
@ -73,26 +36,6 @@ The database includes core tables for `users`, `guards`, `certifications`, `site
|
|||||||
- **Contract Management**: Sites now include contract fields: `contractReference` (codice contratto), `contractStartDate`, `contractEndDate` (date validità contratto in formato YYYY-MM-DD)
|
- **Contract Management**: Sites now include contract fields: `contractReference` (codice contratto), `contractStartDate`, `contractEndDate` (date validità contratto in formato YYYY-MM-DD)
|
||||||
- Sites now reference service types via `serviceTypeId` foreign key; `shiftType` is optional and can be derived from service type
|
- Sites now reference service types via `serviceTypeId` foreign key; `shiftType` is optional and can be derived from service type
|
||||||
- **Multi-Location Support**: Added `location` field (enum: roccapiemonte, milano, roma) to `sites`, `guards`, and `vehicles` tables for complete multi-sede resource isolation
|
- **Multi-Location Support**: Added `location` field (enum: roccapiemonte, milano, roma) to `sites`, `guards`, and `vehicles` tables for complete multi-sede resource isolation
|
||||||
- **Customer Management (October 23, 2025)**: Added `customers` table with `sites.customerId` foreign key for customer-centric organization. Customers include: name, business name, VAT/fiscal code, address, city, province, ZIP, phone, email, PEC, contact person, notes, active status.
|
|
||||||
|
|
||||||
**Recent Features (October 23, 2025)**:
|
|
||||||
- **Customer Management (Anagrafica Clienti)**: New `/customers` page with full CRUD operations:
|
|
||||||
- Comprehensive customer form: name, business name, VAT/fiscal code, address, contacts (phone/email/PEC), referent, notes
|
|
||||||
- Customer status toggle (active/inactive)
|
|
||||||
- Delete confirmation with cascade considerations
|
|
||||||
- Sidebar menu entry for admin/coordinator roles
|
|
||||||
- Backend: Full CRUD routes with validation
|
|
||||||
- **Reports per Cliente**: New customer-centric billing reports replacing site-based approach:
|
|
||||||
- Backend endpoint `/api/reports/customer-billing` aggregating by customer → sites → service types
|
|
||||||
- Separate counters based on service type:
|
|
||||||
* Presidio Fisso → Hours worked
|
|
||||||
* Pattuglia/Ronda → Number of passages (from `serviceType.patrolPassages`)
|
|
||||||
* Ispezione → Number of inspections counted
|
|
||||||
* Pronto Intervento → Number of interventions counted
|
|
||||||
- Frontend: "Report Clienti" tab with 5 KPI cards (customers, hours, passages, inspections, interventions)
|
|
||||||
- Hierarchical display: Customer header → Sites list → Service type details with conditional badges
|
|
||||||
- CSV export with 8 columns: Cliente, Sito, Tipologia Servizio, Ore, Turni, Passaggi, Ispezioni, Interventi
|
|
||||||
- Maintains existing "Report Guardie" and "Report Siti" tabs for compatibility
|
|
||||||
|
|
||||||
**Recent Features (October 17-18, 2025)**:
|
**Recent Features (October 17-18, 2025)**:
|
||||||
- **Multi-Sede Operational Planning**: Redesigned operational planning workflow with location-first approach:
|
- **Multi-Sede Operational Planning**: Redesigned operational planning workflow with location-first approach:
|
||||||
|
|||||||
194
server/routes.ts
194
server/routes.ts
@ -609,13 +609,13 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
const rawDateStr = req.query.date as string || format(new Date(), "yyyy-MM-dd");
|
const rawDateStr = req.query.date as string || format(new Date(), "yyyy-MM-dd");
|
||||||
const normalizedDateStr = rawDateStr.split("/")[0]; // Prende solo la prima parte se c'è uno slash
|
const normalizedDateStr = rawDateStr.split("/")[0]; // Prende solo la prima parte se c'è uno slash
|
||||||
|
|
||||||
// TIMEZONE FIX: Valida formato senza parseISO per evitare shift timezone
|
// Valida la data
|
||||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
const parsedDate = parseISO(normalizedDateStr);
|
||||||
if (!dateRegex.test(normalizedDateStr)) {
|
if (!isValid(parsedDate)) {
|
||||||
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
|
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateStr = normalizedDateStr;
|
const dateStr = format(parsedDate, "yyyy-MM-dd");
|
||||||
|
|
||||||
// Ottieni location dalla query (default: roccapiemonte)
|
// Ottieni location dalla query (default: roccapiemonte)
|
||||||
const location = req.query.location as string || "roccapiemonte";
|
const location = req.query.location as string || "roccapiemonte";
|
||||||
@ -748,13 +748,13 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
const rawDateStr = req.query.date as string || format(new Date(), "yyyy-MM-dd");
|
const rawDateStr = req.query.date as string || format(new Date(), "yyyy-MM-dd");
|
||||||
const normalizedDateStr = rawDateStr.split("/")[0]; // Prende solo la prima parte se c'è uno slash
|
const normalizedDateStr = rawDateStr.split("/")[0]; // Prende solo la prima parte se c'è uno slash
|
||||||
|
|
||||||
// TIMEZONE FIX: Valida formato senza parseISO per evitare shift timezone
|
// Valida la data
|
||||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
const parsedDate = parseISO(normalizedDateStr);
|
||||||
if (!dateRegex.test(normalizedDateStr)) {
|
if (!isValid(parsedDate)) {
|
||||||
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
|
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateStr = normalizedDateStr;
|
const dateStr = format(parsedDate, "yyyy-MM-dd");
|
||||||
|
|
||||||
// Ottieni location dalla query (default: roccapiemonte)
|
// Ottieni location dalla query (default: roccapiemonte)
|
||||||
const location = req.query.location as string || "roccapiemonte";
|
const location = req.query.location as string || "roccapiemonte";
|
||||||
@ -1142,12 +1142,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pre-validate all dates are within contract period
|
// Pre-validate all dates are within contract period
|
||||||
// TIMEZONE FIX: Parse date as YYYY-MM-DD components to avoid timezone shifts
|
const startDateParsed = parseISO(startDate);
|
||||||
const [year, month, day] = startDate.split("-").map(Number);
|
|
||||||
|
|
||||||
for (let dayOffset = 0; dayOffset < days; dayOffset++) {
|
for (let dayOffset = 0; dayOffset < days; dayOffset++) {
|
||||||
// Create date using local timezone components (no UTC conversion)
|
const shiftDate = addDays(startDateParsed, dayOffset);
|
||||||
const shiftDate = new Date(year, month - 1, day + dayOffset);
|
|
||||||
const shiftDateStr = format(shiftDate, "yyyy-MM-dd");
|
const shiftDateStr = format(shiftDate, "yyyy-MM-dd");
|
||||||
|
|
||||||
if (site.contractStartDate && site.contractEndDate) {
|
if (site.contractStartDate && site.contractEndDate) {
|
||||||
@ -1166,8 +1163,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
const createdShiftsInTx = [];
|
const createdShiftsInTx = [];
|
||||||
|
|
||||||
for (let dayOffset = 0; dayOffset < days; dayOffset++) {
|
for (let dayOffset = 0; dayOffset < days; dayOffset++) {
|
||||||
// TIMEZONE FIX: Build date from components to maintain correct day
|
const shiftDate = addDays(startDateParsed, dayOffset);
|
||||||
const shiftDate = new Date(year, month - 1, day + dayOffset);
|
|
||||||
|
|
||||||
// Use site service schedule or default 24h
|
// Use site service schedule or default 24h
|
||||||
const serviceStart = site.serviceStartTime || "00:00";
|
const serviceStart = site.serviceStartTime || "00:00";
|
||||||
@ -1176,9 +1172,11 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
const [startHour, startMin] = serviceStart.split(":").map(Number);
|
const [startHour, startMin] = serviceStart.split(":").map(Number);
|
||||||
const [endHour, endMin] = serviceEnd.split(":").map(Number);
|
const [endHour, endMin] = serviceEnd.split(":").map(Number);
|
||||||
|
|
||||||
// Build timestamps using date components (no timezone conversion)
|
const shiftStart = new Date(shiftDate);
|
||||||
const shiftStart = new Date(year, month - 1, day + dayOffset, startHour, startMin, 0, 0);
|
shiftStart.setHours(startHour, startMin, 0, 0);
|
||||||
const shiftEnd = new Date(year, month - 1, day + dayOffset, endHour, endMin, 0, 0);
|
|
||||||
|
const shiftEnd = new Date(shiftDate);
|
||||||
|
shiftEnd.setHours(endHour, endMin, 0, 0);
|
||||||
|
|
||||||
// If service ends before it starts, it spans midnight (add 1 day to end)
|
// If service ends before it starts, it spans midnight (add 1 day to end)
|
||||||
if (shiftEnd <= shiftStart) {
|
if (shiftEnd <= shiftStart) {
|
||||||
@ -1925,166 +1923,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Report fatturazione per cliente
|
|
||||||
app.get("/api/reports/customer-billing", isAuthenticated, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const rawMonth = req.query.month as string || format(new Date(), "yyyy-MM");
|
|
||||||
const location = req.query.location as string || "roccapiemonte";
|
|
||||||
|
|
||||||
// Parse mese (formato: YYYY-MM)
|
|
||||||
const [year, month] = rawMonth.split("-").map(Number);
|
|
||||||
const monthStart = new Date(year, month - 1, 1);
|
|
||||||
monthStart.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const monthEnd = new Date(year, month, 0);
|
|
||||||
monthEnd.setHours(23, 59, 59, 999);
|
|
||||||
|
|
||||||
// Ottieni tutti i turni del mese per la sede con dettagli cliente e servizio
|
|
||||||
const monthShifts = await db
|
|
||||||
.select({
|
|
||||||
shift: shifts,
|
|
||||||
site: sites,
|
|
||||||
customer: customers,
|
|
||||||
serviceType: serviceTypes,
|
|
||||||
})
|
|
||||||
.from(shifts)
|
|
||||||
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
|
||||||
.leftJoin(customers, eq(sites.customerId, customers.id))
|
|
||||||
.leftJoin(serviceTypes, eq(sites.serviceTypeId, serviceTypes.id))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
gte(shifts.startTime, monthStart),
|
|
||||||
lte(shifts.startTime, monthEnd),
|
|
||||||
ne(shifts.status, "cancelled"),
|
|
||||||
eq(sites.location, location as any)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Raggruppa per cliente
|
|
||||||
const customerBillingMap: Record<string, any> = {};
|
|
||||||
|
|
||||||
monthShifts.forEach((shiftData: any) => {
|
|
||||||
const customerId = shiftData.customer?.id || "no-customer";
|
|
||||||
const customerName = shiftData.customer?.name || "Nessun Cliente";
|
|
||||||
const siteId = shiftData.site.id;
|
|
||||||
const siteName = shiftData.site.name;
|
|
||||||
const serviceTypeName = shiftData.serviceType?.name || "Non specificato";
|
|
||||||
|
|
||||||
const shiftStart = new Date(shiftData.shift.startTime);
|
|
||||||
const shiftEnd = new Date(shiftData.shift.endTime);
|
|
||||||
const minutes = differenceInMinutes(shiftEnd, shiftStart);
|
|
||||||
const hours = minutes / 60;
|
|
||||||
|
|
||||||
// Inizializza customer se non esiste
|
|
||||||
if (!customerBillingMap[customerId]) {
|
|
||||||
customerBillingMap[customerId] = {
|
|
||||||
customerId,
|
|
||||||
customerName,
|
|
||||||
sites: {},
|
|
||||||
totalHours: 0,
|
|
||||||
totalShifts: 0,
|
|
||||||
totalPatrolPassages: 0,
|
|
||||||
totalInspections: 0,
|
|
||||||
totalInterventions: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inizializza sito se non esiste
|
|
||||||
if (!customerBillingMap[customerId].sites[siteId]) {
|
|
||||||
customerBillingMap[customerId].sites[siteId] = {
|
|
||||||
siteId,
|
|
||||||
siteName,
|
|
||||||
serviceTypes: {},
|
|
||||||
totalHours: 0,
|
|
||||||
totalShifts: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inizializza service type se non esiste
|
|
||||||
if (!customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName]) {
|
|
||||||
customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName] = {
|
|
||||||
name: serviceTypeName,
|
|
||||||
hours: 0,
|
|
||||||
shifts: 0,
|
|
||||||
passages: 0,
|
|
||||||
inspections: 0,
|
|
||||||
interventions: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aggiorna conteggi
|
|
||||||
customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName].hours += hours;
|
|
||||||
customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName].shifts += 1;
|
|
||||||
customerBillingMap[customerId].sites[siteId].totalHours += hours;
|
|
||||||
customerBillingMap[customerId].sites[siteId].totalShifts += 1;
|
|
||||||
customerBillingMap[customerId].totalHours += hours;
|
|
||||||
customerBillingMap[customerId].totalShifts += 1;
|
|
||||||
|
|
||||||
// Conteggio specifico per tipo servizio (basato su parametri)
|
|
||||||
const serviceType = shiftData.serviceType;
|
|
||||||
if (serviceType) {
|
|
||||||
// Pattuglia/Ronda: conta numero passaggi
|
|
||||||
if (serviceType.name.toLowerCase().includes("pattuglia") ||
|
|
||||||
serviceType.name.toLowerCase().includes("ronda")) {
|
|
||||||
const passages = serviceType.patrolPassages || 1;
|
|
||||||
customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName].passages += passages;
|
|
||||||
customerBillingMap[customerId].totalPatrolPassages += passages;
|
|
||||||
}
|
|
||||||
// Ispezione: conta numero ispezioni
|
|
||||||
else if (serviceType.name.toLowerCase().includes("ispezione")) {
|
|
||||||
customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName].inspections += 1;
|
|
||||||
customerBillingMap[customerId].totalInspections += 1;
|
|
||||||
}
|
|
||||||
// Pronto Intervento: conta numero interventi
|
|
||||||
else if (serviceType.name.toLowerCase().includes("pronto") ||
|
|
||||||
serviceType.name.toLowerCase().includes("intervento")) {
|
|
||||||
customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName].interventions += 1;
|
|
||||||
customerBillingMap[customerId].totalInterventions += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Converti mappa in array e arrotonda ore
|
|
||||||
const customerReports = Object.values(customerBillingMap).map((customer: any) => {
|
|
||||||
const sitesArray = Object.values(customer.sites).map((site: any) => {
|
|
||||||
const serviceTypesArray = Object.values(site.serviceTypes).map((st: any) => ({
|
|
||||||
...st,
|
|
||||||
hours: Math.round(st.hours * 10) / 10,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
...site,
|
|
||||||
serviceTypes: serviceTypesArray,
|
|
||||||
totalHours: Math.round(site.totalHours * 10) / 10,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...customer,
|
|
||||||
sites: sitesArray,
|
|
||||||
totalHours: Math.round(customer.totalHours * 10) / 10,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
month: rawMonth,
|
|
||||||
location,
|
|
||||||
customers: customerReports,
|
|
||||||
summary: {
|
|
||||||
totalCustomers: customerReports.length,
|
|
||||||
totalHours: Math.round(customerReports.reduce((sum: number, c: any) => sum + c.totalHours, 0) * 10) / 10,
|
|
||||||
totalShifts: customerReports.reduce((sum: number, c: any) => sum + c.totalShifts, 0),
|
|
||||||
totalPatrolPassages: customerReports.reduce((sum: number, c: any) => sum + c.totalPatrolPassages, 0),
|
|
||||||
totalInspections: customerReports.reduce((sum: number, c: any) => sum + c.totalInspections, 0),
|
|
||||||
totalInterventions: customerReports.reduce((sum: number, c: any) => sum + c.totalInterventions, 0),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching customer billing report:", error);
|
|
||||||
res.status(500).json({ message: "Failed to fetch customer billing report", error: String(error) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============= CERTIFICATION ROUTES =============
|
// ============= CERTIFICATION ROUTES =============
|
||||||
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
10
version.json
10
version.json
@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.35",
|
"version": "1.0.34",
|
||||||
"lastUpdate": "2025-10-23T08:29:30.422Z",
|
"lastUpdate": "2025-10-23T08:03:06.051Z",
|
||||||
"changelog": [
|
"changelog": [
|
||||||
{
|
|
||||||
"version": "1.0.35",
|
|
||||||
"date": "2025-10-23",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.35"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"version": "1.0.34",
|
"version": "1.0.34",
|
||||||
"date": "2025-10-23",
|
"date": "2025-10-23",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user