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