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:
parent
efcaca356a
commit
b05bd3a0b9
@ -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";
|
||||
|
||||
export default function Reports() {
|
||||
const { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({
|
||||
queryKey: ["/api/shifts"],
|
||||
});
|
||||
type Location = "roccapiemonte" | "milano" | "roma";
|
||||
|
||||
const { data: guards, isLoading: guardsLoading } = useQuery<Guard[]>({
|
||||
queryKey: ["/api/guards"],
|
||||
});
|
||||
|
||||
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 };
|
||||
interface GuardReport {
|
||||
guardId: string;
|
||||
guardName: string;
|
||||
badgeNumber: string;
|
||||
ordinaryHours: number;
|
||||
overtimeHours: number;
|
||||
totalHours: number;
|
||||
mealVouchers: number;
|
||||
workingDays: number;
|
||||
}
|
||||
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);
|
||||
|
||||
// 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;
|
||||
// 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 monthlyHours = monthlyShifts.reduce((acc, shift) => {
|
||||
return acc + differenceInHours(new Date(shift.endTime), new Date(shift.startTime));
|
||||
}, 0);
|
||||
// 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");
|
||||
});
|
||||
|
||||
// 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 (
|
||||
<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" />
|
||||
{/* 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>
|
||||
) : (
|
||||
|
||||
<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 */}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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 Attive
|
||||
Guardie
|
||||
</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>
|
||||
<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>
|
||||
|
||||
{/* Hours per Guard */}
|
||||
{/* Tabella guardie */}
|
||||
<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>
|
||||
<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>
|
||||
{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}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold font-mono">{stat.hours}h</p>
|
||||
</div>
|
||||
</div>
|
||||
{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>
|
||||
) : (
|
||||
<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>
|
||||
<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>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8">Nessun sito con ore fatturabili</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
235
server/routes.ts
235
server/routes.ts
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user