Initial commit adds core UI components, including layout elements, form controls, and navigation elements, along with the main application structure and routing. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 99f0fce6-9386-489a-9632-1d81223cab44 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/99f0fce6-9386-489a-9632-1d81223cab44/nGJAldO
228 lines
8.5 KiB
TypeScript
228 lines
8.5 KiB
TypeScript
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 { Skeleton } from "@/components/ui/skeleton";
|
|
import { differenceInHours, format, startOfMonth, endOfMonth } from "date-fns";
|
|
import { it } from "date-fns/locale";
|
|
|
|
export default function Reports() {
|
|
const { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({
|
|
queryKey: ["/api/shifts"],
|
|
});
|
|
|
|
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 };
|
|
}
|
|
hoursPerGuard[guardId].hours += hours;
|
|
});
|
|
});
|
|
|
|
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;
|
|
});
|
|
|
|
const monthlyHours = monthlyShifts.reduce((acc, shift) => {
|
|
return acc + differenceInHours(new Date(shift.endTime), new Date(shift.startTime));
|
|
}, 0);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-3xl font-semibold mb-2">Report e Statistiche</h1>
|
|
<p className="text-muted-foreground">
|
|
Ore lavorate e copertura servizi
|
|
</p>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<Skeleton className="h-32" />
|
|
<Skeleton className="h-32" />
|
|
<Skeleton className="h-32" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Summary Cards */}
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardDescription className="flex items-center gap-2">
|
|
<Clock className="h-4 w-4" />
|
|
Ore Totali Lavorate
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-3xl font-semibold" data-testid="text-total-hours">
|
|
{totalHours}h
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{completedShifts.length} turni completati
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardDescription className="flex items-center gap-2">
|
|
<Calendar className="h-4 w-4" />
|
|
Ore Mese Corrente
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-3xl font-semibold" data-testid="text-monthly-hours">
|
|
{monthlyHours}h
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{format(currentMonth, "MMMM yyyy", { locale: it })}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardDescription className="flex items-center gap-2">
|
|
<Users className="h-4 w-4" />
|
|
Guardie Attive
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-3xl font-semibold" data-testid="text-active-guards">
|
|
{guardStats.length}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Con turni completati
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Hours per Guard */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<BarChart3 className="h-5 w-5" />
|
|
Ore per Guardia
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Ore totali lavorate per ogni guardia
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{guardStats.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{guardStats.map((stat, index) => (
|
|
<div
|
|
key={index}
|
|
className="flex items-center gap-4"
|
|
data-testid={`guard-stat-${index}`}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium truncate">{stat.name}</p>
|
|
<div className="mt-1 h-2 bg-secondary rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-primary"
|
|
style={{
|
|
width: `${(stat.hours / (guardStats[0]?.hours || 1)) * 100}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-lg font-semibold font-mono">{stat.hours}h</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8">
|
|
<BarChart3 className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
|
|
<p className="text-sm text-muted-foreground">
|
|
Nessun dato disponibile
|
|
</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Recent Shifts Summary */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<TrendingUp className="h-5 w-5" />
|
|
Turni Recenti
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Ultimi turni completati
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{completedShifts.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{completedShifts.slice(0, 5).map((shift) => (
|
|
<div
|
|
key={shift.id}
|
|
className="flex items-center justify-between p-3 rounded-md border"
|
|
data-testid={`recent-shift-${shift.id}`}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium truncate">{shift.site.name}</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{format(new Date(shift.startTime), "dd MMM yyyy", { locale: it })}
|
|
</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="font-mono text-sm">
|
|
{differenceInHours(new Date(shift.endTime), new Date(shift.startTime))}h
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{shift.assignments.length} guardie
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8">
|
|
<Calendar className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
|
|
<p className="text-sm text-muted-foreground">
|
|
Nessun turno completato
|
|
</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|