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 { useQuery } from "@tanstack/react-query";
import { ShiftWithDetails, Guard } from "@shared/schema";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; 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 { 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"; import { it } from "date-fns/locale";
export default function Reports() { type Location = "roccapiemonte" | "milano" | "roma";
const { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({
queryKey: ["/api/shifts"],
});
const { data: guards, isLoading: guardsLoading } = useQuery<Guard[]>({ interface GuardReport {
queryKey: ["/api/guards"], guardId: string;
}); guardName: string;
badgeNumber: string;
const isLoading = shiftsLoading || guardsLoading; ordinaryHours: number;
overtimeHours: number;
// Calculate statistics totalHours: number;
const completedShifts = shifts?.filter(s => s.status === "completed") || []; mealVouchers: number;
const totalHours = completedShifts.reduce((acc, shift) => { workingDays: number;
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;
}); interface SiteReport {
siteId: string;
siteName: string;
serviceTypes: {
name: string;
hours: number;
shifts: number;
}[];
totalHours: number;
totalShifts: number;
}
export default function Reports() {
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 guardStats = Object.values(hoursPerGuard).sort((a, b) => b.hours - a.hours); // Query per report siti
const { data: siteReport, isLoading: isLoadingSites } = useQuery<{
// Monthly statistics month: string;
const currentMonth = new Date(); location: string;
const monthStart = startOfMonth(currentMonth); sites: SiteReport[];
const monthEnd = endOfMonth(currentMonth); summary: {
totalSites: number;
const monthlyShifts = completedShifts.filter(s => { totalHours: number;
const shiftDate = new Date(s.startTime); totalShifts: number;
return shiftDate >= monthStart && shiftDate <= monthEnd; };
}>({
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 monthlyHours = monthlyShifts.reduce((acc, shift) => { // Genera mesi disponibili (ultimi 12 mesi)
return acc + differenceInHours(new Date(shift.endTime), new Date(shift.startTime)); const availableMonths = Array.from({ length: 12 }, (_, i) => {
}, 0); const date = new Date();
date.setMonth(date.getMonth() - i);
return format(date, "yyyy-MM");
});
// Export CSV guardie
const exportGuardsCSV = () => {
if (!guardReport?.guards) return;
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();
};
// 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 ( return (
<div className="space-y-6"> <div className="h-full overflow-auto p-6 space-y-6">
{/* Header */}
<div> <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"> <p className="text-muted-foreground">
Ore lavorate e copertura servizi Ore lavorate, buoni pasto e fatturazione
</p> </p>
</div> </div>
{isLoading ? ( {/* Filtri */}
<div className="grid gap-4 md:grid-cols-3"> <Card>
<Skeleton className="h-32" /> <CardContent className="pt-6">
<Skeleton className="h-32" /> <div className="flex flex-wrap items-center gap-4">
<Skeleton className="h-32" /> <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> </div>
) : (
<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>
{/* 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 */} {/* Summary cards */}
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-4">
<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>
<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> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2"> <CardDescription className="flex items-center gap-2">
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
Guardie Attive Guardie
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-3xl font-semibold" data-testid="text-active-guards"> <p className="text-2xl font-semibold">{guardReport.summary.totalGuards}</p>
{guardStats.length} </CardContent>
</p> </Card>
<p className="text-xs text-muted-foreground mt-1">
Con turni completati <Card>
</p> <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> </CardContent>
</Card> </Card>
</div> </div>
{/* Hours per Guard */} {/* Tabella guardie */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <div className="flex items-center justify-between">
<BarChart3 className="h-5 w-5" /> <div>
Ore per Guardia <CardTitle>Dettaglio Ore per Guardia</CardTitle>
</CardTitle> <CardDescription>Ordinarie, straordinarie e buoni pasto</CardDescription>
<CardDescription> </div>
Ore totali lavorate per ogni guardia <Button onClick={exportGuardsCSV} data-testid="button-export-guards">
</CardDescription> <Download className="h-4 w-4 mr-2" />
Export CSV
</Button>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{guardStats.length > 0 ? ( {guardReport.guards.length > 0 ? (
<div className="space-y-3"> <div className="overflow-x-auto">
{guardStats.map((stat, index) => ( <table className="w-full">
<div <thead>
key={index} <tr className="border-b">
className="flex items-center gap-4" <th className="text-left p-3 font-medium">Guardia</th>
data-testid={`guard-stat-${index}`} <th className="text-left p-3 font-medium">Badge</th>
> <th className="text-right p-3 font-medium">Ore Ord.</th>
<div className="flex-1 min-w-0"> <th className="text-right p-3 font-medium">Ore Strao.</th>
<p className="font-medium truncate">{stat.name}</p> <th className="text-right p-3 font-medium">Totale</th>
<div className="mt-1 h-2 bg-secondary rounded-full overflow-hidden"> <th className="text-center p-3 font-medium">Buoni Pasto</th>
<div <th className="text-center p-3 font-medium">Giorni</th>
className="h-full bg-primary" </tr>
style={{ </thead>
width: `${(stat.hours / (guardStats[0]?.hours || 1)) * 100}%`, <tbody>
}} {guardReport.guards.map((guard) => (
/> <tr key={guard.guardId} className="border-b hover:bg-muted/50" data-testid={`guard-row-${guard.guardId}`}>
</div> <td className="p-3">{guard.guardName}</td>
</div> <td className="p-3"><Badge variant="outline">{guard.badgeNumber}</Badge></td>
<div className="text-right"> <td className="p-3 text-right font-mono">{guard.ordinaryHours}h</td>
<p className="text-lg font-semibold font-mono">{stat.hours}h</p> <td className="p-3 text-right font-mono text-orange-600 dark:text-orange-500">
</div> {guard.overtimeHours > 0 ? `${guard.overtimeHours}h` : "-"}
</div> </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> </div>
) : ( ) : (
<div className="text-center py-8"> <p className="text-center text-muted-foreground py-8">Nessuna guardia con ore lavorate</p>
<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> </CardContent>
</Card> </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>
) : (
<p className="text-center text-muted-foreground py-8">Nessun sito con ore fatturabili</p>
)} )}
</CardContent>
</Card>
</>
) : null}
</TabsContent>
</Tabs>
</div> </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 ============= // ============= CERTIFICATION ROUTES =============
app.post("/api/certifications", isAuthenticated, async (req, res) => { app.post("/api/certifications", isAuthenticated, async (req, res) => {
try { try {