diff --git a/.env b/.env
index 80c96c4..a7fb491 100644
--- a/.env
+++ b/.env
@@ -9,3 +9,5 @@ REACT_APP_FAVICON_FILENAME=gepafin-favicon.ico
REACT_APP_HUB_ID=p4lk3bcx1RStqTaIVVbXs
REACT_APP_EVALUATION_FLOW_ID=1
REACT_APP_LOCAL_DEVELOPMENT=1
+REACT_APP_AR1_API_URL=http://78.46.41.91:18091
+REACT_APP_RENDICONTAZIONE_API_URL=http://78.46.41.91:18090
diff --git a/src/layouts/DefaultLayout/components/AppSidebar/index.js b/src/layouts/DefaultLayout/components/AppSidebar/index.js
index 2c77e79..9de0681 100644
--- a/src/layouts/DefaultLayout/components/AppSidebar/index.js
+++ b/src/layouts/DefaultLayout/components/AppSidebar/index.js
@@ -55,6 +55,13 @@ const AppSidebar = () => {
id: 3,
enable: intersection(permissions, ['APPLY_CALLS', 'APPLY_CONFIDI_CALLS']).length
},
+ {
+ label: __('Dichiarazione AR1', 'gepafin'),
+ icon: 'pi pi-id-card',
+ href: '/ar1',
+ id: 22,
+ enable: intersection(permissions, ['APPLY_CALLS', 'APPLY_CONFIDI_CALLS']).length
+ },
{
label: __('Bandi disponibili', 'gepafin'),
icon: 'pi pi-bookmark',
diff --git a/src/modules/ar1/components/Ar1ComplianceModal.js b/src/modules/ar1/components/Ar1ComplianceModal.js
new file mode 100644
index 0000000..8538922
--- /dev/null
+++ b/src/modules/ar1/components/Ar1ComplianceModal.js
@@ -0,0 +1,137 @@
+import React, { useEffect, useState } from 'react';
+import { __ } from '@wordpress/i18n';
+import { useNavigate } from 'react-router-dom';
+import { Dialog } from 'primereact/dialog';
+import { Button } from 'primereact/button';
+import { Message } from 'primereact/message';
+import Ar1Service from '../service/ar1Service';
+import Ar1StatusTag from './Ar1StatusTag';
+
+const DISMISS_SESSION_KEY_PREFIX = 'ar1-compliance-dismissed-';
+const DISMISS_WINDOW_HOURS = 24;
+
+/**
+ * Dialog AR1 mostrato al login se l'azienda ha AR1 MISSING/EXPIRED/APPROACHING.
+ * - dismissable=false (EXPIRED/MISSING): bloccante, solo CTA "Compila ora"
+ * - dismissable=true (APPROACHING): X chiude + salva in sessionStorage 24h
+ *
+ * Da montare nel layout principale. Esempio:
+ *
+ */
+const Ar1ComplianceModal = ({ companyId }) => {
+ const navigate = useNavigate();
+ const [status, setStatus] = useState(null);
+ const [visible, setVisible] = useState(false);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ if (!companyId) return;
+ const dismissKey = DISMISS_SESSION_KEY_PREFIX + companyId;
+ const dismissed = sessionStorage.getItem(dismissKey);
+ if (dismissed) {
+ const dismissedAt = parseInt(dismissed, 10);
+ if (Date.now() - dismissedAt < DISMISS_WINDOW_HOURS * 3600 * 1000) {
+ setLoading(false);
+ return;
+ }
+ }
+
+ Ar1Service.getStatusForCompany(companyId,
+ (resp) => {
+ setLoading(false);
+ const showFor = ['MISSING', 'EXPIRED', 'APPROACHING'];
+ if (resp && showFor.includes(resp.status)) {
+ setStatus(resp);
+ setVisible(true);
+ }
+ },
+ (err) => {
+ setLoading(false);
+ console.warn('Ar1ComplianceModal: status check failed', err);
+ }
+ );
+ }, [companyId]);
+
+ const handleDismiss = () => {
+ if (!status?.is_popup_dismissible) return;
+ sessionStorage.setItem(DISMISS_SESSION_KEY_PREFIX + companyId, Date.now().toString());
+ setVisible(false);
+ };
+
+ const goToCompile = () => {
+ setVisible(false);
+ navigate('/ar1');
+ };
+
+ if (loading || !status) return null;
+
+ const canDismiss = status.is_popup_dismissible;
+ const isUrgent = status.status === 'EXPIRED' || status.status === 'MISSING';
+
+ return (
+
+ );
+};
+
+export default Ar1ComplianceModal;
diff --git a/src/modules/ar1/components/Ar1StatusTag.js b/src/modules/ar1/components/Ar1StatusTag.js
new file mode 100644
index 0000000..2fb4fbf
--- /dev/null
+++ b/src/modules/ar1/components/Ar1StatusTag.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import { __ } from '@wordpress/i18n';
+import { Tag } from 'primereact/tag';
+
+/**
+ * Badge per lo status AR1. Stati possibili:
+ * MISSING, DRAFT, AWAITING_SIGNATURE, SIGNED, VERIFIED, VALID, APPROACHING, EXPIRED, SUPERSEDED
+ */
+const STATUS_CONFIG = {
+ MISSING: { severity: 'danger', label: 'Da compilare', icon: 'pi pi-exclamation-circle' },
+ DRAFT: { severity: 'warning', label: 'Bozza in corso', icon: 'pi pi-pencil' },
+ AWAITING_SIGNATURE: { severity: 'info', label: 'Attesa firma', icon: 'pi pi-hourglass' },
+ SIGNED: { severity: 'info', label: 'Firmato', icon: 'pi pi-verified' },
+ VERIFIED: { severity: 'success', label: 'Verificato', icon: 'pi pi-check-circle' },
+ VALID: { severity: 'success', label: 'Valido', icon: 'pi pi-check-circle' },
+ APPROACHING: { severity: 'warning', label: 'In scadenza', icon: 'pi pi-clock' },
+ EXPIRED: { severity: 'danger', label: 'Scaduto', icon: 'pi pi-times-circle' },
+ SUPERSEDED: { severity: 'secondary', label: 'Sostituito', icon: 'pi pi-history' },
+};
+
+const Ar1StatusTag = ({ status }) => {
+ const cfg = STATUS_CONFIG[status] || { severity: 'secondary', label: status || '—', icon: 'pi pi-circle' };
+ return ;
+};
+
+export default Ar1StatusTag;
diff --git a/src/modules/ar1/pages/Ar1Home.js b/src/modules/ar1/pages/Ar1Home.js
new file mode 100644
index 0000000..df9fac8
--- /dev/null
+++ b/src/modules/ar1/pages/Ar1Home.js
@@ -0,0 +1,248 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { __ } from '@wordpress/i18n';
+import { useNavigate } from 'react-router-dom';
+
+import { Card } from 'primereact/card';
+import { Button } from 'primereact/button';
+import { Toast } from 'primereact/toast';
+import { DataTable } from 'primereact/datatable';
+import { Column } from 'primereact/column';
+import { ConfirmDialog, confirmDialog } from 'primereact/confirmdialog';
+import { Dropdown } from 'primereact/dropdown';
+import { Dialog } from 'primereact/dialog';
+
+import { useStoreValue } from '../../../store';
+
+import Ar1Service from '../service/ar1Service';
+import Ar1StatusTag from '../components/Ar1StatusTag';
+
+const VARIANT_OPTIONS = [
+ { label: 'A1 — Persona Giuridica (societa, ente)', value: 'A1' },
+ { label: 'A2 — Ditta Individuale (P.IVA persona fisica)', value: 'A2' },
+ { label: 'A3 — Persona Fisica (senza P.IVA)', value: 'A3' },
+];
+
+/**
+ * Ar1Home: schermata principale modulo AR1 per il beneficiario.
+ * - Card status con countdown
+ * - CTA dinamici (Compila / Riprendi / Firma / Rinnova)
+ * - Storico dichiarazioni
+ */
+const Ar1Home = () => {
+ const navigate = useNavigate();
+ const toast = useRef(null);
+ const user = useStoreValue('getUser');
+ const companyId = user?.companyId;
+
+ const [status, setStatus] = useState(null);
+ const [history, setHistory] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [variantDialogOpen, setVariantDialogOpen] = useState(false);
+ const [selectedVariant, setSelectedVariant] = useState('A1');
+ const [creating, setCreating] = useState(false);
+
+ const loadAll = () => {
+ if (!companyId) return;
+ setLoading(true);
+ Ar1Service.getStatusForCompany(companyId,
+ (resp) => setStatus(resp),
+ (err) => console.warn('getStatus failed', err)
+ );
+ Ar1Service.listFormsForCompany(companyId,
+ (resp) => {
+ setHistory(resp?.items || []);
+ setLoading(false);
+ },
+ (err) => {
+ setLoading(false);
+ if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Impossibile caricare lo storico' });
+ }
+ );
+ };
+
+ useEffect(() => {
+ loadAll();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [companyId]);
+
+ const startNewDraft = () => {
+ setCreating(true);
+ Ar1Service.createDraft(companyId, selectedVariant,
+ (resp) => {
+ setCreating(false);
+ setVariantDialogOpen(false);
+ if (resp?.id) navigate(`/ar1/wizard/${resp.id}`);
+ },
+ (err) => {
+ setCreating(false);
+ if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Impossibile creare il form' });
+ }
+ );
+ };
+
+ const resumeForm = (formId) => navigate(`/ar1/wizard/${formId}`);
+ const goToSignature = (formId) => navigate(`/ar1/signature/${formId}`);
+
+ const deleteDraft = (formId) => {
+ confirmDialog({
+ message: __('Sei sicuro di voler eliminare questa bozza? L\'operazione non puo essere annullata.', 'gepafin'),
+ header: __('Conferma eliminazione', 'gepafin'),
+ icon: 'pi pi-exclamation-triangle',
+ acceptLabel: __('Elimina', 'gepafin'),
+ rejectLabel: __('Annulla', 'gepafin'),
+ acceptClassName: 'p-button-danger',
+ accept: () => {
+ Ar1Service.deleteForm(formId,
+ () => {
+ if (toast.current) toast.current.show({ severity: 'success', summary: 'OK', detail: 'Bozza eliminata' });
+ loadAll();
+ },
+ (err) => {
+ if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Eliminazione fallita' });
+ }
+ );
+ }
+ });
+ };
+
+ 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);
+
+ return (
+
+
+
+ {status.variant && (
+
+ {__('Variante:', 'gepafin')} {status.variant}
+
+ )}
+ {status.days_to_expiry !== null && status.days_to_expiry !== undefined && (
+
+ {status.days_to_expiry < 0
+ ? __(`Scaduta da ${Math.abs(status.days_to_expiry)} giorni`, 'gepafin')
+ : __(`Scade tra ${status.days_to_expiry} giorni`, 'gepafin')}
+
+ )}
+
+
+ {status.must_recompile_reason && (
+
+
+ {status.must_recompile_reason}
+
+ )}
+
+
+ {hasActive && status.status === 'DRAFT' && (
+
+
+ );
+ };
+
+ const statusTpl = (row) => ;
+ const dateTpl = (row, field) => {
+ const v = row[field];
+ if (!v) return '—';
+ try { return new Date(v).toLocaleDateString('it-IT'); } catch (e) { return v; }
+ };
+ const actionsTpl = (row) => (
+
+ {row.status === 'DRAFT' && (
+ <>
+ resumeForm(row.id)} tooltip={__('Riprendi', 'gepafin')} />
+ deleteDraft(row.id)} tooltip={__('Elimina', 'gepafin')} />
+ >
+ )}
+ {row.status === 'AWAITING_SIGNATURE' && (
+ goToSignature(row.id)} tooltip={__('Firma', 'gepafin')} />
+ )}
+ {['SIGNED', 'VERIFIED', 'EXPIRED'].includes(row.status) && (
+ {
+ try {
+ const blob = await Ar1Service.downloadPdfSigned(row.id);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `AR1_${row.variant}_signed.pdf`;
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch (e) {
+ if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: e.message });
+ }
+ }} tooltip={__('Scarica firmato', 'gepafin')} />
+ )}
+
+ );
+
+ return (
+
+
+
+
+
{__('Dichiarazione AR1 — Adeguata Verifica', 'gepafin')}
+
+ {__('Modulo di aggiornamento dell\'adeguata verifica ai sensi del D.Lgs. 231/2007 (normativa antiriciclaggio).', 'gepafin')}
+
+
+ {renderStatusCard()}
+
+
+ 10}
+ rows={10}
+ >
+
+
+
+ dateTpl(r, 'created_at')} />
+ dateTpl(r, 'signed_at')} />
+ dateTpl(r, 'expires_at')} />
+
+
+
+
+
+
+ );
+};
+
+export default Ar1Home;
diff --git a/src/modules/ar1/pages/Ar1Signature.js b/src/modules/ar1/pages/Ar1Signature.js
new file mode 100644
index 0000000..f79bdfb
--- /dev/null
+++ b/src/modules/ar1/pages/Ar1Signature.js
@@ -0,0 +1,210 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { __ } from '@wordpress/i18n';
+import { useNavigate, useParams } from 'react-router-dom';
+
+import { Card } from 'primereact/card';
+import { Button } from 'primereact/button';
+import { Toast } from 'primereact/toast';
+import { FileUpload } from 'primereact/fileupload';
+import { Message } from 'primereact/message';
+import { ProgressSpinner } from 'primereact/progressspinner';
+
+import Ar1Service from '../service/ar1Service';
+import Ar1StatusTag from '../components/Ar1StatusTag';
+
+/**
+ * Pagina firma AR1.
+ * URL: /ar1/signature/:formId
+ *
+ * Flusso: genera PDF → download unsigned → firma FEQ client side → upload signed → DocVerify.
+ */
+const Ar1Signature = () => {
+ const { formId } = useParams();
+ const navigate = useNavigate();
+ const toast = useRef(null);
+
+ const [form, setForm] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [generating, setGenerating] = useState(false);
+ const [uploading, setUploading] = useState(false);
+
+ const refreshForm = () => {
+ Ar1Service.getForm(formId,
+ (resp) => { setForm(resp); setLoading(false); },
+ (err) => {
+ setLoading(false);
+ if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Form non trovato' });
+ }
+ );
+ };
+
+ useEffect(() => { if (formId) refreshForm(); /* eslint-disable-next-line */ }, [formId]);
+
+ const handleGeneratePdf = () => {
+ setGenerating(true);
+ Ar1Service.generatePdf(formId,
+ () => {
+ setGenerating(false);
+ refreshForm();
+ if (toast.current) toast.current.show({ severity: 'success', summary: 'OK', detail: 'PDF generato' });
+ },
+ (err) => {
+ setGenerating(false);
+ if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Generazione PDF fallita' });
+ }
+ );
+ };
+
+ const handleDownloadUnsigned = async () => {
+ try {
+ const blob = await Ar1Service.downloadPdfUnsigned(formId);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `AR1_${form.variant}_da-firmare.pdf`;
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch (e) {
+ if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: e.message });
+ }
+ };
+
+ const handleUploadSigned = (event) => {
+ const file = event.files?.[0];
+ if (!file) return;
+
+ const ext = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
+ if (ext !== '.pdf' && ext !== '.p7m') {
+ if (toast.current) toast.current.show({ severity: 'warn', summary: 'Formato non valido', detail: 'Accettati: .pdf (PAdES) o .p7m (CAdES)' });
+ return;
+ }
+
+ setUploading(true);
+ if (toast.current) toast.current.show({ severity: 'info', summary: 'Verifica in corso...', detail: 'Analisi firma digitale (fino a 60 secondi)' });
+
+ Ar1Service.uploadSignature(formId, file,
+ (resp) => {
+ setUploading(false);
+ 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.' });
+ }
+ },
+ (err) => {
+ setUploading(false);
+ 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
+ });
+ } else {
+ if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: typeof err?.detail === 'string' ? err.detail : (err?.detail?.message || 'Upload fallito') });
+ }
+ }
+ );
+ };
+
+ if (loading) return ;
+ if (!form) return
;
+
+ const hasUnsignedPdf = !!form.pdf_unsigned_path;
+ const hasSignedPdf = !!form.pdf_signed_path;
+ const canUploadSig = form.status === 'AWAITING_SIGNATURE';
+ const isDone = ['VERIFIED', 'SIGNED'].includes(form.status);
+
+ return (
+
+
+
{__('Firma AR1', 'gepafin')} — {form.variant}
+
+
+
+ {!hasUnsignedPdf && (
+
+
{__('Il PDF del tuo modulo AR1 non e ancora stato generato.', 'gepafin')}
+
+
+ )}
+ {hasUnsignedPdf && (
+
+
{__('Scarica il PDF, firmalo con il tuo strumento FEQ (CNS, Aruba, Namirial, Dike) e ricaricalo qui sotto.', 'gepafin')}
+
+ {!isDone && (
+
+ )}
+
+ )}
+
+
+
+ {!canUploadSig && !isDone && (
+
+ )}
+ {canUploadSig && (
+
+
{__('Formati accettati: PDF con firma PAdES oppure file .p7m (CAdES). Dimensione massima 50 MB.', 'gepafin')}
+
+
+ )}
+ {isDone && (
+
+
+ {hasSignedPdf && (
+ {
+ try {
+ const blob = await Ar1Service.downloadPdfSigned(formId);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `AR1_${form.variant}_signed${form.pdf_signed_path?.endsWith('.p7m') ? '.p7m' : '.pdf'}`;
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch (e) {
+ if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: e.message });
+ }
+ }} outlined />
+ )}
+
+ )}
+
+
+ {form.signature_verified_at && (
+
+ {__('Firmatario:', 'gepafin')} {form.signature_signer_name || '—'}
+ {__('Codice fiscale:', 'gepafin')} {form.signature_signer_cf || '—'}
+ {__('Metodo:', 'gepafin')} {form.signature_type || '—'}
+ {__('Verificato il:', 'gepafin')} {new Date(form.signature_verified_at).toLocaleString('it-IT')}
+ {__('Scade il:', 'gepafin')} {form.expires_at ? new Date(form.expires_at).toLocaleDateString('it-IT') : '—'}
+
+ )}
+
+
+ navigate('/ar1')} />
+
+
+ );
+};
+
+export default Ar1Signature;
diff --git a/src/modules/ar1/pages/Ar1Wizard.js b/src/modules/ar1/pages/Ar1Wizard.js
new file mode 100644
index 0000000..b251df3
--- /dev/null
+++ b/src/modules/ar1/pages/Ar1Wizard.js
@@ -0,0 +1,372 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { __ } from '@wordpress/i18n';
+import { useNavigate, useParams } from 'react-router-dom';
+
+import { Card } from 'primereact/card';
+import { Button } from 'primereact/button';
+import { InputText } from 'primereact/inputtext';
+import { InputTextarea } from 'primereact/inputtextarea';
+import { Calendar } from 'primereact/calendar';
+import { RadioButton } from 'primereact/radiobutton';
+import { Checkbox } from 'primereact/checkbox';
+import { Dropdown } from 'primereact/dropdown';
+import { Toast } from 'primereact/toast';
+import { Steps } from 'primereact/steps';
+import { ProgressSpinner } from 'primereact/progressspinner';
+import { Message } from 'primereact/message';
+import { FileUpload } from 'primereact/fileupload';
+
+import Ar1Service from '../service/ar1Service';
+
+/**
+ * Wizard data-driven: legge schema_snapshot del form e genera step/field dinamicamente.
+ * Uno step per quadro. Auto-save onBlur via PUT /quadri.
+ *
+ * URL: /ar1/wizard/:formId
+ */
+const Ar1Wizard = () => {
+ const { formId } = useParams();
+ const navigate = useNavigate();
+ const toast = useRef(null);
+
+ const [form, setForm] = useState(null);
+ const [quadriValues, setQuadriValues] = useState({});
+ const [loading, setLoading] = useState(true);
+ const [activeIndex, setActiveIndex] = useState(0);
+ const [saving, setSaving] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+
+ const quadri = useMemo(
+ () => (form?.schema_snapshot?.quadri || []).filter(q => !q.is_legal_frame),
+ [form]
+ );
+
+ useEffect(() => {
+ if (!formId) return;
+ Ar1Service.getForm(formId,
+ (resp) => {
+ setForm(resp);
+ setQuadriValues(resp.quadri || {});
+ setLoading(false);
+ },
+ (err) => {
+ setLoading(false);
+ if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Form non trovato' });
+ }
+ );
+ }, [formId]);
+
+ const isReadonly = form && form.status !== 'DRAFT';
+
+ const saveQuadro = (quadroId) => {
+ if (isReadonly) return;
+ const patch = { [quadroId]: quadriValues[quadroId] || {} };
+ setSaving(true);
+ Ar1Service.updateQuadri(formId, patch,
+ (resp) => { setSaving(false); setForm(resp); },
+ (err) => {
+ setSaving(false);
+ if (toast.current) toast.current.show({ severity: 'warn', summary: 'Save fallito', detail: err?.detail || 'Riprovare' });
+ }
+ );
+ };
+
+ const handleFieldChange = (quadroId, fieldId, value) => {
+ setQuadriValues(prev => ({
+ ...prev,
+ [quadroId]: { ...(prev[quadroId] || {}), [fieldId]: value }
+ }));
+ };
+
+ const handleRowFieldChange = (quadroId, rowIndex, fieldId, value) => {
+ setQuadriValues(prev => {
+ const q = prev[quadroId] || { rows: [] };
+ const rows = [...(q.rows || [])];
+ rows[rowIndex] = { ...(rows[rowIndex] || {}), [fieldId]: value };
+ return { ...prev, [quadroId]: { ...q, rows } };
+ });
+ };
+
+ const addRow = (quadroId, maxRows) => {
+ setQuadriValues(prev => {
+ const q = prev[quadroId] || { rows: [] };
+ if ((q.rows || []).length >= maxRows) return prev;
+ return { ...prev, [quadroId]: { ...q, rows: [...(q.rows || []), {}] } };
+ });
+ };
+
+ const removeRow = (quadroId, rowIndex) => {
+ setQuadriValues(prev => {
+ const q = prev[quadroId] || { rows: [] };
+ const rows = (q.rows || []).filter((_, i) => i !== rowIndex);
+ return { ...prev, [quadroId]: { ...q, rows } };
+ });
+ };
+
+ const submitFinale = () => {
+ setSubmitting(true);
+ const currentQuadro = quadri[activeIndex];
+ const patch = { [currentQuadro.id]: quadriValues[currentQuadro.id] || {} };
+ Ar1Service.updateQuadri(formId, patch,
+ () => {
+ Ar1Service.submitForSignature(formId,
+ () => {
+ setSubmitting(false);
+ 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' });
+ }
+ );
+ },
+ (err) => {
+ setSubmitting(false);
+ if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Save fallito' });
+ }
+ );
+ };
+
+ const renderField = (field, value, onChange, path = '') => {
+ const key = `${path}-${field.id}`;
+ const disabled = isReadonly;
+ const req = field.required ? ' *' : '';
+ const commonLabel = ;
+
+ switch (field.type) {
+ case 'text':
+ case 'email':
+ return (
+
+ {commonLabel}
+ onChange(field.id, field.uppercase ? e.target.value.toUpperCase() : e.target.value)}
+ disabled={disabled}
+ maxLength={field.max_length}
+ placeholder={field.placeholder}
+ style={{ width: '100%' }}
+ />
+ {field.legal_ref && {field.legal_ref}}
+
+ );
+ case 'textarea':
+ return (
+
+ {commonLabel}
+ onChange(field.id, e.target.value)} disabled={disabled} rows={3} maxLength={field.max_length} style={{ width: '100%' }} />
+
+ );
+ case 'date':
+ return (
+
+ {commonLabel}
+ onChange(field.id, e.value ? e.value.toISOString().slice(0, 10) : null)} disabled={disabled} dateFormat="dd/mm/yy" showIcon style={{ width: '100%' }} />
+
+ );
+ case 'checkbox':
+ return (
+
+ onChange(field.id, e.checked)} disabled={disabled} />
+
+
+ );
+ case 'radio':
+ return (
+
+ {commonLabel}
+ {(field.options || []).map((opt, idx) => {
+ const optVal = typeof opt === 'string' ? opt : opt.value;
+ const optLabel = typeof opt === 'string' ? opt : opt.label;
+ const rid = `${key}-opt-${idx}`;
+ return (
+
+ onChange(field.id, e.value)} disabled={disabled} />
+
+
+ );
+ })}
+
+ );
+ case 'enum':
+ return (
+
+ {commonLabel}
+ ({ label: o.replace(/_/g, ' '), value: o }))} onChange={(e) => onChange(field.id, e.value)} disabled={disabled} style={{ width: '100%' }} showClear />
+
+ );
+ case 'yes_no_with_note': {
+ const v = typeof value === 'object' && value ? value : {};
+ const yes = v.value === 'si' || v.value === 'yes' || v.value === 'true';
+ const no = v.value === 'no' || v.value === 'false';
+ return (
+
+ {commonLabel}
+ {field.legal_ref &&
{field.legal_ref}}
+
+
+ onChange(field.id, { ...v, value: 'si' })} disabled={disabled} />
+
+
+
+ onChange(field.id, { ...v, value: 'no' })} disabled={disabled} />
+
+
+
+ {yes && (
+
onChange(field.id, { ...v, note: e.target.value })}
+ disabled={disabled}
+ rows={2}
+ placeholder={field.note_label || __('Specificare', 'gepafin')}
+ style={{ width: '100%' }}
+ />
+ )}
+
+ );
+ }
+ default:
+ return (
+
+ {commonLabel}
+ onChange(field.id, e.target.value)} disabled={disabled} style={{ width: '100%' }} />
+
+ );
+ }
+ };
+
+ const renderQuadro = (quadro) => {
+ const q = quadriValues[quadro.id] || {};
+
+ if (quadro.upload_slots) {
+ return (
+
+
{quadro.title}
+ {quadro.description &&
{quadro.description}
}
+
+ {quadro.upload_slots.map(slot => (
+
+
+ {
+ const file = e.files[0];
+ handleFieldChange(quadro.id, slot.id, { filename: file.name, size: file.size });
+ if (toast.current) toast.current.show({ severity: 'info', summary: 'File selezionato', detail: file.name });
+ }}
+ auto
+ chooseLabel={q[slot.id]?.filename || __('Scegli file', 'gepafin')}
+ style={{ marginTop: 6 }}
+ />
+
+ ))}
+
+ );
+ }
+
+ if (quadro.row_type) {
+ const rows = q.rows || [];
+ return (
+
+
{quadro.title}
+ {quadro.description &&
{quadro.description}
}
+ {rows.map((row, idx) => (
+
+ {(quadro.row_fields || []).map(field => renderField(field, row[field.id], (fid, val) => handleRowFieldChange(quadro.id, idx, fid, val), `q-${quadro.id}-row-${idx}`))}
+ {!isReadonly && (
+ removeRow(quadro.id, idx)} />
+ )}
+
+ ))}
+ {!isReadonly && rows.length < (quadro.max_rows || 4) && (
+
addRow(quadro.id, quadro.max_rows || 4)} />
+ )}
+
+ );
+ }
+
+ return (
+
+
{quadro.title}
+ {quadro.description &&
{quadro.description}
}
+ {(quadro.fields || []).map(field => renderField(field, q[field.id], (fid, val) => handleFieldChange(quadro.id, fid, val), `q-${quadro.id}`))}
+ {quadro.nested_full && (
+
+
{__('Dettaglio aggiuntivo', 'gepafin')}
+ {(quadro.nested_full.fields || []).map(field => renderField(
+ field,
+ (q.nested || {})[field.id],
+ (fid, val) => handleFieldChange(quadro.id, 'nested', { ...((q.nested) || {}), [fid]: val }),
+ `q-${quadro.id}-nested`
+ ))}
+
+ )}
+
+ );
+ };
+
+ if (loading) return ;
+ if (!form) return
;
+
+ const steps = quadri.map(q => ({ label: q.id }));
+ const activeQuadro = quadri[activeIndex];
+ const isLastStep = activeIndex === quadri.length - 1;
+
+ return (
+
+
+
{__('Compilazione AR1', 'gepafin')} — {form.variant}
+ {isReadonly && (
+
+ )}
+
+
{
+ if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id);
+ setActiveIndex(e.index);
+ }}
+ readOnly={false}
+ style={{ marginBottom: 20 }}
+ />
+
+ !isReadonly && saveQuadro(activeQuadro.id)}>
+ {activeQuadro && renderQuadro(activeQuadro)}
+
+
+
+
{
+ if (!isReadonly) saveQuadro(activeQuadro.id);
+ setActiveIndex(activeIndex - 1);
+ }}
+ />
+
+ {saving && {__('Salvataggio...', 'gepafin')}}
+ {!isLastStep && (
+ {
+ if (!isReadonly) saveQuadro(activeQuadro.id);
+ setActiveIndex(activeIndex + 1);
+ }}
+ />
+ )}
+ {isLastStep && !isReadonly && (
+
+ )}
+
+
+
+ );
+};
+
+export default Ar1Wizard;
diff --git a/src/modules/ar1/service/ar1Service.js b/src/modules/ar1/service/ar1Service.js
new file mode 100644
index 0000000..4a0129e
--- /dev/null
+++ b/src/modules/ar1/service/ar1Service.js
@@ -0,0 +1,166 @@
+/**
+ * Client HTTP per ar1-compiler (microservizio BFLOWS).
+ * Il microservizio valida lo stesso JWT di GEPAFIN-BE (HS512 shared secret).
+ *
+ * Env var: REACT_APP_AR1_API_URL (es. http://78.46.41.91:18091)
+ *
+ * Pattern replicato 1:1 da rendicontazioneService.js.
+ */
+import { storeGet } from '../../../store';
+
+const BASE_URL = process.env.REACT_APP_AR1_API_URL || '';
+
+const buildHeaders = () => {
+ const token = storeGet('getToken');
+ const h = { 'Content-Type': 'application/json' };
+ if (token) h['Authorization'] = `Bearer ${token}`;
+ return h;
+};
+
+const buildHeadersMultipart = () => {
+ const token = storeGet('getToken');
+ const h = {};
+ if (token) h['Authorization'] = `Bearer ${token}`;
+ // niente Content-Type: fetch imposta boundary per multipart/form-data
+ return h;
+};
+
+const handleResponse = async (response, onSuccess, onError) => {
+ let body = null;
+ try { body = await response.json(); } catch (e) { body = { detail: response.statusText }; }
+ if (response.status >= 200 && response.status < 300) {
+ if (onSuccess) onSuccess(body);
+ } else {
+ if (onError) onError({ status: response.status, ...body });
+ }
+};
+
+const handleError = (err, onError) => {
+ if (onError) onError({ status: 0, detail: err.message });
+};
+
+const Ar1Service = {
+ // ---------- Status pubblico (per compliance modal al login) ----------
+ getStatusForCompany(companyId, onSuccess, onError) {
+ fetch(`${BASE_URL}/public/ar1-status/${companyId}`, {
+ method: 'GET', mode: 'cors', headers: buildHeaders()
+ })
+ .then(r => handleResponse(r, onSuccess, onError))
+ .catch(e => handleError(e, onError));
+ },
+
+ // ---------- CRUD form beneficiario ----------
+ createDraft(companyId, variant, onSuccess, onError) {
+ fetch(`${BASE_URL}/api/ar1-forms`, {
+ method: 'POST', mode: 'cors', headers: buildHeaders(),
+ body: JSON.stringify({ company_id: companyId, variant })
+ })
+ .then(r => handleResponse(r, onSuccess, onError))
+ .catch(e => handleError(e, onError));
+ },
+
+ getForm(formId, onSuccess, onError) {
+ fetch(`${BASE_URL}/api/ar1-forms/${formId}`, {
+ method: 'GET', mode: 'cors', headers: buildHeaders()
+ })
+ .then(r => handleResponse(r, onSuccess, onError))
+ .catch(e => handleError(e, onError));
+ },
+
+ listFormsForCompany(companyId, onSuccess, onError) {
+ fetch(`${BASE_URL}/api/ar1-forms/company/${companyId}`, {
+ method: 'GET', mode: 'cors', headers: buildHeaders()
+ })
+ .then(r => handleResponse(r, onSuccess, onError))
+ .catch(e => handleError(e, onError));
+ },
+
+ updateQuadri(formId, quadriPatch, onSuccess, onError) {
+ fetch(`${BASE_URL}/api/ar1-forms/${formId}/quadri`, {
+ method: 'PUT', mode: 'cors', headers: buildHeaders(),
+ body: JSON.stringify({ quadri: quadriPatch })
+ })
+ .then(r => handleResponse(r, onSuccess, onError))
+ .catch(e => handleError(e, onError));
+ },
+
+ submitForSignature(formId, onSuccess, onError) {
+ fetch(`${BASE_URL}/api/ar1-forms/${formId}/submit-for-signature`, {
+ method: 'PUT', mode: 'cors', headers: buildHeaders()
+ })
+ .then(r => handleResponse(r, onSuccess, onError))
+ .catch(e => handleError(e, onError));
+ },
+
+ deleteForm(formId, onSuccess, onError) {
+ fetch(`${BASE_URL}/api/ar1-forms/${formId}`, {
+ method: 'DELETE', mode: 'cors', headers: buildHeaders()
+ })
+ .then(r => {
+ if (r.status === 204) {
+ if (onSuccess) onSuccess({});
+ } else {
+ handleResponse(r, onSuccess, onError);
+ }
+ })
+ .catch(e => handleError(e, onError));
+ },
+
+ // ---------- PDF ----------
+ generatePdf(formId, onSuccess, onError) {
+ fetch(`${BASE_URL}/api/ar1-forms/${formId}/generate-pdf`, {
+ method: 'POST', mode: 'cors', headers: buildHeaders()
+ })
+ .then(r => handleResponse(r, onSuccess, onError))
+ .catch(e => handleError(e, onError));
+ },
+
+ downloadPdfUnsigned(formId) {
+ return fetch(`${BASE_URL}/api/ar1-forms/${formId}/pdf-unsigned`, {
+ method: 'GET', mode: 'cors', headers: buildHeadersMultipart()
+ }).then(r => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return r.blob();
+ });
+ },
+
+ downloadPdfSigned(formId) {
+ return fetch(`${BASE_URL}/api/ar1-forms/${formId}/pdf-signed`, {
+ method: 'GET', mode: 'cors', headers: buildHeadersMultipart()
+ }).then(r => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return r.blob();
+ });
+ },
+
+ // ---------- Firma ----------
+ uploadSignature(formId, fileObject, onSuccess, onError) {
+ const formData = new FormData();
+ formData.append('file', fileObject);
+ fetch(`${BASE_URL}/api/ar1-forms/${formId}/upload-signature`, {
+ method: 'POST', mode: 'cors', headers: buildHeadersMultipart(),
+ body: formData
+ })
+ .then(r => handleResponse(r, onSuccess, onError))
+ .catch(e => handleError(e, onError));
+ },
+
+ reVerifySignature(formId, onSuccess, onError) {
+ fetch(`${BASE_URL}/api/ar1-forms/${formId}/verify`, {
+ method: 'POST', mode: 'cors', headers: buildHeaders()
+ })
+ .then(r => handleResponse(r, onSuccess, onError))
+ .catch(e => handleError(e, onError));
+ },
+
+ // ---------- Archive manuale (di solito automatico) ----------
+ archiveToCompanyDocument(formId, onSuccess, onError) {
+ fetch(`${BASE_URL}/api/ar1-forms/${formId}/archive-to-company-document`, {
+ method: 'POST', mode: 'cors', headers: buildHeaders()
+ })
+ .then(r => handleResponse(r, onSuccess, onError))
+ .catch(e => handleError(e, onError));
+ },
+};
+
+export default Ar1Service;
diff --git a/src/routes.js b/src/routes.js
index 002e772..65985e2 100644
--- a/src/routes.js
+++ b/src/routes.js
@@ -21,6 +21,9 @@ import PraticaRendicontazioneEdit from './modules/rendicontazione/pages/PraticaR
import DevSwitchUser from './modules/rendicontazione/pages/DevSwitchUser';
import IstruttoriaQueue from './modules/rendicontazione/pages/IstruttoriaQueue';
import IstruttoriaPratica from './modules/rendicontazione/pages/IstruttoriaPratica';
+import Ar1Home from './modules/ar1/pages/Ar1Home';
+import Ar1Wizard from './modules/ar1/pages/Ar1Wizard';
+import Ar1Signature from './modules/ar1/pages/Ar1Signature';
import BandoFlowEdit from './pages/BandoFlowEdit';
import Imieibandi from './pages/Imieibandi';
import BandoApplication from './pages/BandoApplication';
@@ -163,6 +166,27 @@ const routes = ({ role, chosenCompanyId }) => {
{'ROLE_PRE_INSTRUCTOR' === role ? : null}
{'ROLE_INSTRUCTOR_MANAGER' === role ? : null}
}/>
+
+ {'ROLE_BENEFICIARY' === role ? : null}
+ {'ROLE_SUPER_ADMIN' === role ? : null}
+ {'ROLE_CONFIDI' === role ? : null}
+ {'ROLE_PRE_INSTRUCTOR' === role ? : null}
+ {'ROLE_INSTRUCTOR_MANAGER' === role ? : null}
+ }/>
+
+ {'ROLE_BENEFICIARY' === role ? : null}
+ {'ROLE_SUPER_ADMIN' === role ? : null}
+ {'ROLE_CONFIDI' === role ? : null}
+ {'ROLE_PRE_INSTRUCTOR' === role ? : null}
+ {'ROLE_INSTRUCTOR_MANAGER' === role ? : null}
+ }/>
+
+ {'ROLE_BENEFICIARY' === role ? : null}
+ {'ROLE_SUPER_ADMIN' === role ? : null}
+ {'ROLE_CONFIDI' === role ? : null}
+ {'ROLE_PRE_INSTRUCTOR' === role ? : null}
+ {'ROLE_INSTRUCTOR_MANAGER' === role ? : null}
+ }/>
{'ROLE_BENEFICIARY' === role ? : null}
{'ROLE_SUPER_ADMIN' === role ? : null}