Compare commits

...

6 Commits

Author SHA1 Message Date
Kitzanos
abf415487d feat(ar1): accorgimenti Leonardo 6/7 — datepicker no-passato + rimozione Scade tra X giorni
- #1 Calendar: minDate=oggi sui campi date con min_today (scadenza documento
  non selezionabile nel passato)
- #6 Ar1Home: rimossa la dicitura Scade tra X giorni / Scaduta da X giorni
2026-07-06 17:04:49 +02:00
Kitzanos
05cf15cad7 fix(ar1): R7 nominativo coincidenza C/D + datepicker timezone + draft continuo + cosmetici
Round 2 test UI 4/7/2026:
- R7 (regressione): campo nominativo di coincidenza (C/D) reso con blocco
  dedicato renderCoincidenceRef, indipendente dal ramo show_if fragile che
  aveva propagato la rotttura; Dropdown editabile prefillato dai titolari del
  Quadro B, valore sempre persistito -> presente in UI e PDF
- datepicker: commit della data in locale (YYYY-MM-DD da get*()) invece di
  toISOString, che shiftava -1 giorno in CET (01/01/1980 -> 1979-12-31)
- R6: con autosave sospeso il draft locale segue ogni modifica (non piu solo
  lo snapshot al 401): niente perdita dei dati inseriti dopo il banner
- cosmetico R1: pannello errori azzerato al cambio step valido / Avanti
2026-07-04 11:06:08 +02:00
Kitzanos
6dc715d997 fix(ar1): esiti test UI 4/7 — barra Steps, banner 401, recap firma, note su NO
Report test UI 4/7/2026 (P1, P2a, P3, P6, P7):
- P1: salto in avanti dalla barra Steps valida TUTTI i quadri intermedi
  (validateRange) e posiziona sul primo incompleto; indietro sempre libero
- P2a: selezione No azzera la nota nel payload (niente note orfane persistite)
- P3: Firma non rilevata come banner fisso in pagina (pattern recap), non toast
- P6: banner sessione scaduta unico (ref dedup) + autosave sospeso dopo il
  primo 401 (solo backup locale, niente raffiche di PUT)
- P7: Bozza recuperata come Message in pagina con dismiss, non toast volatile
2026-07-04 08:43:54 +02:00
Kitzanos
6e5e8c61f5 feat(ar1): show_if nel wizard + dedup dettaglio Quadro C/D
- evaluator show_if (== \x27x\x27, == true, != true): Quadro E mostra le sezioni
  1a-2b solo con motivo B; campi condizionali coerenti col seed
- nested_full visibile e validato solo se la condizione vale: LR gia
  indicato tra i TE del Quadro B non richiede il dettaglio aggiuntivo
2026-07-03 23:43:23 +02:00
Kitzanos
7289113a8f 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
2026-07-03 23:35:26 +02:00
548556d4fc fix(ar1): popup compliance solo per ROLE_BENEFICIARY/ROLE_CONFIDI
Il popup 'Dichiarazione AR1 - Adeguata Verifica' veniva mostrato anche
ad admin / istruttore / direttore. AR1 (D.Lgs.231/2007) si applica solo
alle aziende beneficiarie: admin, istruttore manager, pre-istruttore
e direttore non hanno una azienda da dichiarare.

- whitelist AR1_POPUP_ALLOWED_ROLES = [ROLE_BENEFICIARY, ROLE_CONFIDI]
- gate role nel useEffect (exit early se ruolo non ammesso)
- role aggiunto a deps array

Difesa in profondita: il gate vive dentro il componente, quindi resta
attivo a prescindere da come/dove viene montato a monte.
2026-05-08 07:55:55 +02:00
4 changed files with 296 additions and 38 deletions

View File

