diff --git a/.env b/.env index 80c96c4..a7fb491 100644 --- a/.env +++ b/.env @@ -9,3 +9,5 @@ REACT_APP_FAVICON_FILENAME=gepafin-favicon.ico REACT_APP_HUB_ID=p4lk3bcx1RStqTaIVVbXs REACT_APP_EVALUATION_FLOW_ID=1 REACT_APP_LOCAL_DEVELOPMENT=1 +REACT_APP_AR1_API_URL=http://78.46.41.91:18091 +REACT_APP_RENDICONTAZIONE_API_URL=http://78.46.41.91:18090 diff --git a/src/layouts/DefaultLayout/components/AppSidebar/index.js b/src/layouts/DefaultLayout/components/AppSidebar/index.js index 2c77e79..9de0681 100644 --- a/src/layouts/DefaultLayout/components/AppSidebar/index.js +++ b/src/layouts/DefaultLayout/components/AppSidebar/index.js @@ -55,6 +55,13 @@ const AppSidebar = () => { id: 3, enable: intersection(permissions, ['APPLY_CALLS', 'APPLY_CONFIDI_CALLS']).length }, + { + label: __('Dichiarazione AR1', 'gepafin'), + icon: 'pi pi-id-card', + href: '/ar1', + id: 22, + enable: intersection(permissions, ['APPLY_CALLS', 'APPLY_CONFIDI_CALLS']).length + }, { label: __('Bandi disponibili', 'gepafin'), icon: 'pi pi-bookmark', diff --git a/src/modules/ar1/components/Ar1ComplianceModal.js b/src/modules/ar1/components/Ar1ComplianceModal.js new file mode 100644 index 0000000..8538922 --- /dev/null +++ b/src/modules/ar1/components/Ar1ComplianceModal.js @@ -0,0 +1,137 @@ +import React, { useEffect, useState } from 'react'; +import { __ } from '@wordpress/i18n'; +import { useNavigate } from 'react-router-dom'; +import { Dialog } from 'primereact/dialog'; +import { Button } from 'primereact/button'; +import { Message } from 'primereact/message'; +import Ar1Service from '../service/ar1Service'; +import Ar1StatusTag from './Ar1StatusTag'; + +const DISMISS_SESSION_KEY_PREFIX = 'ar1-compliance-dismissed-'; +const DISMISS_WINDOW_HOURS = 24; + +/** + * Dialog AR1 mostrato al login se l'azienda ha AR1 MISSING/EXPIRED/APPROACHING. + * - dismissable=false (EXPIRED/MISSING): bloccante, solo CTA "Compila ora" + * - dismissable=true (APPROACHING): X chiude + salva in sessionStorage 24h + * + * Da montare nel layout principale. Esempio: + * + */ +const Ar1ComplianceModal = ({ companyId }) => { + const navigate = useNavigate(); + const [status, setStatus] = useState(null); + const [visible, setVisible] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!companyId) return; + const dismissKey = DISMISS_SESSION_KEY_PREFIX + companyId; + const dismissed = sessionStorage.getItem(dismissKey); + if (dismissed) { + const dismissedAt = parseInt(dismissed, 10); + if (Date.now() - dismissedAt < DISMISS_WINDOW_HOURS * 3600 * 1000) { + setLoading(false); + return; + } + } + + Ar1Service.getStatusForCompany(companyId, + (resp) => { + setLoading(false); + const showFor = ['MISSING', 'EXPIRED', 'APPROACHING']; + if (resp && showFor.includes(resp.status)) { + setStatus(resp); + setVisible(true); + } + }, + (err) => { + setLoading(false); + console.warn('Ar1ComplianceModal: status check failed', err); + } + ); + }, [companyId]); + + const handleDismiss = () => { + if (!status?.is_popup_dismissible) return; + sessionStorage.setItem(DISMISS_SESSION_KEY_PREFIX + companyId, Date.now().toString()); + setVisible(false); + }; + + const goToCompile = () => { + setVisible(false); + navigate('/ar1'); + }; + + if (loading || !status) return null; + + const canDismiss = status.is_popup_dismissible; + const isUrgent = status.status === 'EXPIRED' || status.status === 'MISSING'; + + return ( + {__('Dichiarazione AR1 — Adeguata Verifica', 'gepafin')}} + visible={visible} + modal + closable={canDismiss} + closeOnEscape={canDismiss} + dismissableMask={canDismiss} + onHide={handleDismiss} + style={{ width: '560px', maxWidth: '95vw' }} + > +
+
+ +
+ + {isUrgent && ( + + )} + + {!isUrgent && ( + + )} + +

+ {__('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')} +

