diff --git a/.replit b/.replit index 9a975de..aa839ef 100644 --- a/.replit +++ b/.replit @@ -19,10 +19,6 @@ externalPort = 80 localPort = 33035 externalPort = 3001 -[[ports]] -localPort = 37831 -externalPort = 5173 - [[ports]] localPort = 41295 externalPort = 6000 diff --git a/client/src/pages/reports.tsx b/client/src/pages/reports.tsx index 0bdb5da..812dd79 100644 --- a/client/src/pages/reports.tsx +++ b/client/src/pages/reports.tsx @@ -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("roccapiemonte"); const [selectedMonth, setSelectedMonth] = useState(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 (
{/* Header */} @@ -177,7 +245,7 @@ export default function Reports() { {/* Tabs Report */} - + Report Guardie @@ -186,6 +254,10 @@ export default function Reports() { Report Siti + + + Report Clienti + {/* Tab Report Guardie */} @@ -393,6 +465,154 @@ export default function Reports() { ) : null} + + {/* Tab Report Clienti */} + + {isLoadingCustomers ? ( +
+ + +
+ ) : customerReport ? ( + <> + {/* Summary cards */} +
+ + + + + Clienti + + + +

{customerReport.summary.totalCustomers}

+
+
+ + + + + + Ore Totali + + + +

{customerReport.summary.totalHours}h

+
+
+ + + + Passaggi + + +

{customerReport.summary.totalPatrolPassages}

+
+
+ + + + Ispezioni + + +

{customerReport.summary.totalInspections}

+
+
+ + + + Interventi + + +

{customerReport.summary.totalInterventions}

+
+
+
+ + {/* Tabella clienti */} + + +
+
+ Fatturazione per Cliente + Dettaglio siti e servizi erogati +
+ +
+
+ + {customerReport.customers.length > 0 ? ( +
+ {customerReport.customers.map((customer) => ( +
+ {/* Header Cliente */} +
+
+

{customer.customerName}

+

{customer.sites.length} siti attivi

+
+
+ {customer.totalHours}h totali + {customer.totalShifts} turni + {customer.totalPatrolPassages > 0 && ( + {customer.totalPatrolPassages} passaggi + )} + {customer.totalInspections > 0 && ( + {customer.totalInspections} ispezioni + )} + {customer.totalInterventions > 0 && ( + {customer.totalInterventions} interventi + )} +
+
+ + {/* Lista Siti */} +
+ {customer.sites.map((site) => ( +
+
+

{site.siteName}

+
+ {site.totalHours}h + {site.totalShifts} turni +
+
+
+ {site.serviceTypes.map((st, idx) => ( +
+ {st.name} +
+ {st.hours > 0 && {st.hours}h} + {st.passages > 0 && ( + {st.passages} passaggi + )} + {st.inspections > 0 && ( + {st.inspections} ispezioni + )} + {st.interventions > 0 && ( + {st.interventions} interventi + )} +
+
+ ))} +
+
+ ))} +
+
+ ))} +
+ ) : ( +

Nessun cliente con servizi fatturabili

+ )} +
+
+ + ) : null} +
); diff --git a/server/routes.ts b/server/routes.ts index 2dc6cad..74033a2 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -1925,6 +1925,166 @@ export async function registerRoutes(app: Express): Promise { } }); + // 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 = {}; + + 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 {