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
This commit is contained in:
marco370 2025-10-22 08:13:59 +00:00
parent efcaca356a
commit b05bd3a0b9
2 changed files with 605 additions and 198 deletions

View File

@ -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<ShiftWithDetails[]>({
queryKey: ["/api/shifts"],
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
const [selectedMonth, setSelectedMonth] = useState<string>(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<Guard[]>({
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<string, { name: string; hours: number }> = {};
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 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 monthlyShifts = completedShifts.filter(s => {
const shiftDate = new Date(s.startTime);
return shiftDate >= monthStart && shiftDate <= monthEnd;
});
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 (
<div className="space-y-6">
<div className="h-full overflow-auto p-6 space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-semibold mb-2">Report e Statistiche</h1>
<h1 className="text-3xl font-bold">Report e Export</h1>
<p className="text-muted-foreground">
Ore lavorate e copertura servizi
Ore lavorate, buoni pasto e fatturazione
</p>
</div>
{isLoading ? (
<div className="grid gap-4 md:grid-cols-3">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
) : (
<>
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Clock className="h-4 w-4" />
Ore Totali Lavorate
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold" data-testid="text-total-hours">
{totalHours}h
</p>
<p className="text-xs text-muted-foreground mt-1">
{completedShifts.length} turni completati
</p>
</CardContent>
</Card>
{/* Filtri */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-wrap items-center gap-4">
<div className="flex-1 min-w-[200px]">
<label className="text-sm font-medium mb-2 block">Sede</label>
<Select value={selectedLocation} onValueChange={(v) => setSelectedLocation(v as Location)}>
<SelectTrigger data-testid="select-location">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
</div>
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Ore Mese Corrente
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold" data-testid="text-monthly-hours">
{monthlyHours}h
</p>
<p className="text-xs text-muted-foreground mt-1">
{format(currentMonth, "MMMM yyyy", { locale: it })}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Users className="h-4 w-4" />
Guardie Attive
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold" data-testid="text-active-guards">
{guardStats.length}
</p>
<p className="text-xs text-muted-foreground mt-1">
Con turni completati
</p>
</CardContent>
</Card>
<div className="flex-1 min-w-[200px]">
<label className="text-sm font-medium mb-2 block">Mese</label>
<Select value={selectedMonth} onValueChange={setSelectedMonth}>
<SelectTrigger data-testid="select-month">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableMonths.map(month => {
const [year, monthNum] = month.split("-");
const date = new Date(parseInt(year), parseInt(monthNum) - 1);
return (
<SelectItem key={month} value={month}>
{format(date, "MMMM yyyy", { locale: it })}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Hours per Guard */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Ore per Guardia
</CardTitle>
<CardDescription>
Ore totali lavorate per ogni guardia
</CardDescription>
</CardHeader>
<CardContent>
{guardStats.length > 0 ? (
<div className="space-y-3">
{guardStats.map((stat, index) => (
<div
key={index}
className="flex items-center gap-4"
data-testid={`guard-stat-${index}`}
>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{stat.name}</p>
<div className="mt-1 h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary"
style={{
width: `${(stat.hours / (guardStats[0]?.hours || 1)) * 100}%`,
}}
/>
{/* Tabs Report */}
<Tabs defaultValue="guards" className="space-y-6">
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="guards" data-testid="tab-guard-report">
<Users className="h-4 w-4 mr-2" />
Report Guardie
</TabsTrigger>
<TabsTrigger value="sites" data-testid="tab-site-report">
<Building2 className="h-4 w-4 mr-2" />
Report Siti
</TabsTrigger>
</TabsList>
{/* Tab Report Guardie */}
<TabsContent value="guards" className="space-y-4">
{isLoadingGuards ? (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-64 w-full" />
</div>
) : guardReport ? (
<>
{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Users className="h-4 w-4" />
Guardie
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{guardReport.summary.totalGuards}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Clock className="h-4 w-4" />
Ore Ordinarie
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{guardReport.summary.totalOrdinaryHours}h</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Ore Straordinarie
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{guardReport.summary.totalOvertimeHours}h</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Buoni Pasto</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{guardReport.summary.totalMealVouchers}</p>
</CardContent>
</Card>
</div>
{/* Tabella guardie */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Dettaglio Ore per Guardia</CardTitle>
<CardDescription>Ordinarie, straordinarie e buoni pasto</CardDescription>
</div>
<Button onClick={exportGuardsCSV} data-testid="button-export-guards">
<Download className="h-4 w-4 mr-2" />
Export CSV
</Button>
</div>
</CardHeader>
<CardContent>
{guardReport.guards.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left p-3 font-medium">Guardia</th>
<th className="text-left p-3 font-medium">Badge</th>
<th className="text-right p-3 font-medium">Ore Ord.</th>
<th className="text-right p-3 font-medium">Ore Strao.</th>
<th className="text-right p-3 font-medium">Totale</th>
<th className="text-center p-3 font-medium">Buoni Pasto</th>
<th className="text-center p-3 font-medium">Giorni</th>
</tr>
</thead>
<tbody>
{guardReport.guards.map((guard) => (
<tr key={guard.guardId} className="border-b hover:bg-muted/50" data-testid={`guard-row-${guard.guardId}`}>
<td className="p-3">{guard.guardName}</td>
<td className="p-3"><Badge variant="outline">{guard.badgeNumber}</Badge></td>
<td className="p-3 text-right font-mono">{guard.ordinaryHours}h</td>
<td className="p-3 text-right font-mono text-orange-600 dark:text-orange-500">
{guard.overtimeHours > 0 ? `${guard.overtimeHours}h` : "-"}
</td>
<td className="p-3 text-right font-mono font-semibold">{guard.totalHours}h</td>
<td className="p-3 text-center">{guard.mealVouchers}</td>
<td className="p-3 text-center text-muted-foreground">{guard.workingDays}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-center text-muted-foreground py-8">Nessuna guardia con ore lavorate</p>
)}
</CardContent>
</Card>
</>
) : null}
</TabsContent>
{/* Tab Report Siti */}
<TabsContent value="sites" className="space-y-4">
{isLoadingSites ? (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-64 w-full" />
</div>
) : siteReport ? (
<>
{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Building2 className="h-4 w-4" />
Siti Attivi
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{siteReport.summary.totalSites}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Clock className="h-4 w-4" />
Ore Fatturabili
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{siteReport.summary.totalHours}h</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Turni Totali</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{siteReport.summary.totalShifts}</p>
</CardContent>
</Card>
</div>
{/* Tabella siti */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Ore Fatturabili per Sito</CardTitle>
<CardDescription>Raggruppate per tipologia servizio</CardDescription>
</div>
<Button onClick={exportSitesCSV} data-testid="button-export-sites">
<Download className="h-4 w-4 mr-2" />
Export CSV
</Button>
</div>
</CardHeader>
<CardContent>
{siteReport.sites.length > 0 ? (
<div className="space-y-4">
{siteReport.sites.map((site) => (
<div key={site.siteId} className="border rounded-md p-4" data-testid={`site-report-${site.siteId}`}>
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-lg">{site.siteName}</h3>
<div className="flex items-center gap-2">
<Badge>{site.totalHours}h totali</Badge>
<Badge variant="outline">{site.totalShifts} turni</Badge>
</div>
</div>
<div className="space-y-2">
{site.serviceTypes.map((st, idx) => (
<div key={idx} className="flex items-center justify-between text-sm p-2 rounded bg-muted/50">
<span>{st.name}</span>
<div className="flex items-center gap-4">
<span className="text-muted-foreground">{st.shifts} turni</span>
<span className="font-mono font-semibold">{st.hours}h</span>
</div>
</div>
))}
</div>
</div>
</div>
<div className="text-right">
<p className="text-lg font-semibold font-mono">{stat.hours}h</p>
</div>
))}
</div>
))}
</div>
) : (
<div className="text-center py-8">
<BarChart3 className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="text-sm text-muted-foreground">
Nessun dato disponibile
</p>
</div>
)}
</CardContent>
</Card>
{/* Recent Shifts Summary */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Turni Recenti
</CardTitle>
<CardDescription>
Ultimi turni completati
</CardDescription>
</CardHeader>
<CardContent>
{completedShifts.length > 0 ? (
<div className="space-y-3">
{completedShifts.slice(0, 5).map((shift) => (
<div
key={shift.id}
className="flex items-center justify-between p-3 rounded-md border"
data-testid={`recent-shift-${shift.id}`}
>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{shift.site.name}</p>
<p className="text-sm text-muted-foreground">
{format(new Date(shift.startTime), "dd MMM yyyy", { locale: it })}
</p>
</div>
<div className="text-right">
<p className="font-mono text-sm">
{differenceInHours(new Date(shift.endTime), new Date(shift.startTime))}h
</p>
<p className="text-xs text-muted-foreground">
{shift.assignments.length} guardie
</p>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8">
<Calendar className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="text-sm text-muted-foreground">
Nessun turno completato
</p>
</div>
)}
</CardContent>
</Card>
</>
)}
) : (
<p className="text-center text-muted-foreground py-8">Nessun sito con ore fatturabili</p>
)}
</CardContent>
</Card>
</>
) : null}
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -1673,6 +1673,241 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// ============= 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<string, number> = {};
const dailyHours: Record<string, number> = {}; // 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<string, any> = {};
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 {