2026-07-14 13:47:44 +02:00
|
|
|
//=============================================
|
|
|
|
|
// File: Statistiche.tsx
|
|
|
|
|
// Componente per la gestione delle statistiche
|
|
|
|
|
// da parte dei Referenti Academy
|
|
|
|
|
// @author: "villari.andrea@libero.it"
|
2026-07-14 15:09:47 +02:00
|
|
|
// @version: "1.0.0 2026-07-14"
|
2026-07-14 13:47:44 +02:00
|
|
|
//=============================================
|
|
|
|
|
|
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
|
|
import { AlertCircle, BarChart3, Filter } from "lucide-react";
|
|
|
|
|
import { getCorsi, getStatisticheAcademy, getUtenti } from "@/api/client";
|
|
|
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
import {
|
|
|
|
|
Card,
|
|
|
|
|
CardContent,
|
|
|
|
|
CardDescription,
|
|
|
|
|
CardHeader,
|
|
|
|
|
CardTitle,
|
|
|
|
|
} from "@/components/ui/card";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from "@/components/ui/select";
|
|
|
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
|
|
|
import {
|
|
|
|
|
Table,
|
|
|
|
|
TableBody,
|
|
|
|
|
TableCell,
|
|
|
|
|
TableHead,
|
|
|
|
|
TableHeader,
|
|
|
|
|
TableRow,
|
|
|
|
|
} from "@/components/ui/table";
|
|
|
|
|
import type { StatisticaAcademy, Utente } from "@/types";
|
|
|
|
|
|
|
|
|
|
const ALL_VALUE = "__all__";
|
|
|
|
|
|
|
|
|
|
function currentMonth(): string {
|
|
|
|
|
return new Date().toISOString().slice(0, 7);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatMeseIt(mese: string): string {
|
|
|
|
|
const [year, month] = mese.split("-");
|
|
|
|
|
if (!year || !month) return mese;
|
|
|
|
|
const date = new Date(Number(year), Number(month) - 1, 1);
|
|
|
|
|
return date.toLocaleDateString("it-IT", { month: "long", year: "numeric" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dipendenteLabel(utente: Pick<Utente, "nome" | "cognome">) {
|
|
|
|
|
return `${utente.cognome} ${utente.nome}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function TableSkeleton() {
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{Array.from({ length: 5 }).map((_, index) => (
|
|
|
|
|
<Skeleton key={index} className="h-12 w-full" />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SummaryCard({
|
|
|
|
|
title,
|
|
|
|
|
value,
|
|
|
|
|
description,
|
|
|
|
|
}: {
|
|
|
|
|
title: string;
|
|
|
|
|
value: string | number;
|
|
|
|
|
description: string;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="pb-2">
|
|
|
|
|
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<p className="text-2xl font-semibold">{value}</p>
|
|
|
|
|
<p className="text-xs text-muted-foreground">{description}</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function Statistiche() {
|
|
|
|
|
const [statistiche, setStatistiche] = useState<StatisticaAcademy[]>([]);
|
|
|
|
|
const [categorie, setCategorie] = useState<string[]>([]);
|
|
|
|
|
const [dipendenti, setDipendenti] = useState<Utente[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [meseFilter, setMeseFilter] = useState(currentMonth());
|
|
|
|
|
const [categoriaFilter, setCategoriaFilter] = useState(ALL_VALUE);
|
|
|
|
|
const [dipendenteFilter, setDipendenteFilter] = useState(ALL_VALUE);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
Promise.all([getCorsi(), getUtenti({ ruolo: "Dipendente" })])
|
|
|
|
|
.then(([corsi, dipendentiData]) => {
|
|
|
|
|
const values = new Set(corsi.map((c) => c.categoria));
|
|
|
|
|
setCategorie(Array.from(values).sort());
|
|
|
|
|
setDipendenti(dipendentiData);
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
/* i filtri possono restare vuoti */
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const loadStatistiche = useCallback(async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
|
|
|
|
const data = await getStatisticheAcademy({
|
|
|
|
|
mese: meseFilter || undefined,
|
|
|
|
|
categoria: categoriaFilter === ALL_VALUE ? undefined : categoriaFilter,
|
|
|
|
|
dipendenteId:
|
|
|
|
|
dipendenteFilter === ALL_VALUE
|
|
|
|
|
? undefined
|
|
|
|
|
: Number(dipendenteFilter),
|
|
|
|
|
});
|
|
|
|
|
setStatistiche(data);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : "Errore nel caricamento");
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, [meseFilter, categoriaFilter, dipendenteFilter]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadStatistiche();
|
|
|
|
|
}, [loadStatistiche]);
|
|
|
|
|
|
|
|
|
|
const totali = useMemo(() => {
|
|
|
|
|
const assegnazioni = statistiche.reduce(
|
|
|
|
|
(sum, row) => sum + row.numeroAssegnazioni,
|
|
|
|
|
0
|
|
|
|
|
);
|
|
|
|
|
const completamenti = statistiche.reduce(
|
|
|
|
|
(sum, row) => sum + row.numeroCompletamenti,
|
|
|
|
|
0
|
|
|
|
|
);
|
|
|
|
|
const percentuale =
|
|
|
|
|
assegnazioni === 0
|
|
|
|
|
? 0
|
|
|
|
|
: Math.round((completamenti / assegnazioni) * 10000) / 100;
|
|
|
|
|
|
|
|
|
|
return { assegnazioni, completamenti, percentuale };
|
|
|
|
|
}, [statistiche]);
|
|
|
|
|
|
|
|
|
|
const dipendenteSelezionato = dipendenti.find(
|
|
|
|
|
(d) => String(d.id) === dipendenteFilter
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-2xl font-semibold tracking-tight">Statistiche</h1>
|
|
|
|
|
<p className="text-muted-foreground">
|
|
|
|
|
Riepiloghi formativi aggregati per mese e categoria. Le assegnazioni
|
|
|
|
|
annullate sono escluse dal conteggio.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{error && (
|
|
|
|
|
<Alert variant="destructive">
|
|
|
|
|
<AlertCircle />
|
|
|
|
|
<AlertDescription>{error}</AlertDescription>
|
|
|
|
|
</Alert>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="pb-4">
|
|
|
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
|
|
|
<Filter className="size-4" />
|
|
|
|
|
Filtri
|
|
|
|
|
</CardTitle>
|
|
|
|
|
<CardDescription>
|
|
|
|
|
Filtra per mese di assegnazione, categoria o dipendente.
|
|
|
|
|
</CardDescription>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="flex flex-wrap gap-4">
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label htmlFor="filtro-mese-statistiche">Mese</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="filtro-mese-statistiche"
|
|
|
|
|
type="month"
|
|
|
|
|
value={meseFilter}
|
|
|
|
|
onChange={(e) => setMeseFilter(e.target.value)}
|
|
|
|
|
className="w-44"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label htmlFor="filtro-categoria-statistiche">Categoria</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={categoriaFilter}
|
|
|
|
|
onValueChange={(value) => setCategoriaFilter(value ?? ALL_VALUE)}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger id="filtro-categoria-statistiche" className="w-44">
|
|
|
|
|
<SelectValue placeholder="Tutte" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value={ALL_VALUE}>Tutte le categorie</SelectItem>
|
|
|
|
|
{categorie.map((categoria) => (
|
|
|
|
|
<SelectItem key={categoria} value={categoria}>
|
|
|
|
|
{categoria}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label htmlFor="filtro-dipendente-statistiche">Dipendente</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={dipendenteFilter}
|
|
|
|
|
onValueChange={(value) => setDipendenteFilter(value ?? ALL_VALUE)}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger
|
|
|
|
|
id="filtro-dipendente-statistiche"
|
|
|
|
|
className="w-52"
|
|
|
|
|
>
|
|
|
|
|
<SelectValue placeholder="Tutti" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value={ALL_VALUE}>Tutti i dipendenti</SelectItem>
|
|
|
|
|
{dipendenti.map((dipendente) => (
|
|
|
|
|
<SelectItem key={dipendente.id} value={String(dipendente.id)}>
|
|
|
|
|
{dipendenteLabel(dipendente)}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{!loading && statistiche.length > 0 && (
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
|
|
|
<SummaryCard
|
|
|
|
|
title="Assegnazioni"
|
|
|
|
|
value={totali.assegnazioni}
|
|
|
|
|
description={
|
|
|
|
|
meseFilter
|
|
|
|
|
? `Nel mese ${formatMeseIt(meseFilter)}`
|
|
|
|
|
: "Totale nel periodo"
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<SummaryCard
|
|
|
|
|
title="Completamenti"
|
|
|
|
|
value={totali.completamenti}
|
|
|
|
|
description="Corsi conclusi con successo"
|
|
|
|
|
/>
|
|
|
|
|
<SummaryCard
|
|
|
|
|
title="Tasso completamento"
|
|
|
|
|
value={`${totali.percentuale}%`}
|
|
|
|
|
description="Media sulle righe filtrate"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
|
|
|
<BarChart3 className="size-4" />
|
|
|
|
|
{loading
|
|
|
|
|
? "Caricamento..."
|
|
|
|
|
: `${statistiche.length} righe di riepilogo`}
|
|
|
|
|
</CardTitle>
|
|
|
|
|
{meseFilter && (
|
|
|
|
|
<CardDescription>
|
|
|
|
|
Dati per {formatMeseIt(meseFilter)}
|
|
|
|
|
{categoriaFilter !== ALL_VALUE && ` · ${categoriaFilter}`}
|
|
|
|
|
{dipendenteSelezionato &&
|
|
|
|
|
` · ${dipendenteLabel(dipendenteSelezionato)}`}
|
|
|
|
|
</CardDescription>
|
|
|
|
|
)}
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
{loading ? (
|
|
|
|
|
<TableSkeleton />
|
|
|
|
|
) : statistiche.length === 0 ? (
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Nessun dato disponibile per i filtri selezionati.
|
|
|
|
|
</p>
|
|
|
|
|
) : (
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow>
|
|
|
|
|
<TableHead>Mese</TableHead>
|
|
|
|
|
<TableHead>Categoria</TableHead>
|
|
|
|
|
<TableHead className="text-right">Assegnazioni</TableHead>
|
|
|
|
|
<TableHead className="text-right">Completamenti</TableHead>
|
|
|
|
|
<TableHead className="text-right">Completamento</TableHead>
|
|
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{statistiche.map((row) => (
|
|
|
|
|
<TableRow key={`${row.mese}-${row.categoria}`}>
|
|
|
|
|
<TableCell>{formatMeseIt(row.mese)}</TableCell>
|
|
|
|
|
<TableCell className="font-medium">{row.categoria}</TableCell>
|
|
|
|
|
<TableCell className="text-right">
|
|
|
|
|
{row.numeroAssegnazioni}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="text-right">
|
|
|
|
|
{row.numeroCompletamenti}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="text-right">
|
|
|
|
|
<Badge variant="secondary">
|
|
|
|
|
{row.percentualeCompletamento}%
|
|
|
|
|
</Badge>
|
|
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
))}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|