inserita gestione MieiCorsi.tsx e MioCorsoDettaglio
All checks were successful
Deploy to VPS / build (push) Successful in 29s
Deploy to VPS / deploy (push) Successful in 22s

This commit is contained in:
AV77web 2026-07-14 13:09:01 +02:00
parent 26a97a1304
commit d712da6295
6 changed files with 750 additions and 7 deletions

View file

@ -14,6 +14,8 @@ import ProtectedRoute from "@/components/ProtectedRoute";
import { AuthProvider } from "@/context/AuthContext";
import Dashboard from "@/pages/Dashboard";
import Login from "@/pages/Login";
import MieiCorsi from "@/pages/MieiCorsi";
import MioCorsoDettaglio from "@/pages/MioCorsoDettaglio";
export default function App() {
return (
@ -27,10 +29,15 @@ export default function App() {
path="/i-miei-corsi"
element={
<ProtectedRoute roles={["Dipendente"]}>
<PagePlaceholder
title="I miei corsi"
description="Elenco dei corsi assegnati al dipendente."
/>
<MieiCorsi />
</ProtectedRoute>
}
/>
<Route
path="/i-miei-corsi/:id"
element={
<ProtectedRoute roles={["Dipendente"]}>
<MioCorsoDettaglio />
</ProtectedRoute>
}
/>

View file

@ -163,6 +163,24 @@ export async function getAssegnazioni(
})}`);
}
export async function getAssegnazioneById(id: number): Promise<AssegnazioneCorso> {
return request(`/api/assegnazioni-corsi/${id}`);
}
export async function getCorsoById(id: number): Promise<Corso> {
return request(`/api/corsi/${id}`);
}
export async function completaAssegnazione(
id: number,
data?: { dataCompletamento?: string }
): Promise<AssegnazioneCorso> {
return request(`/api/assegnazioni-corsi/${id}/completa`, {
method: "PUT",
body: JSON.stringify(data ?? {}),
});
}
export async function getStatisticheAcademy(filters?: {
mese?: string;
daMese?: string;

67
src/lib/assegnazione.ts Normal file
View file

@ -0,0 +1,67 @@
//==============================================
// File: assegnazione.ts
// Componente per la gestione delle Assegnazioni
// dei corso ai dipendenti
// @author: "villari.andrea@libero.it"
// @version: "1.0.0 2026-07-14"
//==============================================
import type { StatoAssegnazione, AssegnazioneCorso } from "@/types";
export type ScadenzaFilter = "tutte" | "prossime" | "scadute";
export const STATI_ASSEGNAZIONE: StatoAssegnazione[] = [
"Assegnato",
"Completato",
"Scaduto",
"Annullato",
];
export function formatDateIt(value: string | null | undefined): string {
if (!value) return "—";
const [year, month, day] = value.split("-");
if (!year || !month || !day) return value;
return `${day}/${month}/${year}`;
}
export function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
export function statoBadgeVariant(
stato: StatoAssegnazione
): "default" | "secondary" | "destructive" | "outline" {
switch (stato) {
case "Completato":
return "default";
case "Assegnato":
return "secondary";
case "Scaduto":
return "destructive";
case "Annullato":
return "outline";
}
}
export function canCompletaAssegnazione(assegnazione: AssegnazioneCorso): boolean {
return assegnazione.stato === "Assegnato" || assegnazione.stato === "Scaduto";
}
export function matchesScadenzaFilter(
assegnazione: AssegnazioneCorso,
filter: ScadenzaFilter
): boolean {
if (filter === "tutte") return true;
if (filter === "scadute") return assegnazione.stato === "Scaduto";
if (filter !== "prossime") return true;
if (assegnazione.stato !== "Assegnato") return false;
const today = todayIso();
const limit = new Date();
limit.setDate(limit.getDate() + 30);
const limitIso = limit.toISOString().slice(0, 10);
return (
assegnazione.dataScadenza >= today && assegnazione.dataScadenza <= limitIso
);
}

View file

@ -3,6 +3,7 @@
// Componete Dashborad dell'applicazione
// author: "villari.andrea@libero.it"
// version: "1.0.0 2026-07.14"
//======================================
import { useEffect, useMemo, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import {
@ -194,9 +195,10 @@ function DipendenteDashboard() {
</CardHeader>
<CardContent className="space-y-3">
{prossimeScadenze.map((assegnazione) => (
<div
<Link
key={assegnazione.id}
className="flex items-center justify-between gap-4 rounded-lg border p-3"
to={`/i-miei-corsi/${assegnazione.id}`}
className="flex items-center justify-between gap-4 rounded-lg border p-3 transition-colors hover:bg-muted/50"
>
<div>
<p className="font-medium">{assegnazione.corso.titolo}</p>
@ -211,7 +213,7 @@ function DipendenteDashboard() {
>
{assegnazione.stato}
</Badge>
</div>
</Link>
))}
</CardContent>
</Card>

345
src/pages/MieiCorsi.tsx Normal file
View file

@ -0,0 +1,345 @@
//=====================================
// File: MieiCorsi.tsx
// componente tabella con tutti i corsi
// assegnati al dipendente.
// @author: "villari.andrea@libero.it"
// @version: "1.0.0 2026-07-14"
//=====================================
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { AlertCircle, CheckCircle2, Eye, Filter } from "lucide-react";
import { toast } from "sonner";
import { completaAssegnazione, getAssegnazioni } from "@/api/client";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
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 {
canCompletaAssegnazione,
formatDateIt,
matchesScadenzaFilter,
STATI_ASSEGNAZIONE,
statoBadgeVariant,
todayIso,
type ScadenzaFilter,
} from "@/lib/assegnazione";
import type { AssegnazioneCorso, StatoAssegnazione } from "@/types";
const ALL_VALUE = "__all__";
function TableSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-12 w-full" />
))}
</div>
);
}
export default function MieiCorsi() {
const [assegnazioni, setAssegnazioni] = useState<AssegnazioneCorso[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [statoFilter, setStatoFilter] = useState<string>(ALL_VALUE);
const [categoriaFilter, setCategoriaFilter] = useState<string>(ALL_VALUE);
const [scadenzaFilter, setScadenzaFilter] = useState<ScadenzaFilter>("tutte");
const [completaTarget, setCompletaTarget] = useState<AssegnazioneCorso | null>(
null
);
const [dataCompletamento, setDataCompletamento] = useState(todayIso());
const [submitting, setSubmitting] = useState(false);
const [categorie, setCategorie] = useState<string[]>([]);
const loadAssegnazioni = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getAssegnazioni({
stato:
statoFilter === ALL_VALUE
? undefined
: (statoFilter as StatoAssegnazione),
categoria: categoriaFilter === ALL_VALUE ? undefined : categoriaFilter,
});
setAssegnazioni(data);
if (statoFilter === ALL_VALUE && categoriaFilter === ALL_VALUE) {
const values = new Set(data.map((a) => a.corso.categoria));
setCategorie(Array.from(values).sort());
}
} catch (err) {
setError(err instanceof Error ? err.message : "Errore nel caricamento");
} finally {
setLoading(false);
}
}, [statoFilter, categoriaFilter]);
useEffect(() => {
loadAssegnazioni();
}, [loadAssegnazioni]);
const filtered = useMemo(
() => assegnazioni.filter((a) => matchesScadenzaFilter(a, scadenzaFilter)),
[assegnazioni, scadenzaFilter]
);
function openCompletaDialog(assegnazione: AssegnazioneCorso) {
setDataCompletamento(todayIso());
setCompletaTarget(assegnazione);
}
async function handleCompleta() {
if (!completaTarget) return;
setSubmitting(true);
try {
await completaAssegnazione(completaTarget.id, { dataCompletamento });
toast.success("Corso segnato come completato");
setCompletaTarget(null);
await loadAssegnazioni();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Impossibile completare il corso"
);
} finally {
setSubmitting(false);
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">I miei corsi</h1>
<p className="text-muted-foreground">
Elenco dei corsi formativi assegnati a te. Filtra per stato,
categoria o scadenza.
</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>
I filtri stato e categoria sono applicati dal server; la scadenza
viene filtrata in pagina.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-4">
<div className="space-y-1.5">
<Label htmlFor="filtro-stato">Stato</Label>
<Select
value={statoFilter}
onValueChange={(value) => setStatoFilter(value ?? ALL_VALUE)}
>
<SelectTrigger id="filtro-stato" className="w-44">
<SelectValue placeholder="Tutti gli stati" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>Tutti gli stati</SelectItem>
{STATI_ASSEGNAZIONE.map((stato) => (
<SelectItem key={stato} value={stato}>
{stato}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="filtro-categoria">Categoria</Label>
<Select
value={categoriaFilter}
onValueChange={(value) => setCategoriaFilter(value ?? ALL_VALUE)}
>
<SelectTrigger id="filtro-categoria" className="w-44">
<SelectValue placeholder="Tutte le categorie" />
</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-scadenza">Scadenza</Label>
<Select
value={scadenzaFilter}
onValueChange={(value) =>
setScadenzaFilter((value as ScadenzaFilter) ?? "tutte")
}
>
<SelectTrigger id="filtro-scadenza" className="w-52">
<SelectValue placeholder="Tutte" />
</SelectTrigger>
<SelectContent>
<SelectItem value="tutte">Tutte</SelectItem>
<SelectItem value="prossime">
Prossime (30 giorni)
</SelectItem>
<SelectItem value="scadute">Scadute</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
{loading ? "Caricamento..." : `${filtered.length} corsi`}
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<TableSkeleton />
) : filtered.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nessun corso trovato con i filtri selezionati.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Corso</TableHead>
<TableHead>Categoria</TableHead>
<TableHead>Assegnato il</TableHead>
<TableHead>Scadenza</TableHead>
<TableHead>Stato</TableHead>
<TableHead className="text-right">Azioni</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((assegnazione) => (
<TableRow key={assegnazione.id}>
<TableCell className="font-medium">
{assegnazione.corso.titolo}
</TableCell>
<TableCell>{assegnazione.corso.categoria}</TableCell>
<TableCell>
{formatDateIt(assegnazione.dataAssegnazione)}
</TableCell>
<TableCell>
{formatDateIt(assegnazione.dataScadenza)}
</TableCell>
<TableCell>
<Badge variant={statoBadgeVariant(assegnazione.stato)}>
{assegnazione.stato}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" render={<Link to={`/i-miei-corsi/${assegnazione.id}`} />}>
<Eye className="size-4" />
Dettaglio
</Button>
{canCompletaAssegnazione(assegnazione) && (
<Button
size="sm"
onClick={() => openCompletaDialog(assegnazione)}
>
<CheckCircle2 className="size-4" />
Completa
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog
open={completaTarget !== null}
onOpenChange={(open) => {
if (!open) setCompletaTarget(null);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Segna corso come completato</DialogTitle>
<DialogDescription>
Confermi di aver completato{" "}
<strong>{completaTarget?.corso.titolo}</strong>?
</DialogDescription>
</DialogHeader>
<div className="space-y-1.5">
<Label htmlFor="data-completamento">Data completamento</Label>
<Input
id="data-completamento"
type="date"
value={dataCompletamento}
min={completaTarget?.dataAssegnazione}
max={todayIso()}
onChange={(e) => setDataCompletamento(e.target.value)}
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setCompletaTarget(null)}
disabled={submitting}
>
Annulla
</Button>
<Button onClick={handleCompleta} disabled={submitting}>
{submitting ? "Salvataggio..." : "Conferma completamento"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,304 @@
//====================================
// File: MioCorsoDettaglio.tsx
// componenete con il dettaglio corso
// @author: "villari.andrea@libero.it"
// @version: "1.0.0 2026-07-14"
//====================================
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import {
AlertCircle,
ArrowLeft,
BookOpen,
Calendar,
CheckCircle2,
Clock,
} from "lucide-react";
import { toast } from "sonner";
import {
completaAssegnazione,
getAssegnazioneById,
getCorsoById,
} from "@/api/client";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import {
canCompletaAssegnazione,
formatDateIt,
statoBadgeVariant,
todayIso,
} from "@/lib/assegnazione";
import type { AssegnazioneCorso, Corso } from "@/types";
function DetailSkeleton() {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-48 w-full" />
</div>
);
}
export default function MioCorsoDettaglio() {
const { id } = useParams<{ id: string }>();
const [assegnazione, setAssegnazione] = useState<AssegnazioneCorso | null>(
null
);
const [corso, setCorso] = useState<Corso | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [dataCompletamento, setDataCompletamento] = useState(todayIso());
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
const assegnazioneId = Number(id);
if (!Number.isInteger(assegnazioneId) || assegnazioneId <= 0) {
setError("Identificativo assegnazione non valido");
setLoading(false);
return;
}
setLoading(true);
setError(null);
getAssegnazioneById(assegnazioneId)
.then(async (data) => {
setAssegnazione(data);
const corsoData = await getCorsoById(data.corsoId);
setCorso(corsoData);
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Errore nel caricamento");
})
.finally(() => setLoading(false));
}, [id]);
async function handleCompleta() {
if (!assegnazione) return;
setSubmitting(true);
try {
const updated = await completaAssegnazione(assegnazione.id, {
dataCompletamento,
});
setAssegnazione(updated);
setDialogOpen(false);
toast.success("Corso segnato come completato");
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Impossibile completare il corso"
);
} finally {
setSubmitting(false);
}
}
if (loading) {
return <DetailSkeleton />;
}
if (error || !assegnazione) {
return (
<div className="space-y-4">
<Button variant="outline" size="sm" render={<Link to="/i-miei-corsi" />}>
<ArrowLeft className="size-4" />
Torna all&apos;elenco
</Button>
<Alert variant="destructive">
<AlertCircle />
<AlertDescription>
{error ?? "Assegnazione non trovata"}
</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<Button variant="outline" size="sm" render={<Link to="/i-miei-corsi" />}>
<ArrowLeft className="size-4" />
Torna all&apos;elenco
</Button>
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{assegnazione.corso.titolo}
</h1>
<p className="text-muted-foreground">
Dettaglio dell&apos;assegnazione formativa
</p>
</div>
</div>
<Badge
variant={statoBadgeVariant(assegnazione.stato)}
className="text-sm"
>
{assegnazione.stato}
</Badge>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BookOpen className="size-4" />
Informazioni corso
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div>
<p className="text-muted-foreground">Categoria</p>
<p className="font-medium">{assegnazione.corso.categoria}</p>
</div>
<div>
<p className="text-muted-foreground">Durata</p>
<p className="font-medium">{assegnazione.corso.durataOre} ore</p>
</div>
{corso?.descrizione && (
<div>
<p className="text-muted-foreground">Descrizione</p>
<p>{corso.descrizione}</p>
</div>
)}
{corso && (
<div className="flex flex-wrap gap-2">
{corso.obbligatorio && (
<Badge variant="secondary">Obbligatorio</Badge>
)}
{!corso.attivo && (
<Badge variant="outline">Corso non più attivo</Badge>
)}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Calendar className="size-4" />
Date e stato
</CardTitle>
<CardDescription>
Cronologia dell&apos;assegnazione
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div>
<p className="text-muted-foreground">Data assegnazione</p>
<p className="font-medium">
{formatDateIt(assegnazione.dataAssegnazione)}
</p>
</div>
<div>
<p className="text-muted-foreground">Data scadenza</p>
<p className="font-medium">
{formatDateIt(assegnazione.dataScadenza)}
</p>
</div>
<div>
<p className="text-muted-foreground">Data completamento</p>
<p className="font-medium">
{formatDateIt(assegnazione.dataCompletamento)}
</p>
</div>
</CardContent>
</Card>
</div>
{canCompletaAssegnazione(assegnazione) && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Clock className="size-4" />
Azioni
</CardTitle>
<CardDescription>
Segna il corso come completato quando hai terminato la formazione.
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => {
setDataCompletamento(todayIso());
setDialogOpen(true);
}}>
<CheckCircle2 className="size-4" />
Segna come completato
</Button>
</CardContent>
</Card>
)}
{assegnazione.stato === "Completato" && (
<Alert>
<CheckCircle2 />
<AlertDescription>
Hai completato questo corso il{" "}
{formatDateIt(assegnazione.dataCompletamento)}.
</AlertDescription>
</Alert>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Conferma completamento</DialogTitle>
<DialogDescription>
Indica la data in cui hai completato la formazione su{" "}
<strong>{assegnazione.corso.titolo}</strong>.
</DialogDescription>
</DialogHeader>
<div className="space-y-1.5">
<Label htmlFor="dettaglio-data-completamento">
Data completamento
</Label>
<Input
id="dettaglio-data-completamento"
type="date"
value={dataCompletamento}
min={assegnazione.dataAssegnazione}
max={todayIso()}
onChange={(e) => setDataCompletamento(e.target.value)}
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={submitting}
>
Annulla
</Button>
<Button onClick={handleCompleta} disabled={submitting}>
{submitting ? "Salvataggio..." : "Conferma"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}