feat(v2): FE multi-tranche + custom_checks + manager view

service/rendicontazioneService.js:
- startPractice(appId, cb, cb, opts) v2: accetta period_label + copy_ula_from_previous
- copyUlaOptions(practiceId): preview ULA tranche N-1 per pre-fill
- Custom checks: listCustomChecks, declareCustomCheck (form-data + optional file),
  deleteCustomCheckDocument, verifyCustomCheck, fetchCustomCheckDocumentBlob
- Manager: managerAssignments, managerInstructorsList, reassignInstructor

B1 BandoRendicontazioneSchemaEdit.js (editor superadmin):
- schemaJsonToForm: estrae gate_rules.max_tranches + custom_checks[] top-level
- formToSchemaJson: scrive max_tranches e custom_checks + schema_version=2
- Helpers addCheck/removeCheck/updateCheck (pattern fieldsRepeater esistente)
- Sezione 7 'Tranches di rendicontazione': InputNumber max_tranches (1-20)
- Sezione 8 'Controlli aggiuntivi': array editable con code (snake_case sanitized),
  label, description, requires_document, required

B2 RendicontazioniMie.js (dashboard benef) — RISCRITTA:
- Raggruppamento per application_id con card per bando
- Riquadro info cumulativo (cap totale, gia approvato, disponibile, tranches N/M)
- Elenco tranche con badge stato + bottoni 'Continua' (DRAFT) / 'Apri' (non editable)
- Bottone '+ Nuova rendicontazione' con 4 stati:
  attivo / disabilitato 'Limite raggiunto' / 'Completa prima' / 'Remissione esaurita'
- Dialog avvio: InputText period_label + Checkbox copy_ula (solo se sequence > 1)

B3 PraticaRendicontazioneEdit.js (beneficiario):
- useMemo customChecksDefs da schema_snapshot.custom_checks
- State customChecks + loadCustomChecks useCallback
- Sezione 5/4 'Controlli aggiuntivi (dichiarazioni)':
  per ogni check checkbox 'Dichiaro', badge Obbligatorio/Opzionale/status,
  upload PDF/JPG/PNG 15MB se requires_document, preview filename+size
- Bordo rosso su check obbligatori non dichiarati

B4 IstruttoriaPratica.js (istruttore):
- State customChecks + loadCustomChecks + ccVerifyDialog
- Sezione 'Verifica controlli aggiuntivi' (dopo Verifica documenti):
  lista con label/codice/badge stato beneficiario/validazione/note istruttore
- Azioni: preview, download, thumbs-up (VALIDO toggle), thumbs-down (NON_VALIDO)
- Dialog motivazione NON_VALIDO con InputTextarea (min 5 char)

B5 IstruttoriaQueue.js (manager):
- Toggle 'Coda standard' vs 'Vista manager (riassegnazioni)' visibile solo per
  ROLE_INSTRUCTOR_MANAGER o ROLE_SUPER_ADMIN
- Tabella manager con colonne: Bando/Pratica/Tranche, Stato, Istruttore domanda,
  Assegnato a (o badge 'Da assegnare' se unassigned), Erogato
- Azione 'Riassegna' (o 'Assegna' se unassigned): apre Dialog con Dropdown
  istruttori (pool pre_instructor + manager) + InputTextarea motivazione
- Opzione 'Metti in coda (nessuno)' nel Dropdown per unassign

Tutti i file validati via @babel/parser JSX.
Webpack compila senza errori (solo warning eslint preesistenti non-B).
This commit is contained in:
BFLOWS
2026-04-18 17:53:04 +02:00
parent fca18de751
commit 381fd64fef
6 changed files with 960 additions and 105 deletions

View File

@@ -79,6 +79,15 @@ 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 });
// v2: custom_checks (merge schema+values dal BE)
const [customChecks, setCustomChecks] = useState([]);
const [ccVerifyDialog, setCcVerifyDialog] = useState({ visible: false, cc: null, status: null, notes: '' });
const loadCustomChecks = useCallback(() => {
if (!practiceId) return;
RendicontazioneService.listCustomChecks(practiceId,
(resp) => setCustomChecks(resp?.data?.custom_checks || []),
() => {});
}, [practiceId]);
const load = useCallback(() => {
setLoading(true);
@@ -92,6 +101,7 @@ const IstruttoriaPratica = () => {
}, [practiceId]);
useEffect(() => { load(); }, [load]);
useEffect(() => { loadCustomChecks(); }, [loadCustomChecks]);
const practice = bundle?.practice;
const gate = bundle?.gate_check;
@@ -108,6 +118,9 @@ const IstruttoriaPratica = () => {
const raw = s.required_types || [];
return raw.map(r => typeof r === 'string' ? { code: r, label: r } : r);
}, [sections]);
const customChecksDefs = useMemo(() => {
return practice?.schema_snapshot?.custom_checks || [];
}, [practice]);
const openAmendments = amendments.filter(a => a.status === 'AWAITING' || a.status === 'RESPONSE_RECEIVED');
const isReviewable = practice && ['SUBMITTED', 'UNDER_REVIEW', 'AWAITING_AMENDMENT'].includes(practice.status);
@@ -269,6 +282,45 @@ const IstruttoriaPratica = () => {
}, onErr);
};
// v2: verify custom_check
const verifyCustomCheckInline = (cc, status, notes) => {
RendicontazioneService.verifyCustomCheck(practiceId, cc.code,
{ verification_status: status, verification_notes: notes || null },
(resp) => {
toast.current?.show({ severity: 'success', summary: __('Controllo aggiornato', 'gepafin') });
loadCustomChecks();
}, onErr);
};
const openCcVerifyDialog = (cc, status) => {
setCcVerifyDialog({ visible: true, cc, status, notes: cc.verification_notes || '' });
};
const confirmCcVerify = () => {
const { cc, status, notes } = ccVerifyDialog;
verifyCustomCheckInline(cc, status, notes);
setCcVerifyDialog({ visible: false, cc: null, status: null, notes: '' });
};
const downloadCustomCheckDoc = (cc) => {
RendicontazioneService.fetchCustomCheckDocumentBlob(practiceId, cc.code, 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);
},
(err) => toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail }));
};
const previewCustomCheckDoc = (cc) => {
RendicontazioneService.fetchCustomCheckDocumentBlob(practiceId, cc.code, true,
({ objectUrl }) => {
const w = window.open(objectUrl, '_blank');
if (w) setTimeout(() => URL.revokeObjectURL(objectUrl), 120000);
},
(err) => toast.current?.show({ severity: 'error', summary: __('Errore', 'gepafin'), detail: err?.detail }));
};
// Final notes + checklist (debounced inline save)
const saveFinalNotes = (patch) => {
RendicontazioneService.setInstructorFinalNotes(practiceId, patch, afterOk(__('Verbale aggiornato', 'gepafin')), onErr);
@@ -944,7 +996,110 @@ const IstruttoriaPratica = () => {
</ol>
</div>
{/* VERBALE ISTRUTTORIA */}
{/* VERIFICA CONTROLLI AGGIUNTIVI (v2) */}
{customChecksDefs.length > 0 && (<>
<div className="appPage__spacer"></div>
<div className="appPageSection">
<h2>{__('Verifica controlli aggiuntivi', 'gepafin')}</h2>
<p className="text-color-secondary" style={{ marginTop: 0 }}>
{__('Dichiarazioni aggiuntive del beneficiario. Valida ciascun controllo con VALIDO o NON_VALIDO (richiede motivazione). I controlli obbligatori non dichiarati impediscono l\'approvazione.', 'gepafin')}
</p>
<ol className="appPageSection__list">
{customChecksDefs.map(def => {
const val = customChecks.find(c => c.code === def.code) || {};
const stat = val.verification_status || 'PENDING';
const declared = !!val.beneficiary_declared;
const hasDoc = !!val.filename_original;
const missingRequired = def.required && !declared;
const sevMap = {
PENDING: { severity: 'secondary', label: __('Da verificare', 'gepafin') },
VALIDO: { severity: 'success', label: __('Valido', 'gepafin') },
NON_VALIDO: { severity: 'danger', label: __('Non valido', 'gepafin') }
};
const cfg = sevMap[stat] || sevMap.PENDING;
return (
<li key={def.code} className="appPageSection__listItem">
<div className="appPageSection__listItemRow">
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<strong>{def.label}</strong>
<code style={{ fontSize: '0.85em' }}>{def.code}</code>
{def.required && <Tag severity="warning" value={__('Obbligatorio', 'gepafin')} />}
{declared
? <Tag severity="success" value={__('Dichiarato', 'gepafin')} />
: <Tag severity={missingRequired ? 'danger' : 'secondary'} value={__('Non dichiarato', 'gepafin')} />}
<Tag severity={cfg.severity} value={cfg.label} />
</div>
{def.description && (
<div style={{ marginTop: '0.3rem', fontSize: '0.9em', color: 'var(--text-color-secondary)', whiteSpace: 'pre-wrap' }}>
{def.description}
</div>
)}
{def.requires_document && (
<div style={{ marginTop: '0.4rem', fontSize: '0.9em' }}>
{hasDoc ? (
<span>
<i className="pi pi-file-pdf" style={{ color: 'var(--primary-color)', marginRight: '0.3rem' }} />
<strong>{val.filename_original}</strong>
{val.size_bytes && <small className="text-color-secondary"> ({(val.size_bytes/1024).toFixed(1)} KB)</small>}
</span>
) : (
<span className="text-color-secondary">
<i className="pi pi-file" style={{ marginRight: '0.3rem' }} />
{__('Nessun documento allegato', 'gepafin')}
</span>
)}
</div>
)}
{val.verification_notes && (
<div style={{ marginTop: '0.5rem', padding: '0.4rem 0.6rem', background: 'var(--surface-100)', borderLeft: '3px solid var(--orange-400)', fontSize: '0.9em' }}>
<i className="pi pi-pencil" style={{ marginRight: '0.4rem' }} />{val.verification_notes}
</div>
)}
</div>
<div className="appPageSection__iconActions">
<Button icon="pi pi-eye" rounded outlined severity="info"
disabled={!hasDoc}
onClick={() => previewCustomCheckDoc(val)}
tooltip={__('Anteprima', 'gepafin')} tooltipOptions={{ position: 'top' }} />
<Button icon="pi pi-download" rounded outlined severity="info"
disabled={!hasDoc}
onClick={() => downloadCustomCheckDoc(val)}
tooltip={__('Scarica', 'gepafin')} tooltipOptions={{ position: 'top' }} />
<Button icon="pi pi-thumbs-up" rounded outlined
severity={stat === 'VALIDO' ? 'success' : 'secondary'}
disabled={!isVerifiable}
onClick={() => {
if (stat === 'VALIDO') {
verifyCustomCheckInline({ code: def.code }, 'PENDING', null);
} else {
verifyCustomCheckInline({ code: def.code }, 'VALIDO', val.verification_notes);
}
}}
tooltip={stat === 'VALIDO' ? __('Annulla valido', 'gepafin') : __('Valido', 'gepafin')}
tooltipOptions={{ position: 'top' }} />
<Button icon="pi pi-thumbs-down" rounded outlined
severity={stat === 'NON_VALIDO' ? 'danger' : 'secondary'}
disabled={!isVerifiable}
onClick={() => {
if (stat === 'NON_VALIDO') {
verifyCustomCheckInline({ code: def.code }, 'PENDING', null);
} else {
openCcVerifyDialog({ code: def.code, verification_notes: val.verification_notes }, 'NON_VALIDO');
}
}}
tooltip={stat === 'NON_VALIDO' ? __('Annulla non valido', 'gepafin') : __('Non valido', 'gepafin')}
tooltipOptions={{ position: 'top' }} />
</div>
</div>
</li>
);
})}
</ol>
</div>
</>)}
{/* VERBALE ISTRUTTORIA */}
{isVerifiable && (<>
<div className="appPage__spacer"></div>
<div className="appPageSection" style={{ background: 'var(--surface-50)', padding: '1.25rem', borderRadius: '6px' }}>
@@ -1087,6 +1242,25 @@ const IstruttoriaPratica = () => {
</div>
</form>
</Dialog>
{/* DIALOG VERIFICA CUSTOM CHECK (motivazione NON_VALIDO) */}
<Dialog visible={ccVerifyDialog.visible} style={{ width: '520px' }}
header={__('Marca controllo come non valido', 'gepafin')} modal
onHide={() => setCcVerifyDialog({ visible: false, cc: null, status: null, notes: '' })}>
<form className="appForm p-fluid" onSubmit={(e) => { e.preventDefault(); confirmCcVerify(); }}>
<div className="appForm__field">
<label>{__('Motivazione (obbligatoria)', 'gepafin')}</label>
<InputTextarea value={ccVerifyDialog.notes} rows={4} autoResize
onChange={(e) => setCcVerifyDialog(d => ({ ...d, notes: e.target.value }))}
placeholder={__('Es: dichiarazione non coerente con il bando...', 'gepafin')} />
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1rem' }}>
<Button type="button" outlined label={__('Annulla', 'gepafin')}
onClick={() => setCcVerifyDialog({ visible: false, cc: null, status: null, notes: '' })} />
<Button type="submit" label={__('Conferma', 'gepafin')} icon="pi pi-times" severity="danger"
disabled={!ccVerifyDialog.notes || ccVerifyDialog.notes.trim().length < 5} />
</div>
</form>
</Dialog>
</div>
);
};