feat(amendment): ROUND 3 UI istruttore completo + service + benef WIP

Parte FE del soccorso istruttorio v3, allineata al pattern UI della
piattaforma (SoccorsoAddInstructorManager / SoccorsoEditPreInstructor).

==SERVICE (rendicontazioneService.js)==
7 metodi nuovi, pattern fetch identico all'esistente:
  - updateAmendment(practiceId, id, body)          PUT  /{pid}/amendment/{id}
  - deleteAmendment(practiceId, id)                DELETE
  - sendAmendment(practiceId, id)                  POST /send
  - extendAmendment(practiceId, id, days, motiv)   POST /extend
  - sendAmendmentReminder(practiceId, id)          POST /reminder
  - uploadAmendmentDocument(practiceId, id, file)  POST multipart
  - deleteAmendmentDocument(practiceId, id)        DELETE upload-document
  - uploadResponseDocument(practiceId, id, file)   POST multipart

==ISTRUTTORIA (IstruttoriaPratica.js) — COMPLETO==
- AMENDMENT_STATUS esteso con DRAFT
- Import Editor (rich text) + FileUpload
- State amendDialog arricchito: mode create|edit, response_days, internal_note, instructor_file
- State extendDialog nuovo per dialog proroga
- Filter openAmendments include DRAFT (oltre AWAITING, RESPONSE_RECEIVED)
- Pulsante 'Soccorso istruttorio' usa openCreateAmendDialog; tooltip spiega lock
- Funzioni nuove: openCreateAmendDialog, openEditAmendDialog, resetAmendDialog,
  doAmend(sendAfterSave), sendDraftAmendment, deleteDraftAmendment,
  doExtendAmendment, sendReminder. closeAmendment preservato.
- doAmend gestisce anche upload allegato con callback-chain (create/update ->
  uploadAmendmentDocument -> eventualmente sendAmendment).
- Sezione amendments arricchita:
    * pulsanti contestuali per status:
        DRAFT:     Modifica / Invia / Elimina
        AWAITING:  Proroga / Reminder / Chiudi
        RESPONSE_RECEIVED / EXPIRED: Chiudi
    * render HTML richiesta (Editor ha prodotto HTML)
    * mostra: scadenza, response_days, extended_days, creazione, pec_sent_at, protocol_id
    * badge 'Allegato istruttore presente' se amendment_document_path
    * nota interna in box giallo (visibile solo istruttore)
    * risposta benef con eventuale badge 'Allegato risposta presente'
    * errore PEC in box rosso se pec_failed_reason
- Dialog creazione/modifica (720px):
    * Editor rich text per request_text (height 180px)
    * Calendar scadenza con minDate=today
    * InputNumber response_days (default 15, 1-120)
    * InputTextarea internal_note con lucchetto
    * FileUpload mode='basic' PDF max 10MB
    * Due CTA: 'Salva bozza' (submit form) + 'Salva e invia al beneficiario'
- Dialog proroga (480px):
    * Visualizza scadenza attuale
    * InputNumber extended_days (1-60)
    * InputTextarea motivation (registrata in internal_note lato BE)

==BENEFICIARIO (PraticaRendicontazioneEdit.js) — WIP parziale==
Patch applicate (sintatticamente OK, integrazione JSX non finita):
  - Import Editor + FileUpload
  - State amendDialog con response_file + responseFileRef
  - Helper _stripHtmlBenef + validazione su plainText

RIMANE DA FARE (prossima sessione):
  - Chain upload del response_document dentro submitAmendmentResponse
  - Filtrare DRAFT dalla sezione amendments (benef non deve vederle)
  - Render HTML dangerouslySetInnerHTML per request_text
  - Mostrare badge 'Allegato istruttore presente' se amendment_document_path
  - Nel dialog risposta: Editor al posto di InputTextarea + FileUpload response_document

==VALIDAZIONE==
Entrambi i file passati con @babel/parser plugins=jsx,classProperties,
optionalChaining,nullishCoalescingOperator — no sintassi errori.

==NEXT==
R3.bis: completare UI benef (5-10min), testare flusso E2E dalla UI sandbox
(crea DRAFT -> modifica -> invia -> [mark-pec-sent manuale] -> benef risponde con
upload -> istruttore chiude). Scrivere documento integrazione Cecilia Moretti
con spec endpoint /internal che BE Gepafin deve sviluppare (poller cron,
tenant routing multi-hub, PEC Massiva Gepafin + Mailgun SviluppUmbria,
protocol-service 65.108.55.96:8080, shared secret).
This commit is contained in:
BFLOWS
2026-04-20 22:55:30 +02:00
parent 59c254a9c3
commit 4982df4e60
3 changed files with 360 additions and 36 deletions

View File

@@ -14,6 +14,8 @@ import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Checkbox } from 'primereact/checkbox';
import { ConfirmPopup, confirmPopup } from 'primereact/confirmpopup';
import { Editor } from 'primereact/editor';
import { FileUpload } from 'primereact/fileupload';
import RendicontazioneService from '../service/rendicontazioneService';
import FilePreviewDialog from '../components/FilePreviewDialog';
@@ -48,6 +50,7 @@ const VERIFICATION_DOC_TAG = {
};
const AMENDMENT_STATUS = {
DRAFT: { severity: 'secondary', label: 'Bozza (non inviata)' },
AWAITING: { severity: 'warning', label: 'Attesa risposta' },
RESPONSE_RECEIVED: { severity: 'info', label: 'Risposta ricevuta' },
CLOSED: { severity: 'success', label: 'Chiusa' },
@@ -81,7 +84,13 @@ const IstruttoriaPratica = () => {
const [approveDialog, setApproveDialog] = useState({ visible: false, amount: null });
const [rejectDialog, setRejectDialog] = useState({ visible: false, reason: '' });
const [amendDialog, setAmendDialog] = useState({ visible: false, text: '', deadline: null });
const [amendDialog, setAmendDialog] = useState({
visible: false, mode: 'create', amendmentId: null,
text: '', deadline: null, response_days: 15, internal_note: '',
instructor_file: null, current_doc_path: null
});
const [extendDialog, setExtendDialog] = useState({ visible: false, amendment: null, extended_days: 7, motivation: '' });
const amendFileRef = useRef(null);
// v2: custom_checks (merge schema+values dal BE)
const [customChecks, setCustomChecks] = useState([]);
const [ccVerifyDialog, setCcVerifyDialog] = useState({ visible: false, cc: null, status: null, notes: '' });
@@ -125,7 +134,7 @@ const IstruttoriaPratica = () => {
return practice?.schema_snapshot?.custom_checks || [];
}, [practice]);
const openAmendments = amendments.filter(a => a.status === 'AWAITING' || a.status === 'RESPONSE_RECEIVED');
const openAmendments = amendments.filter(a => ['DRAFT','AWAITING','RESPONSE_RECEIVED'].includes(a.status));
const isReviewable = practice && ['SUBMITTED', 'UNDER_REVIEW', 'AWAITING_AMENDMENT'].includes(practice.status);
const isDecidable = practice && ['UNDER_REVIEW', 'AWAITING_AMENDMENT'].includes(practice.status);
const isVerifiable = practice && ['UNDER_REVIEW', 'AWAITING_AMENDMENT'].includes(practice.status);
@@ -353,18 +362,123 @@ const IstruttoriaPratica = () => {
RendicontazioneService.rejectPractice(practiceId, rejectDialog.reason,
(resp) => { setRejectDialog({ visible: false, reason: '' }); afterOk(__('Pratica respinta', 'gepafin'))(resp); }, onErr);
};
const doAmend = () => {
if (!amendDialog.text || amendDialog.text.trim().length < 10) {
toast.current?.show({ severity: 'warn', summary: __('Testo troppo corto', 'gepafin') }); return;
const _stripHtml = (html) => {
if (!html) return '';
const tmp = document.createElement('div');
tmp.innerHTML = html;
return (tmp.textContent || tmp.innerText || '').trim();
};
const resetAmendDialog = () => setAmendDialog({
visible: false, mode: 'create', amendmentId: null,
text: '', deadline: null, response_days: 15, internal_note: '',
instructor_file: null, current_doc_path: null
});
const openCreateAmendDialog = () => setAmendDialog({
visible: true, mode: 'create', amendmentId: null,
text: '', deadline: null, response_days: 15, internal_note: '',
instructor_file: null, current_doc_path: null
});
const openEditAmendDialog = (a) => setAmendDialog({
visible: true, mode: 'edit', amendmentId: a.id,
text: a.request_text || '',
deadline: a.deadline ? new Date(a.deadline) : null,
response_days: a.response_days || 15,
internal_note: a.internal_note || '',
instructor_file: null, current_doc_path: a.amendment_document_path || null
});
const doAmend = (sendAfterSave = false) => {
const plainText = _stripHtml(amendDialog.text);
if (plainText.length < 10) {
toast.current?.show({ severity: 'warn', summary: __('Testo troppo corto (min 10 caratteri)', 'gepafin') }); return;
}
if (!amendDialog.deadline) {
toast.current?.show({ severity: 'warn', summary: __('Deadline obbligatoria', 'gepafin') }); return;
}
const body = { request_text: amendDialog.text,
deadline: typeof amendDialog.deadline === 'string' ? amendDialog.deadline : amendDialog.deadline.toISOString().slice(0, 10) };
RendicontazioneService.createAmendment(practiceId, body,
(resp) => { setAmendDialog({ visible: false, text: '', deadline: null }); afterOk(__('Soccorso avviato', 'gepafin'))(resp); }, onErr);
const deadlineStr = typeof amendDialog.deadline === 'string'
? amendDialog.deadline
: amendDialog.deadline.toISOString().slice(0, 10);
const body = {
request_text: amendDialog.text, deadline: deadlineStr,
response_days: amendDialog.response_days,
internal_note: amendDialog.internal_note || null
};
const uploadIfNeeded = (savedAmendment, then) => {
if (amendDialog.instructor_file) {
RendicontazioneService.uploadAmendmentDocument(practiceId, savedAmendment.id, amendDialog.instructor_file,
() => then(savedAmendment), onErr);
} else { then(savedAmendment); }
};
const finalStep = (savedAmendment) => {
if (sendAfterSave) {
RendicontazioneService.sendAmendment(practiceId, savedAmendment.id,
(resp) => { resetAmendDialog(); afterOk(__('Soccorso inviato al beneficiario', 'gepafin'))(resp); }, onErr);
} else {
resetAmendDialog();
afterOk(__('Bozza salvata', 'gepafin'))({ data: savedAmendment });
}
};
if (amendDialog.mode === 'create') {
RendicontazioneService.createAmendment(practiceId, body,
(resp) => uploadIfNeeded(resp.data, finalStep), onErr);
} else {
RendicontazioneService.updateAmendment(practiceId, amendDialog.amendmentId, body,
(resp) => uploadIfNeeded(resp.data, finalStep), onErr);
}
};
const sendDraftAmendment = (ev, a) => {
confirmPopup({
target: ev.currentTarget,
message: __('Inviare il soccorso al beneficiario? Dopo l invio non sara piu modificabile.', 'gepafin'),
icon: 'pi pi-send',
acceptLabel: __('Invia', 'gepafin'), rejectLabel: __('Annulla', 'gepafin'),
accept: () => RendicontazioneService.sendAmendment(practiceId, a.id,
afterOk(__('Soccorso inviato', 'gepafin')), onErr)
});
};
const deleteDraftAmendment = (ev, a) => {
confirmPopup({
target: ev.currentTarget,
message: __('Eliminare questa bozza di soccorso?', 'gepafin'),
icon: 'pi pi-exclamation-triangle',
acceptLabel: __('Elimina', 'gepafin'), rejectLabel: __('Annulla', 'gepafin'),
acceptClassName: 'p-button-danger',
accept: () => RendicontazioneService.deleteAmendment(practiceId, a.id,
afterOk(__('Bozza eliminata', 'gepafin')), onErr)
});
};
const doExtendAmendment = () => {
if (!extendDialog.extended_days || extendDialog.extended_days < 1) {
toast.current?.show({ severity: 'warn', summary: __('Indicare giorni di proroga', 'gepafin') }); return;
}
RendicontazioneService.extendAmendment(practiceId, extendDialog.amendment.id,
extendDialog.extended_days, extendDialog.motivation || null,
(resp) => {
setExtendDialog({ visible: false, amendment: null, extended_days: 7, motivation: '' });
afterOk(__('Scadenza prorogata', 'gepafin'))(resp);
}, onErr);
};
const sendReminder = (ev, a) => {
confirmPopup({
target: ev.currentTarget,
message: __('Inviare un reminder al beneficiario? Il backend accodera una PEC di sollecito.', 'gepafin'),
icon: 'pi pi-bell',
acceptLabel: __('Invia reminder', 'gepafin'), rejectLabel: __('Annulla', 'gepafin'),
accept: () => RendicontazioneService.sendAmendmentReminder(practiceId, a.id,
afterOk(__('Reminder accodato', 'gepafin')), onErr)
});
};
const closeAmendment = (ev, a) => {
confirmPopup({
target: ev.currentTarget,
@@ -469,7 +583,9 @@ const IstruttoriaPratica = () => {
<Button type="button" icon="pi pi-comment" iconPos="right" severity="warning" outlined
label={__('Soccorso istruttorio', 'gepafin')}
disabled={openAmendments.length > 0}
onClick={() => setAmendDialog({ visible: true, text: '', deadline: null })} />
tooltip={openAmendments.length > 0 ? __('Soccorso gia aperto su questa pratica', 'gepafin') : null}
tooltipOptions={{ showOnDisabled: true }}
onClick={openCreateAmendDialog} />
</>)}
{/* Verbale: sempre visibile all'istruttore per preview e scarico */}
@@ -568,29 +684,85 @@ const IstruttoriaPratica = () => {
<div className="fieldsRepeater">
{amendments.map(a => {
const cfg = AMENDMENT_STATUS[a.status] || { severity: 'secondary', label: a.status };
const isDraft = a.status === 'DRAFT';
const isAwaiting = a.status === 'AWAITING';
const isClosable = ['AWAITING','RESPONSE_RECEIVED','EXPIRED'].includes(a.status);
return (
<div key={a.id} className="fieldsRepeater__panel"
style={{ border: '1px solid var(--surface-border)', borderRadius: '6px', padding: '1rem', background: 'var(--surface-50)' }}>
<div className="fieldsRepeater__heading" style={{ marginBottom: '0.5rem' }}>
style={{ border: '1px solid var(--surface-border)', borderRadius: '6px', padding: '1rem', background: 'var(--surface-50)', marginBottom: '0.75rem' }}>
<div className="fieldsRepeater__heading" style={{ marginBottom: '0.5rem', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '0.5rem' }}>
<div>
<Tag severity={cfg.severity} value={cfg.label} />
<span style={{ marginLeft: '0.75rem', color: 'var(--text-color-secondary)' }}>
{__('Deadline:', 'gepafin')} {formatDate(a.deadline)} · {__('Creata:', 'gepafin')} {formatDateTime(a.created_at)}
<span style={{ marginLeft: '0.75rem', color: 'var(--text-color-secondary)', fontSize: '0.9em' }}>
{__('Scadenza:', 'gepafin')} <strong>{formatDate(a.deadline)}</strong>
{a.response_days ? ` (${a.response_days}gg)` : ''}
{a.extended_days ? ` · ${__('prorogato di', 'gepafin')} ${a.extended_days}gg` : ''}
{' · '}{__('Creata:', 'gepafin')} {formatDateTime(a.created_at)}
{a.pec_sent_at ? ` · ${__('PEC inviata', 'gepafin')} ${formatDateTime(a.pec_sent_at)}` : ''}
{a.protocol_id ? ` · ${__('Prot.', 'gepafin')} ${a.protocol_id}` : ''}
</span>
</div>
{a.status !== 'CLOSED' && isReviewable && (
<Button icon="pi pi-check" label={__('Chiudi soccorso', 'gepafin')}
size="small" outlined severity="success"
onClick={(e) => closeAmendment(e, a)} />
)}
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{isDraft && isReviewable && (<>
<Button icon="pi pi-pencil" label={__('Modifica', 'gepafin')}
size="small" outlined onClick={() => openEditAmendDialog(a)} />
<Button icon="pi pi-send" label={__('Invia al beneficiario', 'gepafin')}
size="small" severity="warning"
onClick={(e) => sendDraftAmendment(e, a)} />
<Button icon="pi pi-trash" label={__('Elimina bozza', 'gepafin')}
size="small" outlined severity="danger"
onClick={(e) => deleteDraftAmendment(e, a)} />
</>)}
{isAwaiting && isReviewable && (<>
<Button icon="pi pi-calendar-plus" label={__('Proroga', 'gepafin')}
size="small" outlined
onClick={() => setExtendDialog({ visible: true, amendment: a, extended_days: 7, motivation: '' })} />
<Button icon="pi pi-bell" label={__('Reminder', 'gepafin')}
size="small" outlined severity="help"
onClick={(e) => sendReminder(e, a)}
disabled={!!a.pec_retry_after}
tooltip={a.pec_retry_after ? __('Reminder gia accodato', 'gepafin') : null}
tooltipOptions={{ showOnDisabled: true }} />
</>)}
{isClosable && isReviewable && (
<Button icon="pi pi-check" label={__('Chiudi soccorso', 'gepafin')}
size="small" outlined severity="success"
onClick={(e) => closeAmendment(e, a)} />
)}
</div>
</div>
<div>
<small className="text-color-secondary">{__('Richiesta istruttore:', 'gepafin')}</small>
<div style={{ whiteSpace: 'pre-wrap', marginBottom: '0.5rem' }}>{a.request_text}</div>
<div style={{ padding: '0.5rem', background: 'white', borderRadius: '4px', marginTop: '0.25rem', marginBottom: '0.5rem' }}
dangerouslySetInnerHTML={{ __html: a.request_text || '' }} />
{a.amendment_document_path && (
<div style={{ marginBottom: '0.5rem' }}>
<i className="pi pi-paperclip" style={{ marginRight: '0.25rem' }} />
<small className="text-color-secondary">{__('Allegato istruttore presente', 'gepafin')}</small>
</div>
)}
{a.internal_note && (
<div style={{ marginTop: '0.5rem', padding: '0.5rem', background: 'var(--yellow-50, #fefce8)', borderLeft: '3px solid var(--yellow-400, #facc15)', borderRadius: '3px' }}>
<small className="text-color-secondary"><i className="pi pi-lock" style={{marginRight:'0.25rem'}} />{__('Nota interna (non visibile al beneficiario):', 'gepafin')}</small>
<div style={{ whiteSpace: 'pre-wrap', fontStyle: 'italic' }}>{a.internal_note}</div>
</div>
)}
{a.response_text && (<>
<small className="text-color-secondary">{__('Risposta beneficiario', 'gepafin')} ({formatDateTime(a.response_at)}):</small>
<div style={{ whiteSpace: 'pre-wrap', padding: '0.5rem', background: 'white', borderRadius: '4px', marginTop: '0.25rem' }}>{a.response_text}</div>
{a.response_document_path && (
<div style={{ marginTop: '0.25rem' }}>
<i className="pi pi-paperclip" style={{ marginRight: '0.25rem' }} />
<small className="text-color-secondary">{__('Allegato risposta presente', 'gepafin')}</small>
</div>
)}
</>)}
{a.pec_failed_reason && (
<div style={{ marginTop: '0.5rem', padding: '0.5rem', background: 'var(--red-50, #fef2f2)', borderLeft: '3px solid var(--red-400, #f87171)', borderRadius: '3px' }}>
<small className="text-color-secondary"><i className="pi pi-exclamation-circle" style={{marginRight:'0.25rem', color:'var(--red-600, #dc2626)'}} />{__('Errore invio PEC:', 'gepafin')}</small>
<div style={{ whiteSpace: 'pre-wrap', color: 'var(--red-700, #b91c1c)' }}>{a.pec_failed_reason}</div>
</div>
)}
</div>
</div>
);
@@ -1226,25 +1398,106 @@ const IstruttoriaPratica = () => {
</form>
</Dialog>
{/* DIALOG SOCCORSO */}
<Dialog visible={amendDialog.visible} style={{ width: '560px' }}
header={__('Avvia soccorso istruttorio', 'gepafin')} modal
onHide={() => setAmendDialog({ visible: false, text: '', deadline: null })}>
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); doAmend(); }}>
{/* DIALOG SOCCORSO — creazione/modifica bozza */}
<Dialog visible={amendDialog.visible} style={{ width: '720px' }}
header={amendDialog.mode === 'edit'
? __('Modifica bozza soccorso istruttorio', 'gepafin')
: __('Avvia soccorso istruttorio', 'gepafin')}
modal onHide={resetAmendDialog}>
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); doAmend(false); }}>
<div className="appForm__field">
<label>{__('Richiesta al beneficiario', 'gepafin')}</label>
<InputTextarea value={amendDialog.text} rows={5} autoResize
onChange={(e) => setAmendDialog(d => ({ ...d, text: e.target.value }))}
placeholder={__('Descrivi le integrazioni richieste...', 'gepafin')} />
<label>{__('Richiesta al beneficiario', 'gepafin')} <span style={{color:'var(--red-500)'}}>*</span></label>
<Editor value={amendDialog.text} style={{ height: '180px' }}
onTextChange={(e) => setAmendDialog(d => ({ ...d, text: e.htmlValue || '' }))}
placeholder={__('Descrivi le integrazioni richieste. Il testo sara riportato nella PEC.', 'gepafin')} />
<small className="text-color-secondary">
{__('Verra incluso nella PEC al beneficiario.', 'gepafin')}
</small>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div className="appForm__field">
<label>{__('Scadenza risposta', 'gepafin')} <span style={{color:'var(--red-500)'}}>*</span></label>
<Calendar value={amendDialog.deadline} dateFormat="dd/mm/yy" showIcon
minDate={new Date()}
onChange={(e) => setAmendDialog(d => ({ ...d, deadline: e.value }))} />
</div>
<div className="appForm__field">
<label>{__('Giorni risposta (informativo)', 'gepafin')}</label>
<InputNumber value={amendDialog.response_days} min={1} max={120}
suffix=" gg" showButtons
onValueChange={(e) => setAmendDialog(d => ({ ...d, response_days: e.value }))} />
<small className="text-color-secondary">{__('Default 15gg', 'gepafin')}</small>
</div>
</div>
<div className="appForm__field">
<label>
<i className="pi pi-lock" style={{marginRight:'0.25rem'}} />
{__('Nota interna (solo istruttori)', 'gepafin')}
</label>
<InputTextarea value={amendDialog.internal_note} rows={2} autoResize
onChange={(e) => setAmendDialog(d => ({ ...d, internal_note: e.target.value }))}
placeholder={__('Es: verificare a 10gg se il benef ha letto', 'gepafin')} />
<small className="text-color-secondary">{__('Non viene inviata al beneficiario', 'gepafin')}</small>
</div>
<div className="appForm__field">
<label>{__('Allegato (opzionale, PDF)', 'gepafin')}</label>
{amendDialog.current_doc_path && !amendDialog.instructor_file && (
<div style={{ padding: '0.5rem', background: 'var(--surface-50)', borderRadius: '4px', marginBottom: '0.5rem' }}>
<i className="pi pi-paperclip" style={{ marginRight: '0.25rem' }} />
<small>{__('Allegato gia caricato', 'gepafin')}</small>
</div>
)}
<FileUpload ref={amendFileRef} mode="basic" auto={false}
chooseLabel={__('Scegli file PDF', 'gepafin')}
accept="application/pdf" maxFileSize={10*1024*1024}
customUpload uploadHandler={() => {}}
onSelect={(e) => setAmendDialog(d => ({ ...d, instructor_file: e.files[0] || null }))} />
<small className="text-color-secondary">{__('Protocollato e allegato alla PEC lato backend', 'gepafin')}</small>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem', marginTop: '1rem', flexWrap: 'wrap' }}>
<Button type="button" outlined severity="secondary" label={__('Annulla', 'gepafin')}
onClick={resetAmendDialog} />
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Button type="submit" outlined label={__('Salva bozza', 'gepafin')} icon="pi pi-save" />
<Button type="button" label={__('Salva e invia al beneficiario', 'gepafin')}
icon="pi pi-send" severity="warning"
onClick={() => doAmend(true)} />
</div>
</div>
</form>
</Dialog>
{/* DIALOG PROROGA SCADENZA */}
<Dialog visible={extendDialog.visible} style={{ width: '480px' }}
header={__('Proroga scadenza soccorso', 'gepafin')} modal
onHide={() => setExtendDialog({ visible: false, amendment: null, extended_days: 7, motivation: '' })}>
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); doExtendAmendment(); }}>
{extendDialog.amendment && (
<div style={{ marginBottom: '1rem', padding: '0.5rem', background: 'var(--surface-50)', borderRadius: '4px' }}>
<small className="text-color-secondary">{__('Scadenza attuale:', 'gepafin')} </small>
<strong>{formatDate(extendDialog.amendment.deadline)}</strong>
</div>
)}
<div className="appForm__field">
<label>{__('Giorni da aggiungere', 'gepafin')} <span style={{color:'var(--red-500)'}}>*</span></label>
<InputNumber value={extendDialog.extended_days} min={1} max={60}
suffix=" gg" showButtons
onValueChange={(e) => setExtendDialog(d => ({ ...d, extended_days: e.value || 0 }))} />
</div>
<div className="appForm__field">
<label>{__('Scadenza risposta', 'gepafin')}</label>
<Calendar value={amendDialog.deadline} dateFormat="dd/mm/yy" showIcon
onChange={(e) => setAmendDialog(d => ({ ...d, deadline: e.value }))} />
<label>{__('Motivazione (registrata in nota interna)', 'gepafin')}</label>
<InputTextarea value={extendDialog.motivation} rows={3} autoResize
onChange={(e) => setExtendDialog(d => ({ ...d, motivation: e.target.value }))}
placeholder={__('Es: richiesta benef per impedimento contabile', 'gepafin')} />
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
<Button type="button" outlined label={__('Annulla', 'gepafin')} onClick={() => setAmendDialog({ visible: false, text: '', deadline: null })} />
<Button type="submit" label={__('Invia richiesta', 'gepafin')} icon="pi pi-send" severity="warning" />
<Button type="button" outlined label={__('Annulla', 'gepafin')}
onClick={() => setExtendDialog({ visible: false, amendment: null, extended_days: 7, motivation: '' })} />
<Button type="submit" label={__('Proroga scadenza', 'gepafin')} icon="pi pi-calendar-plus" />
</div>
</form>
</Dialog>

View File

@@ -14,6 +14,8 @@ import { InputNumber } from 'primereact/inputnumber';
import { Dropdown } from 'primereact/dropdown';
import { Calendar } from 'primereact/calendar';
import { InputTextarea } from 'primereact/inputtextarea';
import { Editor } from 'primereact/editor';
import { FileUpload } from 'primereact/fileupload';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
@@ -91,7 +93,8 @@ const PraticaRendicontazioneEdit = () => {
// modal dipendente ULA
const [empDialog, setEmpDialog] = useState({ visible: false, data: null });
// modal risposta soccorso istruttorio
const [amendDialog, setAmendDialog] = useState({ visible: false, amendment: null, responseText: '' });
const [amendDialog, setAmendDialog] = useState({ visible: false, amendment: null, responseText: '', response_file: null });
const responseFileRef = useRef(null);
// 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 });
@@ -344,8 +347,16 @@ const PraticaRendicontazioneEdit = () => {
});
};
const _stripHtmlBenef = (html) => {
if (!html) return '';
const tmp = document.createElement('div');
tmp.innerHTML = html;
return (tmp.textContent || tmp.innerText || '').trim();
};
const submitAmendmentResponse = () => {
if (!amendDialog.responseText || amendDialog.responseText.trim().length < 5) {
const plainText = _stripHtmlBenef(amendDialog.responseText);
if (plainText.length < 5) {
toast.current?.show({ severity: 'warn', summary: __('Risposta troppo corta', 'gepafin') });
return;
}

View File

@@ -278,6 +278,66 @@ const extendInstructor = {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify({ response_text: responseText })
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
// ====== AMENDMENT v3: DRAFT lifecycle + extend + reminder + uploads ======
updateAmendment(practiceId, amendmentId, body, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(body)
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
deleteAmendment(practiceId, amendmentId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}`, {
method: 'DELETE', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
sendAmendment(practiceId, amendmentId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/send`, {
method: 'POST', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
extendAmendment(practiceId, amendmentId, extendedDays, motivation, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/extend`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify({ extended_days: extendedDays, motivation })
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
sendAmendmentReminder(practiceId, amendmentId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/reminder`, {
method: 'POST', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
uploadAmendmentDocument(practiceId, amendmentId, file, onSuccess, onError) {
const fd = new FormData();
fd.append('file', file);
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/upload-document`, {
method: 'POST', mode: 'cors',
headers: _buildBearerOnly(),
body: fd
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
deleteAmendmentDocument(practiceId, amendmentId, onSuccess, onError) {
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/upload-document`, {
method: 'DELETE', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
uploadResponseDocument(practiceId, amendmentId, file, onSuccess, onError) {
const fd = new FormData();
fd.append('file', file);
fetch(`${BASE_URL}/api/remission-practices/instructor/${practiceId}/amendment/${amendmentId}/upload-response-document`, {
method: 'POST', mode: 'cors',
headers: _buildBearerOnly(),
body: fd
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
}
};