@@ -6,10 +6,15 @@ import { Button } from 'primereact/button';
import { Message } from 'primereact/message';
import Ar1Service from '../service/ar1Service';
import Ar1StatusTag from './Ar1StatusTag';
import { useStoreValue } from '../../../store';
const DISMISS_SESSION_KEY_PREFIX = 'ar1-compliance-dismissed-';
const DISMISS_WINDOW_HOURS = 24;
// AR1 (D.Lgs.231/2007) si applica solo alle aziende beneficiarie.
// Admin / istruttore / direttore NON devono ricevere il popup.
const AR1_POPUP_ALLOWED_ROLES = ['ROLE_BENEFICIARY', 'ROLE_CONFIDI'];
/**
* Dialog AR1 mostrato al login se l'azienda ha AR1 MISSING/EXPIRED/APPROACHING.
* - dismissable=false (EXPIRED/MISSING): bloccante, solo CTA "Compila ora"
@@ -20,11 +25,17 @@ const DISMISS_WINDOW_HOURS = 24;
*/
const Ar1ComplianceModal = ({ companyId }) => {
const navigate = useNavigate();
const role = useStoreValue('getRole');
const [status, setStatus] = useState(null);
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Gate ruolo: solo aziende vedono il popup AR1.
if (!AR1_POPUP_ALLOWED_ROLES.includes(role)) {
setLoading(false);
return;
}
if (!companyId) return;
const dismissKey = DISMISS_SESSION_KEY_PREFIX + companyId;
const dismissed = sessionStorage.getItem(dismissKey);
@@ -50,7 +61,7 @@ const Ar1ComplianceModal = ({ companyId }) => {
console.warn('Ar1ComplianceModal: status check failed', err);
}
);
}, [companyId]);
}, [companyId, role]);
const handleDismiss = () => {
if (!status?.is_popup_dismissible) return;

View File

@@ -106,7 +106,6 @@ const Ar1Home = () => {
const renderStatusCard = () => {
if (!status) return null;
const isUrgent = ['MISSING', 'EXPIRED'].includes(status.status);
const canCompile = ['MISSING', 'EXPIRED', 'APPROACHING', 'VALID'].includes(status.status);
const hasActive = status.form_id && ['DRAFT', 'AWAITING_SIGNATURE'].includes(status.status);
@@ -119,13 +118,7 @@ const Ar1Home = () => {
{__('Variante:', 'gepafin')} <strong>{status.variant}</strong>
</span>
)}
{status.days_to_expiry !== null && status.days_to_expiry !== undefined && (
<span style={{ color: isUrgent ? '#b71c1c' : '#444', fontWeight: 600 }}>
{status.days_to_expiry < 0
? __(`Scaduta da ${Math.abs(status.days_to_expiry)} giorni`, 'gepafin')
: __(`Scade tra ${status.days_to_expiry} giorni`, 'gepafin')}
</span>
)}
{/* "Scade tra X giorni" / "Scaduta da X giorni" rimosso su richiesta Leonardo 6/7/2026 */}
</div>
{status.must_recompile_reason && (

View File

@@ -76,7 +76,10 @@ const Ar1Signature = () => {
}
};
const [uploadRecap, setUploadRecap] = useState(null);
const handleUploadSigned = (event) => {
setUploadRecap(null);
const file = event.files?.[0];
if (!file) return;
@@ -96,27 +99,30 @@ 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);
resetUploadInput();
if (err?.detail?.code === 'NO_SIGNATURE_DETECTED') {
if (toast.current) toast.current.show({
severity: 'error',
summary: 'Firma non rilevata',
detail: 'Il file caricato non contiene una firma digitale valida. Firmare il PDF con il proprio strumento FEQ e ricaricarlo.',
life: 6000
setUploadRecap({
severity: 'error', outcome: 'NO_SIGNATURE_DETECTED',
title: __('Firma non rilevata', 'gepafin'),
body: __('Il file caricato non contiene una firma digitale valida. Firmare il PDF con il proprio strumento FEQ (CAdES .p7m o PAdES) e ricaricarlo.', 'gepafin')
});
} else {
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: typeof err?.detail === 'string' ? err.detail : (err?.detail?.message || 'Upload fallito') });
setUploadRecap({
severity: 'error', outcome: 'UPLOAD_ERROR',
title: __('Errore', 'gepafin'),
body: typeof err?.detail === 'string' ? err.detail : (err?.detail?.message || __('Upload fallito', 'gepafin'))
});
}
}
);
@@ -136,6 +142,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>

View File

@@ -41,12 +41,121 @@ const Ar1Wizard = () => {
[form]
);
const [stepErrors, setStepErrors] = useState([]);
const [authExpired, setAuthExpired] = useState(false);
const authExpiredRef = useRef(false);
const [draftNotice, setDraftNotice] = useState(null);
// --- 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;
};
// show_if formati seed: "campo == 'VALORE'", "campo == true", "campo != true"
const evalShowIf = (expr, quadroData) => {
if (!expr) return true;
const m = String(expr).match(/^\s*(\w+)\s*(==|!=)\s*(?:'(.*)'|(true|false))\s*$/);
if (!m) return true; // formato ignoto: non nascondere mai per errore
const raw = (quadroData || {})[m[1]];
const val = raw && typeof raw === 'object' && 'value' in raw ? raw.value : raw;
const target = m[4] !== undefined ? (m[4] === 'true') : m[3];
const eq = m[4] !== undefined ? (!!val === target) : (String(val ?? '') === target);
return m[2] === '==' ? eq : !eq;
};
const isFieldVisible = (field, quadroData) => evalShowIf(field.show_if, quadroData);
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 && isFieldVisible(f, d) && isEmptyVal(d[f.id])) errs.push(`Quadro ${quadro.id}: ${f.label}`);
});
if (quadro.nested_full && evalShowIf(quadro.nested_full.show_if, d)) {
const nd = d.nested || {};
(quadro.nested_full.fields || []).forEach(f => {
if (f.required && isFieldVisible(f, nd) && isEmptyVal(nd[f.id])) errs.push(`Quadro ${quadro.id} (dettaglio): ${f.label}`);
});
}
return errs;
};
// Valida tutti i quadri in [from, to): al salto in avanti nessun quadro
// intermedio puo essere incompleto (report test 4/7/2026 P1)
const validateRange = (fromIdx, toIdx) => {
for (let i = fromIdx; i < toIdx; i++) {
const q = quadri[i];
const errs = validateQuadro(q, quadriValues[q.id]);
if (errs.length) return { errs, badIndex: i };
}
return null;
};
// 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 (authExpiredRef.current) return; // banner unico, niente pile di avvisi (P6)
authExpiredRef.current = true;
setAuthExpired(true);
};
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 };
setDraftNotice(__('Bozza recuperata: ripristinati i dati non salvati della sessione precedente.', 'gepafin'));
}
}
} catch (e) { /* draft corrotto: ignora */ }
setQuadriValues(initial);
setLoading(false);
},
(err) => {
@@ -60,22 +169,33 @@ const Ar1Wizard = () => {
const saveQuadro = (quadroId) => {
if (isReadonly) return;
if (authExpiredRef.current) { backupDraft(); return; } // 401 gia noto: solo backup locale
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) });
}
);
};
const handleFieldChange = (quadroId, fieldId, value) => {
setQuadriValues(prev => ({
...prev,
[quadroId]: { ...(prev[quadroId] || {}), [fieldId]: value }
}));
setQuadriValues(prev => {
const next = { ...prev, [quadroId]: { ...(prev[quadroId] || {}), [fieldId]: value } };
// Autosave sospeso (post-401): il draft locale segue OGNI modifica, non solo
// lo snapshot iniziale (fix R6 — niente perdita dei dati inseriti dopo il banner)
if (authExpiredRef.current) {
try { localStorage.setItem(`ar1_draft_${formId}`, JSON.stringify({ ts: Date.now(), quadri: next })); } catch (e) { /* quota */ }
}
return next;
});
};
const handleRowFieldChange = (quadroId, rowIndex, fieldId, value) => {
@@ -105,6 +225,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 +247,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) });
}
);
};
@@ -163,7 +303,7 @@ const Ar1Wizard = () => {
return (
<div key={key} style={{ marginBottom: 14 }}>
{commonLabel}
<Calendar id={key} value={value ? new Date(value) : null} onChange={(e) => onChange(field.id, e.value ? e.value.toISOString().slice(0, 10) : null)} disabled={disabled} dateFormat="dd/mm/yy" showIcon style={{ width: '100%' }} />
<Calendar id={key} value={value ? new Date(value + 'T00:00:00') : null} onChange={(e) => onChange(field.id, e.value ? `${e.value.getFullYear()}-${String(e.value.getMonth() + 1).padStart(2, '0')}-${String(e.value.getDate()).padStart(2, '0')}` : null)} disabled={disabled} minDate={field.min_today ? (() => { const t = new Date(); t.setHours(0,0,0,0); return t; })() : undefined} dateFormat="dd/mm/yy" showIcon style={{ width: '100%' }} />
</div>
);
case 'checkbox':
@@ -211,7 +351,7 @@ const Ar1Wizard = () => {
<label htmlFor={`${key}-yes`}>{__('Si', 'gepafin')}</label>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<RadioButton inputId={`${key}-no`} checked={no} onChange={() => onChange(field.id, { ...v, value: 'no' })} disabled={disabled} />
<RadioButton inputId={`${key}-no`} checked={no} onChange={() => onChange(field.id, { value: 'no' })} disabled={disabled} />
<label htmlFor={`${key}-no`}>{__('No', 'gepafin')}</label>
</div>
</div>
@@ -238,6 +378,43 @@ const Ar1Wizard = () => {
}
};
// Nominativi titolari effettivi dal Quadro B (per prefill coincidenza LR/esecutore)
const titolariB = useMemo(() => {
const b = quadriValues['B'] || {};
const rows = b.rows || [];
return rows.map(r => (r && r.cognome_nome ? String(r.cognome_nome).trim() : '')).filter(Boolean);
}, [quadriValues]);
// Campo nominativo di coincidenza (Quadro C: lr_te_reference, D: esecutore_lr_reference).
// Reso SOLO se la spunta di coincidenza e attiva; il valore e sempre persistito
// cosi da comparire nel PDF (fix regressione R7, modulo 231/2007 mai senza nominativo).
const renderCoincidenceRef = (quadro, field, q) => {
if (!isFieldVisible(field, q)) return null; // spunta OFF: se ne occupa il nested_full
const key = `q-${quadro.id}-${field.id}`;
const val = q[field.id] || '';
const onSet = (v) => handleFieldChange(quadro.id, field.id, v);
return (
<div key={key} style={{ marginBottom: 14 }}>
<label htmlFor={key} style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>{field.label}{field.required ? ' *' : ''}</label>
{titolariB.length > 0 ? (
<Dropdown
id={key}
value={val}
options={titolariB.map(n => ({ label: n, value: n }))}
onChange={(e) => onSet(e.value)}
disabled={isReadonly}
placeholder={__('Seleziona dal Quadro B', 'gepafin')}
editable
style={{ width: '100%' }}
/>
) : (
<InputText id={key} value={val} onChange={(e) => onSet(e.target.value)} disabled={isReadonly} maxLength={field.max_length} placeholder={__('Cognome e Nome', 'gepafin')} style={{ width: '100%' }} />
)}
<small style={{ color: '#888' }}>{__('Compilare con uno dei titolari effettivi indicati al Quadro B.', 'gepafin')}</small>
</div>
);
};
const renderQuadro = (quadro) => {
const q = quadriValues[quadro.id] || {};
@@ -313,8 +490,9 @@ const Ar1Wizard = () => {
<div>
<h3>{quadro.title}</h3>
{quadro.description && <p style={{ color: '#666' }}>{quadro.description}</p>}
{(quadro.fields || []).map(field => renderField(field, q[field.id], (fid, val) => handleFieldChange(quadro.id, fid, val), `q-${quadro.id}`))}
{quadro.nested_full && (
{(quadro.fields || []).filter(field => isFieldVisible(field, q) && !field.coincidence_ref).map(field => renderField(field, q[field.id], (fid, val) => handleFieldChange(quadro.id, fid, val), `q-${quadro.id}`))}
{(quadro.fields || []).filter(field => field.coincidence_ref).map(field => renderCoincidenceRef(quadro, field, q))}
{quadro.nested_full && evalShowIf(quadro.nested_full.show_if, q) && (
<div style={{ marginTop: 16, padding: 12, background: '#f5f5f5', borderRadius: 4 }}>
<h4 style={{ marginTop: 0 }}>{__('Dettaglio aggiuntivo', 'gepafin')}</h4>
{(quadro.nested_full.fields || []).map(field => renderField(
@@ -351,14 +529,52 @@ 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 bad = validateRange(safeIndex, next);
if (bad) {
setStepErrors(bad.errs);
setActiveIndex(bad.badIndex);
if (toast.current) toast.current.show({ severity: 'warn', summary: __('Campi obbligatori mancanti', 'gepafin'), detail: bad.errs.slice(0, 3).join(' — '), life: 6000 });
return;
}
setStepErrors([]);
}
if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id);
setStepErrors([]);
setActiveIndex(next);
}}
readOnly={false}
style={{ marginBottom: 20 }}
/>
{authExpired && (
<Message severity="warn" style={{ marginBottom: 14, display: 'block' }} content={
<div>
<b>{__('Sessione scaduta', 'gepafin')}</b>
<div style={{ marginTop: 4 }}>{__('Il salvataggio automatico e sospeso: i dati compilati sono conservati in questo browser. Effettua di nuovo il login e riapri il modulo per recuperarli e continuare.', 'gepafin')}</div>
</div>
} />
)}
{draftNotice && !authExpired && (
<Message severity="info" style={{ marginBottom: 14, display: 'block' }} content={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<span>{draftNotice}</span>
<Button icon="pi pi-times" text size="small" onClick={() => setDraftNotice(null)} />
</div>
} />
)}
{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 +582,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 +592,17 @@ 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);
}
setStepErrors([]);
setActiveIndex(Math.min(quadri.length - 1, activeIndex + 1));
}}
/>