+ + {status.days_to_expiry !== null && status.days_to_expiry !== undefined && ( +

+ {status.days_to_expiry < 0 + ? __(`Scaduta da ${Math.abs(status.days_to_expiry)} giorni`, 'gepafin') + : __(`Scadenza tra ${status.days_to_expiry} giorni`, 'gepafin')} +

+ )} + +
+ {canDismiss && ( +
+
+
+ ); +}; + +export default Ar1ComplianceModal; diff --git a/src/modules/ar1/components/Ar1StatusTag.js b/src/modules/ar1/components/Ar1StatusTag.js new file mode 100644 index 0000000..2fb4fbf --- /dev/null +++ b/src/modules/ar1/components/Ar1StatusTag.js @@ -0,0 +1,26 @@ +import React from 'react'; +import { __ } from '@wordpress/i18n'; +import { Tag } from 'primereact/tag'; + +/** + * Badge per lo status AR1. Stati possibili: + * MISSING, DRAFT, AWAITING_SIGNATURE, SIGNED, VERIFIED, VALID, APPROACHING, EXPIRED, SUPERSEDED + */ +const STATUS_CONFIG = { + MISSING: { severity: 'danger', label: 'Da compilare', icon: 'pi pi-exclamation-circle' }, + DRAFT: { severity: 'warning', label: 'Bozza in corso', icon: 'pi pi-pencil' }, + AWAITING_SIGNATURE: { severity: 'info', label: 'Attesa firma', icon: 'pi pi-hourglass' }, + SIGNED: { severity: 'info', label: 'Firmato', icon: 'pi pi-verified' }, + VERIFIED: { severity: 'success', label: 'Verificato', icon: 'pi pi-check-circle' }, + VALID: { severity: 'success', label: 'Valido', icon: 'pi pi-check-circle' }, + APPROACHING: { severity: 'warning', label: 'In scadenza', icon: 'pi pi-clock' }, + EXPIRED: { severity: 'danger', label: 'Scaduto', icon: 'pi pi-times-circle' }, + SUPERSEDED: { severity: 'secondary', label: 'Sostituito', icon: 'pi pi-history' }, +}; + +const Ar1StatusTag = ({ status }) => { + const cfg = STATUS_CONFIG[status] || { severity: 'secondary', label: status || '—', icon: 'pi pi-circle' }; + return ; +}; + +export default Ar1StatusTag; diff --git a/src/modules/ar1/pages/Ar1Home.js b/src/modules/ar1/pages/Ar1Home.js new file mode 100644 index 0000000..df9fac8 --- /dev/null +++ b/src/modules/ar1/pages/Ar1Home.js @@ -0,0 +1,248 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { __ } from '@wordpress/i18n'; +import { useNavigate } from 'react-router-dom'; + +import { Card } from 'primereact/card'; +import { Button } from 'primereact/button'; +import { Toast } from 'primereact/toast'; +import { DataTable } from 'primereact/datatable'; +import { Column } from 'primereact/column'; +import { ConfirmDialog, confirmDialog } from 'primereact/confirmdialog'; +import { Dropdown } from 'primereact/dropdown'; +import { Dialog } from 'primereact/dialog'; + +import { useStoreValue } from '../../../store'; + +import Ar1Service from '../service/ar1Service'; +import Ar1StatusTag from '../components/Ar1StatusTag'; + +const VARIANT_OPTIONS = [ + { label: 'A1 — Persona Giuridica (societa, ente)', value: 'A1' }, + { label: 'A2 — Ditta Individuale (P.IVA persona fisica)', value: 'A2' }, + { label: 'A3 — Persona Fisica (senza P.IVA)', value: 'A3' }, +]; + +/** + * Ar1Home: schermata principale modulo AR1 per il beneficiario. + * - Card status con countdown + * - CTA dinamici (Compila / Riprendi / Firma / Rinnova) + * - Storico dichiarazioni + */ +const Ar1Home = () => { + const navigate = useNavigate(); + const toast = useRef(null); + const user = useStoreValue('getUser'); + const companyId = user?.companyId; + + const [status, setStatus] = useState(null); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [variantDialogOpen, setVariantDialogOpen] = useState(false); + const [selectedVariant, setSelectedVariant] = useState('A1'); + const [creating, setCreating] = useState(false); + + const loadAll = () => { + if (!companyId) return; + setLoading(true); + Ar1Service.getStatusForCompany(companyId, + (resp) => setStatus(resp), + (err) => console.warn('getStatus failed', err) + ); + Ar1Service.listFormsForCompany(companyId, + (resp) => { + setHistory(resp?.items || []); + setLoading(false); + }, + (err) => { + setLoading(false); + if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Impossibile caricare lo storico' }); + } + ); + }; + + useEffect(() => { + loadAll(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [companyId]); + + const startNewDraft = () => { + setCreating(true); + Ar1Service.createDraft(companyId, selectedVariant, + (resp) => { + setCreating(false); + setVariantDialogOpen(false); + if (resp?.id) navigate(`/ar1/wizard/${resp.id}`); + }, + (err) => { + setCreating(false); + if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Impossibile creare il form' }); + } + ); + }; + + const resumeForm = (formId) => navigate(`/ar1/wizard/${formId}`); + const goToSignature = (formId) => navigate(`/ar1/signature/${formId}`); + + const deleteDraft = (formId) => { + confirmDialog({ + message: __('Sei sicuro di voler eliminare questa bozza? L\'operazione non puo essere annullata.', 'gepafin'), + header: __('Conferma eliminazione', 'gepafin'), + icon: 'pi pi-exclamation-triangle', + acceptLabel: __('Elimina', 'gepafin'), + rejectLabel: __('Annulla', 'gepafin'), + acceptClassName: 'p-button-danger', + accept: () => { + Ar1Service.deleteForm(formId, + () => { + if (toast.current) toast.current.show({ severity: 'success', summary: 'OK', detail: 'Bozza eliminata' }); + loadAll(); + }, + (err) => { + if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Eliminazione fallita' }); + } + ); + } + }); + }; + + const renderStatusCard = () => { + if (!status) return null; + const isUrgent = ['MISSING', 'EXPIRED'].includes(status.status); + const canCompile = ['MISSING', 'EXPIRED', 'APPROACHING', 'VALID'].includes(status.status); + const hasActive = status.form_id && ['DRAFT', 'AWAITING_SIGNATURE'].includes(status.status); + + return ( + +
+ + {status.variant && ( + + {__('Variante:', 'gepafin')} {status.variant} + + )} + {status.days_to_expiry !== null && status.days_to_expiry !== undefined && ( + + {status.days_to_expiry < 0 + ? __(`Scaduta da ${Math.abs(status.days_to_expiry)} giorni`, 'gepafin') + : __(`Scade tra ${status.days_to_expiry} giorni`, 'gepafin')} + + )} +
+ + {status.must_recompile_reason && ( +
+ + {status.must_recompile_reason} +
+ )} + +
+ {hasActive && status.status === 'DRAFT' && ( +
+
+ ); + }; + + const statusTpl = (row) => ; + const dateTpl = (row, field) => { + const v = row[field]; + if (!v) return '—'; + try { return new Date(v).toLocaleDateString('it-IT'); } catch (e) { return v; } + }; + const actionsTpl = (row) => ( +
+ {row.status === 'DRAFT' && ( + <> +
+ ); + + return ( +
+ + + +

{__('Dichiarazione AR1 — Adeguata Verifica', 'gepafin')}

+

+ {__('Modulo di aggiornamento dell\'adeguata verifica ai sensi del D.Lgs. 231/2007 (normativa antiriciclaggio).', 'gepafin')} +

+ + {renderStatusCard()} + + + 10} + rows={10} + > + + + + dateTpl(r, 'created_at')} /> + dateTpl(r, 'signed_at')} /> + dateTpl(r, 'expires_at')} /> + + + + + setVariantDialogOpen(false)} + style={{ width: '480px', maxWidth: '95vw' }} + modal + > +

+ {__('Seleziona la tipologia di soggetto che rappresenti:', 'gepafin')} +

+ setSelectedVariant(e.value)} + style={{ width: '100%', marginBottom: 16 }} + /> +
+
+
+
+ ); +}; + +export default Ar1Home; diff --git a/src/modules/ar1/pages/Ar1Signature.js b/src/modules/ar1/pages/Ar1Signature.js new file mode 100644 index 0000000..f79bdfb --- /dev/null +++ b/src/modules/ar1/pages/Ar1Signature.js @@ -0,0 +1,210 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { __ } from '@wordpress/i18n'; +import { useNavigate, useParams } from 'react-router-dom'; + +import { Card } from 'primereact/card'; +import { Button } from 'primereact/button'; +import { Toast } from 'primereact/toast'; +import { FileUpload } from 'primereact/fileupload'; +import { Message } from 'primereact/message'; +import { ProgressSpinner } from 'primereact/progressspinner'; + +import Ar1Service from '../service/ar1Service'; +import Ar1StatusTag from '../components/Ar1StatusTag'; + +/** + * Pagina firma AR1. + * URL: /ar1/signature/:formId + * + * Flusso: genera PDF → download unsigned → firma FEQ client side → upload signed → DocVerify. + */ +const Ar1Signature = () => { + const { formId } = useParams(); + const navigate = useNavigate(); + const toast = useRef(null); + + const [form, setForm] = useState(null); + const [loading, setLoading] = useState(true); + const [generating, setGenerating] = useState(false); + const [uploading, setUploading] = useState(false); + + const refreshForm = () => { + Ar1Service.getForm(formId, + (resp) => { setForm(resp); setLoading(false); }, + (err) => { + setLoading(false); + if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Form non trovato' }); + } + ); + }; + + useEffect(() => { if (formId) refreshForm(); /* eslint-disable-next-line */ }, [formId]); + + const handleGeneratePdf = () => { + setGenerating(true); + Ar1Service.generatePdf(formId, + () => { + setGenerating(false); + refreshForm(); + if (toast.current) toast.current.show({ severity: 'success', summary: 'OK', detail: 'PDF generato' }); + }, + (err) => { + setGenerating(false); + if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Generazione PDF fallita' }); + } + ); + }; + + const handleDownloadUnsigned = async () => { + try { + const blob = await Ar1Service.downloadPdfUnsigned(formId); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `AR1_${form.variant}_da-firmare.pdf`; + a.click(); + URL.revokeObjectURL(url); + } catch (e) { + if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: e.message }); + } + }; + + const handleUploadSigned = (event) => { + const file = event.files?.[0]; + if (!file) return; + + const ext = file.name.toLowerCase().slice(file.name.lastIndexOf('.')); + if (ext !== '.pdf' && ext !== '.p7m') { + if (toast.current) toast.current.show({ severity: 'warn', summary: 'Formato non valido', detail: 'Accettati: .pdf (PAdES) o .p7m (CAdES)' }); + return; + } + + setUploading(true); + if (toast.current) toast.current.show({ severity: 'info', summary: 'Verifica in corso...', detail: 'Analisi firma digitale (fino a 60 secondi)' }); + + Ar1Service.uploadSignature(formId, file, + (resp) => { + setUploading(false); + refreshForm(); + const outcome = resp?.outcome; + if (outcome === 'VERIFIED') { + if (toast.current) toast.current.show({ severity: 'success', summary: 'Firma verificata!', detail: 'La dichiarazione e stata archiviata nei tuoi documenti aziendali.' }); + setTimeout(() => navigate('/ar1'), 1500); + } else if (outcome === 'SIGNED_NOT_VERIFIED') { + if (toast.current) toast.current.show({ severity: 'warn', summary: 'Firma accettata', detail: 'La firma e presente ma richiede verifica manuale da parte dell\'istruttore.' }); + } else if (outcome === 'SIGNED_DOCVERIFY_UNAVAILABLE') { + if (toast.current) toast.current.show({ severity: 'warn', summary: 'Verifica rimandata', detail: 'Servizio di verifica momentaneamente non disponibile. L\'istruttore verifichera la firma manualmente.' }); + } + }, + (err) => { + setUploading(false); + if (err?.detail?.code === 'NO_SIGNATURE_DETECTED') { + if (toast.current) toast.current.show({ + severity: 'error', + summary: 'Firma non rilevata', + detail: 'Il file caricato non contiene una firma digitale valida. Firmare il PDF con il proprio strumento FEQ e ricaricarlo.', + life: 6000 + }); + } else { + if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: typeof err?.detail === 'string' ? err.detail : (err?.detail?.message || 'Upload fallito') }); + } + } + ); + }; + + if (loading) return
; + if (!form) return
; + + const hasUnsignedPdf = !!form.pdf_unsigned_path; + const hasSignedPdf = !!form.pdf_signed_path; + const canUploadSig = form.status === 'AWAITING_SIGNATURE'; + const isDone = ['VERIFIED', 'SIGNED'].includes(form.status); + + return ( +
+ +

{__('Firma AR1', 'gepafin')} — {form.variant}

+
+ + + {!hasUnsignedPdf && ( +
+

{__('Il PDF del tuo modulo AR1 non e ancora stato generato.', 'gepafin')}

+
+ )} + {hasUnsignedPdf && ( +
+

{__('Scarica il PDF, firmalo con il tuo strumento FEQ (CNS, Aruba, Namirial, Dike) e ricaricalo qui sotto.', 'gepafin')}

+
+ )} +
+ + + {!canUploadSig && !isDone && ( + + )} + {canUploadSig && ( +
+

{__('Formati accettati: PDF con firma PAdES oppure file .p7m (CAdES). Dimensione massima 50 MB.', 'gepafin')}

+ +
+ )} + {isDone && ( +
+ + {hasSignedPdf && ( +
+ )} +
+ + {form.signature_verified_at && ( + +

{__('Firmatario:', 'gepafin')} {form.signature_signer_name || '—'}

+

{__('Codice fiscale:', 'gepafin')} {form.signature_signer_cf || '—'}

+

{__('Metodo:', 'gepafin')} {form.signature_type || '—'}

+

{__('Verificato il:', 'gepafin')} {new Date(form.signature_verified_at).toLocaleString('it-IT')}

+

{__('Scade il:', 'gepafin')} {form.expires_at ? new Date(form.expires_at).toLocaleDateString('it-IT') : '—'}

+
+ )} + +
+
+
+ ); +}; + +export default Ar1Signature; diff --git a/src/modules/ar1/pages/Ar1Wizard.js b/src/modules/ar1/pages/Ar1Wizard.js new file mode 100644 index 0000000..b251df3 --- /dev/null +++ b/src/modules/ar1/pages/Ar1Wizard.js @@ -0,0 +1,372 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { __ } from '@wordpress/i18n'; +import { useNavigate, useParams } from 'react-router-dom'; + +import { Card } from 'primereact/card'; +import { Button } from 'primereact/button'; +import { InputText } from 'primereact/inputtext'; +import { InputTextarea } from 'primereact/inputtextarea'; +import { Calendar } from 'primereact/calendar'; +import { RadioButton } from 'primereact/radiobutton'; +import { Checkbox } from 'primereact/checkbox'; +import { Dropdown } from 'primereact/dropdown'; +import { Toast } from 'primereact/toast'; +import { Steps } from 'primereact/steps'; +import { ProgressSpinner } from 'primereact/progressspinner'; +import { Message } from 'primereact/message'; +import { FileUpload } from 'primereact/fileupload'; + +import Ar1Service from '../service/ar1Service'; + +/** + * Wizard data-driven: legge schema_snapshot del form e genera step/field dinamicamente. + * Uno step per quadro. Auto-save onBlur via PUT /quadri. + * + * URL: /ar1/wizard/:formId + */ +const Ar1Wizard = () => { + const { formId } = useParams(); + const navigate = useNavigate(); + const toast = useRef(null); + + const [form, setForm] = useState(null); + const [quadriValues, setQuadriValues] = useState({}); + const [loading, setLoading] = useState(true); + const [activeIndex, setActiveIndex] = useState(0); + const [saving, setSaving] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const quadri = useMemo( + () => (form?.schema_snapshot?.quadri || []).filter(q => !q.is_legal_frame), + [form] + ); + + useEffect(() => { + if (!formId) return; + Ar1Service.getForm(formId, + (resp) => { + setForm(resp); + setQuadriValues(resp.quadri || {}); + setLoading(false); + }, + (err) => { + setLoading(false); + if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Form non trovato' }); + } + ); + }, [formId]); + + const isReadonly = form && form.status !== 'DRAFT'; + + const saveQuadro = (quadroId) => { + if (isReadonly) return; + const patch = { [quadroId]: quadriValues[quadroId] || {} }; + setSaving(true); + Ar1Service.updateQuadri(formId, patch, + (resp) => { setSaving(false); setForm(resp); }, + (err) => { + setSaving(false); + if (toast.current) toast.current.show({ severity: 'warn', summary: 'Save fallito', detail: err?.detail || 'Riprovare' }); + } + ); + }; + + const handleFieldChange = (quadroId, fieldId, value) => { + setQuadriValues(prev => ({ + ...prev, + [quadroId]: { ...(prev[quadroId] || {}), [fieldId]: value } + })); + }; + + const handleRowFieldChange = (quadroId, rowIndex, fieldId, value) => { + setQuadriValues(prev => { + const q = prev[quadroId] || { rows: [] }; + const rows = [...(q.rows || [])]; + rows[rowIndex] = { ...(rows[rowIndex] || {}), [fieldId]: value }; + return { ...prev, [quadroId]: { ...q, rows } }; + }); + }; + + const addRow = (quadroId, maxRows) => { + setQuadriValues(prev => { + const q = prev[quadroId] || { rows: [] }; + if ((q.rows || []).length >= maxRows) return prev; + return { ...prev, [quadroId]: { ...q, rows: [...(q.rows || []), {}] } }; + }); + }; + + const removeRow = (quadroId, rowIndex) => { + setQuadriValues(prev => { + const q = prev[quadroId] || { rows: [] }; + const rows = (q.rows || []).filter((_, i) => i !== rowIndex); + return { ...prev, [quadroId]: { ...q, rows } }; + }); + }; + + const submitFinale = () => { + setSubmitting(true); + const currentQuadro = quadri[activeIndex]; + const patch = { [currentQuadro.id]: quadriValues[currentQuadro.id] || {} }; + Ar1Service.updateQuadri(formId, patch, + () => { + Ar1Service.submitForSignature(formId, + () => { + setSubmitting(false); + if (toast.current) toast.current.show({ severity: 'success', summary: 'OK', detail: 'Modulo pronto per la firma' }); + setTimeout(() => navigate(`/ar1/signature/${formId}`), 600); + }, + (err) => { + setSubmitting(false); + if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Submit fallito' }); + } + ); + }, + (err) => { + setSubmitting(false); + if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Save fallito' }); + } + ); + }; + + const renderField = (field, value, onChange, path = '') => { + const key = `${path}-${field.id}`; + const disabled = isReadonly; + const req = field.required ? ' *' : ''; + const commonLabel = ; + + switch (field.type) { + case 'text': + case 'email': + return ( +
+ {commonLabel} + onChange(field.id, field.uppercase ? e.target.value.toUpperCase() : e.target.value)} + disabled={disabled} + maxLength={field.max_length} + placeholder={field.placeholder} + style={{ width: '100%' }} + /> + {field.legal_ref && {field.legal_ref}} +
+ ); + case 'textarea': + return ( +
+ {commonLabel} + onChange(field.id, e.target.value)} disabled={disabled} rows={3} maxLength={field.max_length} style={{ width: '100%' }} /> +
+ ); + case 'date': + return ( +
+ {commonLabel} + onChange(field.id, e.value ? e.value.toISOString().slice(0, 10) : null)} disabled={disabled} dateFormat="dd/mm/yy" showIcon style={{ width: '100%' }} /> +
+ ); + case 'checkbox': + return ( +
+ onChange(field.id, e.checked)} disabled={disabled} /> + +
+ ); + case 'radio': + return ( +
+ {commonLabel} + {(field.options || []).map((opt, idx) => { + const optVal = typeof opt === 'string' ? opt : opt.value; + const optLabel = typeof opt === 'string' ? opt : opt.label; + const rid = `${key}-opt-${idx}`; + return ( +
+ onChange(field.id, e.value)} disabled={disabled} /> + +
+ ); + })} +
+ ); + case 'enum': + return ( +
+ {commonLabel} + ({ label: o.replace(/_/g, ' '), value: o }))} onChange={(e) => onChange(field.id, e.value)} disabled={disabled} style={{ width: '100%' }} showClear /> +
+ ); + case 'yes_no_with_note': { + const v = typeof value === 'object' && value ? value : {}; + const yes = v.value === 'si' || v.value === 'yes' || v.value === 'true'; + const no = v.value === 'no' || v.value === 'false'; + return ( +
+ {commonLabel} + {field.legal_ref && {field.legal_ref}} +
+
+ onChange(field.id, { ...v, value: 'si' })} disabled={disabled} /> + +
+
+ onChange(field.id, { ...v, value: 'no' })} disabled={disabled} /> + +
+
+ {yes && ( + onChange(field.id, { ...v, note: e.target.value })} + disabled={disabled} + rows={2} + placeholder={field.note_label || __('Specificare', 'gepafin')} + style={{ width: '100%' }} + /> + )} +
+ ); + } + default: + return ( +
+ {commonLabel} + onChange(field.id, e.target.value)} disabled={disabled} style={{ width: '100%' }} /> +
+ ); + } + }; + + const renderQuadro = (quadro) => { + const q = quadriValues[quadro.id] || {}; + + if (quadro.upload_slots) { + return ( +
+

{quadro.title}

+ {quadro.description &&

{quadro.description}

} + + {quadro.upload_slots.map(slot => ( +
+ + { + const file = e.files[0]; + handleFieldChange(quadro.id, slot.id, { filename: file.name, size: file.size }); + if (toast.current) toast.current.show({ severity: 'info', summary: 'File selezionato', detail: file.name }); + }} + auto + chooseLabel={q[slot.id]?.filename || __('Scegli file', 'gepafin')} + style={{ marginTop: 6 }} + /> +
+ ))} +
+ ); + } + + if (quadro.row_type) { + const rows = q.rows || []; + return ( +
+

{quadro.title}

+ {quadro.description &&

{quadro.description}

} + {rows.map((row, idx) => ( + + {(quadro.row_fields || []).map(field => renderField(field, row[field.id], (fid, val) => handleRowFieldChange(quadro.id, idx, fid, val), `q-${quadro.id}-row-${idx}`))} + {!isReadonly && ( +
+ ); + } + + return ( +
+

{quadro.title}

+ {quadro.description &&

{quadro.description}

} + {(quadro.fields || []).map(field => renderField(field, q[field.id], (fid, val) => handleFieldChange(quadro.id, fid, val), `q-${quadro.id}`))} + {quadro.nested_full && ( +
+

{__('Dettaglio aggiuntivo', 'gepafin')}

+ {(quadro.nested_full.fields || []).map(field => renderField( + field, + (q.nested || {})[field.id], + (fid, val) => handleFieldChange(quadro.id, 'nested', { ...((q.nested) || {}), [fid]: val }), + `q-${quadro.id}-nested` + ))} +
+ )} +
+ ); + }; + + if (loading) return
; + if (!form) return
; + + const steps = quadri.map(q => ({ label: q.id })); + const activeQuadro = quadri[activeIndex]; + const isLastStep = activeIndex === quadri.length - 1; + + return ( +
+ +

{__('Compilazione AR1', 'gepafin')} — {form.variant}

+ {isReadonly && ( + + )} + + { + if (!isReadonly && activeQuadro) saveQuadro(activeQuadro.id); + setActiveIndex(e.index); + }} + readOnly={false} + style={{ marginBottom: 20 }} + /> + + !isReadonly && saveQuadro(activeQuadro.id)}> + {activeQuadro && renderQuadro(activeQuadro)} + + +
+
+
+ + ); +}; + +export default Ar1Wizard; diff --git a/src/modules/ar1/service/ar1Service.js b/src/modules/ar1/service/ar1Service.js new file mode 100644 index 0000000..4a0129e --- /dev/null +++ b/src/modules/ar1/service/ar1Service.js @@ -0,0 +1,166 @@ +/** + * Client HTTP per ar1-compiler (microservizio BFLOWS). + * Il microservizio valida lo stesso JWT di GEPAFIN-BE (HS512 shared secret). + * + * Env var: REACT_APP_AR1_API_URL (es. http://78.46.41.91:18091) + * + * Pattern replicato 1:1 da rendicontazioneService.js. + */ +import { storeGet } from '../../../store'; + +const BASE_URL = process.env.REACT_APP_AR1_API_URL || ''; + +const buildHeaders = () => { + const token = storeGet('getToken'); + const h = { 'Content-Type': 'application/json' }; + if (token) h['Authorization'] = `Bearer ${token}`; + return h; +}; + +const buildHeadersMultipart = () => { + const token = storeGet('getToken'); + const h = {}; + if (token) h['Authorization'] = `Bearer ${token}`; + // niente Content-Type: fetch imposta boundary per multipart/form-data + return h; +}; + +const handleResponse = async (response, onSuccess, onError) => { + let body = null; + try { body = await response.json(); } catch (e) { body = { detail: response.statusText }; } + if (response.status >= 200 && response.status < 300) { + if (onSuccess) onSuccess(body); + } else { + if (onError) onError({ status: response.status, ...body }); + } +}; + +const handleError = (err, onError) => { + if (onError) onError({ status: 0, detail: err.message }); +}; + +const Ar1Service = { + // ---------- Status pubblico (per compliance modal al login) ---------- + getStatusForCompany(companyId, onSuccess, onError) { + fetch(`${BASE_URL}/public/ar1-status/${companyId}`, { + method: 'GET', mode: 'cors', headers: buildHeaders() + }) + .then(r => handleResponse(r, onSuccess, onError)) + .catch(e => handleError(e, onError)); + }, + + // ---------- CRUD form beneficiario ---------- + createDraft(companyId, variant, onSuccess, onError) { + fetch(`${BASE_URL}/api/ar1-forms`, { + method: 'POST', mode: 'cors', headers: buildHeaders(), + body: JSON.stringify({ company_id: companyId, variant }) + }) + .then(r => handleResponse(r, onSuccess, onError)) + .catch(e => handleError(e, onError)); + }, + + getForm(formId, onSuccess, onError) { + fetch(`${BASE_URL}/api/ar1-forms/${formId}`, { + method: 'GET', mode: 'cors', headers: buildHeaders() + }) + .then(r => handleResponse(r, onSuccess, onError)) + .catch(e => handleError(e, onError)); + }, + + listFormsForCompany(companyId, onSuccess, onError) { + fetch(`${BASE_URL}/api/ar1-forms/company/${companyId}`, { + method: 'GET', mode: 'cors', headers: buildHeaders() + }) + .then(r => handleResponse(r, onSuccess, onError)) + .catch(e => handleError(e, onError)); + }, + + updateQuadri(formId, quadriPatch, onSuccess, onError) { + fetch(`${BASE_URL}/api/ar1-forms/${formId}/quadri`, { + method: 'PUT', mode: 'cors', headers: buildHeaders(), + body: JSON.stringify({ quadri: quadriPatch }) + }) + .then(r => handleResponse(r, onSuccess, onError)) + .catch(e => handleError(e, onError)); + }, + + submitForSignature(formId, onSuccess, onError) { + fetch(`${BASE_URL}/api/ar1-forms/${formId}/submit-for-signature`, { + method: 'PUT', mode: 'cors', headers: buildHeaders() + }) + .then(r => handleResponse(r, onSuccess, onError)) + .catch(e => handleError(e, onError)); + }, + + deleteForm(formId, onSuccess, onError) { + fetch(`${BASE_URL}/api/ar1-forms/${formId}`, { + method: 'DELETE', mode: 'cors', headers: buildHeaders() + }) + .then(r => { + if (r.status === 204) { + if (onSuccess) onSuccess({}); + } else { + handleResponse(r, onSuccess, onError); + } + }) + .catch(e => handleError(e, onError)); + }, + + // ---------- PDF ---------- + generatePdf(formId, onSuccess, onError) { + fetch(`${BASE_URL}/api/ar1-forms/${formId}/generate-pdf`, { + method: 'POST', mode: 'cors', headers: buildHeaders() + }) + .then(r => handleResponse(r, onSuccess, onError)) + .catch(e => handleError(e, onError)); + }, + + downloadPdfUnsigned(formId) { + return fetch(`${BASE_URL}/api/ar1-forms/${formId}/pdf-unsigned`, { + method: 'GET', mode: 'cors', headers: buildHeadersMultipart() + }).then(r => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.blob(); + }); + }, + + downloadPdfSigned(formId) { + return fetch(`${BASE_URL}/api/ar1-forms/${formId}/pdf-signed`, { + method: 'GET', mode: 'cors', headers: buildHeadersMultipart() + }).then(r => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.blob(); + }); + }, + + // ---------- Firma ---------- + uploadSignature(formId, fileObject, onSuccess, onError) { + const formData = new FormData(); + formData.append('file', fileObject); + fetch(`${BASE_URL}/api/ar1-forms/${formId}/upload-signature`, { + method: 'POST', mode: 'cors', headers: buildHeadersMultipart(), + body: formData + }) + .then(r => handleResponse(r, onSuccess, onError)) + .catch(e => handleError(e, onError)); + }, + + reVerifySignature(formId, onSuccess, onError) { + fetch(`${BASE_URL}/api/ar1-forms/${formId}/verify`, { + method: 'POST', mode: 'cors', headers: buildHeaders() + }) + .then(r => handleResponse(r, onSuccess, onError)) + .catch(e => handleError(e, onError)); + }, + + // ---------- Archive manuale (di solito automatico) ---------- + archiveToCompanyDocument(formId, onSuccess, onError) { + fetch(`${BASE_URL}/api/ar1-forms/${formId}/archive-to-company-document`, { + method: 'POST', mode: 'cors', headers: buildHeaders() + }) + .then(r => handleResponse(r, onSuccess, onError)) + .catch(e => handleError(e, onError)); + }, +}; + +export default Ar1Service; diff --git a/src/routes.js b/src/routes.js index 002e772..65985e2 100644 --- a/src/routes.js +++ b/src/routes.js @@ -21,6 +21,9 @@ import PraticaRendicontazioneEdit from './modules/rendicontazione/pages/PraticaR import DevSwitchUser from './modules/rendicontazione/pages/DevSwitchUser'; import IstruttoriaQueue from './modules/rendicontazione/pages/IstruttoriaQueue'; import IstruttoriaPratica from './modules/rendicontazione/pages/IstruttoriaPratica'; +import Ar1Home from './modules/ar1/pages/Ar1Home'; +import Ar1Wizard from './modules/ar1/pages/Ar1Wizard'; +import Ar1Signature from './modules/ar1/pages/Ar1Signature'; import BandoFlowEdit from './pages/BandoFlowEdit'; import Imieibandi from './pages/Imieibandi'; import BandoApplication from './pages/BandoApplication'; @@ -163,6 +166,27 @@ const routes = ({ role, chosenCompanyId }) => { {'ROLE_PRE_INSTRUCTOR' === role ? : null} {'ROLE_INSTRUCTOR_MANAGER' === role ? : null} }/> + + {'ROLE_BENEFICIARY' === role ? : null} + {'ROLE_SUPER_ADMIN' === role ? : null} + {'ROLE_CONFIDI' === role ? : null} + {'ROLE_PRE_INSTRUCTOR' === role ? : null} + {'ROLE_INSTRUCTOR_MANAGER' === role ? : null} + }/> + + {'ROLE_BENEFICIARY' === role ? : null} + {'ROLE_SUPER_ADMIN' === role ? : null} + {'ROLE_CONFIDI' === role ? : null} + {'ROLE_PRE_INSTRUCTOR' === role ? : null} + {'ROLE_INSTRUCTOR_MANAGER' === role ? : null} + }/> + + {'ROLE_BENEFICIARY' === role ? : null} + {'ROLE_SUPER_ADMIN' === role ? : null} + {'ROLE_CONFIDI' === role ? : null} + {'ROLE_PRE_INSTRUCTOR' === role ? : null} + {'ROLE_INSTRUCTOR_MANAGER' === role ? : null} + }/> {'ROLE_BENEFICIARY' === role ? : null} {'ROLE_SUPER_ADMIN' === role ? : null}