VigilanzaTurni/client/src/pages/reports.tsx
marco370 b05bd3a0b9 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
2025-10-22 08:13:59 +00:00

400 lines
16 KiB
TypeScript

import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
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 [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();
},
});
// 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();
},
});
// 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="h-full overflow-auto p-6 space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold">Report e Export</h1>
<p className="text-muted-foreground">
Ore lavorate, buoni pasto e fatturazione
</p>
</div>
{/* 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-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>
) : (
<p className="text-center text-muted-foreground py-8">Nessun sito con ore fatturabili</p>
)}
</CardContent>
</Card>
</>
) : null}
</TabsContent>
</Tabs>
</div>
);
}