From b05bd3a0b9fd8f4740b341b5f6b49aea2302130e Mon Sep 17 00:00:00 2001 From: marco370 <48531002-marco370@users.noreply.replit.com> Date: Wed, 22 Oct 2025 08:13:59 +0000 Subject: [PATCH] Add monthly guard and site reports for specific locations Implement new API endpoints and UI components to generate and display monthly reports for guard hours (including overtime and meal vouchers) and billable site hours, with filtering by month and location. 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/KiuJzNf --- client/src/pages/reports.tsx | 568 +++++++++++++++++++++++------------ server/routes.ts | 235 +++++++++++++++ 2 files changed, 605 insertions(+), 198 deletions(-) diff --git a/client/src/pages/reports.tsx b/client/src/pages/reports.tsx index d1f1587..0bdb5da 100644 --- a/client/src/pages/reports.tsx +++ b/client/src/pages/reports.tsx @@ -1,227 +1,399 @@ +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { ShiftWithDetails, Guard } from "@shared/schema"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { BarChart3, Users, Clock, Calendar, TrendingUp } from "lucide-react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; -import { differenceInHours, format, startOfMonth, endOfMonth } from "date-fns"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { Download, Users, Building2, Clock, TrendingUp } from "lucide-react"; +import { format } from "date-fns"; import { it } from "date-fns/locale"; +type Location = "roccapiemonte" | "milano" | "roma"; + +interface GuardReport { + guardId: string; + guardName: string; + badgeNumber: string; + ordinaryHours: number; + overtimeHours: number; + totalHours: number; + mealVouchers: number; + workingDays: number; +} + +interface SiteReport { + siteId: string; + siteName: string; + serviceTypes: { + name: string; + hours: number; + shifts: number; + }[]; + totalHours: number; + totalShifts: number; +} + export default function Reports() { - const { data: shifts, isLoading: shiftsLoading } = useQuery({ - queryKey: ["/api/shifts"], + const [selectedLocation, setSelectedLocation] = useState("roccapiemonte"); + const [selectedMonth, setSelectedMonth] = useState(format(new Date(), "yyyy-MM")); + + // Query per report guardie + const { data: guardReport, isLoading: isLoadingGuards } = useQuery<{ + month: string; + location: string; + guards: GuardReport[]; + summary: { + totalGuards: number; + totalOrdinaryHours: number; + totalOvertimeHours: number; + totalHours: number; + totalMealVouchers: number; + }; + }>({ + queryKey: ["/api/reports/monthly-guard-hours", selectedMonth, selectedLocation], + queryFn: async () => { + const response = await fetch(`/api/reports/monthly-guard-hours?month=${selectedMonth}&location=${selectedLocation}`); + if (!response.ok) throw new Error("Failed to fetch guard report"); + return response.json(); + }, }); - const { data: guards, isLoading: guardsLoading } = useQuery({ - queryKey: ["/api/guards"], + // Query per report siti + const { data: siteReport, isLoading: isLoadingSites } = useQuery<{ + month: string; + location: string; + sites: SiteReport[]; + summary: { + totalSites: number; + totalHours: number; + totalShifts: number; + }; + }>({ + queryKey: ["/api/reports/billable-site-hours", selectedMonth, selectedLocation], + queryFn: async () => { + const response = await fetch(`/api/reports/billable-site-hours?month=${selectedMonth}&location=${selectedLocation}`); + if (!response.ok) throw new Error("Failed to fetch site report"); + return response.json(); + }, }); - const isLoading = shiftsLoading || guardsLoading; - - // Calculate statistics - const completedShifts = shifts?.filter(s => s.status === "completed") || []; - const totalHours = completedShifts.reduce((acc, shift) => { - return acc + differenceInHours(new Date(shift.endTime), new Date(shift.startTime)); - }, 0); - - // Hours per guard - const hoursPerGuard: Record = {}; - completedShifts.forEach(shift => { - shift.assignments.forEach(assignment => { - const guardId = assignment.guardId; - const guardName = `${assignment.guard.user?.firstName || ""} ${assignment.guard.user?.lastName || ""}`.trim(); - const hours = differenceInHours(new Date(shift.endTime), new Date(shift.startTime)); - - if (!hoursPerGuard[guardId]) { - hoursPerGuard[guardId] = { name: guardName, hours: 0 }; - } - hoursPerGuard[guardId].hours += hours; - }); + // Genera mesi disponibili (ultimi 12 mesi) + const availableMonths = Array.from({ length: 12 }, (_, i) => { + const date = new Date(); + date.setMonth(date.getMonth() - i); + return format(date, "yyyy-MM"); }); - const guardStats = Object.values(hoursPerGuard).sort((a, b) => b.hours - a.hours); + // Export CSV guardie + const exportGuardsCSV = () => { + if (!guardReport?.guards) return; - // Monthly statistics - const currentMonth = new Date(); - const monthStart = startOfMonth(currentMonth); - const monthEnd = endOfMonth(currentMonth); - - const monthlyShifts = completedShifts.filter(s => { - const shiftDate = new Date(s.startTime); - return shiftDate >= monthStart && shiftDate <= monthEnd; - }); + const headers = "Guardia,Badge,Ore Ordinarie,Ore Straordinarie,Ore Totali,Buoni Pasto,Giorni Lavorativi\n"; + const rows = guardReport.guards.map(g => + `"${g.guardName}",${g.badgeNumber},${g.ordinaryHours},${g.overtimeHours},${g.totalHours},${g.mealVouchers},${g.workingDays}` + ).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 = `ore_guardie_${selectedMonth}_${selectedLocation}.csv`; + a.click(); + }; - const monthlyHours = monthlyShifts.reduce((acc, shift) => { - return acc + differenceInHours(new Date(shift.endTime), new Date(shift.startTime)); - }, 0); + // Export CSV siti + const exportSitesCSV = () => { + if (!siteReport?.sites) return; + + const headers = "Sito,Tipologia Servizio,Ore,Turni\n"; + const rows = siteReport.sites.flatMap(s => + s.serviceTypes.map(st => + `"${s.siteName}","${st.name}",${st.hours},${st.shifts}` + ) + ).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 = `ore_siti_${selectedMonth}_${selectedLocation}.csv`; + a.click(); + }; return ( -
+
+ {/* Header */}
-

