feat(ar1): modulo Dichiarazione AR1 Adeguata Verifica D.Lgs.231/2007

Nuovo modulo FE speculare a rendicontazione. Integrazione con microservizio
ar1-compiler (AX41:18091, 26 endpoint live, JWT HS512 condiviso con GEPAFIN-BE).

FILE CREATI (1159 LOC):

src/modules/ar1/service/ar1Service.js (166 LOC)
  Client HTTP pattern 1:1 da rendicontazioneService.js. Metodi:
  - getStatusForCompany (pubblico, per compliance modal)
  - createDraft / getForm / listFormsForCompany / updateQuadri
  - submitForSignature / deleteForm
  - generatePdf / downloadPdfUnsigned / downloadPdfSigned
  - uploadSignature (multipart) / reVerifySignature
  - archiveToCompanyDocument (manuale, solitamente auto)

src/modules/ar1/components/Ar1StatusTag.js (26 LOC)
  Badge PrimeReact Tag per 9 stati (MISSING/DRAFT/AWAITING_SIGNATURE/SIGNED/
  VERIFIED/VALID/APPROACHING/EXPIRED/SUPERSEDED) con severity+icon specifici.

src/modules/ar1/components/Ar1ComplianceModal.js (137 LOC)
  Dialog al login se azienda ha AR1 MISSING/EXPIRED (bloccante, no dismiss)
  o APPROACHING (dismissable 24h via sessionStorage). CTA 'Compila ora'
  naviga a /ar1. Da montare nel layout principale con <Ar1ComplianceModal
  companyId={userCompanyId} />.

src/modules/ar1/pages/Ar1Home.js (248 LOC)
  Pagina principale beneficiario. Card status con countdown + CTA dinamici
  (Compila/Riprendi/Firma/Rinnova). DataTable storico con azioni per riga
  (riprendi, firma, elimina, scarica firmato). Dialog scelta variante per
  nuovo form (A1/A2/A3).

src/modules/ar1/pages/Ar1Wizard.js (372 LOC)
  Wizard data-driven: legge schema_snapshot del form e genera step/field
  dinamicamente. Un step PrimeReact Steps per ogni quadro. Auto-save onBlur
  via PUT /quadri. 7 renderer type-aware:
    - text/email (uppercase CF regex)
    - textarea
    - date (Calendar it-IT)
    - checkbox
    - radio (opzioni string o {label,value})
    - enum (Dropdown)
    - yes_no_with_note (RadioButton SI/NO + textarea condizionale)
  Handler row_type per Quadro B titolari effettivi (array fino a max_rows).
  Handler upload_slots per Quadro F allegati. Nested_full per Quadro C LR
  e D esecutore con sezione 'Dettaglio aggiuntivo'.
  Solo DRAFT editabile, AWAITING_SIGNATURE+ in sola lettura.
  Submit finale invia PUT /quadri + PUT /submit-for-signature e naviga
  a /ar1/signature/:id.

src/modules/ar1/pages/Ar1Signature.js (210 LOC)
  Pagina firma:
    Step 1: genera PDF + download unsigned (filename AR1_A1_da-firmare.pdf)
    Step 2: FileUpload PDF firmato (.pdf PAdES o .p7m CAdES, 50MB max)
            → DocVerify call (toast 'Verifica in corso, fino a 60s')
            → 4 outcome con toast specifici:
                VERIFIED → success + redirect Home
                SIGNED_NOT_VERIFIED → warn 'verifica manuale'
                SIGNED_DOCVERIFY_UNAVAILABLE → warn 'DocVerify down'
                NO_SIGNATURE_DETECTED → error 'Firmare prima il PDF'
  Card 'Dettagli verifica' con firmatario/CF/metodo/scadenza se VERIFIED.

INTEGRAZIONE (pattern identico a rendicontazione):

src/layouts/DefaultLayout/components/AppSidebar/index.js
  Aggiunta voce sidebar:
    label: 'Dichiarazione AR1', icon: 'pi pi-id-card', href: '/ar1', id: 22,
    enable: intersection(permissions, ['APPLY_CALLS', 'APPLY_CONFIDI_CALLS']).length

