diff --git a/src/modules/rendicontazione/components/FilePreviewDialog.js b/src/modules/rendicontazione/components/FilePreviewDialog.js
new file mode 100644
index 0000000..6502e36
--- /dev/null
+++ b/src/modules/rendicontazione/components/FilePreviewDialog.js
@@ -0,0 +1,114 @@
+import React, { useEffect, useState, useRef } from 'react';
+import { __ } from '@wordpress/i18n';
+import { Dialog } from 'primereact/dialog';
+import { Button } from 'primereact/button';
+import { ProgressSpinner } from 'primereact/progressspinner';
+
+import RendicontazioneService from '../service/rendicontazioneService';
+
+/**
+ * Dialog full-height per preview inline PDF/immagini.
+ * Fetcha il blob dal microservizio (Authorization header), crea object URL,
+ * lo monta in iframe. Revoca l'URL alla chiusura.
+ *
+ * Props:
+ * visible boolean
+ * onHide () => void
+ * entityType 'invoice' | 'ula' | 'document'
+ * entityId UUID
+ * title stringa titolo dialog
+ * filename nome file da mostrare e usare per download
+ */
+const FilePreviewDialog = ({ visible, onHide, entityType, entityId, title, filename }) => {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [objectUrl, setObjectUrl] = useState(null);
+ const currentUrlRef = useRef(null);
+
+ useEffect(() => {
+ // cleanup url precedente
+ if (currentUrlRef.current) {
+ URL.revokeObjectURL(currentUrlRef.current);
+ currentUrlRef.current = null;
+ }
+
+ if (!visible || !entityType || !entityId) {
+ setObjectUrl(null);
+ setError(null);
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ RendicontazioneService.fetchEntityFileBlob(entityType, entityId, true,
+ ({ objectUrl }) => {
+ currentUrlRef.current = objectUrl;
+ setObjectUrl(objectUrl);
+ setLoading(false);
+ },
+ (err) => {
+ setError(err.detail || __('Errore caricamento file', 'gepafin'));
+ setLoading(false);
+ }
+ );
+
+ return () => {
+ if (currentUrlRef.current) {
+ URL.revokeObjectURL(currentUrlRef.current);
+ currentUrlRef.current = null;
+ }
+ };
+ }, [visible, entityType, entityId]);
+
+ const onDownload = () => {
+ RendicontazioneService.downloadEntityFile(entityType, entityId,
+ (err) => setError(err.detail || __('Errore download', 'gepafin'))
+ );
+ };
+
+ const footer = (
+
+ );
+
+ return (
+
+ );
+};
+
+export default FilePreviewDialog;
diff --git a/src/modules/rendicontazione/components/FileUploadCell.js b/src/modules/rendicontazione/components/FileUploadCell.js
new file mode 100644
index 0000000..fd1294c
--- /dev/null
+++ b/src/modules/rendicontazione/components/FileUploadCell.js
@@ -0,0 +1,164 @@
+import React, { useRef, useState } from 'react';
+import { __ } from '@wordpress/i18n';
+import { Button } from 'primereact/button';
+import { confirmPopup } from 'primereact/confirmpopup';
+
+import RendicontazioneService from '../service/rendicontazioneService';
+
+/**
+ * Cella compatta per gestire il file allegato a una entita.
+ *
+ * Stati:
+ * nessun file -> bottone "Carica"
+ * file presente -> chip nome + icone (preview, download, delete)
+ * upload in corso -> spinner
+ *
+ * Props:
+ * entityType 'invoice' | 'ula' | 'document'
+ * entityId UUID
+ * filename nome file corrente (null se nessuno)
+ * sizeBytes dimensione in byte (opzionale)
+ * readOnly se true nasconde upload/delete, mostra solo download/preview
+ * compact se true riduce bottone "Carica"
+ * onChange (fileMeta | null) => void — callback dopo upload/delete OK
+ * onPreview () => void — attiva dialog preview esterno
+ * onError (err) => void
+ * toastRef ref al Toast per messaggi
+ */
+const MAX_BYTES = 15 * 1024 * 1024;
+const ACCEPT = '.pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png';
+
+const formatSize = (n) => {
+ if (!n) return '';
+ if (n < 1024) return `${n} B`;
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
+};
+
+const FileUploadCell = ({
+ entityType, entityId, filename, sizeBytes,
+ readOnly = false, compact = false,
+ onChange, onPreview, onError, toastRef
+}) => {
+ const inputRef = useRef(null);
+ const [uploading, setUploading] = useState(false);
+
+ const triggerPick = () => inputRef.current && inputRef.current.click();
+
+ const onFileChange = (e) => {
+ const f = e.target.files && e.target.files[0];
+ e.target.value = ''; // reset per riupload stesso nome
+ if (!f) return;
+ if (f.size > MAX_BYTES) {
+ const msg = __('File troppo grande (max 15 MB)', 'gepafin');
+ toastRef && toastRef.current && toastRef.current.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: msg });
+ onError && onError({ detail: msg });
+ return;
+ }
+ setUploading(true);
+ RendicontazioneService.uploadEntityFile(entityType, entityId, f,
+ (resp) => {
+ setUploading(false);
+ toastRef && toastRef.current && toastRef.current.show({
+ severity: 'success', summary: __('File caricato', 'gepafin'),
+ detail: resp.data && resp.data.filename_original });
+ onChange && onChange(resp.data);
+ },
+ (err) => {
+ setUploading(false);
+ const msg = err.detail || __('Errore upload', 'gepafin');
+ toastRef && toastRef.current && toastRef.current.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: msg });
+ onError && onError(err);
+ }
+ );
+ };
+
+ const onDownloadClick = () => {
+ RendicontazioneService.downloadEntityFile(entityType, entityId,
+ (err) => {
+ const msg = err.detail || __('Errore download', 'gepafin');
+ toastRef && toastRef.current && toastRef.current.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: msg });
+ }
+ );
+ };
+
+ const onDeleteClick = (ev) => {
+ confirmPopup({
+ target: ev.currentTarget,
+ message: __('Eliminare il file allegato?', 'gepafin'),
+ icon: 'pi pi-exclamation-triangle',
+ acceptLabel: __('Elimina', 'gepafin'),
+ rejectLabel: __('Annulla', 'gepafin'),
+ acceptClassName: 'p-button-danger p-button-sm',
+ rejectClassName: 'p-button-text p-button-sm',
+ accept: () => {
+ RendicontazioneService.deleteEntityFile(entityType, entityId,
+ () => {
+ toastRef && toastRef.current && toastRef.current.show({
+ severity: 'info', summary: __('File eliminato', 'gepafin') });
+ onChange && onChange(null);
+ },
+ (err) => {
+ const msg = err.detail || __('Errore eliminazione', 'gepafin');
+ toastRef && toastRef.current && toastRef.current.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: msg });
+ }
+ );
+ }
+ });
+ };
+
+ if (!filename) {
+ if (readOnly) {
+ return {__('— non caricato —', 'gepafin')};
+ }
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ return (
+
+
+
+
+ {filename}
+
+ {sizeBytes ? ({formatSize(sizeBytes)}) : null}
+
+
+ );
+};
+
+export default FileUploadCell;
diff --git a/src/modules/rendicontazione/service/rendicontazioneService.js b/src/modules/rendicontazione/service/rendicontazioneService.js
index 23865cc..0a4cad7 100644
--- a/src/modules/rendicontazione/service/rendicontazioneService.js
+++ b/src/modules/rendicontazione/service/rendicontazioneService.js
@@ -273,3 +273,130 @@ const extendVerify = {
};
Object.assign(RendicontazioneService, extendVerify);
+
+
+// ====================== FILE UPLOAD ======================
+
+const _buildBearerOnly = () => {
+ const token = storeGet('getToken');
+ return token ? { 'Authorization': `Bearer ${token}` } : {};
+};
+
+const extendFiles = {
+ /**
+ * Upload file per entita (invoice/ula/document).
+ * Restituisce i metadata del file via onSuccess.
+ */
+ uploadEntityFile(entityType, entityId, file, onSuccess, onError) {
+ const fd = new FormData();
+ fd.append('file', file);
+ fetch(`${BASE_URL}/api/remission-files/${entityType}/${entityId}/upload`, {
+ method: 'POST', mode: 'cors',
+ headers: _buildBearerOnly(), // no Content-Type: browser mette boundary
+ body: fd
+ }).then(r => handleResponse(r, onSuccess, onError))
+ .catch(e => handleError(e, onError));
+ },
+
+ /**
+ * Elimina file allegato a una entita.
+ */
+ deleteEntityFile(entityType, entityId, onSuccess, onError) {
+ fetch(`${BASE_URL}/api/remission-files/${entityType}/${entityId}`, {
+ method: 'DELETE', mode: 'cors', headers: buildHeaders()
+ }).then(r => handleResponse(r, onSuccess, onError))
+ .catch(e => handleError(e, onError));
+ },
+
+ /**
+ * Fetch file come Blob (per preview in iframe tramite object URL).
+ * onSuccess({blob, objectUrl, filename}).
+ */
+ fetchEntityFileBlob(entityType, entityId, inline, onSuccess, onError) {
+ fetch(`${BASE_URL}/api/remission-files/${entityType}/${entityId}?inline=${inline ? 1 : 0}`, {
+ method: 'GET', mode: 'cors', headers: _buildBearerOnly()
+ }).then(async r => {
+ if (r.status < 200 || r.status >= 300) {
+ let detail = r.statusText;
+ try { const j = await r.json(); detail = j.detail || detail; } catch(e){}
+ if (onError) onError({ status: r.status, detail });
+ return;
+ }
+ // estrae filename da Content-Disposition
+ let filename = 'file';
+ const cd = r.headers.get('Content-Disposition') || '';
+ const m = cd.match(/filename="([^"]+)"/);
+ if (m) filename = m[1];
+ const blob = await r.blob();
+ const objectUrl = URL.createObjectURL(blob);
+ if (onSuccess) onSuccess({ blob, objectUrl, filename });
+ }).catch(e => handleError(e, onError));
+ },
+
+ /**
+ * Download forzato: apre finestra "save as" del browser.
+ */
+ downloadEntityFile(entityType, entityId, onError) {
+ this.fetchEntityFileBlob(entityType, entityId, false,
+ ({ objectUrl, filename }) => {
+ const a = document.createElement('a');
+ a.href = objectUrl;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ setTimeout(() => URL.revokeObjectURL(objectUrl), 60000);
+ },
+ onError
+ );
+ }
+};
+
+Object.assign(RendicontazioneService, extendFiles);
+
+
+// ====================== VERBALE ISTRUTTORIA ======================
+
+const extendVerbale = {
+ /**
+ * Scarica il verbale di istruttoria come PDF (download forzato).
+ */
+ downloadVerbale(practiceId, onError) {
+ fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/verbale.pdf`, {
+ method: 'GET', mode: 'cors', headers: _buildBearerOnly()
+ }).then(async r => {
+ if (r.status < 200 || r.status >= 300) {
+ let detail = r.statusText;
+ try { const j = await r.json(); detail = j.detail || detail; } catch(e){}
+ if (onError) onError({ status: r.status, detail });
+ return;
+ }
+ const blob = await r.blob();
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `verbale_istruttoria_${practiceId.slice(0, 8)}.pdf`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
+ }).catch(e => { if (onError) onError({ status: 0, detail: e.message }); });
+ },
+
+ /**
+ * Apre preview HTML del verbale in una nuova tab (debug rapido).
+ */
+ async openVerbaleHtml(practiceId) {
+ const token = storeGet('getToken');
+ const r = await fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/verbale.html`, {
+ method: 'GET', mode: 'cors',
+ headers: token ? { 'Authorization': `Bearer ${token}` } : {}
+ });
+ if (!r.ok) throw new Error('Verbale HTML fetch failed');
+ const html = await r.text();
+ const w = window.open('', '_blank');
+ if (w) { w.document.open(); w.document.write(html); w.document.close(); }
+ }
+};
+
+Object.assign(RendicontazioneService, extendVerbale);