Report e Statistiche

+

Report e Export

- Ore lavorate e copertura servizi + Ore lavorate, buoni pasto e fatturazione

- {isLoading ? ( -
- - - -
- ) : ( - <> - {/* Summary Cards */} -
- - - - - Ore Totali Lavorate - - - -

- {totalHours}h -

-

- {completedShifts.length} turni completati -

-
-
+ {/* Filtri */} + + +
+
+ + +
- - - - - Ore Mese Corrente - - - -

- {monthlyHours}h -

-

- {format(currentMonth, "MMMM yyyy", { locale: it })} -

-
-
- - - - - - Guardie Attive - - - -

- {guardStats.length} -

-

- Con turni completati -

-
-
+
+ + +
+
+
- {/* Hours per Guard */} - - - - - Ore per Guardia - - - Ore totali lavorate per ogni guardia - - - - {guardStats.length > 0 ? ( -
- {guardStats.map((stat, index) => ( -
-
-

{stat.name}

-
-
+ {/* Tabs Report */} + + + + + Report Guardie + + + + Report Siti + + + + {/* Tab Report Guardie */} + + {isLoadingGuards ? ( +
+ + +
+ ) : guardReport ? ( + <> + {/* Summary cards */} +
+ + + + + Guardie + + + +

{guardReport.summary.totalGuards}

+
+
+ + + + + + Ore Ordinarie + + + +

{guardReport.summary.totalOrdinaryHours}h

+
+
+ + + + + + Ore Straordinarie + + + +

{guardReport.summary.totalOvertimeHours}h

+
+
+ + + + Buoni Pasto + + +

{guardReport.summary.totalMealVouchers}

+
+
+
+ + {/* Tabella guardie */} + + +
+
+ Dettaglio Ore per Guardia + Ordinarie, straordinarie e buoni pasto +
+ +
+
+ + {guardReport.guards.length > 0 ? ( +
+ + + + + + + + + + + + + + {guardReport.guards.map((guard) => ( + + + + + + + + + + ))} + +
GuardiaBadgeOre Ord.Ore Strao.TotaleBuoni PastoGiorni
{guard.guardName}{guard.badgeNumber}{guard.ordinaryHours}h + {guard.overtimeHours > 0 ? `${guard.overtimeHours}h` : "-"} + {guard.totalHours}h{guard.mealVouchers}{guard.workingDays}
+
+ ) : ( +

Nessuna guardia con ore lavorate

+ )} +
+
+ + ) : null} +
+ + {/* Tab Report Siti */} + + {isLoadingSites ? ( +
+ + +
+ ) : siteReport ? ( + <> + {/* Summary cards */} +
+ + + + + Siti Attivi + + + +

{siteReport.summary.totalSites}

+
+
+ + + + + + Ore Fatturabili + + + +

{siteReport.summary.totalHours}h

+
+
+ + + + Turni Totali + + +

{siteReport.summary.totalShifts}

+
+
+
+ + {/* Tabella siti */} + + +
+
+ Ore Fatturabili per Sito + Raggruppate per tipologia servizio +
+ +
+
+ + {siteReport.sites.length > 0 ? ( +
+ {siteReport.sites.map((site) => ( +
+
+

{site.siteName}

+
+ {site.totalHours}h totali + {site.totalShifts} turni +
+
+
+ {site.serviceTypes.map((st, idx) => ( +
+ {st.name} +
+ {st.shifts} turni + {st.hours}h +
+
+ ))} +
-
-
-

{stat.hours}h

-
+ ))}
- ))} -
- ) : ( -
- -

- Nessun dato disponibile -

-
- )} - - - - {/* Recent Shifts Summary */} - - - - - Turni Recenti - - - Ultimi turni completati - - - - {completedShifts.length > 0 ? ( -
- {completedShifts.slice(0, 5).map((shift) => ( -
-
-

{shift.site.name}

-

- {format(new Date(shift.startTime), "dd MMM yyyy", { locale: it })} -

-
-
-

- {differenceInHours(new Date(shift.endTime), new Date(shift.startTime))}h -

-

- {shift.assignments.length} guardie -

-
-
- ))} -
- ) : ( -
- -

- Nessun turno completato -

-
- )} -
-
- - )} + ) : ( +

Nessun sito con ore fatturabili

+ )} + + + + ) : null} + +
); } diff --git a/server/routes.ts b/server/routes.ts index ba47639..6628d50 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -1673,6 +1673,241 @@ export async function registerRoutes(app: Express): Promise { } }); + // ============= REPORTS ROUTES ============= + + // Report mensile ore per guardia (ordinarie/straordinarie + buoni pasto) + app.get("/api/reports/monthly-guard-hours", 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); // ultimo giorno del mese + monthEnd.setHours(23, 59, 59, 999); + + // Ottieni tutte le guardie della sede + const allGuards = await db + .select() + .from(guards) + .where(eq(guards.location, location as any)) + .orderBy(guards.fullName); + + // Ottieni tutti i turni del mese per la sede + const monthShifts = await db + .select({ + shift: shifts, + site: sites, + }) + .from(shifts) + .innerJoin(sites, eq(shifts.siteId, sites.id)) + .where( + and( + gte(shifts.startTime, monthStart), + lte(shifts.startTime, monthEnd), + ne(shifts.status, "cancelled"), + eq(sites.location, location as any) + ) + ); + + // Ottieni tutte le assegnazioni del mese + const shiftIds = monthShifts.map((s: any) => s.shift.id); + const assignments = shiftIds.length > 0 ? await db + .select({ + assignment: shiftAssignments, + guard: guards, + }) + .from(shiftAssignments) + .innerJoin(guards, eq(shiftAssignments.guardId, guards.id)) + .where( + sql`${shiftAssignments.shiftId} IN (${sql.join(shiftIds.map((id: string) => sql`${id}`), sql`, `)})` + ) : []; + + // Calcola statistiche per ogni guardia + const guardReports = allGuards.map((guard: any) => { + const guardAssignments = assignments.filter((a: any) => a.guard.id === guard.id); + + // Raggruppa assegnazioni per settimana (lunedì = inizio settimana) + const weeklyHours: Record = {}; + const dailyHours: Record = {}; // Per calcolare buoni pasto + + guardAssignments.forEach((a: any) => { + const plannedStart = new Date(a.assignment.plannedStartTime); + const plannedEnd = new Date(a.assignment.plannedEndTime); + const minutes = differenceInMinutes(plannedEnd, plannedStart); + const hours = minutes / 60; + + // Settimana (ISO week - lunedì come primo giorno) + const weekStart = startOfWeek(plannedStart, { weekStartsOn: 1 }); + const weekKey = format(weekStart, "yyyy-MM-dd"); + weeklyHours[weekKey] = (weeklyHours[weekKey] || 0) + hours; + + // Giorno (per buoni pasto) + const dayKey = format(plannedStart, "yyyy-MM-dd"); + dailyHours[dayKey] = (dailyHours[dayKey] || 0) + hours; + }); + + // Calcola ore ordinarie e straordinarie + let ordinaryHours = 0; + let overtimeHours = 0; + + Object.values(weeklyHours).forEach((weekHours: number) => { + if (weekHours <= 40) { + ordinaryHours += weekHours; + } else { + ordinaryHours += 40; + overtimeHours += (weekHours - 40); + } + }); + + // Calcola buoni pasto (giorni con ore ≥ 6) + const mealVouchers = Object.values(dailyHours).filter( + (dayHours: number) => dayHours >= 6 + ).length; + + const totalHours = ordinaryHours + overtimeHours; + + return { + guardId: guard.id, + guardName: guard.fullName, + badgeNumber: guard.badgeNumber, + ordinaryHours: Math.round(ordinaryHours * 10) / 10, + overtimeHours: Math.round(overtimeHours * 10) / 10, + totalHours: Math.round(totalHours * 10) / 10, + mealVouchers, + workingDays: Object.keys(dailyHours).length, + }; + }); + + // Filtra solo guardie con ore lavorate + const guardsWithHours = guardReports.filter((g: any) => g.totalHours > 0); + + res.json({ + month: rawMonth, + location, + guards: guardsWithHours, + summary: { + totalGuards: guardsWithHours.length, + totalOrdinaryHours: Math.round(guardsWithHours.reduce((sum: number, g: any) => sum + g.ordinaryHours, 0) * 10) / 10, + totalOvertimeHours: Math.round(guardsWithHours.reduce((sum: number, g: any) => sum + g.overtimeHours, 0) * 10) / 10, + totalHours: Math.round(guardsWithHours.reduce((sum: number, g: any) => sum + g.totalHours, 0) * 10) / 10, + totalMealVouchers: guardsWithHours.reduce((sum: number, g: any) => sum + g.mealVouchers, 0), + }, + }); + } catch (error) { + console.error("Error fetching monthly guard hours:", error); + res.status(500).json({ message: "Failed to fetch monthly guard hours", error: String(error) }); + } + }); + + // Report ore fatturabili per sito per tipologia servizio + app.get("/api/reports/billable-site-hours", 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 sito e servizio + const monthShifts = await db + .select({ + shift: shifts, + site: sites, + serviceType: serviceTypes, + }) + .from(shifts) + .innerJoin(sites, eq(shifts.siteId, sites.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 sito + const siteHoursMap: Record = {}; + + monthShifts.forEach((shiftData: any) => { + 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; + + if (!siteHoursMap[siteId]) { + siteHoursMap[siteId] = { + siteId, + siteName, + serviceTypes: {}, + totalHours: 0, + totalShifts: 0, + }; + } + + if (!siteHoursMap[siteId].serviceTypes[serviceTypeName]) { + siteHoursMap[siteId].serviceTypes[serviceTypeName] = { + hours: 0, + shifts: 0, + }; + } + + siteHoursMap[siteId].serviceTypes[serviceTypeName].hours += hours; + siteHoursMap[siteId].serviceTypes[serviceTypeName].shifts += 1; + siteHoursMap[siteId].totalHours += hours; + siteHoursMap[siteId].totalShifts += 1; + }); + + // Converti mappa in array e arrotonda ore + const siteReports = Object.values(siteHoursMap).map((site: any) => { + const serviceTypesArray = Object.entries(site.serviceTypes).map(([name, data]: [string, any]) => ({ + name, + hours: Math.round(data.hours * 10) / 10, + shifts: data.shifts, + })); + + return { + siteId: site.siteId, + siteName: site.siteName, + serviceTypes: serviceTypesArray, + totalHours: Math.round(site.totalHours * 10) / 10, + totalShifts: site.totalShifts, + }; + }); + + // Ordina per ore totali (decrescente) + siteReports.sort((a: any, b: any) => b.totalHours - a.totalHours); + + res.json({ + month: rawMonth, + location, + sites: siteReports, + summary: { + totalSites: siteReports.length, + totalHours: Math.round(siteReports.reduce((sum: number, s: any) => sum + s.totalHours, 0) * 10) / 10, + totalShifts: siteReports.reduce((sum: number, s: any) => sum + s.totalShifts, 0), + }, + }); + } catch (error) { + console.error("Error fetching billable site hours:", error); + res.status(500).json({ message: "Failed to fetch billable site hours", error: String(error) }); + } + }); + // ============= CERTIFICATION ROUTES ============= app.post("/api/certifications", isAuthenticated, async (req, res) => { try {