Compare commits
2 Commits
2268fd98f5
...
fca18de751
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fca18de751 | ||
|
|
fe0b4f1113 |
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;
|
||||||
@@ -16,6 +16,7 @@ import { Checkbox } from 'primereact/checkbox';
|
|||||||
import { ConfirmPopup, confirmPopup } from 'primereact/confirmpopup';
|
import { ConfirmPopup, confirmPopup } from 'primereact/confirmpopup';
|
||||||
|
|
||||||
import RendicontazioneService from '../service/rendicontazioneService';
|
import RendicontazioneService from '../service/rendicontazioneService';
|
||||||
|
import FilePreviewDialog from '../components/FilePreviewDialog';
|
||||||
|
|
||||||
const CONTRACT_TYPES = {
|
const CONTRACT_TYPES = {
|
||||||
T_IND: 'Tempo indeterminato', T_DET: 'Tempo determinato',
|
T_IND: 'Tempo indeterminato', T_DET: 'Tempo determinato',
|
||||||
@@ -67,7 +68,7 @@ const IstruttoriaPratica = () => {
|
|||||||
const [bundle, setBundle] = useState(null);
|
const [bundle, setBundle] = useState(null);
|
||||||
|
|
||||||
// dialoghi
|
// dialoghi
|
||||||
const [previewDialog, setPreviewDialog] = useState({ visible: false, filename: null, title: null });
|
const [previewDialog, setPreviewDialog] = useState({ visible: false, entityType: null, entityId: null, filename: null, title: null });
|
||||||
const [docNoteDialog, setDocNoteDialog] = useState({ visible: false, doc: null, status: null });
|
const [docNoteDialog, setDocNoteDialog] = useState({ visible: false, doc: null, status: null });
|
||||||
// tabelle: expanded rows + buffer modifiche inline
|
// tabelle: expanded rows + buffer modifiche inline
|
||||||
const [expandedInv, setExpandedInv] = useState({});
|
const [expandedInv, setExpandedInv] = useState({});
|
||||||
@@ -123,10 +124,22 @@ const IstruttoriaPratica = () => {
|
|||||||
detail: typeof err?.detail === 'object' ? JSON.stringify(err.detail) : err?.detail });
|
detail: typeof err?.detail === 'object' ? JSON.stringify(err.detail) : err?.detail });
|
||||||
};
|
};
|
||||||
|
|
||||||
const openPreview = (filename, title) => setPreviewDialog({ visible: true, filename, title });
|
const openPreview = (entityType, entityId, title, filename) => setPreviewDialog({ visible: true, entityType, entityId, title, filename });
|
||||||
const downloadStub = (filename) => {
|
const closePreview = () => setPreviewDialog({ visible: false, entityType: null, entityId: null, filename: null, title: null });
|
||||||
toast.current?.show({ severity: 'info', summary: __('Sandbox', 'gepafin'),
|
const downloadVerbale = () => {
|
||||||
detail: __(`Download di ${filename} — in produzione scarica il file reale dallo storage.`, 'gepafin') });
|
RendicontazioneService.downloadVerbale(practiceId,
|
||||||
|
(err) => toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail || __('Verbale non disponibile', 'gepafin') })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const openVerbaleHtml = () => {
|
||||||
|
RendicontazioneService.openVerbaleHtml(practiceId).catch(() =>
|
||||||
|
toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: __('Verbale non disponibile', 'gepafin') })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const doDownload = (entityType, entityId) => {
|
||||||
|
RendicontazioneService.downloadEntityFile(entityType, entityId,
|
||||||
|
(err) => toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail || __('Download non riuscito', 'gepafin') })
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Quick verify (thumbs up/down) senza rettifica
|
// Quick verify (thumbs up/down) senza rettifica
|
||||||
@@ -403,6 +416,16 @@ const IstruttoriaPratica = () => {
|
|||||||
disabled={openAmendments.length > 0}
|
disabled={openAmendments.length > 0}
|
||||||
onClick={() => setAmendDialog({ visible: true, text: '', deadline: null })} />
|
onClick={() => setAmendDialog({ visible: true, text: '', deadline: null })} />
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
|
{/* Verbale: sempre visibile all'istruttore per preview e scarico */}
|
||||||
|
{['UNDER_REVIEW', 'AWAITING_AMENDMENT', 'APPROVED', 'REJECTED'].includes(practice.status) && (<>
|
||||||
|
<Button type="button" icon="pi pi-eye" iconPos="right" outlined
|
||||||
|
label={__('Anteprima verbale', 'gepafin')}
|
||||||
|
onClick={openVerbaleHtml} />
|
||||||
|
<Button type="button" icon="pi pi-file-pdf" iconPos="right" severity="info"
|
||||||
|
label={__('Scarica verbale PDF', 'gepafin')}
|
||||||
|
onClick={downloadVerbale} />
|
||||||
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -658,10 +681,12 @@ const IstruttoriaPratica = () => {
|
|||||||
body={(r) => (
|
body={(r) => (
|
||||||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||||
<Button icon="pi pi-eye" rounded outlined size="small" severity="info"
|
<Button icon="pi pi-eye" rounded outlined size="small" severity="info"
|
||||||
onClick={() => openPreview(r.pdf_filename || `fattura_${r.invoice_number}.pdf`, `Fattura ${r.invoice_number}`)}
|
disabled={!r.storage_path}
|
||||||
|
onClick={() => openPreview('invoice', r.id, `Fattura ${r.invoice_number}`, r.pdf_filename)}
|
||||||
tooltip={__('Anteprima', 'gepafin')} tooltipOptions={{ position: 'top' }} />
|
tooltip={__('Anteprima', 'gepafin')} tooltipOptions={{ position: 'top' }} />
|
||||||
<Button icon="pi pi-download" rounded outlined size="small" severity="info"
|
<Button icon="pi pi-download" rounded outlined size="small" severity="info"
|
||||||
onClick={() => downloadStub(r.pdf_filename || `fattura_${r.invoice_number}.pdf`)}
|
disabled={!r.storage_path}
|
||||||
|
onClick={() => doDownload('invoice', r.id)}
|
||||||
tooltip={__('Scarica', 'gepafin')} tooltipOptions={{ position: 'top' }} />
|
tooltip={__('Scarica', 'gepafin')} tooltipOptions={{ position: 'top' }} />
|
||||||
<Button icon="pi pi-check" rounded outlined size="small"
|
<Button icon="pi pi-check" rounded outlined size="small"
|
||||||
severity={r.verification_status === 'AMMESSA' ? 'success' : 'secondary'}
|
severity={r.verification_status === 'AMMESSA' ? 'success' : 'secondary'}
|
||||||
@@ -814,12 +839,12 @@ const IstruttoriaPratica = () => {
|
|||||||
body={(r) => (
|
body={(r) => (
|
||||||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||||
<Button icon="pi pi-eye" rounded outlined size="small" severity="info"
|
<Button icon="pi pi-eye" rounded outlined size="small" severity="info"
|
||||||
disabled={!r.supporting_doc_filename}
|
disabled={!r.storage_path}
|
||||||
onClick={() => openPreview(r.supporting_doc_filename, `${r.full_name}`)}
|
onClick={() => openPreview('ula', r.id, `${r.full_name}`, r.supporting_doc_filename)}
|
||||||
tooltip={__('Anteprima allegato', 'gepafin')} tooltipOptions={{ position: 'top' }} />
|
tooltip={__('Anteprima allegato', 'gepafin')} tooltipOptions={{ position: 'top' }} />
|
||||||
<Button icon="pi pi-download" rounded outlined size="small" severity="info"
|
<Button icon="pi pi-download" rounded outlined size="small" severity="info"
|
||||||
disabled={!r.supporting_doc_filename}
|
disabled={!r.storage_path}
|
||||||
onClick={() => downloadStub(r.supporting_doc_filename)}
|
onClick={() => doDownload('ula', r.id)}
|
||||||
tooltip={__('Scarica allegato', 'gepafin')} tooltipOptions={{ position: 'top' }} />
|
tooltip={__('Scarica allegato', 'gepafin')} tooltipOptions={{ position: 'top' }} />
|
||||||
<Button icon="pi pi-check" rounded outlined size="small"
|
<Button icon="pi pi-check" rounded outlined size="small"
|
||||||
severity={r.verification_status === 'AMMESSA' ? 'success' : 'secondary'}
|
severity={r.verification_status === 'AMMESSA' ? 'success' : 'secondary'}
|
||||||
@@ -877,13 +902,13 @@ const IstruttoriaPratica = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="appPageSection__iconActions">
|
<div className="appPageSection__iconActions">
|
||||||
<Button icon="pi pi-eye" rounded outlined severity="info"
|
<Button icon="pi pi-eye" rounded outlined severity="info"
|
||||||
disabled={!doc.filename}
|
disabled={!doc.storage_path}
|
||||||
onClick={() => openPreview(doc.filename, `${dr.label} — ${doc.filename || ''}`)}
|
onClick={() => openPreview('document', doc.id, `${dr.label}`, doc.filename)}
|
||||||
tooltip={__('Anteprima', 'gepafin')} tooltipOptions={{ position: 'top' }}
|
tooltip={__('Anteprima', 'gepafin')} tooltipOptions={{ position: 'top' }}
|
||||||
aria-label={__('Anteprima', 'gepafin')} />
|
aria-label={__('Anteprima', 'gepafin')} />
|
||||||
<Button icon="pi pi-download" rounded outlined severity="info"
|
<Button icon="pi pi-download" rounded outlined severity="info"
|
||||||
disabled={!doc.filename}
|
disabled={!doc.storage_path}
|
||||||
onClick={() => downloadStub(doc.filename)}
|
onClick={() => doDownload('document', doc.id)}
|
||||||
tooltip={__('Scarica', 'gepafin')} tooltipOptions={{ position: 'top' }}
|
tooltip={__('Scarica', 'gepafin')} tooltipOptions={{ position: 'top' }}
|
||||||
aria-label={__('Scarica', 'gepafin')} />
|
aria-label={__('Scarica', 'gepafin')} />
|
||||||
<Button icon="pi pi-clock" rounded outlined severity="warning"
|
<Button icon="pi pi-clock" rounded outlined severity="warning"
|
||||||
@@ -963,22 +988,15 @@ const IstruttoriaPratica = () => {
|
|||||||
|
|
||||||
{/* ============ DIALOGS ============ */}
|
{/* ============ DIALOGS ============ */}
|
||||||
|
|
||||||
{/* Preview PDF */}
|
{/* Preview PDF reale — iframe con blob autenticato */}
|
||||||
<Dialog visible={previewDialog.visible} style={{ width: '640px' }}
|
<FilePreviewDialog
|
||||||
header={previewDialog.title || __('Anteprima', 'gepafin')} modal
|
visible={previewDialog.visible}
|
||||||
onHide={() => setPreviewDialog({ visible: false, filename: null, title: null })}>
|
onHide={closePreview}
|
||||||
<div style={{ padding: '2rem', textAlign: 'center', background: 'var(--surface-100)', borderRadius: '6px' }}>
|
entityType={previewDialog.entityType}
|
||||||
<i className="pi pi-file-pdf" style={{ fontSize: '4rem', color: 'var(--red-500)' }} />
|
entityId={previewDialog.entityId}
|
||||||
<h3 style={{ margin: '1rem 0 0.5rem 0' }}>{previewDialog.filename || '—'}</h3>
|
title={previewDialog.title}
|
||||||
<p className="text-color-secondary" style={{ marginTop: 0 }}>
|
filename={previewDialog.filename}
|
||||||
{__('Anteprima PDF — in sandbox il file reale non è caricato. In produzione qui viene visualizzato il PDF originale della fattura/documento.', 'gepafin')}
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1rem' }}>
|
|
||||||
<Button label={__('Chiudi', 'gepafin')} outlined
|
|
||||||
onClick={() => setPreviewDialog({ visible: false, filename: null, title: null })} />
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Rettifica fattura */}
|
{/* Rettifica fattura */}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { Column } from 'primereact/column';
|
|||||||
|
|
||||||
// api
|
// api
|
||||||
import RendicontazioneService from '../service/rendicontazioneService';
|
import RendicontazioneService from '../service/rendicontazioneService';
|
||||||
|
import FileUploadCell from '../components/FileUploadCell';
|
||||||
|
import FilePreviewDialog from '../components/FilePreviewDialog';
|
||||||
|
|
||||||
// ---------- costanti ----------
|
// ---------- costanti ----------
|
||||||
const IVA_REGIME_LABELS = {
|
const IVA_REGIME_LABELS = {
|
||||||
@@ -81,6 +83,57 @@ const PraticaRendicontazioneEdit = () => {
|
|||||||
const [empDialog, setEmpDialog] = useState({ visible: false, data: null });
|
const [empDialog, setEmpDialog] = useState({ visible: false, data: null });
|
||||||
// modal risposta soccorso istruttorio
|
// modal risposta soccorso istruttorio
|
||||||
const [amendDialog, setAmendDialog] = useState({ visible: false, amendment: null, responseText: '' });
|
const [amendDialog, setAmendDialog] = useState({ visible: false, amendment: null, responseText: '' });
|
||||||
|
// preview file
|
||||||
|
const [previewDialog, setPreviewDialog] = useState({ visible: false, entityType: null, entityId: null, filename: null, title: null });
|
||||||
|
const openPreview = (entityType, entityId, title, filename) => setPreviewDialog({ visible: true, entityType, entityId, title, filename });
|
||||||
|
const closePreview = () => setPreviewDialog({ visible: false, entityType: null, entityId: null, filename: null, title: null });
|
||||||
|
// update locale riga dopo upload/delete
|
||||||
|
const updateInvoiceFile = (invoiceId, fileMeta) => {
|
||||||
|
setPractice(p => p ? { ...p, invoices: p.invoices.map(i => i.id === invoiceId ? {
|
||||||
|
...i,
|
||||||
|
pdf_filename: fileMeta ? fileMeta.filename_original : null,
|
||||||
|
storage_path: fileMeta ? fileMeta.storage_path : null,
|
||||||
|
size_bytes: fileMeta ? fileMeta.size_bytes : null,
|
||||||
|
} : i) } : p);
|
||||||
|
};
|
||||||
|
const updateUlaFile = (empId, fileMeta) => {
|
||||||
|
setPractice(p => p ? { ...p, ula_employees: p.ula_employees.map(e => e.id === empId ? {
|
||||||
|
...e,
|
||||||
|
supporting_doc_filename: fileMeta ? fileMeta.filename_original : null,
|
||||||
|
storage_path: fileMeta ? fileMeta.storage_path : null,
|
||||||
|
size_bytes: fileMeta ? fileMeta.size_bytes : null,
|
||||||
|
} : e) } : p);
|
||||||
|
};
|
||||||
|
const updateDocFile = (docCode, docId, fileMeta) => {
|
||||||
|
setPractice(p => {
|
||||||
|
if (!p) return p;
|
||||||
|
const exists = p.documents.find(d => d.doc_code === docCode);
|
||||||
|
const newDocs = exists
|
||||||
|
? p.documents.map(d => d.doc_code === docCode ? {
|
||||||
|
...d,
|
||||||
|
filename: fileMeta ? fileMeta.filename_original : null,
|
||||||
|
storage_path: fileMeta ? fileMeta.storage_path : null,
|
||||||
|
size_bytes: fileMeta ? fileMeta.size_bytes : null,
|
||||||
|
} : d)
|
||||||
|
: p.documents;
|
||||||
|
return { ...p, documents: newDocs };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// ensure doc record exists (returns id via callback)
|
||||||
|
const ensureDocRecord = (docCode, onReady) => {
|
||||||
|
const existing = practice?.documents?.find(d => d.doc_code === docCode);
|
||||||
|
if (existing && existing.id) { onReady(existing.id); return; }
|
||||||
|
RendicontazioneService.upsertDocument(practiceId, docCode, { filename: null },
|
||||||
|
(resp) => {
|
||||||
|
const newDoc = resp?.data;
|
||||||
|
if (newDoc && newDoc.id) {
|
||||||
|
setPractice(p => p ? { ...p, documents: [...p.documents.filter(d => d.doc_code !== docCode), newDoc] } : p);
|
||||||
|
onReady(newDoc.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => toast.current?.show({ severity: 'error', summary: __('Errore preparazione documento', 'gepafin'), detail: err?.detail })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ---------- load ----------
|
// ---------- load ----------
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
@@ -438,6 +491,17 @@ const PraticaRendicontazioneEdit = () => {
|
|||||||
body={(r) => <span title={r.description}>{r.description.slice(0, 40)}{r.description.length > 40 ? '…' : ''}</span>} />
|
body={(r) => <span title={r.description}>{r.description.slice(0, 40)}{r.description.length > 40 ? '…' : ''}</span>} />
|
||||||
<Column field="taxable" header={__('Imponibile', 'gepafin')} body={(r) => euro(r.taxable)} />
|
<Column field="taxable" header={__('Imponibile', 'gepafin')} body={(r) => euro(r.taxable)} />
|
||||||
<Column field="total" header={__('Totale', 'gepafin')} body={(r) => euro(r.total)} />
|
<Column field="total" header={__('Totale', 'gepafin')} body={(r) => euro(r.total)} />
|
||||||
|
<Column header={__('Allegato PDF', 'gepafin')} style={{ minWidth: '280px' }}
|
||||||
|
body={(r) => (
|
||||||
|
<FileUploadCell
|
||||||
|
entityType="invoice" entityId={r.id}
|
||||||
|
filename={r.pdf_filename} sizeBytes={r.size_bytes}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onPreview={() => openPreview('invoice', r.id, `Fattura ${r.invoice_number}`, r.pdf_filename)}
|
||||||
|
onChange={(meta) => updateInvoiceFile(r.id, meta)}
|
||||||
|
toastRef={toast}
|
||||||
|
/>
|
||||||
|
)} />
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<Column header="" body={(r) => (
|
<Column header="" body={(r) => (
|
||||||
<Button icon="pi pi-trash" severity="danger" outlined size="small"
|
<Button icon="pi pi-trash" severity="danger" outlined size="small"
|
||||||
@@ -478,8 +542,17 @@ const PraticaRendicontazioneEdit = () => {
|
|||||||
<Column field="fte_pct" header="FTE" body={(r) => Number(r.fte_pct).toFixed(2)} />
|
<Column field="fte_pct" header="FTE" body={(r) => Number(r.fte_pct).toFixed(2)} />
|
||||||
<Column header={__('Periodo', 'gepafin')}
|
<Column header={__('Periodo', 'gepafin')}
|
||||||
body={(r) => `${formatDate(r.period_start_date)} → ${formatDate(r.period_end_date)}`} />
|
body={(r) => `${formatDate(r.period_start_date)} → ${formatDate(r.period_end_date)}`} />
|
||||||
<Column field="supporting_doc_filename" header={__('Allegato', 'gepafin')}
|
<Column header={__('Allegato', 'gepafin')} style={{ minWidth: '280px' }}
|
||||||
body={(r) => r.supporting_doc_filename ? <span><i className="pi pi-file" /> {r.supporting_doc_filename}</span> : <span className="text-color-secondary">—</span>} />
|
body={(r) => (
|
||||||
|
<FileUploadCell
|
||||||
|
entityType="ula" entityId={r.id}
|
||||||
|
filename={r.supporting_doc_filename} sizeBytes={r.size_bytes}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onPreview={() => openPreview('ula', r.id, r.full_name, r.supporting_doc_filename)}
|
||||||
|
onChange={(meta) => updateUlaFile(r.id, meta)}
|
||||||
|
toastRef={toast}
|
||||||
|
/>
|
||||||
|
)} />
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<Column header="" body={(r) => (
|
<Column header="" body={(r) => (
|
||||||
<Button icon="pi pi-trash" severity="danger" outlined size="small"
|
<Button icon="pi pi-trash" severity="danger" outlined size="small"
|
||||||
@@ -507,37 +580,35 @@ const PraticaRendicontazioneEdit = () => {
|
|||||||
<div className="fieldsRepeater">
|
<div className="fieldsRepeater">
|
||||||
{docsRequired.map((dr) => {
|
{docsRequired.map((dr) => {
|
||||||
const existing = practice.documents.find(d => d.doc_code === dr.code);
|
const existing = practice.documents.find(d => d.doc_code === dr.code);
|
||||||
|
const hasFile = !!(existing && existing.filename);
|
||||||
return (
|
return (
|
||||||
<div key={dr.code} className="fieldsRepeater__panel"
|
<div key={dr.code} className="fieldsRepeater__panel"
|
||||||
style={{ border: '1px solid var(--surface-border)', borderRadius: '6px', padding: '0.75rem 1rem',
|
style={{ border: '1px solid var(--surface-border)', borderRadius: '6px', padding: '0.75rem 1rem',
|
||||||
display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
<i className={existing?.filename ? 'pi pi-check-circle' : 'pi pi-circle'}
|
<i className={hasFile ? 'pi pi-check-circle' : 'pi pi-circle'}
|
||||||
style={{ color: existing?.filename ? 'var(--green-500)' : 'var(--text-color-secondary)', fontSize: '1.25rem' }} />
|
style={{ color: hasFile ? 'var(--green-500)' : 'var(--text-color-secondary)', fontSize: '1.25rem' }} />
|
||||||
<div style={{ flex: 1, minWidth: '200px' }}>
|
<div style={{ flex: 1, minWidth: '200px' }}>
|
||||||
<strong>{dr.label}</strong>
|
<strong>{dr.label}</strong>
|
||||||
<div><small className="text-color-secondary"><code>{dr.code}</code></small></div>
|
<div><small className="text-color-secondary"><code>{dr.code}</code></small></div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 2, minWidth: '220px' }}>
|
<div style={{ flex: 2, minWidth: '260px' }}>
|
||||||
{existing?.filename
|
{existing && existing.id ? (
|
||||||
? <span><i className="pi pi-file" /> {existing.filename}</span>
|
<FileUploadCell
|
||||||
: <span className="text-color-secondary">{__('Nessun file', 'gepafin')}</span>}
|
entityType="document" entityId={existing.id}
|
||||||
</div>
|
filename={existing.filename} sizeBytes={existing.size_bytes}
|
||||||
{!readOnly && (
|
readOnly={readOnly}
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
onPreview={() => openPreview('document', existing.id, dr.label, existing.filename)}
|
||||||
<Button type="button" icon="pi pi-upload" size="small"
|
onChange={(meta) => updateDocFile(dr.code, existing.id, meta)}
|
||||||
label={existing?.filename ? __('Sostituisci', 'gepafin') : __('Carica', 'gepafin')}
|
toastRef={toast}
|
||||||
outlined={!!existing?.filename}
|
/>
|
||||||
onClick={() => {
|
) : !readOnly ? (
|
||||||
const fname = prompt(__('Nome del file (simulato)', 'gepafin'),
|
<Button type="button" icon="pi pi-upload" size="small" outlined
|
||||||
existing?.filename || `${dr.code}.pdf`);
|
label={__('Carica', 'gepafin')}
|
||||||
if (fname) upsertDocument(dr.code, fname);
|
onClick={() => ensureDocRecord(dr.code, () => {/* reload not needed, setPractice already updated */})} />
|
||||||
}} />
|
) : (
|
||||||
{existing?.filename && (
|
<span className="text-color-secondary">{__('Nessun file', 'gepafin')}</span>
|
||||||
<Button type="button" icon="pi pi-trash" severity="danger" outlined size="small"
|
|
||||||
onClick={() => clearDocument(dr.code)} />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -615,10 +686,9 @@ const PraticaRendicontazioneEdit = () => {
|
|||||||
onValueChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, total: e.value } }))} />
|
onValueChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, total: e.value } }))} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="appForm__field">
|
<div className="appForm__field" style={{ color: 'var(--text-color-secondary)', fontSize: '0.9em', padding: '0.5rem 0.75rem', background: 'var(--surface-50)', borderRadius: '4px' }}>
|
||||||
<label>{__('Nome file PDF (simulato)', 'gepafin')}</label>
|
<i className="pi pi-info-circle" style={{ marginRight: '0.4rem' }} />
|
||||||
<InputText value={invDialog.data.pdf_filename}
|
{__('Dopo aver salvato la fattura potrai caricare il PDF dalla tabella.', 'gepafin')}
|
||||||
onChange={(e) => setInvDialog(d => ({ ...d, data: { ...d.data, pdf_filename: e.target.value } }))} />
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
|
||||||
<Button type="button" outlined label={__('Annulla', 'gepafin')} onClick={() => setInvDialog({ visible: false, data: null })} />
|
<Button type="button" outlined label={__('Annulla', 'gepafin')} onClick={() => setInvDialog({ visible: false, data: null })} />
|
||||||
@@ -706,11 +776,10 @@ const PraticaRendicontazioneEdit = () => {
|
|||||||
options={(ulaSection.supporting_doc_types || []).map(t => typeof t === 'string' ? { value: t, label: t } : { value: t.code, label: t.label })}
|
options={(ulaSection.supporting_doc_types || []).map(t => typeof t === 'string' ? { value: t, label: t } : { value: t.code, label: t.label })}
|
||||||
onChange={(e) => setEmpDialog(d => ({ ...d, data: { ...d.data, supporting_doc_type: e.value } }))} />
|
onChange={(e) => setEmpDialog(d => ({ ...d, data: { ...d.data, supporting_doc_type: e.value } }))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="appForm__field">
|
|
||||||
<label>{__('Nome file allegato (simulato)', 'gepafin')}</label>
|
|
||||||
<InputText value={empDialog.data.supporting_doc_filename}
|
|
||||||
onChange={(e) => setEmpDialog(d => ({ ...d, data: { ...d.data, supporting_doc_filename: e.target.value } }))} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="appForm__field" style={{ color: 'var(--text-color-secondary)', fontSize: '0.9em', padding: '0.5rem 0.75rem', background: 'var(--surface-50)', borderRadius: '4px' }}>
|
||||||
|
<i className="pi pi-info-circle" style={{ marginRight: '0.4rem' }} />
|
||||||
|
{__("Dopo aver salvato il dipendente potrai caricare il LUL o il documento di supporto dalla tabella.", 'gepafin')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
|
||||||
<Button type="button" outlined label={__('Annulla', 'gepafin')} onClick={() => setEmpDialog({ visible: false, data: null })} />
|
<Button type="button" outlined label={__('Annulla', 'gepafin')} onClick={() => setEmpDialog({ visible: false, data: null })} />
|
||||||
@@ -719,6 +788,14 @@ const PraticaRendicontazioneEdit = () => {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<FilePreviewDialog
|
||||||
|
visible={previewDialog.visible}
|
||||||
|
onHide={closePreview}
|
||||||
|
entityType={previewDialog.entityType}
|
||||||
|
entityId={previewDialog.entityId}
|
||||||
|
title={previewDialog.title}
|
||||||
|
filename={previewDialog.filename}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user