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

@@ -76,6 +76,14 @@ const PraticaRendicontazioneEdit = () => {
const [practice, setPractice] = useState(null);
const [loading, setLoading] = useState(true);
const [gate, setGate] = useState(null);
const [customChecks, setCustomChecks] = useState([]); // v2: merge schema+values dal BE
const loadCustomChecks = useCallback(() => {
if (!practiceId) return;
RendicontazioneService.listCustomChecks(practiceId,
(resp) => setCustomChecks(resp?.data?.custom_checks || []),
() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [practiceId]);
// modal fattura
const [invDialog, setInvDialog] = useState({ visible: false, data: null });
@@ -155,6 +163,7 @@ const PraticaRendicontazioneEdit = () => {
};
useEffect(() => { load(); }, [load]);
useEffect(() => { loadCustomChecks(); }, [loadCustomChecks]);
const readOnly = practice && practice.status !== 'DRAFT';
@@ -170,6 +179,10 @@ const PraticaRendicontazioneEdit = () => {
const raw = docsSection.required_types || [];
return raw.map(r => typeof r === 'string' ? { code: r, label: r } : r);
}, [docsSection]);
// v2: custom_checks definition + values (state separato con fetch dedicato)
const customChecksDefs = useMemo(() => {
return practice?.schema_snapshot?.custom_checks || [];
}, [practice]);
const ivaAllowed = useMemo(() => {
const gen = sections.find(x => x.type === 'static_fields');
const ivaField = (gen?.fields || []).find(f => f.id === 'iva_regime');
@@ -253,6 +266,18 @@ const PraticaRendicontazioneEdit = () => {
});
};
// v2: custom_checks
const declareCustomCheck = (code, declared, file) => {
RendicontazioneService.declareCustomCheck(practiceId, code, declared, file,
(resp) => { toast.current?.show({ severity: 'success', summary: __('Controllo aggiornato','gepafin') }); loadCustomChecks(); },
onMutationError);
};
const deleteCustomCheckDoc = (code) => {
RendicontazioneService.deleteCustomCheckDocument(practiceId, code,
(resp) => { toast.current?.show({ severity: 'success', summary: __('Documento rimosso','gepafin') }); loadCustomChecks(); },
onMutationError);
};
// documents
const upsertDocument = (docCode, filename) => {
RendicontazioneService.upsertDocument(practiceId, docCode, { doc_code: docCode, filename },
@@ -617,7 +642,102 @@ const PraticaRendicontazioneEdit = () => {
<div className="appPage__spacer"></div>
{/* BOTTOM ACTIONS */}
{/* SEZIONE 5: CONTROLLI AGGIUNTIVI (v2) */}
{customChecksDefs.length > 0 && (<>
<div className="appPageSection">
<h2>{__((ulaSection.enabled ? '5.' : '4.') + ' Controlli aggiuntivi (dichiarazioni)', 'gepafin')}</h2>
<p className="text-color-secondary" style={{ marginTop: 0 }}>
{__('Dichiarazioni richieste dal bando oltre ai documenti standard. I controlli obbligatori devono essere tutti dichiarati prima di poter inviare la pratica.', 'gepafin')}
</p>
<div className="fieldsRepeater">
{customChecksDefs.map((def) => {
const val = customChecks.find(c => c.code === def.code) || {};
const declared = !!val.beneficiary_declared;
const hasDoc = !!val.filename_original;
const isMissing = def.required && !declared;
return (
<div key={def.code} className="fieldsRepeater__panel"
style={{ border: '1px solid var(--surface-border)', borderRadius: '6px', padding: '1rem',
background: isMissing ? 'var(--red-50)' : 'white' }}>
<div style={{ display:'flex', alignItems:'flex-start', gap:'0.75rem' }}>
<div style={{ flex:'0 0 auto', paddingTop:'4px' }}>
<input type="checkbox"
checked={declared}
disabled={readOnly}
onChange={(e) => declareCustomCheck(def.code, e.target.checked, null)}
style={{ width: '20px', height: '20px', cursor: readOnly ? 'default' : 'pointer' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<strong>{__('Dichiaro', 'gepafin')}: {def.label}</strong>
{def.required && (
<Tag severity={isMissing ? 'danger' : 'success'} value={isMissing ? __('Obbligatorio', 'gepafin') : __('OK', 'gepafin')} />
)}
{!def.required && (
<Tag severity="info" value={__('Opzionale', 'gepafin')} />
)}
{val.verification_status && val.verification_status !== 'PENDING' && (
<Tag severity={val.verification_status === 'VALIDO' ? 'success' : 'danger'}
value={val.verification_status} />
)}
</div>
{def.description && (
<div className="text-color-secondary" style={{ fontSize: '0.9em', marginTop: '0.35rem', whiteSpace: 'pre-wrap' }}>
{def.description}
</div>
)}
{def.requires_document && (
<div style={{ marginTop: '0.75rem', padding: '0.6rem', background: 'var(--surface-50)', borderRadius: '4px' }}>
{hasDoc ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<i className="pi pi-file-pdf" style={{ color: 'var(--primary-color)' }} />
<span style={{ flex: 1, minWidth: '150px' }}>
<strong>{val.filename_original}</strong>
{val.size_bytes && <small className="text-color-secondary"> ({(val.size_bytes/1024).toFixed(1)} KB)</small>}
</span>
{!readOnly && (
<Button icon="pi pi-trash" severity="danger" outlined size="small"
label={__('Rimuovi','gepafin')}
onClick={() => deleteCustomCheckDoc(def.code)} />
)}
</div>
) : (
!readOnly && (
<div>
<small className="text-color-secondary">{__('Allega documento (PDF, JPG, PNG — max 15MB):', 'gepafin')}</small>
<input type="file"
accept="application/pdf,image/jpeg,image/png"
disabled={readOnly}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) declareCustomCheck(def.code, declared, f);
e.target.value = '';
}}
style={{ display: 'block', marginTop: '0.4rem' }} />
</div>
)
)}
</div>
)}
{val.verification_notes && (
<div style={{ marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: val.verification_status === 'NON_VALIDO' ? 'var(--red-50)' : 'var(--surface-50)', borderRadius: '4px', fontSize: '0.85em' }}>
<strong>{__('Note istruttore', 'gepafin')}:</strong> {val.verification_notes}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
<div className="appPage__spacer"></div>
</>)}
{/* BOTTOM ACTIONS */}
{!readOnly && (
<div className="appPageSection">
<div className="appPageSection__actions">