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:
114
src/modules/rendicontazione/components/FilePreviewDialog.js
Normal file
114
src/modules/rendicontazione/components/FilePreviewDialog.js
Normal 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;
|
||||
164
src/modules/rendicontazione/components/FileUploadCell.js
Normal file
164
src/modules/rendicontazione/components/FileUploadCell.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user