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 Login from "@/pages/Login";
|
||||||
import MieiCorsi from "@/pages/MieiCorsi";
|
import MieiCorsi from "@/pages/MieiCorsi";
|
||||||
import MioCorsoDettaglio from "@/pages/MioCorsoDettaglio";
|
import MioCorsoDettaglio from "@/pages/MioCorsoDettaglio";
|
||||||
|
import Corsi from "@/pages/Corsi";
|
||||||
|
import Assegnazioni from "@/pages/Assegnazioni";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -45,10 +47,7 @@ export default function App() {
|
||||||
path="/corsi"
|
path="/corsi"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute roles={["Referente Academy"]}>
|
<ProtectedRoute roles={["Referente Academy"]}>
|
||||||
<PagePlaceholder
|
<Corsi />
|
||||||
title="Catalogo corsi"
|
|
||||||
description="Gestione del catalogo corsi Academy."
|
|
||||||
/>
|
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -56,10 +55,7 @@ export default function App() {
|
||||||
path="/assegnazioni"
|
path="/assegnazioni"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute roles={["Referente Academy"]}>
|
<ProtectedRoute roles={["Referente Academy"]}>
|
||||||
<PagePlaceholder
|
<Assegnazioni />
|
||||||
title="Assegnazioni"
|
|
||||||
description="Gestione delle assegnazioni corsi ai dipendenti."
|
|
||||||
/>
|
|
||||||
</ProtectedRoute>
|
</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 {
|
import type {
|
||||||
AssegnazioneCorso,
|
AssegnazioneCorso,
|
||||||
Corso,
|
Corso,
|
||||||
|
|
@ -9,6 +15,16 @@ import type {
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? "";
|
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> {
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const response = await fetch(`${API_URL}${path}`, {
|
const response = await fetch(`${API_URL}${path}`, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
|
|
@ -19,10 +35,14 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
...options,
|
...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) {
|
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;
|
return data as T;
|
||||||
|
|
@ -171,6 +191,40 @@ export async function getCorsoById(id: number): Promise<Corso> {
|
||||||
return request(`/api/corsi/${id}`);
|
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(
|
export async function completaAssegnazione(
|
||||||
id: number,
|
id: number,
|
||||||
data?: { dataCompletamento?: string }
|
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?: {
|
export async function getStatisticheAcademy(filters?: {
|
||||||
mese?: string;
|
mese?: string;
|
||||||
daMese?: string;
|
daMese?: string;
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,14 @@ export function canCompletaAssegnazione(assegnazione: AssegnazioneCorso): boolea
|
||||||
return assegnazione.stato === "Assegnato" || assegnazione.stato === "Scaduto";
|
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(
|
export function matchesScadenzaFilter(
|
||||||
assegnazione: AssegnazioneCorso,
|
assegnazione: AssegnazioneCorso,
|
||||||
filter: ScadenzaFilter
|
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 { UTENTE_CONSTRAINTS } from "./constraints";
|
||||||
import {
|
import {
|
||||||
buildResult,
|
buildResult,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue