modifiche ad App.tsx ed index.ts
All checks were successful
Deploy to VPS / build (push) Successful in 30s
Deploy to VPS / deploy (push) Successful in 40s

This commit is contained in:
AV77web 2026-07-14 12:23:38 +02:00
parent eadebfa5cd
commit 3cbbf89a06
8 changed files with 728 additions and 28 deletions

View file

@ -1,11 +1,19 @@
//=======================================
// File: App.tsx
// Componete pagina principale dell'applicazione
// author: "villari.andrea@libero.it"
// version: "1.0.0 2026-07.14"
//=======================================
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import GuestRoute from "./components/GuestRoute"; import { Toaster } from "@/components/ui/sonner";
import Layout from "./components/Layout"; import GuestRoute from "@/components/GuestRoute";
import ProtectedRoute from "./components/ProtectedRoute"; import Layout from "@/components/Layout";
import { AuthProvider } from "./context/AuthContext"; import PagePlaceholder from "@/components/PagePlaceholder";
import Dashboard from "./pages/Dashboard"; import ProtectedRoute from "@/components/ProtectedRoute";
import Login from "./pages/Login"; import { AuthProvider } from "@/context/AuthContext";
import "./App.css"; import Dashboard from "@/pages/Dashboard";
import Login from "@/pages/Login";
export default function App() { export default function App() {
return ( return (
@ -15,10 +23,67 @@ export default function App() {
<Route path="/login" element={<GuestRoute><Login /></GuestRoute>} /> <Route path="/login" element={<GuestRoute><Login /></GuestRoute>} />
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}> <Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route
path="/i-miei-corsi"
element={
<ProtectedRoute roles={["Dipendente"]}>
<PagePlaceholder
title="I miei corsi"
description="Elenco dei corsi assegnati al dipendente."
/>
</ProtectedRoute>
}
/>
<Route
path="/corsi"
element={
<ProtectedRoute roles={["Referente Academy"]}>
<PagePlaceholder
title="Catalogo corsi"
description="Gestione del catalogo corsi Academy."
/>
</ProtectedRoute>
}
/>
<Route
path="/assegnazioni"
element={
<ProtectedRoute roles={["Referente Academy"]}>
<PagePlaceholder
title="Assegnazioni"
description="Gestione delle assegnazioni corsi ai dipendenti."
/>
</ProtectedRoute>
}
/>
<Route
path="/dipendenti"
element={
<ProtectedRoute roles={["Referente Academy"]}>
<PagePlaceholder
title="Dipendenti"
description="Elenco dei dipendenti dell'organizzazione."
/>
</ProtectedRoute>
}
/>
<Route
path="/statistiche"
element={
<ProtectedRoute roles={["Referente Academy"]}>
<PagePlaceholder
title="Statistiche"
description="Riepiloghi formativi per mese e categoria."
/>
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Route> </Route>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/login" replace />} /> <Route path="*" element={<Navigate to="/login" replace />} />
</Routes> </Routes>
<Toaster />
</BrowserRouter> </BrowserRouter>
</AuthProvider> </AuthProvider>
); );

View file

@ -1,5 +1,9 @@
import type { import type {
AssegnazioneCorso,
Corso,
Ruolo, Ruolo,
StatisticaAcademy,
StatoAssegnazione,
Utente, Utente,
} from "../types"; } from "../types";
@ -91,25 +95,90 @@ export async function logout(): Promise<void> {
interface MeResponse { interface MeResponse {
id: number; id: number;
email: string; email: string;
name: string; name?: string;
nome?: string;
cognome?: string;
ruolo: Ruolo; ruolo: Ruolo;
} }
export async function getMe(): Promise<Utente> { export async function getMe(): Promise<Utente> {
const data = await request<MeResponse>("/api/me"); const data = await request<MeResponse>("/api/me");
const nameParts = data.name.trim().split(/\s+/);
const nome = nameParts[0] ?? "";
const cognome = nameParts.slice(1).join(" ");
if (data.nome !== undefined) {
return { return {
id: data.id, id: data.id,
nome, nome: data.nome,
cognome, cognome: data.cognome ?? "",
email: data.email, email: data.email,
ruolo: data.ruolo, ruolo: data.ruolo,
}; };
} }
const nameParts = (data.name ?? "").trim().split(/\s+/);
return {
id: data.id,
nome: nameParts[0] ?? "",
cognome: nameParts.slice(1).join(" "),
email: data.email,
ruolo: data.ruolo,
};
}
export interface AssegnazioniFilters {
stato?: StatoAssegnazione;
categoria?: string;
corsoId?: number;
dipendenteId?: number;
}
function buildQuery(params: Record<string, string | number | undefined>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== "") {
search.set(key, String(value));
}
}
const query = search.toString();
return query ? `?${query}` : "";
}
export async function getCorsi(filters?: {
categoria?: string;
attivo?: boolean;
}): Promise<Corso[]> {
return request(`/api/corsi${buildQuery({
categoria: filters?.categoria,
attivo: filters?.attivo === undefined ? undefined : filters.attivo ? "true" : "false",
})}`);
}
export async function getAssegnazioni(
filters?: AssegnazioniFilters
): Promise<AssegnazioneCorso[]> {
return request(`/api/assegnazioni-corsi${buildQuery({
stato: filters?.stato,
categoria: filters?.categoria,
corsoId: filters?.corsoId,
dipendenteId: filters?.dipendenteId,
})}`);
}
export async function getStatisticheAcademy(filters?: {
mese?: string;
daMese?: string;
aMese?: string;
categoria?: string;
dipendenteId?: number;
}): Promise<StatisticaAcademy[]> {
return request(`/api/statistiche/academy${buildQuery({
mese: filters?.mese,
daMese: filters?.daMese,
aMese: filters?.aMese,
categoria: filters?.categoria,
dipendenteId: filters?.dipendenteId,
})}`);
}
export async function register(data: { export async function register(data: {
nome: string; nome: string;
cognome: string; cognome: string;

View file

@ -0,0 +1,78 @@
import { NavLink, useNavigate } from "react-router-dom";
import { LogOut } from "lucide-react";
import { getNavItemsForRole } from "@/config/navigation";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useAuth } from "@/context/AuthContext";
import { cn } from "@/lib/utils";
interface AppSidebarProps {
onNavigate?: () => void;
}
export default function AppSidebar({ onNavigate }: AppSidebarProps) {
const { user, logout } = useAuth();
const navigate = useNavigate();
if (!user) return null;
const navItems = getNavItemsForRole(user.ruolo);
const isReferente = user.ruolo === "Referente Academy";
const handleLogout = async () => {
await logout();
navigate("/login");
};
return (
<aside className="flex h-full w-64 shrink-0 flex-col border-r bg-card">
<div className="border-b px-4 py-4">
<p className="text-sm font-semibold">Academy Aziendale</p>
<p className="text-xs text-muted-foreground">Gestione formazione</p>
</div>
<nav className="flex flex-1 flex-col gap-1 p-3">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
onClick={onNavigate}
className={({ isActive }) =>
cn(
"flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)
}
>
<item.icon className="size-4 shrink-0" />
{item.label}
</NavLink>
))}
</nav>
<div className="mt-auto border-t p-4">
<div className="mb-3 space-y-1">
<p className="text-sm font-medium">
{user.nome} {user.cognome}
</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
<Badge variant="secondary" className="mt-1">
{isReferente ? "Referente Academy" : "Dipendente"}
</Badge>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={handleLogout}
>
<LogOut className="size-4" />
Esci
</Button>
</div>
</aside>
);
}

View file

@ -0,0 +1,40 @@
import { useNavigate } from "react-router-dom";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
interface PagePlaceholderProps {
title: string;
description: string;
backTo?: string;
}
export default function PagePlaceholder({
title,
description,
backTo = "/dashboard",
}: PagePlaceholderProps) {
const navigate = useNavigate();
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<p className="mb-4 text-sm text-muted-foreground">
Questa sezione verrà implementata nel prossimo passo.
</p>
<Button variant="outline" onClick={() => navigate(backTo)}>
Torna alla dashboard
</Button>
</CardContent>
</Card>
);
}

View file

@ -11,7 +11,9 @@ export default function ProtectedRoute({
}) { }) {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
if (loading) return <p className="loading">Caricamento...</p>; if (loading) {
return <p className="text-sm text-muted-foreground">Caricamento...</p>;
}
if (!user) return <Navigate to="/login" replace />; if (!user) return <Navigate to="/login" replace />;
if (roles && !roles.includes(user.ruolo)) { if (roles && !roles.includes(user.ruolo)) {
return ( return (

59
src/config/navigation.ts Normal file
View file

@ -0,0 +1,59 @@
import type { LucideIcon } from "lucide-react";
import {
BarChart3,
BookOpen,
ClipboardList,
LayoutDashboard,
Users,
} from "lucide-react";
import type { Ruolo } from "@/types";
export interface NavItem {
label: string;
to: string;
icon: LucideIcon;
roles: Ruolo[];
}
export const NAV_ITEMS: NavItem[] = [
{
label: "Dashboard",
to: "/dashboard",
icon: LayoutDashboard,
roles: ["Dipendente", "Referente Academy"],
},
{
label: "I miei corsi",
to: "/i-miei-corsi",
icon: BookOpen,
roles: ["Dipendente"],
},
{
label: "Catalogo corsi",
to: "/corsi",
icon: BookOpen,
roles: ["Referente Academy"],
},
{
label: "Assegnazioni",
to: "/assegnazioni",
icon: ClipboardList,
roles: ["Referente Academy"],
},
{
label: "Dipendenti",
to: "/dipendenti",
icon: Users,
roles: ["Referente Academy"],
},
{
label: "Statistiche",
to: "/statistiche",
icon: BarChart3,
roles: ["Referente Academy"],
},
];
export function getNavItemsForRole(ruolo: Ruolo): NavItem[] {
return NAV_ITEMS.filter((item) => item.roles.includes(ruolo));
}

View file

@ -3,17 +3,352 @@
// Componete Dashborad dell'applicazione // Componete Dashborad dell'applicazione
// author: "villari.andrea@libero.it" // author: "villari.andrea@libero.it"
// version: "1.0.0 2026-07.14" // version: "1.0.0 2026-07.14"
//======================================= import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom"; import {
import { useAuth } from "../context/AuthContext"; AlertCircle,
ArrowRight,
BookOpen,
CheckCircle2,
ClipboardList,
Clock,
GraduationCap,
} from "lucide-react";
import { getAssegnazioni, getCorsi, getStatisticheAcademy } 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 { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/context/AuthContext";
import type { AssegnazioneCorso, StatisticaAcademy } from "@/types";
function currentMonth(): string {
return new Date().toISOString().slice(0, 7);
}
function StatCard({
title,
value,
description,
icon: Icon,
}: {
title: string;
value: number | string;
description: string;
icon: React.ComponentType<{ className?: string }>;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{value}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
function QuickLinkCard({
title,
description,
to,
}: {
title: string;
description: string;
to: string;
}) {
return (
<Card className="flex flex-col">
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="mt-auto">
<Link to={to}>
<Button variant="outline" size="sm">
Apri
<ArrowRight className="size-4" />
</Button>
</Link>
</CardContent>
</Card>
);
}
function DashboardSkeleton() {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<Card key={index}>
<CardHeader>
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="mb-2 h-8 w-16" />
<Skeleton className="h-3 w-32" />
</CardContent>
</Card>
))}
</div>
);
}
function DipendenteDashboard() {
const [assegnazioni, setAssegnazioni] = useState<AssegnazioneCorso[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getAssegnazioni()
.then(setAssegnazioni)
.catch((err) =>
setError(err instanceof Error ? err.message : "Errore nel caricamento")
)
.finally(() => setLoading(false));
}, []);
const counts = useMemo(
() => ({
totale: assegnazioni.length,
assegnato: assegnazioni.filter((a) => a.stato === "Assegnato").length,
completato: assegnazioni.filter((a) => a.stato === "Completato").length,
scaduto: assegnazioni.filter((a) => a.stato === "Scaduto").length,
}),
[assegnazioni]
);
const prossimeScadenze = useMemo(
() =>
assegnazioni
.filter((a) => a.stato === "Assegnato" || a.stato === "Scaduto")
.sort((a, b) => a.dataScadenza.localeCompare(b.dataScadenza))
.slice(0, 5),
[assegnazioni]
);
return (
<div className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertCircle />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{loading ? (
<DashboardSkeleton />
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Corsi assegnati"
value={counts.totale}
description="Totale assegnazioni ricevute"
icon={BookOpen}
/>
<StatCard
title="Da completare"
value={counts.assegnato}
description="Corsi ancora in corso"
icon={Clock}
/>
<StatCard
title="Completati"
value={counts.completato}
description="Attività formative concluse"
icon={CheckCircle2}
/>
<StatCard
title="Scaduti"
value={counts.scaduto}
description="Corsi non completati in tempo"
icon={AlertCircle}
/>
</div>
)}
<div className="grid gap-4 lg:grid-cols-2">
<QuickLinkCard
title="I miei corsi"
description="Visualizza, filtra e completa i corsi assegnati."
to="/i-miei-corsi"
/>
</div>
{!loading && prossimeScadenze.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Prossime scadenze</CardTitle>
<CardDescription>
Corsi da completare ordinati per data di scadenza.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{prossimeScadenze.map((assegnazione) => (
<div
key={assegnazione.id}
className="flex items-center justify-between gap-4 rounded-lg border p-3"
>
<div>
<p className="font-medium">{assegnazione.corso.titolo}</p>
<p className="text-sm text-muted-foreground">
Scadenza: {assegnazione.dataScadenza}
</p>
</div>
<Badge
variant={
assegnazione.stato === "Scaduto" ? "destructive" : "secondary"
}
>
{assegnazione.stato}
</Badge>
</div>
))}
</CardContent>
</Card>
)}
</div>
);
}
function ReferenteDashboard() {
const [corsiAttivi, setCorsiAttivi] = useState(0);
const [assegnazioniTotali, setAssegnazioniTotali] = useState(0);
const [completamentiMese, setCompletamentiMese] = useState(0);
const [statistiche, setStatistiche] = useState<StatisticaAcademy[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const mese = currentMonth();
Promise.all([
getCorsi({ attivo: true }),
getAssegnazioni(),
getStatisticheAcademy({ mese }),
])
.then(([corsi, assegnazioni, stats]) => {
setCorsiAttivi(corsi.length);
setAssegnazioniTotali(assegnazioni.length);
setCompletamentiMese(
assegnazioni.filter(
(a) =>
a.stato === "Completato" &&
(a.dataCompletamento?.startsWith(mese) ?? false)
).length
);
setStatistiche(stats.slice(0, 4));
})
.catch((err) =>
setError(err instanceof Error ? err.message : "Errore nel caricamento")
)
.finally(() => setLoading(false));
}, []);
return (
<div className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertCircle />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{loading ? (
<DashboardSkeleton />
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
title="Corsi attivi"
value={corsiAttivi}
description="Nel catalogo Academy"
icon={GraduationCap}
/>
<StatCard
title="Assegnazioni"
value={assegnazioniTotali}
description="Totale assegnazioni nel sistema"
icon={ClipboardList}
/>
<StatCard
title="Completamenti"
value={completamentiMese}
description={`Nel mese ${currentMonth()}`}
icon={CheckCircle2}
/>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<QuickLinkCard
title="Catalogo corsi"
description="Crea e gestisci i corsi dell'Academy."
to="/corsi"
/>
<QuickLinkCard
title="Assegnazioni"
description="Assegna corsi ai dipendenti."
to="/assegnazioni"
/>
<QuickLinkCard
title="Dipendenti"
description="Consulta l'elenco dei dipendenti."
to="/dipendenti"
/>
<QuickLinkCard
title="Statistiche"
description="Riepiloghi per mese e categoria."
to="/statistiche"
/>
</div>
{!loading && statistiche.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Riepilogo del mese</CardTitle>
<CardDescription>
Andamento formativo per categoria ({currentMonth()}).
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{statistiche.map((row) => (
<div
key={`${row.mese}-${row.categoria}`}
className="flex items-center justify-between gap-4 rounded-lg border p-3"
>
<div>
<p className="font-medium">{row.categoria}</p>
<p className="text-sm text-muted-foreground">
{row.numeroCompletamenti} / {row.numeroAssegnazioni} completati
</p>
</div>
<Badge variant="secondary">
{row.percentualeCompletamento}%
</Badge>
</div>
))}
</CardContent>
</Card>
)}
</div>
);
}
export default function Dashboard() { export default function Dashboard() {
const { user } = useAuth(); const { user } = useAuth();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const isReferente = user?.ruolo === "Referente Academy";
const [deniedMessage, setDeniedMessage] = useState<string | null>(null); const [deniedMessage, setDeniedMessage] = useState<string | null>(null);
const isReferente = user?.ruolo === "Referente Academy";
useEffect(() => { useEffect(() => {
const denied = (location.state as { denied?: string } | null)?.denied; const denied = (location.state as { denied?: string } | null)?.denied;
@ -24,14 +359,25 @@ export default function Dashboard() {
}, [location, navigate]); }, [location, navigate]);
return ( return (
<div className="page"> <div className="space-y-6">
<h1>Dashboard</h1> <div>
{deniedMessage && <p className="error">{deniedMessage}</p>} <h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p> <p className="text-muted-foreground">
Benvenuto, <strong>{user?.nome} {user?.cognome}</strong> Benvenuto, {user?.nome} {user?.cognome}.{" "}
{isReferente
? "Gestisci catalogo, assegnazioni e statistiche."
: "Consulta i corsi assegnati e le scadenze."}
</p> </p>
<p className="muted">Ruolo: {isReferente ? "Referente Academy" : "Dipendente"}</p> </div>
<p className="muted">Email: {user?.email}</p>
{deniedMessage && (
<Alert variant="destructive">
<AlertCircle />
<AlertDescription>{deniedMessage}</AlertDescription>
</Alert>
)}
{isReferente ? <ReferenteDashboard /> : <DipendenteDashboard />}
</div> </div>
); );
} }

View file

@ -6,6 +6,8 @@
//======================================= //=======================================
export type Ruolo = "Dipendente" | "Referente Academy"; export type Ruolo = "Dipendente" | "Referente Academy";
export type StatoAssegnazione = "Assegnato" | "Completato" | "Scaduto" | "Annullato";
export interface Utente { export interface Utente {
id: number; id: number;
nome: string; nome: string;
@ -14,3 +16,42 @@ export interface Utente {
ruolo: Ruolo; ruolo: Ruolo;
} }
export interface Corso {
id: number;
titolo: string;
descrizione: string | null;
categoria: string;
durataOre: number;
obbligatorio: boolean;
attivo: boolean;
}
export interface AssegnazioneCorso {
id: number;
corsoId: number;
dipendenteId: number;
dataAssegnazione: string;
dataScadenza: string;
stato: StatoAssegnazione;
dataCompletamento: string | null;
corso: {
id: number;
titolo: string;
categoria: string;
durataOre: number;
};
dipendente: {
id: number;
nome: string;
cognome: string;
email: string;
};
}
export interface StatisticaAcademy {
mese: string;
categoria: string;
numeroAssegnazioni: number;
numeroCompletamenti: number;
percentualeCompletamento: number;
}