diff --git a/src/App.tsx b/src/App.tsx index 5be2a98..bc009d3 100644 --- a/src/App.tsx +++ b/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={ - + } /> @@ -56,10 +55,7 @@ export default function App() { path="/assegnazioni" element={ - + } /> diff --git a/src/api/client.ts b/src/api/client.ts index c5c8589..199f118 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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; + + constructor(message: string, fields?: Record) { + super(message); + this.name = "ApiClientError"; + this.fields = fields; + } +} + async function request(path: string, options: RequestInit = {}): Promise { const response = await fetch(`${API_URL}${path}`, { credentials: "include", @@ -19,10 +35,14 @@ async function request(path: string, options: RequestInit = {}): Promise { ...options, }); - const data = await response.json().catch(() => ({})); + const text = await response.text(); + const data = text ? (JSON.parse(text) as Record) : {}; 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 | undefined + ); } return data as T; @@ -171,6 +191,40 @@ export async function getCorsoById(id: number): Promise { 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 { + return request("/api/corsi", { + method: "POST", + body: JSON.stringify(data), + }); +} + +export async function updateCorso( + id: number, + data: Partial +): Promise { + return request(`/api/corsi/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }); +} + +export async function disattivaCorso(id: number): Promise { + return request(`/api/corsi/${id}/disattiva`, { method: "PUT" }); +} + +export async function deleteCorso(id: number): Promise { + 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 { + return request("/api/assegnazioni-corsi", { + method: "POST", + body: JSON.stringify(data), + }); +} + +export async function updateAssegnazione( + id: number, + data: Partial +): Promise { + return request(`/api/assegnazioni-corsi/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }); +} + +export async function annullaAssegnazione(id: number): Promise { + return request(`/api/assegnazioni-corsi/${id}/annulla`, { method: "PUT" }); +} + +export async function getUtenti(filters?: { ruolo?: Ruolo }): Promise { + return request(`/api/utenti${buildQuery({ ruolo: filters?.ruolo })}`); +} + export async function getStatisticheAcademy(filters?: { mese?: string; daMese?: string; diff --git a/src/lib/assegnazione.ts b/src/lib/assegnazione.ts index ff9dbca..76eda7f 100644 --- a/src/lib/assegnazione.ts +++ b/src/lib/assegnazione.ts @@ -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 diff --git a/src/pages/Assegnazioni.tsx b/src/pages/Assegnazioni.tsx new file mode 100644 index 0000000..97e5fd6 --- /dev/null +++ b/src/pages/Assegnazioni.tsx @@ -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) { + return `${utente.cognome} ${utente.nome}`; +} + +function TableSkeleton() { + return ( +
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+ ); +} + +function FieldError({ message }: { message?: string }) { + if (!message) return null; + return

{message}

; +} + +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( + emptyAssegnazioneForm() + ); + const [fieldErrors, setFieldErrors] = useState({}); + 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( + 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 ( + + + + + {mode === "create" ? "Nuova assegnazione" : "Modifica assegnazione"} + + + {mode === "create" + ? "Assegna un corso attivo a un dipendente con date di assegnazione e scadenza." + : "Aggiorna corso, dipendente o date dell'assegnazione."} + + + +
+
+ + + +
+ +
+ + + +
+ +
+
+ + + updateField("dataAssegnazione", e.target.value) + } + aria-invalid={Boolean(fieldErrors.dataAssegnazione)} + /> + +
+ +
+ + updateField("dataScadenza", e.target.value)} + aria-invalid={Boolean(fieldErrors.dataScadenza)} + /> + +
+
+ + {fieldErrors._form && } + + + + + + +
+
+ ); +} + +export default function Assegnazioni() { + const [assegnazioni, setAssegnazioni] = useState([]); + const [corsi, setCorsi] = useState([]); + const [dipendenti, setDipendenti] = useState([]); + const [categorie, setCategorie] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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(null); + const [annullaTarget, setAnnullaTarget] = useState( + 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 ( +
+
+
+

Assegnazioni

+

+ Assegna corsi ai dipendenti, modifica le date o annulla + un'assegnazione. +

+
+ +
+ + {error && ( + + + {error} + + )} + + + + + + Filtri + + + Filtra per stato, categoria, dipendente o corso. + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + + + + {loading ? "Caricamento..." : `${assegnazioni.length} assegnazioni`} + + + + {loading ? ( + + ) : assegnazioni.length === 0 ? ( +

+ Nessuna assegnazione trovata con i filtri selezionati. +

+ ) : ( + + + + Dipendente + Corso + Categoria + Assegnato il + Scadenza + Stato + Azioni + + + + {assegnazioni.map((assegnazione) => ( + + +
+

+ {dipendenteLabel(assegnazione.dipendente)} +

+

+ {assegnazione.dipendente.email} +

+
+
+ + {assegnazione.corso.titolo} + + {assegnazione.corso.categoria} + + {formatDateIt(assegnazione.dataAssegnazione)} + + + {formatDateIt(assegnazione.dataScadenza)} + + + + {assegnazione.stato} + + + + {(canModificaAssegnazione(assegnazione) || + canAnnullaAssegnazione(assegnazione)) && ( + + + } + > + + Azioni + + + {canModificaAssegnazione(assegnazione) && ( + openEdit(assegnazione)} + > + + Modifica + + )} + {canAnnullaAssegnazione(assegnazione) && ( + setAnnullaTarget(assegnazione)} + > + + Annulla + + )} + + + )} + +
+ ))} +
+
+ )} +
+
+ + + + { + if (!open) setAnnullaTarget(null); + }} + > + + + Annulla assegnazione + + Confermi l'annullamento del corso{" "} + {annullaTarget?.corso.titolo} per{" "} + + {annullaTarget + ? dipendenteLabel(annullaTarget.dipendente) + : ""} + + ? Lo stato passerà ad "Annullato". + + + + + + + + +
+ ); +} diff --git a/src/pages/Corsi.tsx b/src/pages/Corsi.tsx new file mode 100644 index 0000000..3ac3633 --- /dev/null +++ b/src/pages/Corsi.tsx @@ -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 ( +
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+ ); +} + +function FieldError({ message }: { message?: string }) { + if (!message) return null; + return

{message}

; +} + +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(emptyCorsoForm()); + const [fieldErrors, setFieldErrors] = useState({}); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!open) return; + setFieldErrors({}); + setForm(corso ? corsoToForm(corso) : emptyCorsoForm()); + }, [open, corso]); + + function updateField( + 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 ( + + + + + {mode === "create" ? "Nuovo corso" : "Modifica corso"} + + + {mode === "create" + ? "Inserisci i dati del nuovo corso nel catalogo Academy." + : "Aggiorna le informazioni del corso selezionato."} + + + +
+
+ + updateField("titolo", e.target.value)} + aria-invalid={Boolean(fieldErrors.titolo)} + /> + +
+ +
+ + updateField("categoria", e.target.value)} + aria-invalid={Boolean(fieldErrors.categoria)} + /> + +
+ +
+ +