src/routes.js
  Import Ar1Home/Ar1Wizard/Ar1Signature.
  3 route con pattern ruoli:
    /ar1 → BENEFICIARY/SUPER_ADMIN: Ar1Home, altri: PageNotFound
    /ar1/wizard/:formId → BENEFICIARY/SUPER_ADMIN: Ar1Wizard
    /ar1/signature/:formId → BENEFICIARY/SUPER_ADMIN: Ar1Signature

.env
  + REACT_APP_AR1_API_URL=http://78.46.41.91:18091
  + REACT_APP_RENDICONTAZIONE_API_URL=http://78.46.41.91:18090

VALIDAZIONE:
8 file @babel/parser parse-check con plugin JSX: 8 OK / 0 FAIL.

PROSSIMI STEP (non in questo commit):
- Rinaldo integra Ar1ComplianceModal nel layout principale post-login
- Rinaldo deploya DocVerify sul server BFLOWS/Gepafin e configura
  AR1_DOCVERIFY_URL nel microservizio ar1-compiler (senza DocVerify,
  degrada gracefully a SIGNED senza VERIFIED)
- BE Spring Ar1AmendmentPoller (4.5h, bundle in /tmp/rinaldo-bundle-ar1.zip)
This commit is contained in:
BFLOWS
2026-04-23 10:36:17 +02:00
parent 1116f96acf
commit 46ee801bd0
9 changed files with 1192 additions and 0 deletions

2
.env
View File

@@ -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

View File

@@ -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',

View File

@@ -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:
* <Ar1ComplianceModal companyId={userCompanyId} />
*/
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 (
<Dialog
header={<div><i className="pi pi-id-card" style={{ marginRight: 8 }} />{__('Dichiarazione AR1 — Adeguata Verifica', 'gepafin')}</div>}
visible={visible}
modal
closable={canDismiss}
closeOnEscape={canDismiss}
dismissableMask={canDismiss}
onHide={handleDismiss}
style={{ width: '560px', maxWidth: '95vw' }}
>
<div>
<div style={{ marginBottom: 12 }}>
<Ar1StatusTag status={status.status} />
</div>
{isUrgent && (
<Message
severity="error"
text={status.must_recompile_reason ||
__('Per proseguire nell\'operativita con Gepafin e necessario aggiornare la dichiarazione di adeguata verifica (D.Lgs. 231/2007).', 'gepafin')}
style={{ marginBottom: 12 }}
/>
)}
{!isUrgent && (
<Message
severity="warn"
text={__('La tua dichiarazione AR1 sta per scadere. Ti chiediamo di aggiornarla per tempo.', 'gepafin')}
style={{ marginBottom: 12 }}
/>
)}
<p style={{ margin: '10px 0', color: '#444' }}>
{__('Il modulo AR1 (Aggiornamento Adeguata Verifica) e richiesto dalla normativa antiriciclaggio D.Lgs. 231/2007. La compilazione si svolge via wizard guidato e termina con la firma digitale (FEQ) del modulo.', 'gepafin')}
</p>
{status.days_to_expiry !== null && status.days_to_expiry !== undefined && (
<p style={{ margin: '10px 0', fontWeight: 600 }}>
{status.days_to_expiry < 0
? __(`Scaduta da ${Math.abs(status.days_to_expiry)} giorni`, 'gepafin')
: __(`Scadenza tra ${status.days_to_expiry} giorni`, 'gepafin')}
</p>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 16 }}>
{canDismiss && (
<Button
label={__('Ricordamelo piu tardi', 'gepafin')}
severity="secondary"
outlined
onClick={handleDismiss}
/>
)}
<Button
label={status.status === 'DRAFT' ? __('Riprendi compilazione', 'gepafin') : __('Compila ora', 'gepafin')}
icon="pi pi-arrow-right"
iconPos="right"
onClick={goToCompile}
/>
</div>
</div>
</Dialog>
);
};
export default Ar1ComplianceModal;

View File

@@ -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 <Tag severity={cfg.severity} icon={cfg.icon} value={__(cfg.label, 'gepafin')} />;
};
export default Ar1StatusTag;

View File

