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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user