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
|
localPort = 33035
|
||||||
externalPort = 3001
|
externalPort = 3001
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 37831
|
|
||||||
externalPort = 5173
|
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 41295
|
localPort = 41295
|
||||||
externalPort = 6000
|
externalPort = 6000
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
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 =============
|
// ============= CERTIFICATION ROUTES =============
|
||||||
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user