inserita la creazione e la gestione degil utenti
This commit is contained in:
parent
dc7dbc4166
commit
05f1629aee
9 changed files with 797 additions and 221 deletions
|
|
@ -18,7 +18,7 @@ import MieiCorsi from "@/pages/MieiCorsi";
|
|||
import MioCorsoDettaglio from "@/pages/MioCorsoDettaglio";
|
||||
import Corsi from "@/pages/Corsi";
|
||||
import Assegnazioni from "@/pages/Assegnazioni";
|
||||
import Dipendenti from "@/pages/Dipendenti";
|
||||
import Utenti from "@/pages/Utenti";
|
||||
import Statistiche from "@/pages/Statistiche";
|
||||
|
||||
export default function App() {
|
||||
|
|
@ -63,10 +63,10 @@ export default function App() {
|
|||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dipendenti"
|
||||
path="/utenti"
|
||||
element={
|
||||
<ProtectedRoute roles={["Referente Academy"]}>
|
||||
<Dipendenti />
|
||||
<Utenti />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -53,44 +53,11 @@ export async function getCsrfToken(): Promise<string> {
|
|||
return data.csrfToken;
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string): Promise<void> {
|
||||
const csrfToken = await getCsrfToken();
|
||||
const body = new URLSearchParams({
|
||||
csrfToken,
|
||||
email,
|
||||
password,
|
||||
redirect: "false",
|
||||
});
|
||||
|
||||
const response = await fetch(`${API_URL}/api/auth/callback/credentials`, {
|
||||
export async function login(email: string, password: string): Promise<Utente> {
|
||||
return request<Utente>("/api/utenti/login", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
redirect: "manual",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-Auth-Return-Redirect": "1",
|
||||
},
|
||||
body,
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
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> {
|
||||
|
|
@ -265,6 +232,37 @@ export async function getUtenti(filters?: { ruolo?: Ruolo }): Promise<Utente[]>
|
|||
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?: {
|
||||
mese?: string;
|
||||
daMese?: string;
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ export const NAV_ITEMS: NavItem[] = [
|
|||
roles: ["Referente Academy"],
|
||||
},
|
||||
{
|
||||
label: "Dipendenti",
|
||||
to: "/dipendenti",
|
||||
label: "Utenti",
|
||||
to: "/utenti",
|
||||
icon: Users,
|
||||
roles: ["Referente Academy"],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -302,9 +302,9 @@ function ReferenteDashboard() {
|
|||
to="/assegnazioni"
|
||||
/>
|
||||
<QuickLinkCard
|
||||
title="Dipendenti"
|
||||
description="Consulta l'elenco dei dipendenti."
|
||||
to="/dipendenti"
|
||||
title="Utenti"
|
||||
description="Gestisci Dipendenti e Referenti Academy."
|
||||
to="/utenti"
|
||||
/>
|
||||
<QuickLinkCard
|
||||
title="Statistiche"
|
||||
|
|
|
|||
|
|
@ -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'organizzazione iscritti all'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
import { CircleAlert } from "lucide-react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ApiClientError } from "@/api/client";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -19,6 +20,13 @@ import {
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
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() {
|
||||
const { login } = useAuth();
|
||||
|
|
@ -26,16 +34,28 @@ export default function Login() {
|
|||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const validation = validateLoginForm({ email, password });
|
||||
if (validation.success === false) {
|
||||
setFieldErrors(validation.errors);
|
||||
return;
|
||||
}
|
||||
|
||||
setFieldErrors({});
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
await login(validation.data.email, validation.data.password);
|
||||
navigate("/dashboard");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiClientError && err.fields) {
|
||||
setFieldErrors(err.fields);
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Errore di accesso");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -68,10 +88,18 @@ export default function Login() {
|
|||
autoComplete="email"
|
||||
placeholder="nome@academy.it"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.email;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
aria-invalid={Boolean(fieldErrors.email)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<FieldError message={fieldErrors.email} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
|
|
@ -82,10 +110,18 @@ export default function Login() {
|
|||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.password;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
aria-invalid={Boolean(fieldErrors.password)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<FieldError message={fieldErrors.password} />
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
|
|
|
|||
485
src/pages/Utenti.tsx
Normal file
485
src/pages/Utenti.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,17 @@
|
|||
// @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 = {
|
||||
titolo: { maxLength: 200, required: true },
|
||||
descrizione: { maxLength: 65535, required: false },
|
||||
|
|
|
|||
218
src/validation/utente.ts
Normal file
218
src/validation/utente.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue