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:
parent
af98190e6d
commit
18aa847dab
4
.replit
4
.replit
@ -19,10 +19,6 @@ externalPort = 80
|
||||
localPort = 33035
|
||||
externalPort = 3001
|
||||
|
||||
[[ports]]
|
||||
localPort = 37831
|
||||
externalPort = 5173
|
||||
|
||||
[[ports]]
|
||||
localPort = 41295
|
||||
externalPort = 6000
|
||||
|
||||
@ -35,6 +35,30 @@ interface SiteReport {
|
||||
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() {
|
||||
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
|
||||
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)
|
||||
const availableMonths = Array.from({ length: 12 }, (_, i) => {
|
||||
const date = new Date();
|
||||
@ -124,6 +170,28 @@ export default function Reports() {
|
||||
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 (
|
||||
<div className="h-full overflow-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
@ -177,7 +245,7 @@ export default function Reports() {
|
||||
|
||||
{/* Tabs Report */}
|
||||
<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">
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Report Guardie
|
||||
@ -186,6 +254,10 @@ export default function Reports() {
|
||||
<Building2 className="h-4 w-4 mr-2" />
|
||||
Report Siti
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="customers" data-testid="tab-customer-report">
|
||||
<Building2 className="h-4 w-4 mr-2" />
|
||||
Report Clienti
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab Report Guardie */}
|
||||
@ -393,6 +465,154 @@ export default function Reports() {
|
||||
</>
|
||||
) : null}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
160
server/routes.ts
160
server/routes.ts
@ -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 =============
|
||||
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user