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 {