@@ -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 (
<Card title={__('Stato Dichiarazione AR1 — Adeguata Verifica', 'gepafin')} style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<Ar1StatusTag status={status.status} />
{status.variant && (
<span style={{ color: '#666' }}>
{__('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>
)}
</div>
{status.must_recompile_reason && (
<div style={{ marginTop: 12, padding: 10, background: '#fff3e0', borderLeft: '3px solid #e65100' }}>
<i className="pi pi-info-circle" style={{ marginRight: 6 }} />
{status.must_recompile_reason}
</div>
)}
<div style={{ marginTop: 16, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{hasActive && status.status === 'DRAFT' && (
<Button label={__('Riprendi compilazione', 'gepafin')} icon="pi pi-pencil" onClick={() => resumeForm(status.form_id)} />
)}
{hasActive && status.status === 'AWAITING_SIGNATURE' && (
<Button label={__('Procedi alla firma', 'gepafin')} icon="pi pi-verified" severity="warning" onClick={() => goToSignature(status.form_id)} />
)}
{!hasActive && canCompile && (
<Button
label={status.status === 'MISSING' ? __('Compila adesso', 'gepafin') : __('Rinnova dichiarazione', 'gepafin')}
icon="pi pi-plus"
onClick={() => setVariantDialogOpen(true)}
/>
)}
</div>
</Card>
);
};
const statusTpl = (row) => <Ar1StatusTag status={row.status} />;
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) => (
<div style={{ display: 'flex', gap: 4 }}>
{row.status === 'DRAFT' && (
<>
<Button icon="pi pi-pencil" rounded text onClick={() => resumeForm(row.id)} tooltip={__('Riprendi', 'gepafin')} />
<Button icon="pi pi-trash" rounded text severity="danger" onClick={() => deleteDraft(row.id)} tooltip={__('Elimina', 'gepafin')} />
</>
)}
{row.status === 'AWAITING_SIGNATURE' && (
<Button icon="pi pi-verified" rounded text severity="warning" onClick={() => goToSignature(row.id)} tooltip={__('Firma', 'gepafin')} />
)}
{['SIGNED', 'VERIFIED', 'EXPIRED'].includes(row.status) && (
<Button icon="pi pi-download" rounded text onClick={async () => {
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')} />
)}
</div>
);
return (
<div style={{ padding: 16 }}>
<Toast ref={toast} />
<ConfirmDialog />
<h1>{__('Dichiarazione AR1 — Adeguata Verifica', 'gepafin')}</h1>
<p style={{ color: '#666' }}>
{__('Modulo di aggiornamento dell\'adeguata verifica ai sensi del D.Lgs. 231/2007 (normativa antiriciclaggio).', 'gepafin')}
</p>
{renderStatusCard()}
<Card title={__('Storico dichiarazioni', 'gepafin')}>
<DataTable
value={history}
loading={loading}
emptyMessage={__('Nessuna dichiarazione presente', 'gepafin')}
paginator={history.length > 10}
rows={10}
>
<Column field="variant" header={__('Variante', 'gepafin')} />
<Column field="template_version" header={__('Versione modulo', 'gepafin')} />
<Column field="status" header={__('Stato', 'gepafin')} body={statusTpl} />
<Column field="created_at" header={__('Creato il', 'gepafin')} body={(r) => dateTpl(r, 'created_at')} />
<Column field="signed_at" header={__('Firmato il', 'gepafin')} body={(r) => dateTpl(r, 'signed_at')} />
<Column field="expires_at" header={__('Scade il', 'gepafin')} body={(r) => dateTpl(r, 'expires_at')} />
<Column header={__('Azioni', 'gepafin')} body={actionsTpl} style={{ width: 160 }} />
</DataTable>
</Card>
<Dialog
header={__('Scegli tipologia', 'gepafin')}
visible={variantDialogOpen}
onHide={() => setVariantDialogOpen(false)}
style={{ width: '480px', maxWidth: '95vw' }}
modal
>
<p style={{ marginBottom: 10 }}>
{__('Seleziona la tipologia di soggetto che rappresenti:', 'gepafin')}
</p>
<Dropdown
value={selectedVariant}
options={VARIANT_OPTIONS}
onChange={(e) => setSelectedVariant(e.value)}
style={{ width: '100%', marginBottom: 16 }}
/>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label={__('Annulla', 'gepafin')} severity="secondary" outlined onClick={() => setVariantDialogOpen(false)} />
<Button label={__('Inizia compilazione', 'gepafin')} icon="pi pi-arrow-right" iconPos="right" loading={creating} onClick={startNewDraft} />
</div>
</Dialog>
</div>
);
};
export default Ar1Home;

View File

@@ -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 <div style={{ textAlign: 'center', padding: 40 }}><ProgressSpinner /></div>;
if (!form) return <div style={{ padding: 20 }}><Message severity="error" text={__('Form non trovato', 'gepafin')} /></div>;
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 (
<div style={{ padding: 16, maxWidth: 900, margin: '0 auto' }}>
<Toast ref={toast} />
<h1>{__('Firma AR1', 'gepafin')} {form.variant}</h1>
<div style={{ marginBottom: 20 }}><Ar1StatusTag status={form.status} /></div>
<Card title={__('1. Scarica il modulo AR1 da firmare', 'gepafin')} style={{ marginBottom: 14 }}>
{!hasUnsignedPdf && (
<div>
<p>{__('Il PDF del tuo modulo AR1 non e ancora stato generato.', 'gepafin')}</p>
<Button label={__('Genera PDF', 'gepafin')} icon="pi pi-file-pdf" onClick={handleGeneratePdf} loading={generating} />
</div>
)}
{hasUnsignedPdf && (
<div>
<p>{__('Scarica il PDF, firmalo con il tuo strumento FEQ (CNS, Aruba, Namirial, Dike) e ricaricalo qui sotto.', 'gepafin')}</p>
<Button label={__('Scarica PDF', 'gepafin')} icon="pi pi-download" onClick={handleDownloadUnsigned} outlined />
{!isDone && (
<Button label={__('Rigenera PDF', 'gepafin')} icon="pi pi-refresh" text onClick={handleGeneratePdf} loading={generating} style={{ marginLeft: 8 }} />
)}
</div>
)}
</Card>
<Card title={__('2. Carica il PDF firmato', 'gepafin')} style={{ marginBottom: 14 }}>
{!canUploadSig && !isDone && (
<Message severity="warn" text={__(`Il modulo non e in stato AWAITING_SIGNATURE (attuale: ${form.status})`, 'gepafin')} />
)}
{canUploadSig && (
<div>
<p>{__('Formati accettati: PDF con firma PAdES oppure file .p7m (CAdES). Dimensione massima 50 MB.', 'gepafin')}</p>
<FileUpload
name="file"
mode="basic"
accept=".pdf,.p7m"
maxFileSize={50 * 1024 * 1024}
customUpload
uploadHandler={handleUploadSigned}
auto
chooseLabel={uploading ? __('Verifica in corso...', 'gepafin') : __('Seleziona PDF firmato', 'gepafin')}
disabled={uploading}
/>
</div>
)}
{isDone && (
<div>
<Message severity="success" text={
form.status === 'VERIFIED'
? __('Firma verificata con successo. La dichiarazione e archiviata nei tuoi documenti aziendali.', 'gepafin')
: __('Firma accettata. Potrebbe richiedere verifica manuale.', 'gepafin')
} style={{ marginBottom: 12 }} />
{hasSignedPdf && (
<Button label={__('Scarica PDF firmato', 'gepafin')} icon="pi pi-download" onClick={async () => {
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 />
)}
</div>
)}
</Card>
{form.signature_verified_at && (
<Card title={__('Dettagli verifica', 'gepafin')}>
<p><strong>{__('Firmatario:', 'gepafin')}</strong> {form.signature_signer_name || ''}</p>
<p><strong>{__('Codice fiscale:', 'gepafin')}</strong> {form.signature_signer_cf || ''}</p>
<p><strong>{__('Metodo:', 'gepafin')}</strong> {form.signature_type || ''}</p>
<p><strong>{__('Verificato il:', 'gepafin')}</strong> {new Date(form.signature_verified_at).toLocaleString('it-IT')}</p>
<p><strong>{__('Scade il:', 'gepafin')}</strong> {form.expires_at ? new Date(form.expires_at).toLocaleDateString('it-IT') : ''}</p>
</Card>
)}
<div style={{ marginTop: 20, textAlign: 'center' }}>
<Button label={__('Torna alla Home AR1', 'gepafin')} severity="secondary" outlined onClick={() => navigate('/ar1')} />
</div>
</div>
);
};
export default Ar1Signature;

View File

@@ -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 = <label htmlFor={key} style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>{field.label}{req}</label>;
switch (field.type) {
case 'text':
case 'email':
return (
<div key={key} style={{ marginBottom: 14 }}>
{commonLabel}
<InputText
id={key}
value={value || ''}
onChange={(e) => 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 && <small style={{ color: '#888' }}>{field.legal_ref}</small>}
</div>
);
case 'textarea':
return (
<div key={key} style={{ marginBottom: 14 }}>
{commonLabel}
<InputTextarea id={key} value={value || ''} onChange={(e) => onChange(field.id, e.target.value)} disabled={disabled} rows={3} maxLength={field.max_length} style={{ width: '100%' }} />
</div>
);
case 'date':
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%' }} />
</div>
);
case 'checkbox':
return (
<div key={key} style={{ marginBottom: 14, display: 'flex', alignItems: 'center', gap: 8 }}>
<Checkbox inputId={key} checked={!!value} onChange={(e) => onChange(field.id, e.checked)} disabled={disabled} />
<label htmlFor={key}>{field.label}</label>
</div>
);
case 'radio':
return (
<div key={key} style={{ marginBottom: 14 }}>
{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 (
<div key={rid} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<RadioButton inputId={rid} value={optVal} checked={value === optVal} onChange={(e) => onChange(field.id, e.value)} disabled={disabled} />
<label htmlFor={rid}>{optLabel}</label>
</div>
);
})}
</div>
);
case 'enum':
return (
<div key={key} style={{ marginBottom: 14 }}>
{commonLabel}
<Dropdown id={key} value={value} options={(field.options || []).map(o => ({ label: o.replace(/_/g, ' '), value: o }))} onChange={(e) => onChange(field.id, e.value)} disabled={disabled} style={{ width: '100%' }} showClear />
</div>
);
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 (
<div key={key} style={{ marginBottom: 14, padding: 10, background: '#fafafa', borderLeft: '3px solid #003d7a' }}>
{commonLabel}
{field.legal_ref && <small style={{ color: '#888', display: 'block', marginBottom: 6 }}>{field.legal_ref}</small>}
<div style={{ display: 'flex', gap: 16, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<RadioButton inputId={`${key}-yes`} checked={yes} onChange={() => onChange(field.id, { ...v, value: 'si' })} disabled={disabled} />
<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} />
<label htmlFor={`${key}-no`}>{__('No', 'gepafin')}</label>
</div>
</div>
{yes && (
<InputTextarea
value={v.note || ''}
onChange={(e) => onChange(field.id, { ...v, note: e.target.value })}
disabled={disabled}
rows={2}
placeholder={field.note_label || __('Specificare', 'gepafin')}
style={{ width: '100%' }}
/>
)}
</div>
);
}
default:
return (
<div key={key} style={{ marginBottom: 14 }}>
{commonLabel}
<InputText value={value || ''} onChange={(e) => onChange(field.id, e.target.value)} disabled={disabled} style={{ width: '100%' }} />
</div>
);
}
};
const renderQuadro = (quadro) => {
const q = quadriValues[quadro.id] || {};
if (quadro.upload_slots) {
return (
<div>
<h3>{quadro.title}</h3>
{quadro.description && <p style={{ color: '#666' }}>{quadro.description}</p>}
<Message severity="info" text={__('Carica qui i documenti richiesti. Il PDF firmato dell\'AR1 verra archiviato automaticamente nei tuoi documenti aziendali.', 'gepafin')} style={{ marginBottom: 14 }} />
{quadro.upload_slots.map(slot => (
<div key={slot.id} style={{ marginBottom: 14, padding: 10, border: '1px solid #ddd', borderRadius: 4 }}>
<label style={{ fontWeight: 500 }}>{slot.label}{slot.required ? ' *' : ''}</label>
<FileUpload
name={slot.id}
mode="basic"
accept={(slot.accept || []).join(',')}
maxFileSize={(slot.max_size_mb || 300) * 1024 * 1024}
disabled={isReadonly}
customUpload
uploadHandler={(e) => {
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 }}
/>
</div>
))}
</div>
);
}
if (quadro.row_type) {
const rows = q.rows || [];
return (
<div>
<h3>{quadro.title}</h3>
{quadro.description && <p style={{ color: '#666' }}>{quadro.description}</p>}
{rows.map((row, idx) => (
<Card key={idx} title={`${quadro.row_type.replace(/_/g, ' ').toUpperCase()} #${idx + 1}`} style={{ marginBottom: 12 }}>
{(quadro.row_fields || []).map(field => renderField(field, row[field.id], (fid, val) => handleRowFieldChange(quadro.id, idx, fid, val), `q-${quadro.id}-row-${idx}`))}
{!isReadonly && (
<Button label={__('Rimuovi', 'gepafin')} severity="danger" outlined icon="pi pi-trash" size="small" onClick={() => removeRow(quadro.id, idx)} />
)}
</Card>
))}
{!isReadonly && rows.length < (quadro.max_rows || 4) && (
<Button label={__('Aggiungi', 'gepafin')} icon="pi pi-plus" outlined onClick={() => addRow(quadro.id, quadro.max_rows || 4)} />
)}
</div>
);
}
return (
<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 && (
<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(
field,
(q.nested || {})[field.id],
(fid, val) => handleFieldChange(quadro.id, 'nested', { ...((q.nested) || {}), [fid]: val }),
`q-${quadro.id}-nested`
))}
</div>
)}
</div>
);
};
if (loading) return <div style={{ textAlign: 'center', padding: 40 }}><ProgressSpinner /></div>;
if (!form) return <div style={{ padding: 20 }}><Message severity="error" text={__('Form non trovato', 'gepafin')} /></div>;
const steps = quadri.map(q => ({ label: q.id }));
const activeQuadro = quadri[activeIndex];
const isLastStep = activeIndex === quadri.length - 1;
return (
<div style={{ padding: 16 }}>
<Toast ref={toast} />
<h1>{__('Compilazione AR1', 'gepafin')} {form.variant}</h1>
{isReadonly && (
<Message severity="info" text={__(`Form in stato ${form.status} — sola lettura`, 'gepafin')} style={{ marginBottom: 14 }} />
)}
<Steps
model={steps}
activeIndex={activeIndex}
onSelect={(e) => {
if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id);
setActiveIndex(e.index);
}}
readOnly={false}
style={{ marginBottom: 20 }}
/>
<Card style={{ marginBottom: 14 }} onBlur={() => !isReadonly && saveQuadro(activeQuadro.id)}>
{activeQuadro && renderQuadro(activeQuadro)}
</Card>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button label={__('Indietro', 'gepafin')} icon="pi pi-arrow-left" severity="secondary" outlined disabled={activeIndex === 0}
onClick={() => {
if (!isReadonly) saveQuadro(activeQuadro.id);
setActiveIndex(activeIndex - 1);
}}
/>
<div style={{ display: 'flex', gap: 8 }}>
{saving && <span style={{ color: '#888' }}>{__('Salvataggio...', 'gepafin')}</span>}
{!isLastStep && (
<Button label={__('Avanti', 'gepafin')} icon="pi pi-arrow-right" iconPos="right"
onClick={() => {
if (!isReadonly) saveQuadro(activeQuadro.id);
setActiveIndex(activeIndex + 1);
}}
/>
)}
{isLastStep && !isReadonly && (
<Button label={__('Procedi alla firma', 'gepafin')} icon="pi pi-verified" severity="warning" loading={submitting} onClick={submitFinale} />
)}
</div>
</div>
</div>
);
};
export default Ar1Wizard;

