inserita gestione MieiCorsi.tsx e MioCorsoDettaglio
This commit is contained in:
parent
26a97a1304
commit
d712da6295
6 changed files with 750 additions and 7 deletions
13
src/App.tsx
13
src/App.tsx
|
|
@ -14,6 +14,8 @@ import ProtectedRoute from "@/components/ProtectedRoute";
|
||||||
import { AuthProvider } from "@/context/AuthContext";
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
import Dashboard from "@/pages/Dashboard";
|
import Dashboard from "@/pages/Dashboard";
|
||||||
import Login from "@/pages/Login";
|
import Login from "@/pages/Login";
|
||||||
|
import MieiCorsi from "@/pages/MieiCorsi";
|
||||||
|
import MioCorsoDettaglio from "@/pages/MioCorsoDettaglio";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -27,10 +29,15 @@ export default function App() {
|
||||||
path="/i-miei-corsi"
|
path="/i-miei-corsi"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute roles={["Dipendente"]}>
|
<ProtectedRoute roles={["Dipendente"]}>
|
||||||
<PagePlaceholder
|
<MieiCorsi />
|
||||||
title="I miei corsi"
|
</ProtectedRoute>
|
||||||
description="Elenco dei corsi assegnati al dipendente."
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/i-miei-corsi/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute roles={["Dipendente"]}>
|
||||||
|
<MioCorsoDettaglio />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,24 @@ export async function getAssegnazioni(
|
||||||
})}`);
|
})}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAssegnazioneById(id: number): Promise<AssegnazioneCorso> {
|
||||||
|
return request(`/api/assegnazioni-corsi/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCorsoById(id: number): Promise<Corso> {
|
||||||
|
return request(`/api/corsi/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completaAssegnazione(
|
||||||
|
id: number,
|
||||||
|
data?: { dataCompletamento?: string }
|
||||||
|
): Promise<AssegnazioneCorso> {
|
||||||
|
return request(`/api/assegnazioni-corsi/${id}/completa`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(data ?? {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getStatisticheAcademy(filters?: {
|
export async function getStatisticheAcademy(filters?: {
|
||||||
mese?: string;
|
mese?: string;
|
||||||
daMese?: string;
|
daMese?: string;
|
||||||
|
|
|
||||||
67
src/lib/assegnazione.ts
Normal file
67
src/lib/assegnazione.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
//==============================================
|
||||||
|
// File: assegnazione.ts
|
||||||
|
// Componente per la gestione delle Assegnazioni
|
||||||
|
// dei corso ai dipendenti
|
||||||
|
// @author: "villari.andrea@libero.it"
|
||||||
|
// @version: "1.0.0 2026-07-14"
|
||||||
|
//==============================================
|
||||||
|
import type { StatoAssegnazione, AssegnazioneCorso } from "@/types";
|
||||||
|
|
||||||
|
export type ScadenzaFilter = "tutte" | "prossime" | "scadute";
|
||||||
|
|
||||||
|
export const STATI_ASSEGNAZIONE: StatoAssegnazione[] = [
|
||||||
|
"Assegnato",
|
||||||
|
"Completato",
|
||||||
|
"Scaduto",
|
||||||
|
"Annullato",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function formatDateIt(value: string | null | undefined): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
const [year, month, day] = value.split("-");
|
||||||
|
if (!year || !month || !day) return value;
|
||||||
|
return `${day}/${month}/${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function todayIso(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statoBadgeVariant(
|
||||||
|
stato: StatoAssegnazione
|
||||||
|
): "default" | "secondary" | "destructive" | "outline" {
|
||||||
|
switch (stato) {
|
||||||
|
case "Completato":
|
||||||
|
return "default";
|
||||||
|
case "Assegnato":
|
||||||
|
return "secondary";
|
||||||
|
case "Scaduto":
|
||||||
|
return "destructive";
|
||||||
|
case "Annullato":
|
||||||
|
return "outline";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canCompletaAssegnazione(assegnazione: AssegnazioneCorso): boolean {
|
||||||
|
return assegnazione.stato === "Assegnato" || assegnazione.stato === "Scaduto";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesScadenzaFilter(
|
||||||
|
assegnazione: AssegnazioneCorso,
|
||||||
|
filter: ScadenzaFilter
|
||||||
|
): boolean {
|
||||||
|
if (filter === "tutte") return true;
|
||||||
|
if (filter === "scadute") return assegnazione.stato === "Scaduto";
|
||||||
|
if (filter !== "prossime") return true;
|
||||||
|
|
||||||
|
if (assegnazione.stato !== "Assegnato") return false;
|
||||||
|
|
||||||
|
const today = todayIso();
|
||||||
|
const limit = new Date();
|
||||||
|
limit.setDate(limit.getDate() + 30);
|
||||||
|
const limitIso = limit.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
assegnazione.dataScadenza >= today && assegnazione.dataScadenza <= limitIso
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
// 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, useMemo, useState } from "react";
|
||||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
|
|
@ -194,9 +195,10 @@ function DipendenteDashboard() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{prossimeScadenze.map((assegnazione) => (
|
{prossimeScadenze.map((assegnazione) => (
|
||||||
<div
|
<Link
|
||||||
key={assegnazione.id}
|
key={assegnazione.id}
|
||||||
className="flex items-center justify-between gap-4 rounded-lg border p-3"
|
to={`/i-miei-corsi/${assegnazione.id}`}
|
||||||
|
className="flex items-center justify-between gap-4 rounded-lg border p-3 transition-colors hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{assegnazione.corso.titolo}</p>
|
<p className="font-medium">{assegnazione.corso.titolo}</p>
|
||||||
|
|
@ -211,7 +213,7 @@ function DipendenteDashboard() {
|
||||||
>
|
>
|
||||||
{assegnazione.stato}
|
{assegnazione.stato}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
345
src/pages/MieiCorsi.tsx
Normal file
345
src/pages/MieiCorsi.tsx
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
//=====================================
|
||||||
|
// File: MieiCorsi.tsx
|
||||||
|
// componente tabella con tutti i corsi
|
||||||
|
// assegnati al dipendente.
|
||||||
|
// @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, CheckCircle2, Eye, Filter } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { completaAssegnazione, getAssegnazioni } 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 {
|
||||||
|
canCompletaAssegnazione,
|
||||||
|
formatDateIt,
|
||||||
|
matchesScadenzaFilter,
|
||||||
|
STATI_ASSEGNAZIONE,
|
||||||
|
statoBadgeVariant,
|
||||||
|
todayIso,
|
||||||
|
type ScadenzaFilter,
|
||||||
|
} from "@/lib/assegnazione";
|
||||||
|
import type { AssegnazioneCorso, StatoAssegnazione } from "@/types";
|
||||||
|
|
||||||
|
const ALL_VALUE = "__all__";
|
||||||
|
|
||||||
|
function TableSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MieiCorsi() {
|
||||||
|
const [assegnazioni, setAssegnazioni] = useState<AssegnazioneCorso[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [statoFilter, setStatoFilter] = useState<string>(ALL_VALUE);
|
||||||
|
const [categoriaFilter, setCategoriaFilter] = useState<string>(ALL_VALUE);
|
||||||
|
const [scadenzaFilter, setScadenzaFilter] = useState<ScadenzaFilter>("tutte");
|
||||||
|
const [completaTarget, setCompletaTarget] = useState<AssegnazioneCorso | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [dataCompletamento, setDataCompletamento] = useState(todayIso());
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [categorie, setCategorie] = useState<string[]>([]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
setAssegnazioni(data);
|
||||||
|
|
||||||
|
if (statoFilter === ALL_VALUE && categoriaFilter === 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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAssegnazioni();
|
||||||
|
}, [loadAssegnazioni]);
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => assegnazioni.filter((a) => matchesScadenzaFilter(a, scadenzaFilter)),
|
||||||
|
[assegnazioni, scadenzaFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
function openCompletaDialog(assegnazione: AssegnazioneCorso) {
|
||||||
|
setDataCompletamento(todayIso());
|
||||||
|
setCompletaTarget(assegnazione);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCompleta() {
|
||||||
|
if (!completaTarget) return;
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await completaAssegnazione(completaTarget.id, { dataCompletamento });
|
||||||
|
toast.success("Corso segnato come completato");
|
||||||
|
setCompletaTarget(null);
|
||||||
|
await loadAssegnazioni();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : "Impossibile completare il corso"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">I miei corsi</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Elenco dei corsi formativi assegnati a te. Filtra per stato,
|
||||||
|
categoria o scadenza.
|
||||||
|
</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">
|
||||||
|
<Filter className="size-4" />
|
||||||
|
Filtri
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
I filtri stato e categoria sono applicati dal server; la scadenza
|
||||||
|
viene filtrata in pagina.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="filtro-stato">Stato</Label>
|
||||||
|
<Select
|
||||||
|
value={statoFilter}
|
||||||
|
onValueChange={(value) => setStatoFilter(value ?? ALL_VALUE)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="filtro-stato" className="w-44">
|
||||||
|
<SelectValue placeholder="Tutti gli stati" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL_VALUE}>Tutti gli stati</SelectItem>
|
||||||
|
{STATI_ASSEGNAZIONE.map((stato) => (
|
||||||
|
<SelectItem key={stato} value={stato}>
|
||||||
|
{stato}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="filtro-categoria">Categoria</Label>
|
||||||
|
<Select
|
||||||
|
value={categoriaFilter}
|
||||||
|
onValueChange={(value) => setCategoriaFilter(value ?? ALL_VALUE)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="filtro-categoria" className="w-44">
|
||||||
|
<SelectValue placeholder="Tutte le categorie" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL_VALUE}>Tutte le categorie</SelectItem>
|
||||||
|
{categorie.map((categoria) => (
|
||||||
|
<SelectItem key={categoria} value={categoria}>
|
||||||
|
{categoria}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="filtro-scadenza">Scadenza</Label>
|
||||||
|
<Select
|
||||||
|
value={scadenzaFilter}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setScadenzaFilter((value as ScadenzaFilter) ?? "tutte")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="filtro-scadenza" className="w-52">
|
||||||
|
<SelectValue placeholder="Tutte" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="tutte">Tutte</SelectItem>
|
||||||
|
<SelectItem value="prossime">
|
||||||
|
Prossime (30 giorni)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="scadute">Scadute</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{loading ? "Caricamento..." : `${filtered.length} corsi`}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<TableSkeleton />
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Nessun corso trovato con i filtri selezionati.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Corso</TableHead>
|
||||||
|
<TableHead>Categoria</TableHead>
|
||||||
|
<TableHead>Assegnato il</TableHead>
|
||||||
|
<TableHead>Scadenza</TableHead>
|
||||||
|
<TableHead>Stato</TableHead>
|
||||||
|
<TableHead className="text-right">Azioni</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.map((assegnazione) => (
|
||||||
|
<TableRow key={assegnazione.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{assegnazione.corso.titolo}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{assegnazione.corso.categoria}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatDateIt(assegnazione.dataAssegnazione)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatDateIt(assegnazione.dataScadenza)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statoBadgeVariant(assegnazione.stato)}>
|
||||||
|
{assegnazione.stato}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" render={<Link to={`/i-miei-corsi/${assegnazione.id}`} />}>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
Dettaglio
|
||||||
|
</Button>
|
||||||
|
{canCompletaAssegnazione(assegnazione) && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openCompletaDialog(assegnazione)}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
Completa
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={completaTarget !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setCompletaTarget(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Segna corso come completato</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Confermi di aver completato{" "}
|
||||||
|
<strong>{completaTarget?.corso.titolo}</strong>?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="data-completamento">Data completamento</Label>
|
||||||
|
<Input
|
||||||
|
id="data-completamento"
|
||||||
|
type="date"
|
||||||
|
value={dataCompletamento}
|
||||||
|
min={completaTarget?.dataAssegnazione}
|
||||||
|
max={todayIso()}
|
||||||
|
onChange={(e) => setDataCompletamento(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCompletaTarget(null)}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCompleta} disabled={submitting}>
|
||||||
|
{submitting ? "Salvataggio..." : "Conferma completamento"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
304
src/pages/MioCorsoDettaglio.tsx
Normal file
304
src/pages/MioCorsoDettaglio.tsx
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
//====================================
|
||||||
|
// File: MioCorsoDettaglio.tsx
|
||||||
|
// componenete con il dettaglio corso
|
||||||
|
// @author: "villari.andrea@libero.it"
|
||||||
|
// @version: "1.0.0 2026-07-14"
|
||||||
|
//====================================
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ArrowLeft,
|
||||||
|
BookOpen,
|
||||||
|
Calendar,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
completaAssegnazione,
|
||||||
|
getAssegnazioneById,
|
||||||
|
getCorsoById,
|
||||||
|
} 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 { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
canCompletaAssegnazione,
|
||||||
|
formatDateIt,
|
||||||
|
statoBadgeVariant,
|
||||||
|
todayIso,
|
||||||
|
} from "@/lib/assegnazione";
|
||||||
|
import type { AssegnazioneCorso, Corso } from "@/types";
|
||||||
|
|
||||||
|
function DetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MioCorsoDettaglio() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [assegnazione, setAssegnazione] = useState<AssegnazioneCorso | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [corso, setCorso] = useState<Corso | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [dataCompletamento, setDataCompletamento] = useState(todayIso());
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const assegnazioneId = Number(id);
|
||||||
|
if (!Number.isInteger(assegnazioneId) || assegnazioneId <= 0) {
|
||||||
|
setError("Identificativo assegnazione non valido");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
getAssegnazioneById(assegnazioneId)
|
||||||
|
.then(async (data) => {
|
||||||
|
setAssegnazione(data);
|
||||||
|
const corsoData = await getCorsoById(data.corsoId);
|
||||||
|
setCorso(corsoData);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err instanceof Error ? err.message : "Errore nel caricamento");
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
async function handleCompleta() {
|
||||||
|
if (!assegnazione) return;
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const updated = await completaAssegnazione(assegnazione.id, {
|
||||||
|
dataCompletamento,
|
||||||
|
});
|
||||||
|
setAssegnazione(updated);
|
||||||
|
setDialogOpen(false);
|
||||||
|
toast.success("Corso segnato come completato");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : "Impossibile completare il corso"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <DetailSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !assegnazione) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button variant="outline" size="sm" render={<Link to="/i-miei-corsi" />}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
Torna all'elenco
|
||||||
|
</Button>
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle />
|
||||||
|
<AlertDescription>
|
||||||
|
{error ?? "Assegnazione non trovata"}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button variant="outline" size="sm" render={<Link to="/i-miei-corsi" />}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
Torna all'elenco
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
{assegnazione.corso.titolo}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Dettaglio dell'assegnazione formativa
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={statoBadgeVariant(assegnazione.stato)}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
{assegnazione.stato}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<BookOpen className="size-4" />
|
||||||
|
Informazioni corso
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Categoria</p>
|
||||||
|
<p className="font-medium">{assegnazione.corso.categoria}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Durata</p>
|
||||||
|
<p className="font-medium">{assegnazione.corso.durataOre} ore</p>
|
||||||
|
</div>
|
||||||
|
{corso?.descrizione && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Descrizione</p>
|
||||||
|
<p>{corso.descrizione}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{corso && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{corso.obbligatorio && (
|
||||||
|
<Badge variant="secondary">Obbligatorio</Badge>
|
||||||
|
)}
|
||||||
|
{!corso.attivo && (
|
||||||
|
<Badge variant="outline">Corso non più attivo</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Calendar className="size-4" />
|
||||||
|
Date e stato
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Cronologia dell'assegnazione
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Data assegnazione</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{formatDateIt(assegnazione.dataAssegnazione)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Data scadenza</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{formatDateIt(assegnazione.dataScadenza)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Data completamento</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{formatDateIt(assegnazione.dataCompletamento)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canCompletaAssegnazione(assegnazione) && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Clock className="size-4" />
|
||||||
|
Azioni
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Segna il corso come completato quando hai terminato la formazione.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button onClick={() => {
|
||||||
|
setDataCompletamento(todayIso());
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}>
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
Segna come completato
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{assegnazione.stato === "Completato" && (
|
||||||
|
<Alert>
|
||||||
|
<CheckCircle2 />
|
||||||
|
<AlertDescription>
|
||||||
|
Hai completato questo corso il{" "}
|
||||||
|
{formatDateIt(assegnazione.dataCompletamento)}.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Conferma completamento</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Indica la data in cui hai completato la formazione su{" "}
|
||||||
|
<strong>{assegnazione.corso.titolo}</strong>.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="dettaglio-data-completamento">
|
||||||
|
Data completamento
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="dettaglio-data-completamento"
|
||||||
|
type="date"
|
||||||
|
value={dataCompletamento}
|
||||||
|
min={assegnazione.dataAssegnazione}
|
||||||
|
max={todayIso()}
|
||||||
|
onChange={(e) => setDataCompletamento(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDialogOpen(false)}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCompleta} disabled={submitting}>
|
||||||
|
{submitting ? "Salvataggio..." : "Conferma"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue