feat(amendment): ROUND 3.B UI benef completa — response upload + render HTML + dialog rich

Completamento lato beneficiario dopo R3.A (IstruttoriaPratica completo).
Modulo rendicontazione speculare al pattern piattaforma FE.

==5 PATCH APPLICATE==

1. submitAmendmentResponse — callback-chain con upload response_document
   Se l'utente ha selezionato un file nel FileUpload, dopo il submit del
   testo chiama uploadResponseDocument. Se l'upload fallisce, il testo
   resta salvato (toast warn). Success unificato via afterMutation.

2. Sezione amendments benef — filtro DRAFT
   Il benef non deve vedere le bozze: le DRAFT vivono solo lato istruttore
   finche non viene chiamato /send. Doppio filtro (count + map).

3. Sezione amendments benef — render HTML + metadata
   Il request_text ora viene da Editor lato istruttore (HTML), quindi
   serve dangerouslySetInnerHTML. Mostra inoltre response_days, badge
   'Allegato istruttore presente' se amendment_document_path, badge
   'Allegato inviato con la risposta' se response_document_path.

4. Dialog risposta — Editor rich text
   Sostituita InputTextarea con Editor (primereact) coerente con il
   pattern del lato istruttore. height=180px.

5. Dialog risposta — FileUpload response_document + visualizzazione allegato istruttore
   - Header del dialog mostra: richiesta HTML, badge 'Istruttore ha
     allegato un documento' se presente, scadenza con icona calendario
     e response_days in testo di aiuto.
   - Nuovo campo FileUpload basic (PDF max 10MB) agganciato a
     amendDialog.response_file.
   - Width dialog aumentato da 560px a 720px (coerente con
     IstruttoriaPratica dialog create/edit).

==VALIDAZIONE==
@babel/parser JSX: 31 nodes, no errori. File 69148 chars.

==STATO COMPLESSIVO SOCCORSO ISTRUTTORIO v3==
Backend (rendicontazione-api):  COMPLETO — da13ca7 R1 + 34c4a47 R2
Frontend (bflows-bandi-fe):     COMPLETO — 4982df4 R3.A + questo commit
Documento integrazione Cecilia: TODO (prossima sessione)

==NEXT==
- Test E2E UI sandbox (crea DRAFT con allegato istruttore -> modifica ->
  invia -> simula mark-pec-sent via SQL -> benef vede soccorso con badge
  allegato -> benef risponde con response_file -> istruttore vede
  response con badge e chiude)
- Scrivere /opt/docs/gepafin-rendicontazione-amendment-spec-per-BE.md
  per Cecilia Moretti con: spec endpoint /internal (pending-pec,
  pending-reminder, mark-pec-sent, mark-pec-failed), poller cron BE,
  tenant routing hub=1 PEC Massiva + ProtocolService 65.108.55.96:8080,
  hub=2 Mailgun. 5 domande aperte (classifica, SviluppUmbria PEC,
  allegati protocollati, ruoli autorizzati, firma digitale response).
This commit is contained in:
BFLOWS
2026-04-20 23:20:41 +02:00
parent 4982df4e60
commit 1116f96acf

View File

@@ -360,10 +360,33 @@ const PraticaRendicontazioneEdit = () => {
toast.current?.show({ severity: 'warn', summary: __('Risposta troppo corta', 'gepafin') });
return;
}
const fileToUpload = amendDialog.response_file;
const amendmentId = amendDialog.amendment.id;
RendicontazioneService.respondAmendmentBeneficiary(
practiceId, amendDialog.amendment.id, amendDialog.responseText,
(resp) => { setAmendDialog({ visible: false, amendment: null, responseText: '' });
afterMutation(__('Risposta inviata all\'istruttore', 'gepafin'))(resp); },
practiceId, amendmentId, amendDialog.responseText,
(resp) => {
if (fileToUpload) {
RendicontazioneService.uploadResponseDocument(practiceId, amendmentId, fileToUpload,
() => {
setAmendDialog({ visible: false, amendment: null, responseText: '', response_file: null });
afterMutation(__('Risposta trasmessa con allegato', 'gepafin'))(resp);
},
(err) => {
// testo salvato ma upload fallito — avviso e ricarico
setAmendDialog({ visible: false, amendment: null, responseText: '', response_file: null });
toast.current?.show({
severity: 'warn',
summary: __('Risposta salvata, upload allegato fallito', 'gepafin'),
detail: err?.message || ''
});
afterMutation(null)(resp);
});
} else {
setAmendDialog({ visible: false, amendment: null, responseText: '', response_file: null });
afterMutation(__('Risposta inviata all\'istruttore', 'gepafin'))(resp);
}
},
onMutationError);
};
@@ -465,8 +488,8 @@ const PraticaRendicontazioneEdit = () => {
</div>
)}
{/* SOCCORSO ISTRUTTORIO (se presente) */}
{practice.amendments && practice.amendments.length > 0 && (<>
{/* SOCCORSO ISTRUTTORIO (se presente — esclude DRAFT, visibile solo quando PEC partita) */}
{practice.amendments && practice.amendments.filter(a => a.status !== 'DRAFT').length > 0 && (<>
<div className="appPage__spacer"></div>
<div className="appPageSection">
<h2>{__('Richieste di soccorso istruttorio', 'gepafin')}</h2>
@@ -474,7 +497,7 @@ const PraticaRendicontazioneEdit = () => {
{__('L\'istruttore ha chiesto integrazioni o chiarimenti. Rispondi al più presto.', 'gepafin')}
</p>
<div className="fieldsRepeater">
{practice.amendments.map(a => {
{practice.amendments.filter(a => a.status !== 'DRAFT').map(a => {
const statusCfg = {
AWAITING: { sev: 'warning', label: 'In attesa della tua risposta' },
RESPONSE_RECEIVED: { sev: 'info', label: 'Risposta inviata, in attesa di chiusura' },
@@ -484,25 +507,41 @@ const PraticaRendicontazioneEdit = () => {
return (
<div key={a.id} className="fieldsRepeater__panel"
style={{ border: '1px solid var(--surface-border)', borderRadius: '6px', padding: '1rem',
background: a.status === 'AWAITING' ? 'var(--orange-50)' : 'var(--surface-50)' }}>
<div className="fieldsRepeater__heading" style={{ marginBottom: '0.5rem' }}>
background: a.status === 'AWAITING' ? 'var(--orange-50, #fff7ed)' : '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={statusCfg.sev} value={statusCfg.label} />
<span style={{ marginLeft: '0.75rem', color: 'var(--text-color-secondary)' }}>
{__('Scadenza:', 'gepafin')} {new Date(a.deadline).toLocaleDateString('it-IT')}
<span style={{ marginLeft: '0.75rem', color: 'var(--text-color-secondary)', fontSize: '0.9em' }}>
{__('Scadenza:', 'gepafin')} <strong>{new Date(a.deadline).toLocaleDateString('it-IT')}</strong>
{a.response_days ? ` (${a.response_days}gg dalla richiesta)` : ''}
</span>
</div>
{a.status === 'AWAITING' && (
<Button icon="pi pi-reply" label={__('Rispondi', 'gepafin')} size="small" severity="warning"
onClick={() => setAmendDialog({ visible: true, amendment: a, responseText: '' })} />
onClick={() => setAmendDialog({ visible: true, amendment: a, responseText: '', response_file: null })} />
)}
</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 (disponibile via PEC)', 'gepafin')}</small>
</div>
)}
{a.response_text && (<>
<small className="text-color-secondary">{__('Tua risposta:', 'gepafin')}</small>
<div style={{ whiteSpace: 'pre-wrap', padding: '0.5rem', background: 'white', borderRadius: '4px', marginTop: '0.25rem' }}>{a.response_text}</div>
<div style={{ padding: '0.5rem', background: 'white', borderRadius: '4px', marginTop: '0.25rem' }}
dangerouslySetInnerHTML={{ __html: a.response_text }} />
{a.response_document_path && (
<div style={{ marginTop: '0.25rem' }}>
<i className="pi pi-paperclip" style={{ marginRight: '0.25rem' }} />
<small className="text-color-secondary">{__('Allegato inviato con la risposta', 'gepafin')}</small>
</div>
)}
</>)}
</div>
</div>
@@ -911,23 +950,52 @@ const PraticaRendicontazioneEdit = () => {
</Dialog>
{/* ---------- DIALOG RISPOSTA SOCCORSO ---------- */}
<Dialog visible={amendDialog.visible} style={{ width: '560px' }}
<Dialog visible={amendDialog.visible} style={{ width: '720px' }}
header={__('Rispondi al soccorso istruttorio', 'gepafin')} modal
onHide={() => setAmendDialog({ visible: false, amendment: null, responseText: '' })}>
onHide={() => setAmendDialog({ visible: false, amendment: null, responseText: '', response_file: null })}>
{amendDialog.amendment && (
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); submitAmendmentResponse(); }}>
<div style={{ padding: '0.75rem', background: 'var(--surface-50)', borderRadius: '4px', marginBottom: '1rem' }}>
<small className="text-color-secondary">{__('Richiesta istruttore:', 'gepafin')}</small>
<div style={{ whiteSpace: 'pre-wrap', marginTop: '0.25rem' }}>{amendDialog.amendment.request_text}</div>
<div style={{ padding: '0.75rem', background: 'var(--surface-50)', borderRadius: '4px', marginBottom: '1rem', border: '1px solid var(--surface-border)' }}>
<small className="text-color-secondary">
<strong>{__('Richiesta istruttore', 'gepafin')}</strong>
{amendDialog.amendment.response_days ? `${__('hai', 'gepafin')} ${amendDialog.amendment.response_days} ${__('giorni per rispondere', 'gepafin')}` : ''}
</small>
<div style={{ marginTop: '0.25rem' }}
dangerouslySetInnerHTML={{ __html: amendDialog.amendment.request_text || '' }} />
{amendDialog.amendment.amendment_document_path && (
<div style={{ marginTop: '0.5rem', fontSize: '0.9em' }}>
<i className="pi pi-paperclip" style={{ marginRight: '0.25rem' }} />
<span className="text-color-secondary">{__('L\'istruttore ha allegato un documento (trasmesso via PEC)', 'gepafin')}</span>
</div>
)}
<div style={{ marginTop: '0.5rem', fontSize: '0.9em', color: 'var(--text-color-secondary)' }}>
<i className="pi pi-calendar" style={{ marginRight: '0.25rem' }} />
{__('Scadenza:', 'gepafin')} <strong>{new Date(amendDialog.amendment.deadline).toLocaleDateString('it-IT')}</strong>
</div>
</div>
<div className="appForm__field">
<label>{__('La tua risposta', 'gepafin')}</label>
<InputTextarea value={amendDialog.responseText} rows={5} autoResize
onChange={(e) => setAmendDialog(d => ({ ...d, responseText: e.target.value }))}
placeholder={__('Descrivi le integrazioni fornite, allegati caricati, chiarimenti...', 'gepafin')} />
<label>{__('La tua risposta', 'gepafin')} <span style={{color:'var(--red-500)'}}>*</span></label>
<Editor value={amendDialog.responseText} style={{ height: '180px' }}
onTextChange={(e) => setAmendDialog(d => ({ ...d, responseText: e.htmlValue || '' }))}
placeholder={__('Descrivi le integrazioni fornite, gli allegati caricati, i chiarimenti...', 'gepafin')} />
</div>
<div className="appForm__field">
<label>{__('Allegato alla risposta (opzionale, PDF)', 'gepafin')}</label>
<FileUpload ref={responseFileRef} mode="basic" auto={false}
chooseLabel={__('Scegli file PDF', 'gepafin')}
accept="application/pdf" maxFileSize={10*1024*1024}
customUpload uploadHandler={() => {}}
onSelect={(e) => setAmendDialog(d => ({ ...d, response_file: e.files[0] || null }))} />
<small className="text-color-secondary">
{__('Documento integrativo (DURC aggiornato, chiarimento contabile, ecc.)', 'gepafin')}
</small>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
<Button type="button" outlined label={__('Annulla', 'gepafin')} onClick={() => setAmendDialog({ visible: false, amendment: null, responseText: '' })} />
<Button type="button" outlined label={__('Annulla', 'gepafin')}
onClick={() => setAmendDialog({ visible: false, amendment: null, responseText: '', response_file: null })} />
<Button type="submit" label={__('Invia risposta', 'gepafin')} icon="pi pi-send" severity="warning" />
</div>
</form>