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 { 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";
|
||||||
|
|
||||||
|
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() {
|
export default function Reports() {
|
||||||
const { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({
|
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
|
||||||
queryKey: ["/api/shifts"],
|
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[]>({
|
// Query per report siti
|
||||||
queryKey: ["/api/guards"],
|
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;
|
// Genera mesi disponibili (ultimi 12 mesi)
|
||||||
|
const availableMonths = Array.from({ length: 12 }, (_, i) => {
|
||||||
// Calculate statistics
|
const date = new Date();
|
||||||
const completedShifts = shifts?.filter(s => s.status === "completed") || [];
|
date.setMonth(date.getMonth() - i);
|
||||||
const totalHours = completedShifts.reduce((acc, shift) => {
|
return format(date, "yyyy-MM");
|
||||||
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;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const guardStats = Object.values(hoursPerGuard).sort((a, b) => b.hours - a.hours);
|
// Export CSV guardie
|
||||||
|
const exportGuardsCSV = () => {
|
||||||
|
if (!guardReport?.guards) return;
|
||||||
|
|
||||||
// Monthly statistics
|
const headers = "Guardia,Badge,Ore Ordinarie,Ore Straordinarie,Ore Totali,Buoni Pasto,Giorni Lavorativi\n";
|
||||||
const currentMonth = new Date();
|
const rows = guardReport.guards.map(g =>
|
||||||
const monthStart = startOfMonth(currentMonth);
|
`"${g.guardName}",${g.badgeNumber},${g.ordinaryHours},${g.overtimeHours},${g.totalHours},${g.mealVouchers},${g.workingDays}`
|
||||||
const monthEnd = endOfMonth(currentMonth);
|
).join("\n");
|
||||||
|
|
||||||
const monthlyShifts = completedShifts.filter(s => {
|
const csv = headers + rows;
|
||||||
const shiftDate = new Date(s.startTime);
|
const blob = new Blob([csv], { type: "text/csv" });
|
||||||
return shiftDate >= monthStart && shiftDate <= monthEnd;
|
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) => {
|
// Export CSV siti
|
||||||
return acc + differenceInHours(new Date(shift.endTime), new Date(shift.startTime));
|
const exportSitesCSV = () => {
|
||||||
}, 0);
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 =============
|
// ============= CERTIFICATION ROUTES =============
|
||||||
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user