Compare commits
3 commits
4b907da5cf
...
7baf562a45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7baf562a45 | ||
|
|
ab63e622b2 | ||
|
|
59d48f4265 |
5 changed files with 512 additions and 11 deletions
13
src/App.tsx
13
src/App.tsx
|
|
@ -9,7 +9,6 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
|||
import { Toaster } from "@/components/ui/sonner";
|
||||
import GuestRoute from "@/components/GuestRoute";
|
||||
import Layout from "@/components/Layout";
|
||||
import PagePlaceholder from "@/components/PagePlaceholder";
|
||||
import ProtectedRoute from "@/components/ProtectedRoute";
|
||||
import { AuthProvider } from "@/context/AuthContext";
|
||||
import Dashboard from "@/pages/Dashboard";
|
||||
|
|
@ -18,6 +17,8 @@ import MieiCorsi from "@/pages/MieiCorsi";
|
|||
import MioCorsoDettaglio from "@/pages/MioCorsoDettaglio";
|
||||
import Corsi from "@/pages/Corsi";
|
||||
import Assegnazioni from "@/pages/Assegnazioni";
|
||||
import Dipendenti from "@/pages/Dipendenti";
|
||||
import Statistiche from "@/pages/Statistiche";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
|
|
@ -63,10 +64,7 @@ export default function App() {
|
|||
path="/dipendenti"
|
||||
element={
|
||||
<ProtectedRoute roles={["Referente Academy"]}>
|
||||
<PagePlaceholder
|
||||
title="Dipendenti"
|
||||
description="Elenco dei dipendenti dell'organizzazione."
|
||||
/>
|
||||
<Dipendenti />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
|
@ -74,10 +72,7 @@ export default function App() {
|
|||
path="/statistiche"
|
||||
element={
|
||||
<ProtectedRoute roles={["Referente Academy"]}>
|
||||
<PagePlaceholder
|
||||
title="Statistiche"
|
||||
description="Riepiloghi formativi per mese e categoria."
|
||||
/>
|
||||
<Statistiche />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
//=========================================================
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
AlertCircle,
|
||||
Ban,
|
||||
|
|
@ -172,7 +173,7 @@ function AssegnazioneFormDialog({
|
|||
? validateAssegnazioneCreate(form)
|
||||
: validateAssegnazioneUpdate(form);
|
||||
|
||||
if (!validation.success) {
|
||||
if (validation.success === false) {
|
||||
setFieldErrors(validation.errors);
|
||||
return;
|
||||
}
|
||||
|
|
@ -327,6 +328,7 @@ function AssegnazioneFormDialog({
|
|||
}
|
||||
|
||||
export default function Assegnazioni() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [assegnazioni, setAssegnazioni] = useState<AssegnazioneCorso[]>([]);
|
||||
const [corsi, setCorsi] = useState<Corso[]>([]);
|
||||
const [dipendenti, setDipendenti] = useState<Utente[]>([]);
|
||||
|
|
@ -346,6 +348,13 @@ export default function Assegnazioni() {
|
|||
);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const dipendenteId = searchParams.get("dipendenteId");
|
||||
if (dipendenteId && /^\d+$/.test(dipendenteId)) {
|
||||
setDipendenteFilter(dipendenteId);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getCorsi(),
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ function CorsoFormDialog({
|
|||
const validation =
|
||||
mode === "create" ? validateCorsoCreate(form) : validateCorsoUpdate(form);
|
||||
|
||||
if (!validation.success) {
|
||||
if (validation.success === false) {
|
||||
setFieldErrors(validation.errors);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
172
src/pages/Dipendenti.tsx
Normal file
172
src/pages/Dipendenti.tsx
Normal 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'organizzazione iscritti all'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
325
src/pages/Statistiche.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue