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:
@@ -76,6 +76,8 @@ const Ar1Signature = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const [uploadRecap, setUploadRecap] = useState(null);
|
||||
|
||||
const handleUploadSigned = (event) => {
|
||||
const file = event.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -96,14 +98,14 @@ const Ar1Signature = () => {
|
||||
resetUploadInput();
|
||||
refreshForm();
|
||||
const outcome = resp?.outcome;
|
||||
if (outcome === 'VERIFIED') {
|
||||
if (toast.current) toast.current.show({ severity: 'success', summary: 'Firma verificata!', detail: 'La dichiarazione e stata archiviata nei tuoi documenti aziendali.' });
|
||||
setTimeout(() => navigate('/ar1'), 1500);
|
||||
} else if (outcome === 'SIGNED_NOT_VERIFIED') {
|
||||
if (toast.current) toast.current.show({ severity: 'warn', summary: 'Firma accettata', detail: 'La firma e presente ma richiede verifica manuale da parte dell\'istruttore.' });
|
||||
} 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 sig = resp?.signature || {};
|
||||
const recapByOutcome = {
|
||||
VERIFIED: { severity: 'success', title: __('Firma verificata', 'gepafin'), body: __('La dichiarazione e stata archiviata automaticamente nei tuoi documenti aziendali.', 'gepafin') },
|
||||
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') },
|
||||
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') }
|
||||
};
|
||||
const r = recapByOutcome[outcome];
|
||||
if (r) setUploadRecap({ ...r, outcome, signerName: sig.signer_name, signerCf: sig.signer_cf, method: sig.method });
|
||||
},
|
||||
(err) => {
|
||||
setUploading(false);
|
||||
@@ -136,6 +138,27 @@ const Ar1Signature = () => {
|
||||
<h1>{__('Firma AR1', 'gepafin')} — {form.variant}</h1>
|
||||
<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 }}>
|
||||
{!hasUnsignedPdf && (
|
||||
<div>
|
||||
|
||||
@@ -41,12 +41,90 @@ const Ar1Wizard = () => {
|
||||
[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(() => {
|
||||
if (!formId) return;
|
||||
Ar1Service.getForm(formId,
|
||||
(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);
|
||||
},
|
||||
(err) => {
|
||||
@@ -63,10 +141,15 @@ const Ar1Wizard = () => {
|
||||
const patch = { [quadroId]: quadriValues[quadroId] || {} };
|
||||
setSaving(true);
|
||||
Ar1Service.updateQuadri(formId, patch,
|
||||
(resp) => { setSaving(false); setForm(resp); },
|
||||
(resp) => {
|
||||
setSaving(false); setForm(resp);
|
||||
try { localStorage.removeItem(`ar1_draft_${formId}`); } catch (e) { /* noop */ }
|
||||
},
|
||||
(err) => {
|
||||
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 = () => {
|
||||
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);
|
||||
const patch = { [activeQuadro.id]: quadriValues[activeQuadro.id] || {} };
|
||||
Ar1Service.updateQuadri(formId, patch,
|
||||
@@ -112,18 +210,23 @@ const Ar1Wizard = () => {
|
||||
Ar1Service.submitForSignature(formId,
|
||||
() => {
|
||||
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' });
|
||||
setTimeout(() => navigate(`/ar1/signature/${formId}`), 600);
|
||||
},
|
||||
(err) => {
|
||||
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) => {
|
||||
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}
|
||||
activeIndex={safeIndex}
|
||||
onSelect={(e) => {
|
||||
if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id);
|
||||
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);
|
||||
}}
|
||||
readOnly={false}
|
||||
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); }}>
|
||||
{activeQuadro && renderQuadro(activeQuadro)}
|
||||
</Card>
|
||||
@@ -366,6 +489,7 @@ const Ar1Wizard = () => {
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button label={__('Indietro', 'gepafin')} icon="pi pi-arrow-left" severity="secondary" outlined disabled={activeIndex === 0}
|
||||
onClick={() => {
|
||||
setStepErrors([]);
|
||||
if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id);
|
||||
setActiveIndex(Math.max(0, activeIndex - 1));
|
||||
}}
|
||||
@@ -375,7 +499,16 @@ const Ar1Wizard = () => {
|
||||
{!isLastStep && (
|
||||
<Button label={__('Avanti', 'gepafin')} icon="pi pi-arrow-right" iconPos="right"
|
||||
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));
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user