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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hQcuUQFue2bFvrYw2UQhK
This commit is contained in:
parent
82c6d1e2cb
commit
5c50f28f7e
@ -157,7 +157,7 @@ export default function AssociationDetailPage() {
|
||||
) : (
|
||||
<FileDown className="size-4" />
|
||||
)}
|
||||
Genera Attestato
|
||||
Genera Certificato di Adesione
|
||||
</Button>
|
||||
<EditAssociationDialog association={association} onSaved={setAssociation} />
|
||||
<ConfirmDialog
|
||||
|
||||
159
app/dashboard/templates/page.tsx
Normal file
159
app/dashboard/templates/page.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Download, FileText, Loader2, Upload } from "lucide-react";
|
||||
import { AppHeader } from "@/components/layout/app-header";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useStoredUser } from "@/lib/auth";
|
||||
import { downloadTemplate, getTemplates, uploadTemplate } from "@/lib/api";
|
||||
import type { DocumentTemplate } from "@/lib/models";
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const router = useRouter();
|
||||
const { user, ready } = useStoredUser();
|
||||
const [templates, setTemplates] = useState<DocumentTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
|
||||
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const uploadTargetKey = useRef<string | null>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="flex min-h-screen items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || user.role === "CLIENT") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/30">
|
||||
<AppHeader />
|
||||
<main className="mx-auto max-w-5xl space-y-6 p-6">
|
||||
<PageHeader
|
||||
title="Modelli"
|
||||
description="File modello usati per generare i documenti"
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
className="hidden"
|
||||
onChange={handleFileSelected}
|
||||
/>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed py-16 text-center text-sm text-muted-foreground">
|
||||
Nessun modello disponibile
|
||||
</div>
|
||||
) : (
|
||||
templates.map((template) => (
|
||||
<Card key={template.key}>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="size-4 text-muted-foreground" />
|
||||
{template.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>{template.fileName}</p>
|
||||
{template.updatedAt && (
|
||||
<p>Ultimo aggiornamento: {new Date(template.updatedAt).toLocaleString("it-IT")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleDownload(template)}
|
||||
disabled={downloadingKey === template.key}
|
||||
>
|
||||
{downloadingKey === template.key ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-4" />
|
||||
)}
|
||||
Scarica
|
||||
</Button>
|
||||
{user.role === "ADMIN" && (
|
||||
<Button
|
||||
onClick={() => openUploadPicker(template.key)}
|
||||
disabled={uploadingKey === template.key}
|
||||
>
|
||||
{uploadingKey === template.key ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="size-4" />
|
||||
)}
|
||||
Sostituisci
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -36,6 +36,7 @@ export function AppHeader() {
|
||||
return (
|
||||
<header className="sticky top-0 z-20 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/80">
|
||||
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<Link href={homeHref} className="flex items-center gap-2.5">
|
||||
<Image
|
||||
src="/logo-encap.jpg"
|
||||
@ -46,6 +47,15 @@ export function AppHeader() {
|
||||
/>
|
||||
<span className="text-sm font-semibold tracking-wide">ENCAP</span>
|
||||
</Link>
|
||||
{user && (user.role === "ADMIN" || user.role === "MANAGER") && (
|
||||
<Link
|
||||
href="/dashboard/templates"
|
||||
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Modelli
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<DropdownMenu>
|
||||
|
||||
71
lib/api.ts
71
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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTemplates(): Promise<DocumentTemplate[]> {
|
||||
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<void> {
|
||||
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<DocumentTemplate> {
|
||||
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();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user