From 5c50f28f7e2db7efe3dc4caf68c1672e2c890a42 Mon Sep 17 00:00:00 2001 From: Linas Date: Mon, 24 Aug 2026 19:22:18 +0300 Subject: [PATCH] Add Modelli (templates) admin section New /dashboard/templates page (MANAGER/ADMIN, matches backend access): lists document templates, lets MANAGER/ADMIN download the current file and ADMIN replace it. Nav link added to the header. Also renamed "Genera Attestato" to "Genera Certificato di Adesione" and the downloaded file's name prefix to match, for consistency with the document's real name. Verified live: nav link navigates to the page, download round-trips the file, and uploading a new file updates the listed filename and timestamp immediately. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017hQcuUQFue2bFvrYw2UQhK --- app/dashboard/[id]/page.tsx | 2 +- app/dashboard/templates/page.tsx | 159 +++++++++++++++++++++++++++++++ components/layout/app-header.tsx | 30 ++++-- lib/api.ts | 71 +++++++++++++- lib/models.ts | 9 ++ 5 files changed, 258 insertions(+), 13 deletions(-) create mode 100644 app/dashboard/templates/page.tsx diff --git a/app/dashboard/[id]/page.tsx b/app/dashboard/[id]/page.tsx index 99cda0f..072f4c9 100644 --- a/app/dashboard/[id]/page.tsx +++ b/app/dashboard/[id]/page.tsx @@ -157,7 +157,7 @@ export default function AssociationDetailPage() { ) : ( )} - Genera Attestato + Genera Certificato di Adesione ([]); + const [loading, setLoading] = useState(true); + const [downloadingKey, setDownloadingKey] = useState(null); + const [uploadingKey, setUploadingKey] = useState(null); + const fileInputRef = useRef(null); + const uploadTargetKey = useRef(null); + + useEffect(() => { + if (!ready) return; + if (!user || user.role === "CLIENT") { + router.push("/dashboard"); + return; + } + getTemplates() + .then(setTemplates) + .catch((error: Error) => { + if (error.message === "Unauthorized") { + router.push("/login"); + } else { + console.error(error); + } + }) + .finally(() => setLoading(false)); + }, [ready, user, router]); + + const handleDownload = async (template: DocumentTemplate) => { + setDownloadingKey(template.key); + try { + await downloadTemplate(template.key, template.fileName); + } catch (error) { + console.error("Failed to download template:", error); + } finally { + setDownloadingKey(null); + } + }; + + const openUploadPicker = (key: string) => { + uploadTargetKey.current = key; + fileInputRef.current?.click(); + }; + + const handleFileSelected = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + const key = uploadTargetKey.current; + e.target.value = ""; + if (!file || !key) return; + + setUploadingKey(key); + try { + const updated = await uploadTemplate(key, file); + setTemplates((prev) => prev.map((t) => (t.key === key ? updated : t))); + } catch (error) { + console.error("Failed to upload template:", error); + alert("Impossibile caricare il file"); + } finally { + setUploadingKey(null); + } + }; + + if (!ready || loading) { + return ( +
+ +
+ ); + } + + if (!user || user.role === "CLIENT") { + return null; + } + + return ( +
+ +
+ + + + + {templates.length === 0 ? ( +
+ Nessun modello disponibile +
+ ) : ( + templates.map((template) => ( + + + + + {template.name} + + + +
+

{template.fileName}

+ {template.updatedAt && ( +

Ultimo aggiornamento: {new Date(template.updatedAt).toLocaleString("it-IT")}

+ )} +
+
+ + {user.role === "ADMIN" && ( + + )} +
+
+
+ )) + )} +
+
+ ); +} diff --git a/components/layout/app-header.tsx b/components/layout/app-header.tsx index 6a9b888..146b547 100644 --- a/components/layout/app-header.tsx +++ b/components/layout/app-header.tsx @@ -36,16 +36,26 @@ export function AppHeader() { return (
- - ENCAP - ENCAP - +
+ + ENCAP + ENCAP + + {user && (user.role === "ADMIN" || user.role === "MANAGER") && ( + + Modelli + + )} +
{user && ( diff --git a/lib/api.ts b/lib/api.ts index aa4de66..ed46782 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -1,5 +1,5 @@ const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080/api'; -import { LoginResponse, User, Association } from "./models"; +import { LoginResponse, User, Association, DocumentTemplate } from "./models"; // Store token in localStorage (we'll improve this later) export function setAuthToken(token: string) { @@ -125,7 +125,7 @@ export async function downloadAttestato(id: string, associationName: string): Pr const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.download = `attestato-${associationName}.pdf`; + link.download = `certificato-di-adesione-${associationName}.pdf`; document.body.appendChild(link); link.click(); link.remove(); @@ -147,6 +147,73 @@ export async function deleteAssociation(id: string): Promise { } } +export async function getTemplates(): Promise { + const response = await fetch(`${API_BASE_URL}/templates`, { + headers: getHeaders(), + }); + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + removeAuthToken(); + throw new Error('Unauthorized'); + } + throw new Error('Failed to fetch templates'); + } + + return response.json(); +} + +export async function downloadTemplate(key: string, fileName: string): Promise { + const response = await fetch(`${API_BASE_URL}/templates/${key}/download`, { + headers: getHeaders(), + }); + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + removeAuthToken(); + throw new Error('Unauthorized'); + } + throw new Error('Failed to download template'); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); +} + +export async function uploadTemplate(key: string, file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + + const token = getAuthToken(); + const headers: HeadersInit = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${API_BASE_URL}/templates/${key}`, { + method: 'POST', + headers, + body: formData, + }); + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + removeAuthToken(); + throw new Error('Unauthorized'); + } + throw new Error('Failed to upload template'); + } + + return response.json(); +} + export function logout() { removeAuthToken(); } diff --git a/lib/models.ts b/lib/models.ts index b03b84a..5c6bba7 100644 --- a/lib/models.ts +++ b/lib/models.ts @@ -31,6 +31,15 @@ export interface Association { nasFolderNumber?: number; } +export interface DocumentTemplate { + id?: string; + key: string; + name: string; + fileName: string; + contentType: string; + updatedAt?: string; +} + export interface LoginResponse { token: string; user: User;