area Referenti Academy con gestione corsi e assegnazioni
This commit is contained in:
parent
d712da6295
commit
3926e46216
8 changed files with 1696 additions and 10 deletions
12
src/App.tsx
12
src/App.tsx
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
698
src/pages/Assegnazioni.tsx
Normal 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'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'annullamento del corso{" "}
|
||||
<strong>{annullaTarget?.corso.titolo}</strong> per{" "}
|
||||
<strong>
|
||||
{annullaTarget
|
||||
? dipendenteLabel(annullaTarget.dipendente)
|
||||
: ""}
|
||||
</strong>
|
||||
? Lo stato passerà ad "Annullato".
|
||||
</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
616
src/pages/Corsi.tsx
Normal 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'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">Sì</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'eliminazione di{" "}
|
||||
<strong>{deleteTarget?.titolo}</strong>? L'operazione non è
|
||||
reversibile. Se esistono assegnazioni collegate, l'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>
|
||||
);
|
||||
}
|
||||
120
src/validation/assegnazione.ts
Normal file
120
src/validation/assegnazione.ts
Normal 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
152
src/validation/corso.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue