fix(ar1): validazione obbligatori client + recap firma persistente + gestione JWT scaduto

Report Gepafin 3/7/2026:
- Wizard: Avanti/Steps/Procedi-alla-firma bloccati su quadri obbligatori
  incompleti, pannello errori per-campo, consumo errori 422 strutturati BE
- 401 durante autosave (JWT scaduto): bozza in localStorage + avviso sticky,
  ripristino automatico alla riapertura del form in DRAFT
- Signature: recap esito upload p7m/PAdES persistente in pagina (prima:
  toast volatile + redirect dopo 1.5s), dettagli firmatario/metodo,
  navigazione solo su azione utente
This commit is contained in:
Kitzanos
2026-07-03 23:35:26 +02:00
parent 548556d4fc
commit 7289113a8f
2 changed files with 171 additions and 15 deletions

View File

@@ -76,6 +76,8 @@ const Ar1Signature = () => {
} }
}; };
const [uploadRecap, setUploadRecap] = useState(null);
const handleUploadSigned = (event) => { const handleUploadSigned = (event) => {
const file = event.files?.[0]; const file = event.files?.[0];
if (!file) return; if (!file) return;
@@ -96,14 +98,14 @@ const Ar1Signature = () => {
resetUploadInput(); resetUploadInput();
refreshForm(); refreshForm();
const outcome = resp?.outcome; const outcome = resp?.outcome;
if (outcome === 'VERIFIED') { const sig = resp?.signature || {};
if (toast.current) toast.current.show({ severity: 'success', summary: 'Firma verificata!', detail: 'La dichiarazione e stata archiviata nei tuoi documenti aziendali.' }); const recapByOutcome = {
setTimeout(() => navigate('/ar1'), 1500); VERIFIED: { severity: 'success', title: __('Firma verificata', 'gepafin'), body: __('La dichiarazione e stata archiviata automaticamente nei tuoi documenti aziendali.', 'gepafin') },
} else if (outcome === 'SIGNED_NOT_VERIFIED') { SIGNED_NOT_VERIFIED: { severity: 'warn', title: __('Firma accettata — verifica manuale', 'gepafin'), body: __('La firma e presente ma richiede verifica manuale da parte dell\'istruttore.', 'gepafin') },
if (toast.current) toast.current.show({ severity: 'warn', summary: 'Firma accettata', detail: 'La firma e presente ma richiede verifica manuale da parte dell\'istruttore.' }); SIGNED_DOCVERIFY_UNAVAILABLE: { severity: 'warn', title: __('Firma accettata — verifica rimandata', 'gepafin'), body: __('Servizio di verifica momentaneamente non disponibile. L\'istruttore verifichera la firma manualmente.', 'gepafin') }
} else if (outcome === 'SIGNED_DOCVERIFY_UNAVAILABLE') { };
if (toast.current) toast.current.show({ severity: 'warn', summary: 'Verifica rimandata', detail: 'Servizio di verifica momentaneamente non disponibile. L\'istruttore verifichera la firma manualmente.' }); const r = recapByOutcome[outcome];
} if (r) setUploadRecap({ ...r, outcome, signerName: sig.signer_name, signerCf: sig.signer_cf, method: sig.method });
}, },
(err) => { (err) => {
setUploading(false); setUploading(false);
@@ -136,6 +138,27 @@ const Ar1Signature = () => {
<h1>{__('Firma AR1', 'gepafin')} {form.variant}</h1> <h1>{__('Firma AR1', 'gepafin')} {form.variant}</h1>
<div style={{ marginBottom: 20 }}><Ar1StatusTag status={form.status} /></div> <div style={{ marginBottom: 20 }}><Ar1StatusTag status={form.status} /></div>
{uploadRecap && (
<Message severity={uploadRecap.severity} style={{ marginBottom: 14, display: 'block' }} content={
<div style={{ width: '100%' }}>
<b>{uploadRecap.title}</b>
<div style={{ marginTop: 4 }}>{uploadRecap.body}</div>
{(uploadRecap.signerName || uploadRecap.signerCf || uploadRecap.method) && (
<div style={{ marginTop: 6, fontSize: '0.9em' }}>
{uploadRecap.signerName && <span>{__('Firmatario', 'gepafin')}: <b>{uploadRecap.signerName}</b>{' '}</span>}
{uploadRecap.signerCf && <span>({uploadRecap.signerCf}){' '}</span>}
{uploadRecap.method && <span> {uploadRecap.method}</span>}
</div>
)}
{uploadRecap.outcome === 'VERIFIED' && (
<div style={{ marginTop: 10 }}>
<Button label={__('Torna ai documenti', 'gepafin')} icon="pi pi-arrow-left" size="small" onClick={() => navigate('/ar1')} />
</div>
)}
</div>
} />
)}
<Card title={__('1. Scarica il modulo AR1 da firmare', 'gepafin')} style={{ marginBottom: 14 }}> <Card title={__('1. Scarica il modulo AR1 da firmare', 'gepafin')} style={{ marginBottom: 14 }}>
{!hasUnsignedPdf && ( {!hasUnsignedPdf && (
<div> <div>

View File

@@ -41,12 +41,90 @@ const Ar1Wizard = () => {
[form] [form]
); );
const [stepErrors, setStepErrors] = useState([]);
// --- Validazione client obbligatori (specchio di app/validation.py BE) ---
const isEmptyVal = (v) => {
if (v === null || v === undefined) return true;
if (typeof v === 'string') return v.trim() === '';
if (Array.isArray(v)) return v.length === 0;
if (typeof v === 'object') {
if ('value' in v) return isEmptyVal(v.value);
return Object.values(v).every(isEmptyVal);
}
return false;
};
const validateQuadro = (quadro, data) => {
const errs = [];
const d = data || {};
if (!quadro || quadro.is_legal_frame) return errs;
if (quadro.row_type) {
const rows = d.rows || [];
if (rows.length === 0) {
errs.push(`Quadro ${quadro.id}: obbligatoria almeno una riga di dati`);
return errs;
}
rows.forEach((row, i) => {
(quadro.row_fields || []).forEach(f => {
if (f.required && !f.show_if && isEmptyVal((row || {})[f.id])) {
errs.push(`Quadro ${quadro.id} (riga ${i + 1}): ${f.label}`);
}
});
});
return errs;
}
(quadro.upload_slots || []).forEach(sl => {
if (sl.required && isEmptyVal(d[sl.id])) errs.push(`Quadro ${quadro.id}: allegato obbligatorio — ${sl.label}`);
});
(quadro.fields || []).forEach(f => {
if (f.required && !f.show_if && isEmptyVal(d[f.id])) errs.push(`Quadro ${quadro.id}: ${f.label}`);
});
return errs;
};
// detail BE puo essere stringa o {message, errors[]} (validazione 422)
const errDetail = (err) => {
const d = err?.detail;
if (d && typeof d === 'object') {
const list = (d.errors || []).slice(0, 5).join('; ');
return `${d.message || 'Errore'}${list ? ': ' + list : ''}`;
}
return d || 'Errore';
};
// JWT scaduto durante la compilazione: bozza in localStorage + avviso persistente
const backupDraft = () => {
try {
localStorage.setItem(`ar1_draft_${formId}`, JSON.stringify({ ts: Date.now(), quadri: quadriValues }));
} catch (e) { /* quota o private mode: pazienza */ }
};
const handleAuthExpired = () => {
backupDraft();
if (toast.current) toast.current.show({
severity: 'warn', sticky: true,
summary: __('Sessione scaduta', 'gepafin'),
detail: __('Il salvataggio non e riuscito perche la sessione e scaduta. I dati compilati sono conservati in questo browser: effettua di nuovo il login e riapri il modulo per recuperarli.', 'gepafin')
});
};
useEffect(() => { useEffect(() => {
if (!formId) return; if (!formId) return;
Ar1Service.getForm(formId, Ar1Service.getForm(formId,
(resp) => { (resp) => {
setForm(resp); setForm(resp);
setQuadriValues(resp.quadri || {}); let initial = resp.quadri || {};
try {
const raw = localStorage.getItem(`ar1_draft_${formId}`);
if (raw && resp.status === 'DRAFT') {
const draft = JSON.parse(raw);
if (draft && draft.quadri) {
initial = { ...initial, ...draft.quadri };
if (toast.current) toast.current.show({ severity: 'info', summary: 'Bozza recuperata', detail: 'Ripristinati i dati non salvati della sessione precedente.', life: 6000 });
}
}
} catch (e) { /* draft corrotto: ignora */ }
setQuadriValues(initial);
setLoading(false); setLoading(false);
}, },
(err) => { (err) => {
@@ -63,10 +141,15 @@ const Ar1Wizard = () => {
const patch = { [quadroId]: quadriValues[quadroId] || {} }; const patch = { [quadroId]: quadriValues[quadroId] || {} };
setSaving(true); setSaving(true);
Ar1Service.updateQuadri(formId, patch, Ar1Service.updateQuadri(formId, patch,
(resp) => { setSaving(false); setForm(resp); }, (resp) => {
setSaving(false); setForm(resp);
try { localStorage.removeItem(`ar1_draft_${formId}`); } catch (e) { /* noop */ }
},
(err) => { (err) => {
setSaving(false); setSaving(false);
if (toast.current) toast.current.show({ severity: 'warn', summary: 'Save fallito', detail: err?.detail || 'Riprovare' }); if (err?.status === 401) { handleAuthExpired(); return; }
backupDraft();
if (toast.current) toast.current.show({ severity: 'warn', summary: 'Save fallito', detail: errDetail(err) });
} }
); );
}; };
@@ -105,6 +188,21 @@ const Ar1Wizard = () => {
const submitFinale = () => { const submitFinale = () => {
if (!activeQuadro) return; if (!activeQuadro) return;
// Gate client: tutti i quadri obbligatori completi prima della firma
const allErrs = [];
let firstBadIndex = -1;
quadri.forEach((q, i) => {
const errs = validateQuadro(q, quadriValues[q.id]);
if (errs.length && firstBadIndex === -1) firstBadIndex = i;
allErrs.push(...errs);
});
if (allErrs.length) {
setStepErrors(allErrs);
if (firstBadIndex >= 0) setActiveIndex(firstBadIndex);
if (toast.current) toast.current.show({ severity: 'warn', summary: __('Quadri obbligatori incompleti', 'gepafin'), detail: allErrs.slice(0, 4).join(' — '), life: 8000 });
return;
}
setStepErrors([]);
setSubmitting(true); setSubmitting(true);
const patch = { [activeQuadro.id]: quadriValues[activeQuadro.id] || {} }; const patch = { [activeQuadro.id]: quadriValues[activeQuadro.id] || {} };
Ar1Service.updateQuadri(formId, patch, Ar1Service.updateQuadri(formId, patch,
@@ -112,18 +210,23 @@ const Ar1Wizard = () => {
Ar1Service.submitForSignature(formId, Ar1Service.submitForSignature(formId,
() => { () => {
setSubmitting(false); setSubmitting(false);
try { localStorage.removeItem(`ar1_draft_${formId}`); } catch (e) { /* noop */ }
if (toast.current) toast.current.show({ severity: 'success', summary: 'OK', detail: 'Modulo pronto per la firma' }); if (toast.current) toast.current.show({ severity: 'success', summary: 'OK', detail: 'Modulo pronto per la firma' });
setTimeout(() => navigate(`/ar1/signature/${formId}`), 600); setTimeout(() => navigate(`/ar1/signature/${formId}`), 600);
}, },
(err) => { (err) => {
setSubmitting(false); setSubmitting(false);
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Submit fallito' }); if (err?.status === 401) { handleAuthExpired(); return; }
const beErrs = (err?.detail && typeof err.detail === 'object' && err.detail.errors) || [];
if (beErrs.length) setStepErrors(beErrs);
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: errDetail(err) });
} }
); );
}, },
(err) => { (err) => {
setSubmitting(false); setSubmitting(false);
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Save fallito' }); if (err?.status === 401) { handleAuthExpired(); return; }
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: errDetail(err) });
} }
); );
}; };
@@ -351,14 +454,34 @@ const Ar1Wizard = () => {
model={steps} model={steps}
activeIndex={safeIndex} activeIndex={safeIndex}
onSelect={(e) => { onSelect={(e) => {
if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id);
const next = Math.max(0, Math.min(e.index, quadri.length - 1)); const next = Math.max(0, Math.min(e.index, quadri.length - 1));
if (!isReadonly && next > safeIndex) {
const errs = validateQuadro(activeQuadro, quadriValues[activeQuadro.id]);
if (errs.length) {
setStepErrors(errs);
if (toast.current) toast.current.show({ severity: 'warn', summary: __('Campi obbligatori mancanti', 'gepafin'), detail: errs.slice(0, 3).join(' — '), life: 6000 });
return;
}
setStepErrors([]);
}
if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id);
setActiveIndex(next); setActiveIndex(next);
}} }}
readOnly={false} readOnly={false}
style={{ marginBottom: 20 }} style={{ marginBottom: 20 }}
/> />
{stepErrors.length > 0 && (
<Message severity="warn" style={{ marginBottom: 14, display: 'block' }} content={
<div>
<b>{__('Campi obbligatori mancanti', 'gepafin')}:</b>
<ul style={{ margin: '6px 0 0 0', paddingLeft: 18 }}>
{stepErrors.slice(0, 10).map((e, i) => <li key={i}>{e}</li>)}
{stepErrors.length > 10 && <li>{__('...e altri', 'gepafin')} {stepErrors.length - 10}</li>}
</ul>
</div>
} />
)}
<Card style={{ marginBottom: 14 }} onBlur={() => { if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id); }}> <Card style={{ marginBottom: 14 }} onBlur={() => { if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id); }}>
{activeQuadro && renderQuadro(activeQuadro)} {activeQuadro && renderQuadro(activeQuadro)}
</Card> </Card>
@@ -366,6 +489,7 @@ const Ar1Wizard = () => {
<div style={{ display: 'flex', justifyContent: 'space-between' }}> <div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button label={__('Indietro', 'gepafin')} icon="pi pi-arrow-left" severity="secondary" outlined disabled={activeIndex === 0} <Button label={__('Indietro', 'gepafin')} icon="pi pi-arrow-left" severity="secondary" outlined disabled={activeIndex === 0}
onClick={() => { onClick={() => {
setStepErrors([]);
if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id); if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id);
setActiveIndex(Math.max(0, activeIndex - 1)); setActiveIndex(Math.max(0, activeIndex - 1));
}} }}
@@ -375,7 +499,16 @@ const Ar1Wizard = () => {
{!isLastStep && ( {!isLastStep && (
<Button label={__('Avanti', 'gepafin')} icon="pi pi-arrow-right" iconPos="right" <Button label={__('Avanti', 'gepafin')} icon="pi pi-arrow-right" iconPos="right"
onClick={() => { onClick={() => {
if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id); if (!isReadonly) {
const errs = validateQuadro(activeQuadro, quadriValues[activeQuadro.id]);
if (errs.length) {
setStepErrors(errs);
if (toast.current) toast.current.show({ severity: 'warn', summary: __('Campi obbligatori mancanti', 'gepafin'), detail: errs.slice(0, 3).join(' — '), life: 6000 });
return;
}
setStepErrors([]);
saveQuadro(activeQuadro.id);
}
setActiveIndex(Math.min(quadri.length - 1, activeIndex + 1)); setActiveIndex(Math.min(quadri.length - 1, activeIndex + 1));
}} }}
/> />