Add customer billing reports and CSV export functionality

Implement a new API endpoint to fetch customer billing data, including site and service type breakdowns, and add functionality to export this data to CSV.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/kHMnjKS
This commit is contained in:
marco370 2025-10-23 08:26:33 +00:00
parent af98190e6d
commit 18aa847dab
3 changed files with 381 additions and 5 deletions

View File

@ -19,10 +19,6 @@ externalPort = 80
localPort = 33035 localPort = 33035
externalPort = 3001 externalPort = 3001
[[ports]]
localPort = 37831
externalPort = 5173
[[ports]] [[ports]]
localPort = 41295 localPort = 41295
externalPort = 6000 externalPort = 6000

View File

@ -35,6 +35,30 @@ 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"));
@ -79,6 +103,28 @@ 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();
@ -124,6 +170,28 @@ 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 */}
@ -177,7 +245,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-md grid-cols-2"> <TabsList className="grid w-full max-w-[600px] grid-cols-3">
<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
@ -186,6 +254,10 @@ 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 */}
@ -393,6 +465,154 @@ 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

@ -1925,6 +1925,166 @@ 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 {