area Referenti Academy con gestione corsi e assegnazioni

This commit is contained in:
AV77web 2026-07-14 13:25:21 +02:00
parent d712da6295
commit 3926e46216
8 changed files with 1696 additions and 10 deletions

View file

@ -16,6 +16,8 @@ import Dashboard from "@/pages/Dashboard";
import Login from "@/pages/Login";
import MieiCorsi from "@/pages/MieiCorsi";
import MioCorsoDettaglio from "@/pages/MioCorsoDettaglio";
import Corsi from "@/pages/Corsi";
import Assegnazioni from "@/pages/Assegnazioni";
export default function App() {
return (
@ -45,10 +47,7 @@ export default function App() {
path="/corsi"
element={
<ProtectedRoute roles={["Referente Academy"]}>
<PagePlaceholder
title="Catalogo corsi"
description="Gestione del catalogo corsi Academy."
/>
<Corsi />
</ProtectedRoute>
}
/>
@ -56,10 +55,7 @@ export default function App() {
path="/assegnazioni"
element={
<ProtectedRoute roles={["Referente Academy"]}>
<PagePlaceholder
title="Assegnazioni"
description="Gestione delle assegnazioni corsi ai dipendenti."
/>
<Assegnazioni />
</ProtectedRoute>
}
/>

View file

@ -1,3 +1,9 @@
//============================================
// File: client.ts
// Script per le chiamate API verso il backend
// @author: "villari.andrea@libero.it"
// @version: "1.0.0 2026-07-14"
//============================================
import type {
AssegnazioneCorso,
Corso,
@ -9,6 +15,16 @@ import type {
const API_URL = import.meta.env.VITE_API_URL ?? "";
export class ApiClientError extends Error {
fields?: Record<string, string>;
constructor(message: string, fields?: Record<string, string>) {
super(message);
this.name = "ApiClientError";
this.fields = fields;
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_URL}${path}`, {
credentials: "include",
@ -19,10 +35,14 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
...options,
});
const data = await response.json().catch(() => ({}));
const text = await response.text();
const data = text ? (JSON.parse(text) as Record<string, unknown>) : {};
if (!response.ok) {
throw new Error(data.error ?? `Errore ${response.status}`);
throw new ApiClientError(
(data.error as string | undefined) ?? `Errore ${response.status}`,
data.fields as Record<string, string> | undefined
);
}
return data as T;
@ -171,6 +191,40 @@ export async function getCorsoById(id: number): Promise<Corso> {
return request(`/api/corsi/${id}`);
}
export interface CorsoInput {
titolo: string;
descrizione: string | null;
categoria: string;
durataOre: number;
obbligatorio: boolean;
attivo: boolean;
}
export async function createCorso(data: CorsoInput): Promise<Corso> {
return request("/api/corsi", {
method: "POST",
body: JSON.stringify(data),
});
}
export async function updateCorso(
id: number,
data: Partial<CorsoInput>
): Promise<Corso> {
return request(`/api/corsi/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
export async function disattivaCorso(id: number): Promise<Corso> {
return request(`/api/corsi/${id}/disattiva`, { method: "PUT" });
}
export async function deleteCorso(id: number): Promise<void> {
await request(`/api/corsi/${id}`, { method: "DELETE" });
}
export async function completaAssegnazione(
id: number,
data?: { dataCompletamento?: string }
@ -181,6 +235,40 @@ export async function completaAssegnazione(
});
}
export interface AssegnazioneInput {
corsoId: number;
dipendenteId: number;
dataAssegnazione: string;
dataScadenza: string;
}
export async function createAssegnazione(
data: AssegnazioneInput
): Promise<AssegnazioneCorso> {
return request("/api/assegnazioni-corsi", {
method: "POST",
body: JSON.stringify(data),
});
}
export async function updateAssegnazione(
id: number,
data: Partial<AssegnazioneInput & { stato: StatoAssegnazione }>
): Promise<AssegnazioneCorso> {
return request(`/api/assegnazioni-corsi/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
export async function annullaAssegnazione(id: number): Promise<AssegnazioneCorso> {
return request(`/api/assegnazioni-corsi/${id}/annulla`, { method: "PUT" });
}
export async function getUtenti(filters?: { ruolo?: Ruolo }): Promise<Utente[]> {
return request(`/api/utenti${buildQuery({ ruolo: filters?.ruolo })}`);
}
export async function getStatisticheAcademy(filters?: {
mese?: string;
daMese?: string;

View file

@ -46,6 +46,14 @@ export function canCompletaAssegnazione(assegnazione: AssegnazioneCorso): boolea
return assegnazione.stato === "Assegnato" || assegnazione.stato === "Scaduto";
}
export function canAnnullaAssegnazione(assegnazione: AssegnazioneCorso): boolean {
return assegnazione.stato !== "Annullato";
}
export function canModificaAssegnazione(assegnazione: AssegnazioneCorso): boolean {
return assegnazione.stato !== "Annullato";
}
export function matchesScadenzaFilter(
assegnazione: AssegnazioneCorso,
filter: ScadenzaFilter

698
src/pages/Assegnazioni.tsx Normal file
View file

@ -0,0 +1,698 @@
//=========================================================
// File: Asseganzioni.tsx
// componente per la gestione delle assegnazioni dei corsi
// ai dipendenti da parte dei Referenti Academy.
// @author: "villari.andrea@libero.it"
// @version: "1.0.0 2026-07-14"
//=========================================================
import { useCallback, useEffect, useState } from "react";
import {
AlertCircle,
Ban,
Filter,
MoreHorizontal,
Pencil,
Plus,
} from "lucide-react";
import { toast } from "sonner";
import {
annullaAssegnazione,
ApiClientError,
createAssegnazione,
getAssegnazioni,
getCorsi,
getUtenti,
updateAssegnazione,
} 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
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 {
canAnnullaAssegnazione,
canModificaAssegnazione,
formatDateIt,
STATI_ASSEGNAZIONE,
statoBadgeVariant,
todayIso,
} from "@/lib/assegnazione";
import type { AssegnazioneCorso, Corso, StatoAssegnazione, Utente } from "@/types";
import {
assegnazioneToForm,
emptyAssegnazioneForm,
validateAssegnazioneCreate,
validateAssegnazioneUpdate,
type AssegnazioneFormInput,
} from "@/validation/assegnazione";
import type { FieldErrors } from "@/validation/primitives";
const ALL_VALUE = "__all__";
function addDaysIso(days: number): string {
const date = new Date();
date.setDate(date.getDate() + days);
return date.toISOString().slice(0, 10);
}
function dipendenteLabel(utente: Pick<Utente, "nome" | "cognome" | "email">) {
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 FieldError({ message }: { message?: string }) {
if (!message) return null;
return <p className="text-xs text-destructive">{message}</p>;
}
interface AssegnazioneFormDialogProps {
open: boolean;
mode: "create" | "edit";
assegnazione: AssegnazioneCorso | null;
corsi: Corso[];
dipendenti: Utente[];
onOpenChange: (open: boolean) => void;
onSaved: () => void;
}
function AssegnazioneFormDialog({
open,
mode,
assegnazione,
corsi,
dipendenti,
onOpenChange,
onSaved,
}: AssegnazioneFormDialogProps) {
const [form, setForm] = useState<AssegnazioneFormInput>(
emptyAssegnazioneForm()
);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) return;
setFieldErrors({});
if (assegnazione) {
setForm(assegnazioneToForm(assegnazione));
} else {
setForm(
emptyAssegnazioneForm({
dataAssegnazione: todayIso(),
dataScadenza: addDaysIso(30),
})
);
}
}, [open, assegnazione]);
function updateField<K extends keyof AssegnazioneFormInput>(
key: K,
value: AssegnazioneFormInput[K]
) {
setForm((prev) => ({ ...prev, [key]: value }));
setFieldErrors((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
}
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const validation =
mode === "create"
? validateAssegnazioneCreate(form)
: validateAssegnazioneUpdate(form);
if (!validation.success) {
setFieldErrors(validation.errors);
return;
}
setSubmitting(true);
setFieldErrors({});
try {
if (mode === "create") {
await createAssegnazione(validation.data);
toast.success("Assegnazione creata con successo");
} else if (assegnazione) {
await updateAssegnazione(assegnazione.id, validation.data);
toast.success("Assegnazione aggiornata con successo");
}
onOpenChange(false);
onSaved();
} catch (err) {
if (err instanceof ApiClientError && err.fields) {
setFieldErrors(err.fields);
}
toast.error(
err instanceof Error ? err.message : "Operazione non riuscita"
);
} finally {
setSubmitting(false);
}
}
const corsiForm = corsi.filter(
(corso) => corso.attivo || String(corso.id) === form.corsoId
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{mode === "create" ? "Nuova assegnazione" : "Modifica assegnazione"}
</DialogTitle>
<DialogDescription>
{mode === "create"
? "Assegna un corso attivo a un dipendente con date di assegnazione e scadenza."
: "Aggiorna corso, dipendente o date dell'assegnazione."}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="assegnazione-dipendente">Dipendente</Label>
<Select
value={form.dipendenteId || null}
onValueChange={(value) =>
updateField("dipendenteId", value ?? "")
}
>
<SelectTrigger
id="assegnazione-dipendente"
className="w-full"
aria-invalid={Boolean(fieldErrors.dipendenteId)}
>
<SelectValue placeholder="Seleziona dipendente" />
</SelectTrigger>
<SelectContent>
{dipendenti.map((dipendente) => (
<SelectItem key={dipendente.id} value={String(dipendente.id)}>
{dipendenteLabel(dipendente)}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={fieldErrors.dipendenteId} />
</div>
<div className="space-y-1.5">
<Label htmlFor="assegnazione-corso">Corso</Label>
<Select
value={form.corsoId || null}
onValueChange={(value) => updateField("corsoId", value ?? "")}
>
<SelectTrigger
id="assegnazione-corso"
className="w-full"
aria-invalid={Boolean(fieldErrors.corsoId)}
>
<SelectValue placeholder="Seleziona corso" />
</SelectTrigger>
<SelectContent>
{corsiForm.map((corso) => (
<SelectItem key={corso.id} value={String(corso.id)}>
{corso.titolo}
{!corso.attivo ? " (disattivo)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={fieldErrors.corsoId} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="assegnazione-data">Data assegnazione</Label>
<Input
id="assegnazione-data"
type="date"
value={form.dataAssegnazione}
onChange={(e) =>
updateField("dataAssegnazione", e.target.value)
}
aria-invalid={Boolean(fieldErrors.dataAssegnazione)}
/>
<FieldError message={fieldErrors.dataAssegnazione} />
</div>
<div className="space-y-1.5">
<Label htmlFor="assegnazione-scadenza">Data scadenza</Label>
<Input
id="assegnazione-scadenza"
type="date"
value={form.dataScadenza}
min={form.dataAssegnazione || undefined}
onChange={(e) => updateField("dataScadenza", e.target.value)}
aria-invalid={Boolean(fieldErrors.dataScadenza)}
/>
<FieldError message={fieldErrors.dataScadenza} />
</div>
</div>
{fieldErrors._form && <FieldError message={fieldErrors._form} />}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
Annulla
</Button>
<Button type="submit" disabled={submitting}>
{submitting
? "Salvataggio..."
: mode === "create"
? "Crea assegnazione"
: "Salva modifiche"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export default function Assegnazioni() {
const [assegnazioni, setAssegnazioni] = useState<AssegnazioneCorso[]>([]);
const [corsi, setCorsi] = useState<Corso[]>([]);
const [dipendenti, setDipendenti] = useState<Utente[]>([]);
const [categorie, setCategorie] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [statoFilter, setStatoFilter] = useState(ALL_VALUE);
const [categoriaFilter, setCategoriaFilter] = useState(ALL_VALUE);
const [dipendenteFilter, setDipendenteFilter] = useState(ALL_VALUE);
const [corsoFilter, setCorsoFilter] = useState(ALL_VALUE);
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<"create" | "edit">("create");
const [editingAssegnazione, setEditingAssegnazione] =
useState<AssegnazioneCorso | null>(null);
const [annullaTarget, setAnnullaTarget] = useState<AssegnazioneCorso | null>(
null
);
const [actionLoading, setActionLoading] = useState(false);
useEffect(() => {
Promise.all([
getCorsi(),
getUtenti({ ruolo: "Dipendente" }),
])
.then(([corsiData, dipendentiData]) => {
setCorsi(corsiData);
setDipendenti(dipendentiData);
})
.catch(() => {
/* i filtri del form possono restare vuoti */
});
}, []);
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,
dipendenteId:
dipendenteFilter === ALL_VALUE
? undefined
: Number(dipendenteFilter),
corsoId:
corsoFilter === ALL_VALUE ? undefined : Number(corsoFilter),
});
setAssegnazioni(data);
if (
statoFilter === ALL_VALUE &&
categoriaFilter === ALL_VALUE &&
dipendenteFilter === ALL_VALUE &&
corsoFilter === 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, dipendenteFilter, corsoFilter]);
useEffect(() => {
loadAssegnazioni();
}, [loadAssegnazioni]);
function openCreate() {
setFormMode("create");
setEditingAssegnazione(null);
setFormOpen(true);
}
function openEdit(assegnazione: AssegnazioneCorso) {
setFormMode("edit");
setEditingAssegnazione(assegnazione);
setFormOpen(true);
}
async function handleAnnulla() {
if (!annullaTarget) return;
setActionLoading(true);
try {
await annullaAssegnazione(annullaTarget.id);
toast.success("Assegnazione annullata");
setAnnullaTarget(null);
await loadAssegnazioni();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Impossibile annullare l'assegnazione"
);
} finally {
setActionLoading(false);
}
}
const corsiAttivi = corsi.filter((c) => c.attivo);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Assegnazioni</h1>
<p className="text-muted-foreground">
Assegna corsi ai dipendenti, modifica le date o annulla
un&apos;assegnazione.
</p>
</div>
<Button onClick={openCreate} disabled={corsiAttivi.length === 0}>
<Plus className="size-4" />
Nuova assegnazione
</Button>
</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 stato, categoria, dipendente o corso.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-4">
<div className="space-y-1.5">
<Label htmlFor="filtro-stato-assegnazioni">Stato</Label>
<Select
value={statoFilter}
onValueChange={(value) => setStatoFilter(value ?? ALL_VALUE)}
>
<SelectTrigger id="filtro-stato-assegnazioni" className="w-44">
<SelectValue placeholder="Tutti" />
</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-assegnazioni">Categoria</Label>
<Select
value={categoriaFilter}
onValueChange={(value) => setCategoriaFilter(value ?? ALL_VALUE)}
>
<SelectTrigger id="filtro-categoria-assegnazioni" 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-assegnazioni">Dipendente</Label>
<Select
value={dipendenteFilter}
onValueChange={(value) => setDipendenteFilter(value ?? ALL_VALUE)}
>
<SelectTrigger
id="filtro-dipendente-assegnazioni"
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>
<div className="space-y-1.5">
<Label htmlFor="filtro-corso-assegnazioni">Corso</Label>
<Select
value={corsoFilter}
onValueChange={(value) => setCorsoFilter(value ?? ALL_VALUE)}
>
<SelectTrigger id="filtro-corso-assegnazioni" className="w-52">
<SelectValue placeholder="Tutti" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>Tutti i corsi</SelectItem>
{corsi.map((corso) => (
<SelectItem key={corso.id} value={String(corso.id)}>
{corso.titolo}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
{loading ? "Caricamento..." : `${assegnazioni.length} assegnazioni`}
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<TableSkeleton />
) : assegnazioni.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nessuna assegnazione trovata con i filtri selezionati.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Dipendente</TableHead>
<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>
{assegnazioni.map((assegnazione) => (
<TableRow key={assegnazione.id}>
<TableCell>
<div>
<p className="font-medium">
{dipendenteLabel(assegnazione.dipendente)}
</p>
<p className="text-xs text-muted-foreground">
{assegnazione.dipendente.email}
</p>
</div>
</TableCell>
<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">
{(canModificaAssegnazione(assegnazione) ||
canAnnullaAssegnazione(assegnazione)) && (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="icon-sm" />
}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">Azioni</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canModificaAssegnazione(assegnazione) && (
<DropdownMenuItem
onClick={() => openEdit(assegnazione)}
>
<Pencil className="size-4" />
Modifica
</DropdownMenuItem>
)}
{canAnnullaAssegnazione(assegnazione) && (
<DropdownMenuItem
onClick={() => setAnnullaTarget(assegnazione)}
>
<Ban className="size-4" />
Annulla
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<AssegnazioneFormDialog
open={formOpen}
mode={formMode}
assegnazione={editingAssegnazione}
corsi={corsi}
dipendenti={dipendenti}
onOpenChange={setFormOpen}
onSaved={loadAssegnazioni}
/>
<Dialog
open={annullaTarget !== null}
onOpenChange={(open) => {
if (!open) setAnnullaTarget(null);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Annulla assegnazione</DialogTitle>
<DialogDescription>
Confermi l&apos;annullamento del corso{" "}
<strong>{annullaTarget?.corso.titolo}</strong> per{" "}
<strong>
{annullaTarget
? dipendenteLabel(annullaTarget.dipendente)
: ""}
</strong>
? Lo stato passerà ad &quot;Annullato&quot;.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setAnnullaTarget(null)}
disabled={actionLoading}
>
Indietro
</Button>
<Button
variant="destructive"
onClick={handleAnnulla}
disabled={actionLoading}
>
{actionLoading ? "Annullamento..." : "Annulla assegnazione"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

616
src/pages/Corsi.tsx Normal file
View file

@ -0,0 +1,616 @@
//===============================================
// File: Corsi.tsx
// Componente per la gestione dei corsi da parte
// del Referente Academy
// @author: "villari.andrea@libero.it"
// @version: "1.0.0 2026-07-14"
//===============================================
import { useCallback, useEffect, useState } from "react";
import {
AlertCircle,
Ban,
Filter,
MoreHorizontal,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import {
ApiClientError,
createCorso,
deleteCorso,
disattivaCorso,
getCorsi,
updateCorso,
} 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
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 { cn } from "@/lib/utils";
import type { Corso } from "@/types";
import {
corsoToForm,
emptyCorsoForm,
validateCorsoCreate,
validateCorsoUpdate,
type CorsoFormInput,
} from "@/validation/corso";
import type { FieldErrors } from "@/validation/primitives";
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>
);
}
function FieldError({ message }: { message?: string }) {
if (!message) return null;
return <p className="text-xs text-destructive">{message}</p>;
}
interface CorsoFormDialogProps {
open: boolean;
mode: "create" | "edit";
corso: Corso | null;
onOpenChange: (open: boolean) => void;
onSaved: () => void;
}
function CorsoFormDialog({
open,
mode,
corso,
onOpenChange,
onSaved,
}: CorsoFormDialogProps) {
const [form, setForm] = useState<CorsoFormInput>(emptyCorsoForm());
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) return;
setFieldErrors({});
setForm(corso ? corsoToForm(corso) : emptyCorsoForm());
}, [open, corso]);
function updateField<K extends keyof CorsoFormInput>(
key: K,
value: CorsoFormInput[K]
) {
setForm((prev) => ({ ...prev, [key]: value }));
setFieldErrors((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
}
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const validation =
mode === "create" ? validateCorsoCreate(form) : validateCorsoUpdate(form);
if (!validation.success) {
setFieldErrors(validation.errors);
return;
}
setSubmitting(true);
setFieldErrors({});
try {
if (mode === "create") {
await createCorso(validation.data);
toast.success("Corso creato con successo");
} else if (corso) {
await updateCorso(corso.id, validation.data);
toast.success("Corso aggiornato con successo");
}
onOpenChange(false);
onSaved();
} catch (err) {
if (err instanceof ApiClientError && err.fields) {
setFieldErrors(err.fields);
}
toast.error(
err instanceof Error ? err.message : "Operazione non riuscita"
);
} finally {
setSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{mode === "create" ? "Nuovo corso" : "Modifica corso"}
</DialogTitle>
<DialogDescription>
{mode === "create"
? "Inserisci i dati del nuovo corso nel catalogo Academy."
: "Aggiorna le informazioni del corso selezionato."}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="corso-titolo">Titolo</Label>
<Input
id="corso-titolo"
value={form.titolo}
onChange={(e) => updateField("titolo", e.target.value)}
aria-invalid={Boolean(fieldErrors.titolo)}
/>
<FieldError message={fieldErrors.titolo} />
</div>
<div className="space-y-1.5">
<Label htmlFor="corso-categoria">Categoria</Label>
<Input
id="corso-categoria"
value={form.categoria}
onChange={(e) => updateField("categoria", e.target.value)}
aria-invalid={Boolean(fieldErrors.categoria)}
/>
<FieldError message={fieldErrors.categoria} />
</div>
<div className="space-y-1.5">
<Label htmlFor="corso-descrizione">Descrizione</Label>
<textarea
id="corso-descrizione"
value={form.descrizione}
onChange={(e) => updateField("descrizione", e.target.value)}
rows={3}
className={cn(
"w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30",
fieldErrors.descrizione && "border-destructive ring-destructive/20"
)}
/>
<FieldError message={fieldErrors.descrizione} />
</div>
<div className="space-y-1.5">
<Label htmlFor="corso-durata">Durata (ore)</Label>
<Input
id="corso-durata"
type="number"
min={1}
value={form.durataOre}
onChange={(e) => updateField("durataOre", e.target.value)}
aria-invalid={Boolean(fieldErrors.durataOre)}
/>
<FieldError message={fieldErrors.durataOre} />
</div>
<div className="flex flex-wrap gap-6">
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.obbligatorio}
onChange={(e) => updateField("obbligatorio", e.target.checked)}
className="size-4 rounded border border-input"
/>
Corso obbligatorio
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.attivo}
onChange={(e) => updateField("attivo", e.target.checked)}
className="size-4 rounded border border-input"
/>
Corso attivo
</label>
</div>
{fieldErrors._form && <FieldError message={fieldErrors._form} />}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
Annulla
</Button>
<Button type="submit" disabled={submitting}>
{submitting
? "Salvataggio..."
: mode === "create"
? "Crea corso"
: "Salva modifiche"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export default function Corsi() {
const [corsi, setCorsi] = useState<Corso[]>([]);
const [categorie, setCategorie] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [categoriaFilter, setCategoriaFilter] = useState(ALL_VALUE);
const [attivoFilter, setAttivoFilter] = useState(ALL_VALUE);
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<"create" | "edit">("create");
const [editingCorso, setEditingCorso] = useState<Corso | null>(null);
const [disattivaTarget, setDisattivaTarget] = useState<Corso | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Corso | null>(null);
const [actionLoading, setActionLoading] = useState(false);
const loadCorsi = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getCorsi({
categoria: categoriaFilter === ALL_VALUE ? undefined : categoriaFilter,
attivo:
attivoFilter === ALL_VALUE
? undefined
: attivoFilter === "true",
});
setCorsi(data);
if (categoriaFilter === ALL_VALUE && attivoFilter === ALL_VALUE) {
const values = new Set(data.map((c) => c.categoria));
setCategorie(Array.from(values).sort());
}
} catch (err) {
setError(err instanceof Error ? err.message : "Errore nel caricamento");
} finally {
setLoading(false);
}
}, [categoriaFilter, attivoFilter]);
useEffect(() => {
loadCorsi();
}, [loadCorsi]);
function openCreate() {
setFormMode("create");
setEditingCorso(null);
setFormOpen(true);
}
function openEdit(corso: Corso) {
setFormMode("edit");
setEditingCorso(corso);
setFormOpen(true);
}
async function handleDisattiva() {
if (!disattivaTarget) return;
setActionLoading(true);
try {
await disattivaCorso(disattivaTarget.id);
toast.success("Corso disattivato");
setDisattivaTarget(null);
await loadCorsi();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Impossibile disattivare il corso"
);
} finally {
setActionLoading(false);
}
}
async function handleDelete() {
if (!deleteTarget) return;
setActionLoading(true);
try {
await deleteCorso(deleteTarget.id);
toast.success("Corso eliminato");
setDeleteTarget(null);
await loadCorsi();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Impossibile eliminare il corso"
);
} finally {
setActionLoading(false);
}
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Catalogo corsi
</h1>
<p className="text-muted-foreground">
Gestisci i corsi dell&apos;Academy: crea, modifica, disattiva o
elimina dal catalogo.
</p>
</div>
<Button onClick={openCreate}>
<Plus className="size-4" />
Nuovo corso
</Button>
</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 il catalogo per categoria o stato attivo.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-4">
<div className="space-y-1.5">
<Label htmlFor="filtro-categoria-corsi">Categoria</Label>
<Select
value={categoriaFilter}
onValueChange={(value) => setCategoriaFilter(value ?? ALL_VALUE)}
>
<SelectTrigger id="filtro-categoria-corsi" 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-attivo-corsi">Stato</Label>
<Select
value={attivoFilter}
onValueChange={(value) => setAttivoFilter(value ?? ALL_VALUE)}
>
<SelectTrigger id="filtro-attivo-corsi" className="w-44">
<SelectValue placeholder="Tutti" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>Tutti</SelectItem>
<SelectItem value="true">Solo attivi</SelectItem>
<SelectItem value="false">Solo disattivi</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
{loading ? "Caricamento..." : `${corsi.length} corsi`}
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<TableSkeleton />
) : corsi.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nessun corso trovato con i filtri selezionati.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Titolo</TableHead>
<TableHead>Categoria</TableHead>
<TableHead>Durata</TableHead>
<TableHead>Obbligatorio</TableHead>
<TableHead>Stato</TableHead>
<TableHead className="text-right">Azioni</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{corsi.map((corso) => (
<TableRow key={corso.id}>
<TableCell>
<div>
<p className="font-medium">{corso.titolo}</p>
{corso.descrizione && (
<p className="max-w-xs truncate text-xs text-muted-foreground">
{corso.descrizione}
</p>
)}
</div>
</TableCell>
<TableCell>{corso.categoria}</TableCell>
<TableCell>{corso.durataOre} h</TableCell>
<TableCell>
{corso.obbligatorio ? (
<Badge variant="secondary"></Badge>
) : (
<span className="text-muted-foreground">No</span>
)}
</TableCell>
<TableCell>
<Badge variant={corso.attivo ? "default" : "outline"}>
{corso.attivo ? "Attivo" : "Disattivo"}
</Badge>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="icon-sm" />
}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">Azioni</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openEdit(corso)}>
<Pencil className="size-4" />
Modifica
</DropdownMenuItem>
{corso.attivo && (
<DropdownMenuItem
onClick={() => setDisattivaTarget(corso)}
>
<Ban className="size-4" />
Disattiva
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteTarget(corso)}
>
<Trash2 className="size-4" />
Elimina
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<CorsoFormDialog
open={formOpen}
mode={formMode}
corso={editingCorso}
onOpenChange={setFormOpen}
onSaved={loadCorsi}
/>
<Dialog
open={disattivaTarget !== null}
onOpenChange={(open) => {
if (!open) setDisattivaTarget(null);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Disattiva corso</DialogTitle>
<DialogDescription>
Il corso <strong>{disattivaTarget?.titolo}</strong> non sarà più
disponibile per nuove assegnazioni. Le assegnazioni esistenti
restano invariate.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDisattivaTarget(null)}
disabled={actionLoading}
>
Annulla
</Button>
<Button onClick={handleDisattiva} disabled={actionLoading}>
{actionLoading ? "Disattivazione..." : "Disattiva"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Elimina corso</DialogTitle>
<DialogDescription>
Confermi l&apos;eliminazione di{" "}
<strong>{deleteTarget?.titolo}</strong>? L&apos;operazione non è
reversibile. Se esistono assegnazioni collegate, l&apos;eliminazione
verrà rifiutata dal sistema.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteTarget(null)}
disabled={actionLoading}
>
Annulla
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={actionLoading}
>
{actionLoading ? "Eliminazione..." : "Elimina"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,120 @@
//=======================================
import {
buildResult,
normalizePositiveInt,
normalizeString,
validateDate,
validatePositiveInt,
validateRequired,
type FieldErrors,
type ValidationResult,
} from "./primitives";
export interface AssegnazioneFormInput {
corsoId: string;
dipendenteId: string;
dataAssegnazione: string;
dataScadenza: string;
}
export interface AssegnazionePayload {
corsoId: number;
dipendenteId: number;
dataAssegnazione: string;
dataScadenza: string;
}
function validateDateRange(
errors: FieldErrors,
dataAssegnazione: string,
dataScadenza: string
): void {
if (!dataAssegnazione || !dataScadenza) return;
if (dataScadenza < dataAssegnazione) {
errors.dataScadenza =
"La data di scadenza non può essere precedente alla data di assegnazione";
}
}
function toPayload(form: AssegnazioneFormInput): AssegnazionePayload {
return {
corsoId: normalizePositiveInt(form.corsoId),
dipendenteId: normalizePositiveInt(form.dipendenteId),
dataAssegnazione: normalizeString(form.dataAssegnazione),
dataScadenza: normalizeString(form.dataScadenza),
};
}
function validateFields(
data: AssegnazionePayload,
errors: FieldErrors
): void {
validatePositiveInt(errors, "corsoId", data.corsoId, "Corso");
validatePositiveInt(errors, "dipendenteId", data.dipendenteId, "Dipendente");
validateRequired(
errors,
"dataAssegnazione",
data.dataAssegnazione,
"Data di assegnazione obbligatoria"
);
validateDate(
errors,
"dataAssegnazione",
data.dataAssegnazione,
"Data di assegnazione"
);
validateRequired(
errors,
"dataScadenza",
data.dataScadenza,
"Data di scadenza obbligatoria"
);
validateDate(errors, "dataScadenza", data.dataScadenza, "Data di scadenza");
if (data.dataAssegnazione && data.dataScadenza) {
validateDateRange(errors, data.dataAssegnazione, data.dataScadenza);
}
}
export function validateAssegnazioneCreate(
form: AssegnazioneFormInput
): ValidationResult<AssegnazionePayload> {
const data = toPayload(form);
const errors: FieldErrors = {};
validateFields(data, errors);
return buildResult(data, errors);
}
export function validateAssegnazioneUpdate(
form: AssegnazioneFormInput
): ValidationResult<AssegnazionePayload> {
return validateAssegnazioneCreate(form);
}
export function emptyAssegnazioneForm(
defaults?: Partial<AssegnazioneFormInput>
): AssegnazioneFormInput {
return {
corsoId: "",
dipendenteId: "",
dataAssegnazione: "",
dataScadenza: "",
...defaults,
};
}
export function assegnazioneToForm(assegnazione: {
corsoId: number;
dipendenteId: number;
dataAssegnazione: string;
dataScadenza: string;
}): AssegnazioneFormInput {
return {
corsoId: String(assegnazione.corsoId),
dipendenteId: String(assegnazione.dipendenteId),
dataAssegnazione: assegnazione.dataAssegnazione,
dataScadenza: assegnazione.dataScadenza,
};
}

152
src/validation/corso.ts Normal file
View file

@ -0,0 +1,152 @@
//=======================================
// File: cors.ts
// Script per la validazione dei dati del
// corso lato frontend.
// @author: "villari.andrea@libero.it"
// @version: "1.0.0 2026-07-14"
//=======================================
import { CORSO_CONSTRAINTS } from "./constraints";
import {
buildResult,
normalizePositiveInt,
normalizeString,
validateBoolean,
validateMaxLength,
validatePositiveInt,
validateRequired,
type FieldErrors,
type ValidationResult,
} from "./primitives";
export interface CorsoFormInput {
titolo: string;
descrizione: string;
categoria: string;
durataOre: string;
obbligatorio: boolean;
attivo: boolean;
}
export interface CorsoPayload {
titolo: string;
descrizione: string | null;
categoria: string;
durataOre: number;
obbligatorio: boolean;
attivo: boolean;
}
function validateCorsoFields(
data: CorsoPayload,
errors: FieldErrors,
options: { requireAll: boolean }
): void {
if (options.requireAll || data.titolo !== undefined) {
validateRequired(errors, "titolo", data.titolo, "Titolo obbligatorio");
validateMaxLength(
errors,
"titolo",
data.titolo,
CORSO_CONSTRAINTS.titolo.maxLength,
"Titolo"
);
}
if (options.requireAll || data.categoria !== undefined) {
validateRequired(errors, "categoria", data.categoria, "Categoria obbligatoria");
validateMaxLength(
errors,
"categoria",
data.categoria,
CORSO_CONSTRAINTS.categoria.maxLength,
"Categoria"
);
}
if (options.requireAll || data.durataOre !== undefined) {
validatePositiveInt(errors, "durataOre", data.durataOre, "Durata prevista");
if (
!Number.isNaN(data.durataOre) &&
data.durataOre > CORSO_CONSTRAINTS.durataOre.max
) {
errors.durataOre = "Durata prevista non valida";
}
}
if (data.descrizione) {
validateMaxLength(
errors,
"descrizione",
data.descrizione,
CORSO_CONSTRAINTS.descrizione.maxLength,
"Descrizione"
);
}
}
function toPayload(form: CorsoFormInput): CorsoPayload {
return {
titolo: normalizeString(form.titolo),
descrizione: normalizeString(form.descrizione) || null,
categoria: normalizeString(form.categoria),
durataOre: normalizePositiveInt(form.durataOre),
obbligatorio: form.obbligatorio,
attivo: form.attivo,
};
}
export function validateCorsoCreate(
form: CorsoFormInput
): ValidationResult<CorsoPayload> {
const data = toPayload(form);
const errors: FieldErrors = {};
validateBoolean(errors, "obbligatorio", form.obbligatorio, "Obbligatorietà");
validateBoolean(errors, "attivo", form.attivo, "Stato attivo");
validateCorsoFields(data, errors, { requireAll: true });
return buildResult(data, errors);
}
export function validateCorsoUpdate(
form: CorsoFormInput
): ValidationResult<CorsoPayload> {
const data = toPayload(form);
const errors: FieldErrors = {};
validateBoolean(errors, "obbligatorio", form.obbligatorio, "Obbligatorietà");
validateBoolean(errors, "attivo", form.attivo, "Stato attivo");
validateCorsoFields(data, errors, { requireAll: true });
return buildResult(data, errors);
}
export function emptyCorsoForm(): CorsoFormInput {
return {
titolo: "",
descrizione: "",
categoria: "",
durataOre: "",
obbligatorio: false,
attivo: true,
};
}
export function corsoToForm(corso: {
titolo: string;
descrizione: string | null;
categoria: string;
durataOre: number;
obbligatorio: boolean;
attivo: boolean;
}): CorsoFormInput {
return {
titolo: corso.titolo,
descrizione: corso.descrizione ?? "",
categoria: corso.categoria,
durataOre: String(corso.durataOre),
obbligatorio: corso.obbligatorio,
attivo: corso.attivo,
};
}

View file

@ -1,3 +1,11 @@
//================================================
// File: utnte.ts
// Script per la validazione dei dati Utente lato
// frontend
// @author: "villari.andrea@libero.it"
// @version: "1.0.0 2026-07-14"
//================================================
import { UTENTE_CONSTRAINTS } from "./constraints";
import {
buildResult,