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:
@@ -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',
|
||||
|
||||
137
src/modules/ar1/components/Ar1ComplianceModal.js
Normal file
137
src/modules/ar1/components/Ar1ComplianceModal.js
Normal 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;
|
||||
26
src/modules/ar1/components/Ar1StatusTag.js
Normal file
26
src/modules/ar1/components/Ar1StatusTag.js
Normal 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;
|
||||
248
src/modules/ar1/pages/Ar1Home.js
Normal file
248
src/modules/ar1/pages/Ar1Home.js
Normal 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;
|
||||
210
src/modules/ar1/pages/Ar1Signature.js
Normal file
210
src/modules/ar1/pages/Ar1Signature.js
Normal 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;
|
||||
372
src/modules/ar1/pages/Ar1Wizard.js
Normal file
372
src/modules/ar1/pages/Ar1Wizard.js
Normal 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;
|
||||
166
src/modules/ar1/service/ar1Service.js
Normal file
166
src/modules/ar1/service/ar1Service.js
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user