inserite le gestioni Dipendenti.tsx e Statistiche.tsx

This commit is contained in:
AV77web 2026-07-14 13:47:44 +02:00
parent 59d48f4265
commit ab63e622b2
5 changed files with 512 additions and 11 deletions

View file

@ -9,7 +9,6 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import GuestRoute from "@/components/GuestRoute"; import GuestRoute from "@/components/GuestRoute";
import Layout from "@/components/Layout"; import Layout from "@/components/Layout";
import PagePlaceholder from "@/components/PagePlaceholder";
import ProtectedRoute from "@/components/ProtectedRoute"; import ProtectedRoute from "@/components/ProtectedRoute";
import { AuthProvider } from "@/context/AuthContext"; import { AuthProvider } from "@/context/AuthContext";
import Dashboard from "@/pages/Dashboard"; import Dashboard from "@/pages/Dashboard";
@ -18,6 +17,8 @@ import MieiCorsi from "@/pages/MieiCorsi";
import MioCorsoDettaglio from "@/pages/MioCorsoDettaglio"; import MioCorsoDettaglio from "@/pages/MioCorsoDettaglio";
import Corsi from "@/pages/Corsi"; import Corsi from "@/pages/Corsi";
import Assegnazioni from "@/pages/Assegnazioni"; import Assegnazioni from "@/pages/Assegnazioni";
import Dipendenti from "@/pages/Dipendenti";
import Statistiche from "@/pages/Statistiche";
export default function App() { export default function App() {
return ( return (
@ -63,10 +64,7 @@ export default function App() {
path="/dipendenti" path="/dipendenti"
element={ element={
<ProtectedRoute roles={["Referente Academy"]}> <ProtectedRoute roles={["Referente Academy"]}>
<PagePlaceholder <Dipendenti />
title="Dipendenti"
description="Elenco dei dipendenti dell'organizzazione."
/>
</ProtectedRoute> </ProtectedRoute>
} }
/> />
@ -74,10 +72,7 @@ export default function App() {
path="/statistiche" path="/statistiche"
element={ element={
<ProtectedRoute roles={["Referente Academy"]}> <ProtectedRoute roles={["Referente Academy"]}>
<PagePlaceholder <Statistiche />
title="Statistiche"
description="Riepiloghi formativi per mese e categoria."
/>
</ProtectedRoute> </ProtectedRoute>
} }
/> />

View file

@ -7,6 +7,7 @@
//========================================================= //=========================================================
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { import {
AlertCircle, AlertCircle,
Ban, Ban,
@ -172,7 +173,7 @@ function AssegnazioneFormDialog({
? validateAssegnazioneCreate(form) ? validateAssegnazioneCreate(form)
: validateAssegnazioneUpdate(form); : validateAssegnazioneUpdate(form);
if (!validation.success) { if (validation.success === false) {
setFieldErrors(validation.errors); setFieldErrors(validation.errors);
return; return;
} }
@ -327,6 +328,7 @@ function AssegnazioneFormDialog({
} }
export default function Assegnazioni() { export default function Assegnazioni() {
const [searchParams] = useSearchParams();
const [assegnazioni, setAssegnazioni] = useState<AssegnazioneCorso[]>([]); const [assegnazioni, setAssegnazioni] = useState<AssegnazioneCorso[]>([]);
const [corsi, setCorsi] = useState<Corso[]>([]); const [corsi, setCorsi] = useState<Corso[]>([]);
const [dipendenti, setDipendenti] = useState<Utente[]>([]); const [dipendenti, setDipendenti] = useState<Utente[]>([]);
@ -346,6 +348,13 @@ export default function Assegnazioni() {
); );
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
useEffect(() => {
const dipendenteId = searchParams.get("dipendenteId");
if (dipendenteId && /^\d+$/.test(dipendenteId)) {
setDipendenteFilter(dipendenteId);
}
}, [searchParams]);
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
getCorsi(), getCorsi(),

View file

@ -139,7 +139,7 @@ function CorsoFormDialog({
const validation = const validation =
mode === "create" ? validateCorsoCreate(form) : validateCorsoUpdate(form); mode === "create" ? validateCorsoCreate(form) : validateCorsoUpdate(form);
if (!validation.success) { if (validation.success === false) {
setFieldErrors(validation.errors); setFieldErrors(validation.errors);
return; return;
} }

172
src/pages/Dipendenti.tsx Normal file
View file

@ -0,0 +1,172 @@
//==========================================
// File: Dipendenti.tsx
// Componente per la gestione dei dipendenti
// @author: "villari.andrea@libero.it"
// @version: "1.0.0 2026-07-14"
//==========================================
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { AlertCircle, ClipboardList, Search, Users } from "lucide-react";
import { getUtenti } from "@/api/client";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { Utente } from "@/types";
function TableSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-12 w-full" />
))}
</div>
);
}
function matchesSearch(utente: Utente, query: string): boolean {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
return [utente.nome, utente.cognome, utente.email]
.join(" ")
.toLowerCase()
.includes(normalized);
}
export default function Dipendenti() {
const [dipendenti, setDipendenti] = useState<Utente[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
useEffect(() => {
setLoading(true);
setError(null);
getUtenti({ ruolo: "Dipendente" })
.then(setDipendenti)
.catch((err) =>
setError(err instanceof Error ? err.message : "Errore nel caricamento")
)
.finally(() => setLoading(false));
}, []);
const filtered = useMemo(
() => dipendenti.filter((d) => matchesSearch(d, search)),
[dipendenti, search]
);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dipendenti</h1>
<p className="text-muted-foreground">
Elenco dei dipendenti dell&apos;organizzazione iscritti all&apos;Academy.
</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">
<Search className="size-4" />
Ricerca
</CardTitle>
<CardDescription>
Filtra per nome, cognome o email.
</CardDescription>
</CardHeader>
<CardContent>
<div className="max-w-sm space-y-1.5">
<Label htmlFor="ricerca-dipendenti">Cerca dipendente</Label>
<Input
id="ricerca-dipendenti"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Es. Maria, Bianchi, academy.it"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Users className="size-4" />
{loading ? "Caricamento..." : `${filtered.length} dipendenti`}
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<TableSkeleton />
) : filtered.length === 0 ? (
<p className="text-sm text-muted-foreground">
{search
? "Nessun dipendente corrisponde alla ricerca."
: "Nessun dipendente registrato."}
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Cognome</TableHead>
<TableHead>Nome</TableHead>
<TableHead>Email</TableHead>
<TableHead className="text-right">Azioni</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((dipendente) => (
<TableRow key={dipendente.id}>
<TableCell className="font-medium">
{dipendente.cognome}
</TableCell>
<TableCell>{dipendente.nome}</TableCell>
<TableCell>{dipendente.email}</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
render={
<Link
to={`/assegnazioni?dipendenteId=${dipendente.id}`}
/>
}
>
<ClipboardList className="size-4" />
Assegnazioni
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

325
src/pages/Statistiche.tsx Normal file
View file

@ -0,0 +1,325 @@
//=============================================
// File: Statistiche.tsx
// Componente per la gestione delle statistiche
// da parte dei Referenti Academy
// @author: "villari.andrea@libero.it"
// @version "1.0.0 2026-07-14"
//=============================================
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>
);
}