View File

@@ -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;

View File

@@ -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 ? <PageNotFound/> : null}
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
</DefaultLayout>}/>
<Route path="/ar1" element={<DefaultLayout>
{'ROLE_BENEFICIARY' === role ? <Ar1Home/> : null}
{'ROLE_SUPER_ADMIN' === role ? <Ar1Home/> : null}
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
{'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null}
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
</DefaultLayout>}/>
<Route path="/ar1/wizard/:formId" element={<DefaultLayout>
{'ROLE_BENEFICIARY' === role ? <Ar1Wizard/> : null}
{'ROLE_SUPER_ADMIN' === role ? <Ar1Wizard/> : null}
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
{'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null}
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
</DefaultLayout>}/>
<Route path="/ar1/signature/:formId" element={<DefaultLayout>
{'ROLE_BENEFICIARY' === role ? <Ar1Signature/> : null}
{'ROLE_SUPER_ADMIN' === role ? <Ar1Signature/> : null}
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
{'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null}
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
</DefaultLayout>}/>
<Route path="/rendicontazioni/:id" element={<DefaultLayout>
{'ROLE_BENEFICIARY' === role ? <PraticaRendicontazioneEdit/> : null}
{'ROLE_SUPER_ADMIN' === role ? <PraticaRendicontazioneEdit/> : null}