inserita la creazione e la gestione degil utenti
Some checks are pending
Deploy to VPS / deploy (push) Blocked by required conditions
Deploy to VPS / build (push) Successful in 30s

This commit is contained in:
AV77web 2026-07-14 16:04:08 +02:00
parent dc7dbc4166
commit 05f1629aee
9 changed files with 797 additions and 221 deletions

View file

@ -18,7 +18,7 @@ import MieiCorsi from "@/pages/MieiCorsi";
import MioCorsoDettaglio from "@/pages/MioCorsoDettaglio"; import MioCorsoDettaglio from "@/pages/MioCorsoDettaglio";
import Corsi from "@/pages/Corsi"; import Corsi from "@/pages/Corsi";
import Assegnazioni from "@/pages/Assegnazioni"; import Assegnazioni from "@/pages/Assegnazioni";
import Dipendenti from "@/pages/Dipendenti"; import Utenti from "@/pages/Utenti";
import Statistiche from "@/pages/Statistiche"; import Statistiche from "@/pages/Statistiche";
export default function App() { export default function App() {
@ -63,10 +63,10 @@ export default function App() {
} }
/> />
<Route <Route
path="/dipendenti" path="/utenti"
element={ element={
<ProtectedRoute roles={["Referente Academy"]}> <ProtectedRoute roles={["Referente Academy"]}>
<Dipendenti /> <Utenti />
</ProtectedRoute> </ProtectedRoute>
} }
/> />

View file

@ -53,44 +53,11 @@ export async function getCsrfToken(): Promise<string> {
return data.csrfToken; return data.csrfToken;
} }
export async function login(email: string, password: string): Promise<void> { export async function login(email: string, password: string): Promise<Utente> {
const csrfToken = await getCsrfToken(); return request<Utente>("/api/utenti/login", {
const body = new URLSearchParams({
csrfToken,
email,
password,
redirect: "false",
});
const response = await fetch(`${API_URL}/api/auth/callback/credentials`, {
method: "POST", method: "POST",
credentials: "include", body: JSON.stringify({ email, password }),
redirect: "manual",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-Auth-Return-Redirect": "1",
},
body,
}); });
if (response.type === "opaqueredirect" || response.status === 302) {
throw new Error("Login non riuscito: frontend non aggiornato o configurazione Auth errata");
}
const data = await response.json().catch(() => ({})) as { url?: string };
if (!response.ok || data.url?.includes("error=")) {
const errorType = data.url?.match(/error=([^&]+)/)?.[1];
if (errorType === "CredentialsSignin") {
throw new Error("Credenziali non valide");
}
if (errorType === "MissingCSRF") {
throw new Error("Sessione scaduta: ricarica la pagina e riprova");
}
if (errorType === "Configuration") {
throw new Error("Errore di configurazione del server (database o auth)");
}
throw new Error("Accesso non riuscito");
}
} }
export async function logout(): Promise<void> { export async function logout(): Promise<void> {
@ -265,6 +232,37 @@ export async function getUtenti(filters?: { ruolo?: Ruolo }): Promise<Utente[]>
return request(`/api/utenti${buildQuery({ ruolo: filters?.ruolo })}`); return request(`/api/utenti${buildQuery({ ruolo: filters?.ruolo })}`);
} }
export interface UtenteInput {
nome: string;
cognome: string;
email: string;
password: string;
ruolo: Ruolo;
}
export async function createUtente(data: UtenteInput): Promise<Utente> {
return request("/api/utenti/register", {
method: "POST",
body: JSON.stringify(data),
});
}
export async function updateUtente(
id: number,
data: {
nome: string;
cognome: string;
email: string;
ruolo: Ruolo;
password?: string;
}
): Promise<Utente> {
return request(`/api/utenti/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
export async function getStatisticheAcademy(filters?: { export async function getStatisticheAcademy(filters?: {
mese?: string; mese?: string;
daMese?: string; daMese?: string;

View file

@ -41,8 +41,8 @@ export const NAV_ITEMS: NavItem[] = [
roles: ["Referente Academy"], roles: ["Referente Academy"],
}, },
{ {
label: "Dipendenti", label: "Utenti",
to: "/dipendenti", to: "/utenti",
icon: Users, icon: Users,
roles: ["Referente Academy"], roles: ["Referente Academy"],
}, },

View file

@ -302,9 +302,9 @@ function ReferenteDashboard() {
to="/assegnazioni" to="/assegnazioni"
/> />
<QuickLinkCard <QuickLinkCard
title="Dipendenti" title="Utenti"
description="Consulta l'elenco dei dipendenti." description="Gestisci Dipendenti e Referenti Academy."
to="/dipendenti" to="/utenti"
/> />
<QuickLinkCard <QuickLinkCard
title="Statistiche" title="Statistiche"

View file

@ -1,172 +0,0 @@
//==========================================
// File: Dipendenti.tsx
// Componente per la gestione dei dipendenti
// @author: "villari.andrea@libero.it"
// @version: "1.0.0 2026-07-14"
//==========================================
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { AlertCircle, ClipboardList, Search, Users } from "lucide-react";
import { getUtenti } from "@/api/client";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { Utente } from "@/types";
function TableSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-12 w-full" />
))}
</div>
);
}
function matchesSearch(utente: Utente, query: string): boolean {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
return [utente.nome, utente.cognome, utente.email]
.join(" ")
.toLowerCase()
.includes(normalized);
}
export default function Dipendenti() {
const [dipendenti, setDipendenti] = useState<Utente[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
useEffect(() => {
setLoading(true);
setError(null);
getUtenti({ ruolo: "Dipendente" })
.then(setDipendenti)
.catch((err) =>
setError(err instanceof Error ? err.message : "Errore nel caricamento")
)
.finally(() => setLoading(false));
}, []);
const filtered = useMemo(
() => dipendenti.filter((d) => matchesSearch(d, search)),
[dipendenti, search]
);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dipendenti</h1>
<p className="text-muted-foreground">
Elenco dei dipendenti dell&apos;organizzazione iscritti all&apos;Academy.
</p>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2 text-base">
<Search className="size-4" />
Ricerca
</CardTitle>
<CardDescription>
Filtra per nome, cognome o email.
</CardDescription>
</CardHeader>
<CardContent>
<div className="max-w-sm space-y-1.5">
<Label htmlFor="ricerca-dipendenti">Cerca dipendente</Label>
<Input
id="ricerca-dipendenti"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Es. Maria, Bianchi, academy.it"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Users className="size-4" />
{loading ? "Caricamento..." : `${filtered.length} dipendenti`}
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<TableSkeleton />
) : filtered.length === 0 ? (
<p className="text-sm text-muted-foreground">
{search
? "Nessun dipendente corrisponde alla ricerca."
: "Nessun dipendente registrato."}
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Cognome</TableHead>
<TableHead>Nome</TableHead>
<TableHead>Email</TableHead>
<TableHead className="text-right">Azioni</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((dipendente) => (
<TableRow key={dipendente.id}>
<TableCell className="font-medium">
{dipendente.cognome}
</TableCell>
<TableCell>{dipendente.nome}</TableCell>
<TableCell>{dipendente.email}</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
render={
<Link
to={`/assegnazioni?dipendenteId=${dipendente.id}`}
/>
}
>
<ClipboardList className="size-4" />
Assegnazioni
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

View file

@ -7,6 +7,7 @@
import { CircleAlert } from "lucide-react"; import { CircleAlert } from "lucide-react";
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ApiClientError } from "@/api/client";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -19,6 +20,13 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { validateLoginForm } from "@/validation/utente";
import type { FieldErrors } from "@/validation/primitives";
function FieldError({ message }: { message?: string }) {
if (!message) return null;
return <p className="text-xs text-destructive">{message}</p>;
}
export default function Login() { export default function Login() {
const { login } = useAuth(); const { login } = useAuth();
@ -26,16 +34,28 @@ export default function Login() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => { const handleSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
const validation = validateLoginForm({ email, password });
if (validation.success === false) {
setFieldErrors(validation.errors);
return;
}
setFieldErrors({});
setLoading(true); setLoading(true);
try { try {
await login(email, password); await login(validation.data.email, validation.data.password);
navigate("/dashboard"); navigate("/dashboard");
} catch (err) { } catch (err) {
if (err instanceof ApiClientError && err.fields) {
setFieldErrors(err.fields);
}
setError(err instanceof Error ? err.message : "Errore di accesso"); setError(err instanceof Error ? err.message : "Errore di accesso");
} finally { } finally {
setLoading(false); setLoading(false);
@ -68,10 +88,18 @@ export default function Login() {
autoComplete="email" autoComplete="email"
placeholder="nome@academy.it" placeholder="nome@academy.it"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => {
required setEmail(e.target.value);
setFieldErrors((prev) => {
const next = { ...prev };
delete next.email;
return next;
});
}}
aria-invalid={Boolean(fieldErrors.email)}
disabled={loading} disabled={loading}
/> />
<FieldError message={fieldErrors.email} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
@ -82,10 +110,18 @@ export default function Login() {
autoComplete="current-password" autoComplete="current-password"
placeholder="••••••••" placeholder="••••••••"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => {
required setPassword(e.target.value);
setFieldErrors((prev) => {
const next = { ...prev };
delete next.password;
return next;
});
}}
aria-invalid={Boolean(fieldErrors.password)}
disabled={loading} disabled={loading}
/> />
<FieldError message={fieldErrors.password} />
</div> </div>
<Button type="submit" className="w-full" disabled={loading}> <Button type="submit" className="w-full" disabled={loading}>

485
src/pages/Utenti.tsx Normal file
View file

@ -0,0 +1,485 @@
//==========================================
// File: Utenti.tsx
// Componente per la gestione degli utenti
// @author: "villari.andrea@libero.it"
// @version: "1.0.0 2026-07-14"
//==========================================
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import {
AlertCircle,
ClipboardList,
Pencil,
Plus,
Search,
Users,
} from "lucide-react";
import { toast } from "sonner";
import {
ApiClientError,
createUtente,
getUtenti,
updateUtente,
} from "@/api/client";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { Ruolo, Utente } from "@/types";
import {
emptyUtenteForm,
utenteToForm,
validateUtenteCreate,
validateUtenteUpdate,
type UtenteFormInput,
} from "@/validation/utente";
import type { FieldErrors } from "@/validation/primitives";
const ALL_VALUE = "__all__";
const RUOLI: Ruolo[] = ["Dipendente", "Referente Academy"];
function TableSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 4 }).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>;
}
function matchesSearch(utente: Utente, query: string): boolean {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
return [utente.nome, utente.cognome, utente.email, utente.ruolo]
.join(" ")
.toLowerCase()
.includes(normalized);
}
interface UtenteFormDialogProps {
open: boolean;
mode: "create" | "edit";
utente: Utente | null;
onOpenChange: (open: boolean) => void;
onSaved: () => void;
}
function UtenteFormDialog({
open,
mode,
utente,
onOpenChange,
onSaved,
}: UtenteFormDialogProps) {
const [form, setForm] = useState<UtenteFormInput>(emptyUtenteForm());
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) return;
setFieldErrors({});
setForm(utente ? utenteToForm(utente) : emptyUtenteForm());
}, [open, utente]);
function updateField<K extends keyof UtenteFormInput>(
key: K,
value: UtenteFormInput[K]
) {
setForm((prev) => ({ ...prev, [key]: value }));
setFieldErrors((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
}
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setSubmitting(true);
setFieldErrors({});
try {
if (mode === "create") {
const result = validateUtenteCreate(form);
if (result.success === false) {
setFieldErrors(result.errors);
return;
}
await createUtente(result.data);
toast.success("Utente creato con successo");
} else if (utente) {
const result = validateUtenteUpdate(form);
if (result.success === false) {
setFieldErrors(result.errors);
return;
}
await updateUtente(utente.id, result.data);
toast.success("Utente 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 utente" : "Modifica utente"}
</DialogTitle>
<DialogDescription>
{mode === "create"
? "Registra un nuovo Dipendente o Referente Academy."
: "Aggiorna i dati utente. Lascia la password vuota per non modificarla."}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="utente-nome">Nome</Label>
<Input
id="utente-nome"
value={form.nome}
onChange={(e) => updateField("nome", e.target.value)}
aria-invalid={Boolean(fieldErrors.nome)}
/>
<FieldError message={fieldErrors.nome} />
</div>
<div className="space-y-1.5">
<Label htmlFor="utente-cognome">Cognome</Label>
<Input
id="utente-cognome"
value={form.cognome}
onChange={(e) => updateField("cognome", e.target.value)}
aria-invalid={Boolean(fieldErrors.cognome)}
/>
<FieldError message={fieldErrors.cognome} />
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="utente-email">Email</Label>
<Input
id="utente-email"
type="email"
value={form.email}
onChange={(e) => updateField("email", e.target.value)}
aria-invalid={Boolean(fieldErrors.email)}
/>
<FieldError message={fieldErrors.email} />
</div>
<div className="space-y-1.5">
<Label htmlFor="utente-ruolo">Ruolo</Label>
<Select
value={form.ruolo || null}
onValueChange={(value) =>
updateField("ruolo", (value ?? "") as Ruolo)
}
>
<SelectTrigger
id="utente-ruolo"
className="w-full"
aria-invalid={Boolean(fieldErrors.ruolo)}
>
<SelectValue placeholder="Seleziona ruolo" />
</SelectTrigger>
<SelectContent>
{RUOLI.map((ruolo) => (
<SelectItem key={ruolo} value={ruolo}>
{ruolo}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={fieldErrors.ruolo} />
</div>
<div className="space-y-1.5">
<Label htmlFor="utente-password">
Password{mode === "edit" ? " (opzionale)" : ""}
</Label>
<Input
id="utente-password"
type="password"
value={form.password}
onChange={(e) => updateField("password", e.target.value)}
autoComplete={mode === "create" ? "new-password" : "off"}
aria-invalid={Boolean(fieldErrors.password)}
/>
<FieldError message={fieldErrors.password} />
{mode === "create" && (
<p className="text-xs text-muted-foreground">
Minimo 8 caratteri.
</p>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
Annulla
</Button>
<Button type="submit" disabled={submitting}>
{submitting
? "Salvataggio..."
: mode === "create"
? "Registra utente"
: "Salva modifiche"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export default function Utenti() {
const [utenti, setUtenti] = useState<Utente[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [ruoloFilter, setRuoloFilter] = useState<string>(ALL_VALUE);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<"create" | "edit">("create");
const [selectedUtente, setSelectedUtente] = useState<Utente | null>(null);
const loadUtenti = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getUtenti(
ruoloFilter === ALL_VALUE
? undefined
: { ruolo: ruoloFilter as Ruolo }
);
setUtenti(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Errore nel caricamento");
} finally {
setLoading(false);
}
}, [ruoloFilter]);
useEffect(() => {
loadUtenti();
}, [loadUtenti]);
const filtered = useMemo(
() => utenti.filter((u) => matchesSearch(u, search)),
[utenti, search]
);
function openCreateDialog() {
setDialogMode("create");
setSelectedUtente(null);
setDialogOpen(true);
}
function openEditDialog(utente: Utente) {
setDialogMode("edit");
setSelectedUtente(utente);
setDialogOpen(true);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Utenti</h1>
<p className="text-muted-foreground">
Gestisci i Dipendenti e i Referenti Academy dell&apos;organizzazione.
</p>
</div>
<Button onClick={openCreateDialog}>
<Plus className="size-4" />
Nuovo utente
</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">
<Search className="size-4" />
Filtri
</CardTitle>
<CardDescription>
Filtra per ruolo o cerca per nome, cognome ed email.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row">
<div className="max-w-sm flex-1 space-y-1.5">
<Label htmlFor="ricerca-utenti">Cerca utente</Label>
<Input
id="ricerca-utenti"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Es. Maria, Bianchi, academy.it"
/>
</div>
<div className="w-full max-w-xs space-y-1.5">
<Label htmlFor="filtro-ruolo">Ruolo</Label>
<Select
value={ruoloFilter}
onValueChange={(value) => setRuoloFilter(value ?? ALL_VALUE)}
>
<SelectTrigger id="filtro-ruolo">
<SelectValue placeholder="Tutti i ruoli" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>Tutti i ruoli</SelectItem>
{RUOLI.map((ruolo) => (
<SelectItem key={ruolo} value={ruolo}>
{ruolo}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Users className="size-4" />
{loading ? "Caricamento..." : `${filtered.length} utenti`}
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<TableSkeleton />
) : filtered.length === 0 ? (
<p className="text-sm text-muted-foreground">
{search
? "Nessun utente corrisponde ai filtri selezionati."
: "Nessun utente registrato."}
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Cognome</TableHead>
<TableHead>Nome</TableHead>
<TableHead>Email</TableHead>
<TableHead>Ruolo</TableHead>
<TableHead className="text-right">Azioni</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((utente) => (
<TableRow key={utente.id}>
<TableCell className="font-medium">
{utente.cognome}
</TableCell>
<TableCell>{utente.nome}</TableCell>
<TableCell>{utente.email}</TableCell>
<TableCell>
<Badge variant="secondary">{utente.ruolo}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => openEditDialog(utente)}
>
<Pencil className="size-4" />
Modifica
</Button>
{utente.ruolo === "Dipendente" && (
<Button
variant="outline"
size="sm"
render={
<Link
to={`/assegnazioni?dipendenteId=${utente.id}`}
/>
}
>
<ClipboardList className="size-4" />
Assegnazioni
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<UtenteFormDialog
open={dialogOpen}
mode={dialogMode}
utente={selectedUtente}
onOpenChange={setDialogOpen}
onSaved={loadUtenti}
/>
</div>
);
}

View file

@ -5,6 +5,17 @@
// @version: "1.0.0 2026-07-14" // @version: "1.0.0 2026-07-14"
//=================================================== //===================================================
export const UTENTE_CONSTRAINTS = {
nome: { maxLength: 100, required: true },
cognome: { maxLength: 100, required: true },
email: { maxLength: 255, required: true },
password: { minLength: 8, maxLength: 72, required: true },
ruolo: {
allowedValues: ["Dipendente", "Referente Academy"] as const,
required: true,
},
} as const;
export const CORSO_CONSTRAINTS = { export const CORSO_CONSTRAINTS = {
titolo: { maxLength: 200, required: true }, titolo: { maxLength: 200, required: true },
descrizione: { maxLength: 65535, required: false }, descrizione: { maxLength: 65535, required: false },

218
src/validation/utente.ts Normal file
View file

@ -0,0 +1,218 @@
//=======================================
// File: utente.ts
// Validazione lato frontend dei dati utente.
// @author: "villari.andrea@libero.it"
// @version: "1.0.0 2026-07-14"
//=======================================
import { UTENTE_CONSTRAINTS } from "./constraints";
import {
buildResult,
normalizeEmail,
normalizeString,
validateEmailFormat,
validateEnum,
validateMaxLength,
validateMinLength,
validateRequired,
type FieldErrors,
type ValidationResult,
} from "./primitives";
import type { Ruolo, Utente } from "@/types";
export interface UtenteFormInput {
nome: string;
cognome: string;
email: string;
password: string;
ruolo: Ruolo | "";
}
export interface UtenteCreatePayload {
nome: string;
cognome: string;
email: string;
password: string;
ruolo: Ruolo;
}
export interface UtenteUpdatePayload {
nome: string;
cognome: string;
email: string;
ruolo: Ruolo;
password?: string;
}
export interface LoginFormInput {
email: string;
password: string;
}
function validateNomeCognomeEmail(
data: Pick<UtenteFormInput, "nome" | "cognome" | "email">,
errors: FieldErrors
): void {
validateRequired(errors, "nome", data.nome, "Nome obbligatorio");
validateMaxLength(
errors,
"nome",
data.nome,
UTENTE_CONSTRAINTS.nome.maxLength,
"Nome"
);
validateRequired(errors, "cognome", data.cognome, "Cognome obbligatorio");
validateMaxLength(
errors,
"cognome",
data.cognome,
UTENTE_CONSTRAINTS.cognome.maxLength,
"Cognome"
);
validateRequired(errors, "email", data.email, "Email obbligatoria");
validateMaxLength(
errors,
"email",
data.email,
UTENTE_CONSTRAINTS.email.maxLength,
"Email"
);
validateEmailFormat(errors, "email", data.email);
}
function validatePasswordField(
errors: FieldErrors,
password: string,
options: { required: boolean }
): void {
if (options.required) {
validateRequired(errors, "password", password, "Password obbligatoria");
}
if (!password) return;
validateMinLength(
errors,
"password",
password,
UTENTE_CONSTRAINTS.password.minLength,
"Password"
);
validateMaxLength(
errors,
"password",
password,
UTENTE_CONSTRAINTS.password.maxLength,
"Password"
);
}
export function emptyUtenteForm(): UtenteFormInput {
return {
nome: "",
cognome: "",
email: "",
password: "",
ruolo: "Dipendente",
};
}
export function utenteToForm(utente: Utente): UtenteFormInput {
return {
nome: utente.nome,
cognome: utente.cognome,
email: utente.email,
password: "",
ruolo: utente.ruolo,
};
}
export function validateLoginForm(
raw: LoginFormInput
): ValidationResult<LoginFormInput> {
const data: LoginFormInput = {
email: normalizeEmail(raw.email),
password: raw.password?.toString() ?? "",
};
const errors: FieldErrors = {};
validateRequired(errors, "email", data.email, "Email obbligatoria");
validateMaxLength(
errors,
"email",
data.email,
UTENTE_CONSTRAINTS.email.maxLength,
"Email"
);
validateEmailFormat(errors, "email", data.email);
validateRequired(errors, "password", data.password, "Password obbligatoria");
validateMaxLength(
errors,
"password",
data.password,
UTENTE_CONSTRAINTS.password.maxLength,
"Password"
);
return buildResult(data, errors);
}
export function validateUtenteCreate(
raw: UtenteFormInput
): ValidationResult<UtenteCreatePayload> {
const data: UtenteCreatePayload = {
nome: normalizeString(raw.nome),
cognome: normalizeString(raw.cognome),
email: normalizeEmail(raw.email),
password: raw.password?.toString() ?? "",
ruolo: normalizeString(raw.ruolo) as Ruolo,
};
const errors: FieldErrors = {};
validateNomeCognomeEmail(data, errors);
validatePasswordField(errors, data.password, { required: true });
validateRequired(errors, "ruolo", data.ruolo, "Ruolo obbligatorio");
validateEnum(
errors,
"ruolo",
data.ruolo,
UTENTE_CONSTRAINTS.ruolo.allowedValues,
"Ruolo"
);
return buildResult(data, errors);
}
export function validateUtenteUpdate(
raw: UtenteFormInput
): ValidationResult<UtenteUpdatePayload> {
const data: UtenteUpdatePayload = {
nome: normalizeString(raw.nome),
cognome: normalizeString(raw.cognome),
email: normalizeEmail(raw.email),
ruolo: normalizeString(raw.ruolo) as Ruolo,
};
const errors: FieldErrors = {};
validateNomeCognomeEmail(
{
nome: data.nome ?? "",
cognome: data.cognome ?? "",
email: data.email ?? "",
},
errors
);
validateEnum(
errors,
"ruolo",
data.ruolo,
UTENTE_CONSTRAINTS.ruolo.allowedValues,
"Ruolo"
);
const password = raw.password?.toString() ?? "";
if (password) {
data.password = password;
validatePasswordField(errors, password, { required: false });
}
return buildResult(data, errors);
}