Compare commits

..

No commits in common. "f34e8f9136c375839c2aa14462e483fd1198d666" and "1598eb208bdc9c70174c3d99328c2b71e057dfcc" have entirely different histories.

10 changed files with 19 additions and 1092 deletions

View File

@ -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

View File

@ -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} />

View File

@ -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",

View File

@ -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>
);
}

View File

@ -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>
); );

View File

@ -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:

View File

@ -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 {

View File

@ -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",