feat(rendicontazione): componenti riusabili upload/preview PDF

- components/FilePreviewDialog: Dialog full-height con iframe,
  blob URL autenticato (fetch + Bearer token), revoca URL alla chiusura,
  bottone scarica, usato da istruttore e beneficiario
- components/FileUploadCell: cella compatta per righe DataTable,
  stati nessun-file/uploading/caricato, upload drag&drop (accept .pdf/.jpg/.png max 15MB),
  preview/download/sostituisci/elimina con conferma confirmPopup, readOnly per istruttore
- service: 4 nuovi metodi file (uploadEntityFile multipart senza Content-Type forzato,
  deleteEntityFile, fetchEntityFileBlob con parse Content-Disposition,
  downloadEntityFile con anchor tag e revoke URL)
- service: 2 metodi verbale (downloadVerbale blob PDF,
  openVerbaleHtml apre HTML in nuova tab per preview rapida)

Nessun pdf.js, solo iframe nativo + ObjectURL. Zero dipendenze aggiuntive.
This commit is contained in:
BFLOWS
2026-04-18 16:55:06 +02:00
parent 2268fd98f5
commit fe0b4f1113
3 changed files with 405 additions and 0 deletions

View File

@@ -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 = (
<div className="flex justify-content-between">
<span className="text-color-secondary">{filename}</span>
<div>
<Button label={__('Scarica', 'gepafin')} icon="pi pi-download"
className="p-button-sm p-button-outlined"
onClick={onDownload} disabled={!objectUrl} />
<Button label={__('Chiudi', 'gepafin')} icon="pi pi-times"
className="p-button-sm p-button-text ml-2"
onClick={onHide} />
</div>
</div>
);
return (
<Dialog
header={title || __('Anteprima file', 'gepafin')}
visible={visible}
onHide={onHide}
style={{ width: '90vw', height: '92vh' }}
contentStyle={{ padding: 0, overflow: 'hidden' }}
maximizable
footer={footer}
>
{loading && (
<div className="flex align-items-center justify-content-center" style={{ height: '100%' }}>
<ProgressSpinner />
</div>
)}
{error && !loading && (
<div className="p-4">
<div className="p-error">{error}</div>
</div>
)}
{!loading && !error && objectUrl && (
<iframe
src={objectUrl}
title={filename || 'preview'}
style={{ width: '100%', height: '100%', border: 'none' }}
/>
)}
</Dialog>
);
};
export default FilePreviewDialog;

View File

@@ -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 <span className="text-color-secondary text-sm">{__('— non caricato —', 'gepafin')}</span>;
}
return (
<>
<Button
icon={uploading ? 'pi pi-spin pi-spinner' : 'pi pi-upload'}
label={compact ? null : __('Carica', 'gepafin')}
className={compact ? 'p-button-sm p-button-text' : 'p-button-sm p-button-outlined'}
disabled={uploading}
onClick={triggerPick}
tooltip={__('Carica PDF/JPG/PNG (max 15 MB)', 'gepafin')}
tooltipOptions={{ position: 'top' }}
/>
<input type="file" ref={inputRef} accept={ACCEPT}
style={{ display: 'none' }} onChange={onFileChange} />
</>
);
}
return (
<div className="flex align-items-center gap-1" style={{ flexWrap: 'nowrap' }}>
<div className="flex align-items-center" style={{ minWidth: 0, flex: 1 }}>
<i className="pi pi-file-pdf text-primary mr-1" />
<span className="text-sm" style={{
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 200
}} title={filename}>
{filename}
</span>
{sizeBytes ? <span className="text-color-secondary text-xs ml-1">({formatSize(sizeBytes)})</span> : null}
</div>
<Button icon="pi pi-eye" className="p-button-text p-button-sm"
tooltip={__('Anteprima', 'gepafin')} tooltipOptions={{ position: 'top' }}
onClick={() => onPreview && onPreview()} />
<Button icon="pi pi-download" className="p-button-text p-button-sm"
tooltip={__('Scarica', 'gepafin')} tooltipOptions={{ position: 'top' }}
onClick={onDownloadClick} />
{!readOnly && (
<>
<Button icon="pi pi-refresh" className="p-button-text p-button-sm p-button-secondary"
tooltip={__('Sostituisci', 'gepafin')} tooltipOptions={{ position: 'top' }}
disabled={uploading} onClick={triggerPick} />
<Button icon="pi pi-trash" className="p-button-text p-button-sm p-button-danger"
tooltip={__('Elimina', 'gepafin')} tooltipOptions={{ position: 'top' }}
disabled={uploading} onClick={onDeleteClick} />
<input type="file" ref={inputRef} accept={ACCEPT}
style={{ display: 'none' }} onChange={onFileChange} />
</>
)}
</div>
);
};
export default FileUploadCell;

View File

@@ -273,3 +273,130 @@ const extendVerify = {
}; };
Object.assign(RendicontazioneService, 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);