feat(ar1-admin): riscrittura italiana + 5 tab con Testi PEC + editor form-based

Riscrittura completa di Ar1AdminConfig.js (490 -> 888 LOC) con UI italianizzata,
labels parlanti, editor form-based per layout template, tab 'Testi comunicazioni'
con editor dei 5 template email + anteprima server-side.

CAMBIAMENTI FUNZIONALI:

Tab 1 Template:
  - 'In uso' (ACTIVE/DRAFT) vs 'Archiviati' ora in DUE Card separate, non mescolati
  - Nomi varianti in italiano ('A1 — Persona Giuridica (societa, ente)', ecc)
  - Status tag italiano ('In uso', 'Archiviato', 'Bozza')
  - Editor layout: DEFAULT modalita form (Brand/Header/Intro/Privacy con campi
    espliciti nome, logo_url, colori primario+accento, titoli, saluto, corpo
    introduttivo, URL privacy, testo piede). Toggle 'Modalita avanzata (JSON raw)'
    per chi vuole editare tutto il layout_config.
  - Bottone 'Anteprima PDF' presente (placeholder toast TODO — endpoint BE da wirare)
  - Bottone 'Nuova versione' eredita automaticamente layout_config da ACTIVE corrente

Tab 2 Policy:
  - Tutti i label tradotti in italiano con help text inline per ogni campo
  - Dropdown 'Categoria documento aziendale' da GET /admin/document-categories
    (cross-schema read a gepafin_schema.document_category) invece di InputNumber
    raw. Mostra 'DURC — Documento Unico...', 'ANTIRICICLAGGIO — Dichiarazione...'
  - Switch con descrizioni espanse (cosa fa, quando si attiva)
  - Divider visivo tra campi numerici e switch booleani

Tab 3 Regole reminder:
  - Colonna 'Regola' con label italiano parlante + kind tecnico in sottotitolo
  - Colonna 'Quando parte' calcolata dinamicamente: '30 giorni PRIMA',
    'Il giorno della scadenza', '5 giorni DOPO', ecc
  - Colonna 'Ricorrenza' formattata ('una tantum' vs 'ogni 30 giorni')
  - Dialog edit: Dropdown PEC_KIND_OPTIONS con 5 etichette italiane (kind
    disabled se editing esistente), help text inline sul campo offset_days
    che cambia live ('3 giorni prima della scadenza' / 'giorno della scadenza'
    / '3 giorni dopo la scadenza')

Tab 4 Invio massivo:
  - Label italiano 'Solo aziende con AR1 scaduta' / 'Solo aziende senza AR1'
  - Pulsante 'Anteprima (non invia)' con toast descrittivo
  - Pulsante 'Invia PEC' richiede ConfirmDialog
  - Messaggio warning giallo chiarisce che la PEC sara dispatchata dal BE Gepafin
  - Box esito con matched / marked_for_pec / company_ids (trimmato a 30+…)

Tab 5 Testi comunicazioni (NUOVO):
  - Banner info + elenco variabili supportate come Tag cliccabili (7 variabili)
  - DataTable 5 righe: Tipo comunicazione (label IT + kind mono) / Oggetto /
    Versione (Tag 'v1', 'v2', ...) / Aggiornato il / Azione 'Modifica'
  - Dialog edit massimizzabile: subject + body_html (textarea monospace 10
    righe) + body_text fallback (5 righe) + note interne
  - Bottone 'Anteprima (dati di esempio)' chiama POST /admin/ar1-email-templates/
    {kind}/preview e mostra rendering HTML interpolato (dangerouslySetInnerHTML)
    con subject renderizzato + body in box stile email
  - Save bump version lato BE (toast 'Testo aggiornato (version N)')

SERVICE:
  ar1Service.js esteso da 213 -> 247 LOC:
    + listDocumentCategories (GET /admin/document-categories)
    + listEmailTemplates / getEmailTemplate / updateEmailTemplate /
      previewEmailTemplate (4 metodi admin email)

VALIDAZIONE:
  Parse-check @babel/parser plugin JSX: 2/2 OK (service + Ar1AdminConfig).
  Hot-reload CRA webpack compiled with 1 warning (solo unused-vars pre-esistenti).

COSE NON ANCORA FATTE (next):
  - Endpoint BE POST /admin/ar1-templates/:id/preview per anteprima PDF
    (wiring FE: rimuovere toast TODO dentro openEditLayout/tplActiveActionsTpl)
  - Test manuale dal browser con hard-refresh
This commit is contained in:
BFLOWS
2026-04-23 14:32:34 +02:00
parent 2028239759
commit 4a719ded5b
2 changed files with 585 additions and 153 deletions

View File

@@ -16,20 +16,58 @@ import { Checkbox } from 'primereact/checkbox';
import { Dialog } from 'primereact/dialog'; import { Dialog } from 'primereact/dialog';
import { Tag } from 'primereact/tag'; import { Tag } from 'primereact/tag';
import { Message } from 'primereact/message'; import { Message } from 'primereact/message';
import { Dropdown } from 'primereact/dropdown';
import { Divider } from 'primereact/divider';
import { ConfirmDialog, confirmDialog } from 'primereact/confirmdialog'; import { ConfirmDialog, confirmDialog } from 'primereact/confirmdialog';
// service // service
import Ar1Service from '../service/ar1Service'; import Ar1Service from '../service/ar1Service';
// ==================================================
// Mappe label in italiano — SINGLE SOURCE OF TRUTH
// ==================================================
// Stati template
const TEMPLATE_STATUS_LABEL = {
ACTIVE: 'In uso',
ARCHIVED: 'Archiviato',
DRAFT: 'Bozza'
};
// Varianti template
const VARIANT_LABEL = {
A1: 'A1 — Persona Giuridica (societa, ente)',
A2: 'A2 — Ditta Individuale (P.IVA persona fisica)',
A3: 'A3 — Persona Fisica (senza P.IVA)'
};
// Kind regole reminder PEC
const PEC_KIND_LABEL = {
AR1_REMINDER_30D: 'Promemoria 30 giorni prima della scadenza',
AR1_REMINDER_7D: 'Promemoria 7 giorni prima della scadenza',
AR1_EXPIRED: 'Notifica alla scadenza (giorno 0)',
AR1_POST_EXPIRED_RECURRING: 'Sollecito ricorrente dopo la scadenza',
AR1_BULK_MANUAL: 'Invio manuale massivo (superadmin)'
};
const PEC_KIND_OPTIONS = [
{ label: PEC_KIND_LABEL.AR1_REMINDER_30D, value: 'AR1_REMINDER_30D' },
{ label: PEC_KIND_LABEL.AR1_REMINDER_7D, value: 'AR1_REMINDER_7D' },
{ label: PEC_KIND_LABEL.AR1_EXPIRED, value: 'AR1_EXPIRED' },
{ label: PEC_KIND_LABEL.AR1_POST_EXPIRED_RECURRING, value: 'AR1_POST_EXPIRED_RECURRING' },
{ label: PEC_KIND_LABEL.AR1_BULK_MANUAL, value: 'AR1_BULK_MANUAL' }
];
/** /**
* Ar1AdminConfig — configurazione AR1 per superadmin. * Ar1AdminConfig — configurazione AR1 per superadmin.
* URL: /ar1-admin * URL: /ar1-admin
* *
* 4 sezioni (TabView): * 5 sezioni (TabView):
* 1. Template — lista + layout editor L2 + nuova versione * 1. Template — lista (In uso / Archiviati separati) + anteprima PDF + editor form-based layout
* 2. Policy — singleton (validity, popup, auto-archive, category) * 2. Policy — singleton con dropdown categoria documento + labels in italiano
* 3. Regole reminder PEC — CRUD pec-schedule-config * 3. Regole — CRUD regole reminder PEC con kind parlante + help inline
* 4. Invio massivo PEC — bulk-request-recompilation (dry-run + live) * 4. Invio massivo — bulk-request-recompilation (dry-run + live)
* 5. Testi PEC — editor dei 5 template email AR1 (sync BE Gepafin via pull /internal/ar1-email-templates)
*/ */
const Ar1AdminConfig = () => { const Ar1AdminConfig = () => {
const toast = useRef(null); const toast = useRef(null);
@@ -40,15 +78,18 @@ const Ar1AdminConfig = () => {
const [loadingTpl, setLoadingTpl] = useState(false); const [loadingTpl, setLoadingTpl] = useState(false);
const [editLayoutOpen, setEditLayoutOpen] = useState(false); const [editLayoutOpen, setEditLayoutOpen] = useState(false);
const [editLayoutTpl, setEditLayoutTpl] = useState(null); const [editLayoutTpl, setEditLayoutTpl] = useState(null);
const [layoutJsonText, setLayoutJsonText] = useState(''); const [layoutForm, setLayoutForm] = useState({}); // form strutturato
const [layoutAdvancedJson, setLayoutAdvancedJson] = useState(''); // modalita JSON raw
const [useAdvancedEditor, setUseAdvancedEditor] = useState(false);
const [newVersionOpen, setNewVersionOpen] = useState(false); const [newVersionOpen, setNewVersionOpen] = useState(false);
const [newVersionVariant, setNewVersionVariant] = useState('A1'); const [newVersionVariant, setNewVersionVariant] = useState('A1');
const [newVersionData, setNewVersionData] = useState({ version: '', layout_config: '{}', activate_now: true }); const [newVersionData, setNewVersionData] = useState({ version: '', activate_now: true });
// ========= POLICY ========= // ========= POLICY =========
const [policy, setPolicy] = useState(null); const [policy, setPolicy] = useState(null);
const [policyDraft, setPolicyDraft] = useState(null); const [policyDraft, setPolicyDraft] = useState(null);
const [savingPolicy, setSavingPolicy] = useState(false); const [savingPolicy, setSavingPolicy] = useState(false);
const [docCategories, setDocCategories] = useState([]);
// ========= PEC RULES ========= // ========= PEC RULES =========
const [pecRules, setPecRules] = useState([]); const [pecRules, setPecRules] = useState([]);
@@ -63,24 +104,50 @@ const Ar1AdminConfig = () => {
const [bulkResult, setBulkResult] = useState(null); const [bulkResult, setBulkResult] = useState(null);
const [bulkRunning, setBulkRunning] = useState(false); const [bulkRunning, setBulkRunning] = useState(false);
// ========= EMAIL TEMPLATES =========
const [emailTemplates, setEmailTemplates] = useState([]);
const [loadingEmail, setLoadingEmail] = useState(false);
const [availableVariables, setAvailableVariables] = useState([]);
const [editEmailOpen, setEditEmailOpen] = useState(false);
const [editEmailData, setEditEmailData] = useState(null);
const [previewHtml, setPreviewHtml] = useState(null);
const [previewSubject, setPreviewSubject] = useState(null);
// ---- load all ---- // ---- load all ----
const loadTemplates = () => { const loadTemplates = () => {
setLoadingTpl(true); setLoadingTpl(true);
Ar1Service.listTemplates( Ar1Service.listTemplates(
(resp) => { setTemplates(resp?.items || resp || []); setLoadingTpl(false); }, (resp) => { setTemplates(resp?.items || resp || []); setLoadingTpl(false); },
(err) => { setLoadingTpl(false); if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Load templates fallito' }); } (err) => { setLoadingTpl(false); if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Caricamento template fallito' }); }
); );
}; };
const loadPolicy = () => { const loadPolicy = () => {
Ar1Service.getPolicy( Ar1Service.getPolicy(
(resp) => { setPolicy(resp); setPolicyDraft(resp); }, (resp) => { setPolicy(resp); setPolicyDraft(resp); },
(err) => { if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Load policy fallito' }); } (err) => { if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Caricamento policy fallito' }); }
); );
}; };
const loadPecRules = () => { const loadPecRules = () => {
Ar1Service.listPecSchedule( Ar1Service.listPecSchedule(
(resp) => setPecRules(resp?.items || resp || []), (resp) => setPecRules(resp?.items || resp || []),
(err) => { if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Load pec rules fallito' }); } (err) => { if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Caricamento regole fallito' }); }
);
};
const loadDocCategories = () => {
Ar1Service.listDocumentCategories(
(resp) => setDocCategories(resp?.items || []),
(err) => console.warn('Categorie non caricate:', err)
);
};
const loadEmailTemplates = () => {
setLoadingEmail(true);
Ar1Service.listEmailTemplates(
(resp) => {
setEmailTemplates(resp?.items || []);
setAvailableVariables(resp?.available_variables || []);
setLoadingEmail(false);
},
(err) => { setLoadingEmail(false); if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Caricamento testi fallito' }); }
); );
}; };
@@ -88,54 +155,106 @@ const Ar1AdminConfig = () => {
loadTemplates(); loadTemplates();
loadPolicy(); loadPolicy();
loadPecRules(); loadPecRules();
loadDocCategories();
loadEmailTemplates();
}, []); }, []);
// ========= TEMPLATE HANDLERS ========= // ==================================================
// SEZIONE 1: TEMPLATE
// ==================================================
// Divido active vs archived per UI separata
const activeTemplates = templates.filter(t => t.status === 'ACTIVE' || t.status === 'DRAFT');
const archivedTemplates = templates.filter(t => t.status === 'ARCHIVED');
const openEditLayout = (tpl) => { const openEditLayout = (tpl) => {
setEditLayoutTpl(tpl); setEditLayoutTpl(tpl);
setLayoutJsonText(JSON.stringify(tpl.layout_config || {}, null, 2)); const lc = tpl.layout_config || {};
// popolo form con chiavi comuni; altre restano in "advanced JSON"
setLayoutForm({
brand_name: lc.brand?.name || 'Gepafin S.p.A.',
brand_logo_url: lc.brand?.logo_url || '',
brand_color_primary: lc.brand?.color_primary || '#003d7a',
brand_color_accent: lc.brand?.color_accent || '#e65100',
header_title: lc.header?.title || 'Modulo AR1 — Adeguata Verifica',
header_subtitle: lc.header?.subtitle || 'D.Lgs. 231/2007',
intro_salutation: lc.intro?.salutation || 'Gentile Cliente,',
intro_body: lc.intro?.body || '',
privacy_url: lc.privacy?.url || '',
privacy_body: lc.privacy?.body || ''
});
setLayoutAdvancedJson(JSON.stringify(lc, null, 2));
setUseAdvancedEditor(false);
setEditLayoutOpen(true); setEditLayoutOpen(true);
}; };
const buildLayoutFromForm = () => ({
brand: {
name: layoutForm.brand_name,
logo_url: layoutForm.brand_logo_url,
color_primary: layoutForm.brand_color_primary,
color_accent: layoutForm.brand_color_accent
},
header: {
title: layoutForm.header_title,
subtitle: layoutForm.header_subtitle
},
intro: {
salutation: layoutForm.intro_salutation,
body: layoutForm.intro_body
},
privacy: {
url: layoutForm.privacy_url,
body: layoutForm.privacy_body
}
});
const saveLayout = () => { const saveLayout = () => {
let parsed; let payload;
try { parsed = JSON.parse(layoutJsonText); } if (useAdvancedEditor) {
catch (e) { if (toast.current) toast.current.show({ severity: 'error', summary: 'JSON invalido', detail: e.message }); return; } try { payload = JSON.parse(layoutAdvancedJson); }
Ar1Service.updateTemplateLayout(editLayoutTpl.id, parsed, catch (e) { if (toast.current) toast.current.show({ severity: 'error', summary: 'JSON non valido', detail: e.message }); return; }
} else {
payload = buildLayoutFromForm();
}
Ar1Service.updateTemplateLayout(editLayoutTpl.id, payload,
() => { () => {
if (toast.current) toast.current.show({ severity: 'success', summary: 'OK', detail: 'Layout aggiornato' }); if (toast.current) toast.current.show({ severity: 'success', summary: 'Salvato', detail: 'Layout aggiornato' });
setEditLayoutOpen(false); setEditLayoutOpen(false);
loadTemplates(); loadTemplates();
}, },
(err) => { if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Save fallito' }); } (err) => { if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Salvataggio fallito' }); }
); );
}; };
const openNewVersion = (variant) => { const openNewVersion = (variant) => {
setNewVersionVariant(variant); setNewVersionVariant(variant);
setNewVersionData({ version: '', layout_config: '{}', activate_now: true }); setNewVersionData({ version: '', activate_now: true });
setNewVersionOpen(true); setNewVersionOpen(true);
}; };
const saveNewVersion = () => { const saveNewVersion = () => {
let layoutParsed; // Eredita layout_config dalla versione ACTIVE corrente
try { layoutParsed = JSON.parse(newVersionData.layout_config); } const active = templates.find(t => t.variant === newVersionVariant && t.status === 'ACTIVE');
catch (e) { if (toast.current) toast.current.show({ severity: 'error', summary: 'Layout JSON invalido', detail: e.message }); return; } const layoutConfig = active?.layout_config || {};
Ar1Service.createNewTemplateVersion(newVersionVariant, { Ar1Service.createNewTemplateVersion(newVersionVariant, {
version: newVersionData.version, version: newVersionData.version,
layout_config: layoutParsed, layout_config: layoutConfig,
activate_now: newVersionData.activate_now activate_now: newVersionData.activate_now
}, },
() => { () => {
if (toast.current) toast.current.show({ severity: 'success', summary: 'OK', detail: `Nuova versione ${newVersionVariant} v${newVersionData.version} creata` }); if (toast.current) toast.current.show({ severity: 'success', summary: 'Creata', detail: `Nuova versione ${newVersionVariant} v${newVersionData.version}` });
setNewVersionOpen(false); setNewVersionOpen(false);
loadTemplates(); loadTemplates();
}, },
(err) => { if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Create fallito' }); } (err) => { if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Creazione fallita' }); }
); );
}; };
// ========= POLICY HANDLERS ========= // ==================================================
// SEZIONE 2: POLICY
// ==================================================
const savePolicy = () => { const savePolicy = () => {
setSavingPolicy(true); setSavingPolicy(true);
const patch = { const patch = {
@@ -151,16 +270,19 @@ const Ar1AdminConfig = () => {
setSavingPolicy(false); setSavingPolicy(false);
setPolicy(resp); setPolicy(resp);
setPolicyDraft(resp); setPolicyDraft(resp);
if (toast.current) toast.current.show({ severity: 'success', summary: 'OK', detail: 'Policy aggiornata' }); if (toast.current) toast.current.show({ severity: 'success', summary: 'Salvata', detail: 'Policy aggiornata' });
}, },
(err) => { (err) => {
setSavingPolicy(false); setSavingPolicy(false);
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Save policy fallito' }); if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Salvataggio fallito' });
} }
); );
}; };
// ========= PEC RULE HANDLERS ========= // ==================================================
// SEZIONE 3: REGOLE PEC
// ==================================================
const openPecDialog = (rule) => { const openPecDialog = (rule) => {
if (rule) { if (rule) {
setPecEditing(rule); setPecEditing(rule);
@@ -174,7 +296,7 @@ const Ar1AdminConfig = () => {
}); });
} else { } else {
setPecEditing(null); setPecEditing(null);
setPecDraft({ kind: '', offset_days: 0, is_recurring: false, recurring_interval_days: null, enabled: true, description: '' }); setPecDraft({ kind: '', offset_days: 30, is_recurring: false, recurring_interval_days: null, enabled: true, description: '' });
} }
setPecDialogOpen(true); setPecDialogOpen(true);
}; };
@@ -182,307 +304,583 @@ const Ar1AdminConfig = () => {
const savePecRule = () => { const savePecRule = () => {
const payload = { ...pecDraft }; const payload = { ...pecDraft };
const onOk = () => { const onOk = () => {
if (toast.current) toast.current.show({ severity: 'success', summary: 'OK', detail: pecEditing ? 'Regola aggiornata' : 'Regola creata' }); if (toast.current) toast.current.show({ severity: 'success', summary: 'Salvata', detail: pecEditing ? 'Regola aggiornata' : 'Regola creata' });
setPecDialogOpen(false); setPecDialogOpen(false);
loadPecRules(); loadPecRules();
}; };
const onKo = (err) => { if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Save fallito' }); }; const onKo = (err) => { if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Salvataggio fallito' }); };
if (pecEditing) Ar1Service.updatePecRule(pecEditing.id, payload, onOk, onKo); if (pecEditing) Ar1Service.updatePecRule(pecEditing.id, payload, onOk, onKo);
else Ar1Service.createPecRule(payload, onOk, onKo); else Ar1Service.createPecRule(payload, onOk, onKo);
}; };
const deletePecRule = (rule) => { const deletePecRule = (rule) => {
confirmDialog({ confirmDialog({
message: `Eliminare la regola "${rule.kind}"?`, message: `Eliminare la regola "${PEC_KIND_LABEL[rule.kind] || rule.kind}"? Non sara piu inviata la PEC corrispondente.`,
header: 'Conferma eliminazione', header: 'Conferma eliminazione',
icon: 'pi pi-exclamation-triangle', icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Elimina',
rejectLabel: 'Annulla',
acceptClassName: 'p-button-danger', acceptClassName: 'p-button-danger',
accept: () => { accept: () => {
Ar1Service.deletePecRule(rule.id, Ar1Service.deletePecRule(rule.id,
() => { () => {
if (toast.current) toast.current.show({ severity: 'success', summary: 'OK', detail: 'Regola eliminata' }); if (toast.current) toast.current.show({ severity: 'success', summary: 'Eliminata', detail: 'Regola eliminata' });
loadPecRules(); loadPecRules();
}, },
(err) => { if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Delete fallito' }); } (err) => { if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Eliminazione fallita' }); }
); );
} }
}); });
}; };
// ========= BULK HANDLERS ========= // ==================================================
// SEZIONE 4: BULK
// ==================================================
const runBulk = (dryRun) => { const runBulk = (dryRun) => {
setBulkRunning(true); setBulkRunning(true);
const companyIds = bulkCompanyIds.trim() const companyIds = bulkCompanyIds.trim()
? bulkCompanyIds.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n)) ? bulkCompanyIds.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n))
: null; : null;
const payload = { const payload = { dry_run: dryRun, only_expired: bulkOnlyExpired, only_missing: bulkOnlyMissing, company_ids: companyIds };
dry_run: dryRun,
only_expired: bulkOnlyExpired,
only_missing: bulkOnlyMissing,
company_ids: companyIds,
};
Ar1Service.bulkRequestRecompilation(payload, Ar1Service.bulkRequestRecompilation(payload,
(resp) => { (resp) => {
setBulkRunning(false); setBulkRunning(false);
setBulkResult({ ...resp, was_dry_run: dryRun }); setBulkResult({ ...resp, was_dry_run: dryRun });
if (toast.current) toast.current.show({ if (toast.current) toast.current.show({
severity: 'success', severity: 'success',
summary: dryRun ? 'Dry-run completato' : 'Bulk eseguito', summary: dryRun ? 'Anteprima completata' : 'PEC inviate',
detail: `${resp.matched || 0} aziende matchate` detail: `${resp.matched || 0} aziende matchate`
}); });
}, },
(err) => { (err) => {
setBulkRunning(false); setBulkRunning(false);
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Bulk fallito' }); if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Invio fallito' });
} }
); );
}; };
// ---------- RENDER ---------- // ==================================================
const tplStatusTpl = (row) => { // SEZIONE 5: TESTI PEC
const severity = row.status === 'ACTIVE' ? 'success' : row.status === 'ARCHIVED' ? 'secondary' : 'warning'; // ==================================================
return <Tag severity={severity} value={row.status} />;
const openEditEmail = (tpl) => {
setEditEmailData({ ...tpl });
setPreviewHtml(null);
setPreviewSubject(null);
setEditEmailOpen(true);
}; };
const tplActionsTpl = (row) => ( const saveEmail = () => {
if (!editEmailData) return;
Ar1Service.updateEmailTemplate(editEmailData.kind, {
subject: editEmailData.subject,
body_html: editEmailData.body_html,
body_text: editEmailData.body_text,
description: editEmailData.description || null
},
(resp) => {
if (toast.current) toast.current.show({
severity: 'success',
summary: 'Salvato',
detail: `Testo "${PEC_KIND_LABEL[resp.kind] || resp.kind}" aggiornato (version ${resp.version})`
});
setEditEmailOpen(false);
loadEmailTemplates();
},
(err) => { if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Salvataggio fallito' }); }
);
};
const runPreview = () => {
if (!editEmailData) return;
Ar1Service.previewEmailTemplate(editEmailData.kind, {}, // usa default mock del BE
(resp) => {
setPreviewSubject(resp.subject);
setPreviewHtml(resp.body_html);
},
(err) => { if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Anteprima fallita' }); }
);
};
// ==================================================
// RENDER
// ==================================================
const tplStatusTpl = (row) => {
const severity = row.status === 'ACTIVE' ? 'success' : row.status === 'ARCHIVED' ? 'secondary' : 'warning';
return <Tag severity={severity} value={TEMPLATE_STATUS_LABEL[row.status] || row.status} />;
};
const variantTpl = (row) => VARIANT_LABEL[row.variant] || row.variant;
const tplActiveActionsTpl = (row) => (
<div style={{ display: 'flex', gap: 4 }}> <div style={{ display: 'flex', gap: 4 }}>
<Button icon="pi pi-pencil" rounded text onClick={() => openEditLayout(row)} tooltip="Modifica layout L2" /> <Button icon="pi pi-eye" rounded text severity="info" tooltip="Anteprima PDF (dati mock)" onClick={() => {
<Button icon="pi pi-plus-circle" rounded text severity="warning" onClick={() => openNewVersion(row.variant)} tooltip="Nuova versione" /> // Anteprima PDF: apre endpoint dummy (TODO backend — fallback: mostra un alert)
if (toast.current) toast.current.show({ severity: 'info', summary: 'Anteprima', detail: `TODO: anteprima PDF ${row.variant} v${row.version}` });
}} />
<Button icon="pi pi-pencil" rounded text tooltip="Modifica layout grafico" onClick={() => openEditLayout(row)} disabled={row.status === 'ARCHIVED'} />
<Button icon="pi pi-plus-circle" rounded text severity="warning" tooltip="Nuova versione" onClick={() => openNewVersion(row.variant)} />
</div> </div>
); );
const pecKindTpl = (row) => (
<div>
<div style={{ fontWeight: 500 }}>{PEC_KIND_LABEL[row.kind] || row.kind}</div>
<small style={{ color: '#888' }}>{row.kind}</small>
</div>
);
const pecWhenTpl = (row) => {
if (row.offset_days > 0) return `${row.offset_days} giorni PRIMA della scadenza`;
if (row.offset_days === 0) return 'Il giorno della scadenza';
return `${Math.abs(row.offset_days)} giorni DOPO la scadenza`;
};
const pecRecurringTpl = (row) => {
if (!row.is_recurring) return <span style={{ color: '#888' }}>una tantum</span>;
return <span>ogni <strong>{row.recurring_interval_days}</strong> giorni</span>;
};
const pecActionsTpl = (row) => ( const pecActionsTpl = (row) => (
<div style={{ display: 'flex', gap: 4 }}> <div style={{ display: 'flex', gap: 4 }}>
<Button icon="pi pi-pencil" rounded text onClick={() => openPecDialog(row)} /> <Button icon="pi pi-pencil" rounded text tooltip="Modifica" onClick={() => openPecDialog(row)} />
<Button icon="pi pi-trash" rounded text severity="danger" onClick={() => deletePecRule(row)} /> <Button icon="pi pi-trash" rounded text severity="danger" tooltip="Elimina" onClick={() => deletePecRule(row)} />
</div> </div>
); );
const pecBoolTpl = (row, field) => <i className={row[field] ? 'pi pi-check' : 'pi pi-times'} style={{ color: row[field] ? '#2e7d32' : '#c62828' }} />; const pecEnabledTpl = (row) => (
<i className={row.enabled ? 'pi pi-check-circle' : 'pi pi-times-circle'}
style={{ color: row.enabled ? '#2e7d32' : '#999', fontSize: 18 }} />
);
const emailActionsTpl = (row) => (
<Button label="Modifica" icon="pi pi-pencil" size="small" onClick={() => openEditEmail(row)} />
);
return ( return (
<div style={{ padding: 16 }}> <div style={{ padding: 16 }}>
<Toast ref={toast} /> <Toast ref={toast} />
<ConfirmDialog /> <ConfirmDialog />
<h1>{__('Configurazione AR1', 'gepafin')}</h1> <h1>Configurazione AR1 Adeguata Verifica</h1>
<p style={{ color: '#666' }}> <p style={{ color: '#666' }}>
{__('Gestione template, policy, regole di scadenza e invio massivo PEC per il modulo di adeguata verifica.', 'gepafin')} Gestione template, policy, regole di scadenza, invio massivo e testi PEC per il modulo antiriciclaggio (D.Lgs. 231/2007).
</p> </p>
<TabView activeIndex={activeIndex} onTabChange={(e) => setActiveIndex(e.index)}> <TabView activeIndex={activeIndex} onTabChange={(e) => setActiveIndex(e.index)}>
{/* === TAB 1: TEMPLATES === */} {/* ================ TAB 1: TEMPLATE ================ */}
<TabPanel header={__('Template', 'gepafin')} leftIcon="pi pi-file-edit"> <TabPanel header="Template" leftIcon="pi pi-file-edit">
<Card> <Card title="Template in uso" style={{ marginBottom: 14 }}>
<Message severity="info" text={__('I template AR1 hanno 3 varianti (A1 Persona Giuridica, A2 Ditta Individuale, A3 Persona Fisica). Puoi modificare il layout grafico (L2) della versione ACTIVE oppure creare una nuova versione semver.', 'gepafin')} style={{ marginBottom: 14 }} /> <Message severity="info" style={{ marginBottom: 14 }} text="Questi sono i template attualmente usati per la compilazione AR1. Le modifiche al layout grafico sono immediate per tutte le nuove bozze. Per cambiamenti strutturali, crea una nuova versione." />
<DataTable value={templates} loading={loadingTpl} emptyMessage={__('Nessun template', 'gepafin')}> <DataTable value={activeTemplates} loading={loadingTpl} emptyMessage="Nessun template in uso">
<Column field="variant" header="Variante" style={{ width: 100 }} /> <Column field="variant" header="Variante" body={variantTpl} />
<Column field="version" header="Versione" style={{ width: 100 }} /> <Column field="version" header="Versione" style={{ width: 120 }} body={(r) => <strong>v{r.version}</strong>} />
<Column field="status" header="Stato" body={tplStatusTpl} style={{ width: 120 }} /> <Column field="status" header="Stato" body={tplStatusTpl} style={{ width: 140 }} />
<Column field="quadri_count" header="Quadri" style={{ width: 80 }} body={(r) => r.quadri_count ?? (r.questions_snapshot?.quadri?.length ?? '—')} /> <Column field="quadri_count" header="N. quadri" style={{ width: 110 }} body={(r) => r.quadri_count ?? (r.questions_snapshot?.quadri?.length ?? '—')} />
<Column header="Azioni" body={tplActionsTpl} style={{ width: 120 }} /> <Column header="Azioni" body={tplActiveActionsTpl} style={{ width: 170 }} />
</DataTable> </DataTable>
</Card> </Card>
{archivedTemplates.length > 0 && (
<Card title={`Template archiviati (${archivedTemplates.length})`}>
<DataTable value={archivedTemplates} paginator={archivedTemplates.length > 10} rows={10}>
<Column field="variant" header="Variante" body={variantTpl} />
<Column field="version" header="Versione" body={(r) => `v${r.version}`} />
<Column field="status" header="Stato" body={tplStatusTpl} />
<Column field="created_at" header="Creato il" body={(r) => r.created_at ? new Date(r.created_at).toLocaleDateString('it-IT') : '—'} />
</DataTable>
</Card>
)}
</TabPanel> </TabPanel>
{/* === TAB 2: POLICY === */} {/* ================ TAB 2: POLICY ================ */}
<TabPanel header={__('Policy', 'gepafin')} leftIcon="pi pi-cog"> <TabPanel header="Policy" leftIcon="pi pi-cog">
<Card> <Card>
{!policyDraft && <p>{__('Caricamento...', 'gepafin')}</p>} {!policyDraft && <p>Caricamento</p>}
{policyDraft && ( {policyDraft && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
<div> <div>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>{__('Validita (giorni)', 'gepafin')}</label> <label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>Validita dichiarazione (giorni)</label>
<InputNumber value={policyDraft.validity_days} onValueChange={(e) => setPolicyDraft({ ...policyDraft, validity_days: e.value })} min={30} max={1825} style={{ width: '100%' }} /> <InputNumber value={policyDraft.validity_days} onValueChange={(e) => setPolicyDraft({ ...policyDraft, validity_days: e.value })} min={30} max={1825} style={{ width: '100%' }} />
<small style={{ color: '#888' }}>30-1825 giorni (default 365)</small> <small style={{ color: '#888' }}>Giorni dopo la firma prima che la dichiarazione scada (default 365, min 30, max 1825).</small>
</div> </div>
<div> <div>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>{__('Dismiss pop-up (ore)', 'gepafin')}</label> <label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>Pausa pop-up (ore)</label>
<InputNumber value={policyDraft.popup_dismiss_hours} onValueChange={(e) => setPolicyDraft({ ...policyDraft, popup_dismiss_hours: e.value })} min={1} max={168} style={{ width: '100%' }} /> <InputNumber value={policyDraft.popup_dismiss_hours} onValueChange={(e) => setPolicyDraft({ ...policyDraft, popup_dismiss_hours: e.value })} min={1} max={168} style={{ width: '100%' }} />
<small style={{ color: '#888' }}>1-168 ore (default 24)</small> <small style={{ color: '#888' }}>Ore di silenzio dopo che l'utente clicca "Ricordamelo piu tardi" sul pop-up (default 24).</small>
</div> </div>
<div> <div style={{ gridColumn: '1 / -1' }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>{__('Categoria documento (company_document)', 'gepafin')}</label> <label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>Categoria documento aziendale</label>
<InputNumber value={policyDraft.company_document_category_id} onValueChange={(e) => setPolicyDraft({ ...policyDraft, company_document_category_id: e.value })} min={1} style={{ width: '100%' }} /> <Dropdown
<small style={{ color: '#888' }}>default 4 = ANTIRICICLAGGIO</small> value={policyDraft.company_document_category_id}
options={docCategories.map(c => ({ label: `${c.category_name} — ${c.description || ''}`, value: c.id }))}
onChange={(e) => setPolicyDraft({ ...policyDraft, company_document_category_id: e.value })}
style={{ width: '100%' }}
placeholder="Seleziona categoria…"
/>
<small style={{ color: '#888' }}>Categoria in cui l'AR1 firmato viene archiviato nei documenti aziendali (visibile in "I miei documenti").</small>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 22 }}> <Divider style={{ gridColumn: '1 / -1' }} />
<InputSwitch checked={!!policyDraft.popup_force_on_expired} onChange={(e) => setPolicyDraft({ ...policyDraft, popup_force_on_expired: e.value })} />
<label>{__('Pop-up forzato se EXPIRED', 'gepafin')}</label> <div style={{ gridColumn: '1 / -1', display: 'flex', flexDirection: 'column', gap: 14 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<InputSwitch checked={!!policyDraft.popup_force_on_expired} onChange={(e) => setPolicyDraft({ ...policyDraft, popup_force_on_expired: e.value })} />
<span>
<strong>Pop-up bloccante se scaduta</strong><br />
<small style={{ color: '#888' }}>Se attivo, al login con AR1 scaduta il pop-up non puo essere chiuso (solo tramite compilazione).</small>
</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<InputSwitch checked={!!policyDraft.auto_archive_on_company_document} onChange={(e) => setPolicyDraft({ ...policyDraft, auto_archive_on_company_document: e.value })} />
<span>
<strong>Archiviazione automatica</strong><br />
<small style={{ color: '#888' }}>Alla verifica della firma (VERIFIED), il PDF viene archiviato automaticamente nei documenti aziendali nella categoria selezionata sopra.</small>
</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<InputSwitch checked={!!policyDraft.allow_bulk_recompilation_request} onChange={(e) => setPolicyDraft({ ...policyDraft, allow_bulk_recompilation_request: e.value })} />
<span>
<strong>Invio massivo PEC abilitato</strong><br />
<small style={{ color: '#888' }}>Se attivo, il superadmin puo richiedere via PEC la ricompilazione a piu aziende contemporaneamente (tab "Invio massivo").</small>
</span>
</label>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ gridColumn: '1 / -1', display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 12 }}>
<InputSwitch checked={!!policyDraft.auto_archive_on_company_document} onChange={(e) => setPolicyDraft({ ...policyDraft, auto_archive_on_company_document: e.value })} /> <Button label="Annulla" severity="secondary" outlined onClick={() => setPolicyDraft(policy)} disabled={savingPolicy} />
<label>{__('Auto-archivio su company_document', 'gepafin')}</label> <Button label="Salva policy" icon="pi pi-save" onClick={savePolicy} loading={savingPolicy} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<InputSwitch checked={!!policyDraft.allow_bulk_recompilation_request} onChange={(e) => setPolicyDraft({ ...policyDraft, allow_bulk_recompilation_request: e.value })} />
<label>{__('Invio massivo PEC abilitato', 'gepafin')}</label>
</div>
<div style={{ gridColumn: '1 / -1', display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
<Button label={__('Annulla', 'gepafin')} severity="secondary" outlined onClick={() => setPolicyDraft(policy)} disabled={savingPolicy} />
<Button label={__('Salva policy', 'gepafin')} icon="pi pi-save" onClick={savePolicy} loading={savingPolicy} />
</div> </div>
</div> </div>
)} )}
</Card> </Card>
</TabPanel> </TabPanel>
{/* === TAB 3: PEC RULES === */} {/* ================ TAB 3: REGOLE ================ */}
<TabPanel header={__('Regole reminder PEC', 'gepafin')} leftIcon="pi pi-bell"> <TabPanel header="Regole reminder" leftIcon="pi pi-bell">
<Card> <Card>
<Message severity="info" style={{ marginBottom: 14 }} text="Le regole definiscono QUANDO partono automaticamente le PEC di sollecito. Per il contenuto dei testi, vai al tab 'Testi comunicazioni'." />
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 10 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 10 }}>
<Button label={__('Nuova regola', 'gepafin')} icon="pi pi-plus" onClick={() => openPecDialog(null)} /> <Button label="Nuova regola" icon="pi pi-plus" onClick={() => openPecDialog(null)} />
</div> </div>
<DataTable value={pecRules} emptyMessage={__('Nessuna regola', 'gepafin')}> <DataTable value={pecRules} emptyMessage="Nessuna regola configurata">
<Column field="kind" header="Kind" /> <Column field="kind" header="Regola" body={pecKindTpl} />
<Column field="offset_days" header="Offset (gg)" style={{ width: 110 }} /> <Column field="offset_days" header="Quando parte" body={pecWhenTpl} style={{ width: 240 }} />
<Column field="is_recurring" header="Ricorrente" body={(r) => pecBoolTpl(r, 'is_recurring')} style={{ width: 110 }} /> <Column field="is_recurring" header="Ricorrenza" body={pecRecurringTpl} style={{ width: 160 }} />
<Column field="recurring_interval_days" header="Intervallo" style={{ width: 110 }} /> <Column field="enabled" header="Attiva" body={pecEnabledTpl} style={{ width: 90 }} />
<Column field="enabled" header="Attiva" body={(r) => pecBoolTpl(r, 'enabled')} style={{ width: 90 }} /> <Column header="Azioni" body={pecActionsTpl} style={{ width: 120 }} />
<Column field="description" header="Descrizione" />
<Column header="Azioni" body={pecActionsTpl} style={{ width: 110 }} />
</DataTable> </DataTable>
</Card> </Card>
</TabPanel> </TabPanel>
{/* === TAB 4: BULK PEC === */} {/* ================ TAB 4: BULK PEC ================ */}
<TabPanel header={__('Invio massivo PEC', 'gepafin')} leftIcon="pi pi-send"> <TabPanel header="Invio massivo" leftIcon="pi pi-send">
<Card> <Card>
<Message severity="warn" text={__('Questa azione invia PEC di richiesta aggiornamento AR1 a tutte le aziende matchate. Esegui sempre prima un dry-run per verificare il numero di destinatari.', 'gepafin')} style={{ marginBottom: 14 }} /> <Message severity="warn" style={{ marginBottom: 14 }} text="Questa azione segna le aziende selezionate per l'invio di una PEC di sollecito AR1. Esegui sempre prima l'anteprima per verificare il numero di destinatari." />
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>{__('ID aziende (virgola-separati, vuoto = tutte)', 'gepafin')}</label> <label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>ID aziende (virgola-separati, vuoto = tutte)</label>
<InputText value={bulkCompanyIds} onChange={(e) => setBulkCompanyIds(e.target.value)} placeholder="es. 1, 7, 42" style={{ width: '100%' }} /> <InputText value={bulkCompanyIds} onChange={(e) => setBulkCompanyIds(e.target.value)} placeholder="es. 1, 7, 42" style={{ width: '100%' }} />
</div> </div>
<div style={{ display: 'flex', gap: 16, marginBottom: 14 }}> <div style={{ display: 'flex', gap: 20, marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Checkbox inputId="bulk-expired" checked={bulkOnlyExpired} onChange={(e) => setBulkOnlyExpired(e.checked)} /> <Checkbox inputId="bulk-expired" checked={bulkOnlyExpired} onChange={(e) => setBulkOnlyExpired(e.checked)} />
<label htmlFor="bulk-expired">{__('Solo EXPIRED', 'gepafin')}</label> <label htmlFor="bulk-expired">Solo aziende con AR1 scaduta</label>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Checkbox inputId="bulk-missing" checked={bulkOnlyMissing} onChange={(e) => setBulkOnlyMissing(e.checked)} /> <Checkbox inputId="bulk-missing" checked={bulkOnlyMissing} onChange={(e) => setBulkOnlyMissing(e.checked)} />
<label htmlFor="bulk-missing">{__('Solo MISSING', 'gepafin')}</label> <label htmlFor="bulk-missing">Solo aziende senza AR1</label>
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<Button label={__('Dry-run (anteprima)', 'gepafin')} icon="pi pi-eye" severity="info" outlined onClick={() => runBulk(true)} loading={bulkRunning} /> <Button label="Anteprima (non invia)" icon="pi pi-eye" severity="info" outlined onClick={() => runBulk(true)} loading={bulkRunning} />
<Button label={__('Invia PEC (live)', 'gepafin')} icon="pi pi-send" severity="warning" onClick={() => { <Button label="Invia PEC" icon="pi pi-send" severity="warning" onClick={() => {
confirmDialog({ confirmDialog({
message: `Confermi invio PEC live? Questa azione notifichera i BE poller per dispatch PEC reale.`, message: 'Confermi l\'invio reale delle PEC? Il BE Gepafin dispatchera le comunicazioni alle aziende matchate.',
header: 'Conferma invio PEC', header: 'Conferma invio',
icon: 'pi pi-exclamation-triangle', icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Invia',
rejectLabel: 'Annulla',
accept: () => runBulk(false) accept: () => runBulk(false)
}); });
}} loading={bulkRunning} /> }} loading={bulkRunning} />
</div> </div>
{bulkResult && ( {bulkResult && (
<Card title={bulkResult.was_dry_run ? __('Anteprima (dry-run)', 'gepafin') : __('Esito invio', 'gepafin')} style={{ marginTop: 14 }}> <Card title={bulkResult.was_dry_run ? 'Anteprima' : 'Esito invio'} style={{ marginTop: 14 }}>
<p><strong>{__('Aziende matchate:', 'gepafin')}</strong> {bulkResult.matched ?? 0}</p> <p><strong>Aziende matchate:</strong> {bulkResult.matched ?? 0}</p>
{!bulkResult.was_dry_run && ( {!bulkResult.was_dry_run && (
<p><strong>{__('Form segnati per PEC:', 'gepafin')}</strong> {bulkResult.marked_for_pec ?? bulkResult.marked ?? 0}</p> <p><strong>Form segnati per invio PEC:</strong> {bulkResult.marked_for_pec ?? bulkResult.marked ?? 0}</p>
)} )}
{bulkResult.company_ids && bulkResult.company_ids.length > 0 && ( {bulkResult.company_ids && bulkResult.company_ids.length > 0 && (
<p><strong>{__('ID:', 'gepafin')}</strong> {bulkResult.company_ids.join(', ')}</p> <p><strong>ID aziende:</strong> {bulkResult.company_ids.slice(0, 30).join(', ')}{bulkResult.company_ids.length > 30 ? '' : ''}</p>
)} )}
</Card> </Card>
)} )}
</Card> </Card>
</TabPanel> </TabPanel>
{/* ================ TAB 5: TESTI PEC ================ */}
<TabPanel header="Testi comunicazioni" leftIcon="pi pi-envelope">
<Card>
<Message severity="info" style={{ marginBottom: 10 }} text="Qui modifichi oggetto e corpo delle PEC inviate dal sistema. Ogni modifica incrementa la versione: il backend Gepafin sincronizza automaticamente i testi nei suoi template per-hub (PEC Massiva / Mailgun)." />
{availableVariables.length > 0 && (
<div style={{ marginBottom: 14, padding: 10, background: '#f5f5f5', borderRadius: 4 }}>
<strong>Variabili disponibili</strong> (usa nel testo come <code>{'{{nome_variabile}}'}</code>):<br />
{availableVariables.map(v => <Tag key={v} value={v} style={{ marginRight: 4, marginTop: 4 }} />)}
</div>
)}
<DataTable value={emailTemplates} loading={loadingEmail} emptyMessage="Nessun testo configurato">
<Column field="kind" header="Tipo comunicazione" body={(r) => (
<div>
<div style={{ fontWeight: 500 }}>{r.label || PEC_KIND_LABEL[r.kind] || r.kind}</div>
<small style={{ color: '#888' }}>{r.kind}</small>
</div>
)} />
<Column field="subject" header="Oggetto" body={(r) => <span style={{ fontSize: 13 }}>{r.subject}</span>} />
<Column field="version" header="Ver." style={{ width: 70 }} body={(r) => <Tag value={`v${r.version}`} severity="secondary" />} />
<Column field="updated_at" header="Aggiornato il" style={{ width: 150 }} body={(r) => r.updated_at ? new Date(r.updated_at).toLocaleDateString('it-IT') : '—'} />
<Column header="Azioni" body={emailActionsTpl} style={{ width: 150 }} />
</DataTable>
</Card>
</TabPanel>
</TabView> </TabView>
{/* DIALOG: edit layout L2 */} {/* ==================== DIALOGS ==================== */}
{/* Edit layout template */}
<Dialog <Dialog
header={editLayoutTpl ? `${__('Modifica layout', 'gepafin')} ${editLayoutTpl.variant} v${editLayoutTpl.version}` : ''} header={editLayoutTpl ? `Modifica layout ${editLayoutTpl.variant} v${editLayoutTpl.version}` : ''}
visible={editLayoutOpen} visible={editLayoutOpen}
onHide={() => setEditLayoutOpen(false)} onHide={() => setEditLayoutOpen(false)}
style={{ width: '720px', maxWidth: '95vw' }} style={{ width: '780px', maxWidth: '95vw' }}
modal modal
> >
<Message severity="info" text={__('Layout L2 in JSON. Modifiche applicate solo a template ACTIVE. Per cambi strutturali usare "Nuova versione".', 'gepafin')} style={{ marginBottom: 10 }} /> <div style={{ marginBottom: 10, display: 'flex', alignItems: 'center', gap: 10 }}>
<InputTextarea value={layoutJsonText} onChange={(e) => setLayoutJsonText(e.target.value)} rows={18} style={{ width: '100%', fontFamily: 'monospace', fontSize: 12 }} /> <InputSwitch checked={useAdvancedEditor} onChange={(e) => setUseAdvancedEditor(e.value)} />
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 12 }}> <label>Modalita avanzata (JSON raw)</label>
<Button label={__('Annulla', 'gepafin')} severity="secondary" outlined onClick={() => setEditLayoutOpen(false)} /> </div>
<Button label={__('Salva layout', 'gepafin')} icon="pi pi-save" onClick={saveLayout} />
{!useAdvancedEditor && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div style={{ gridColumn: '1 / -1' }}><strong>Brand</strong></div>
<div>
<label>Nome brand</label>
<InputText value={layoutForm.brand_name || ''} onChange={(e) => setLayoutForm({ ...layoutForm, brand_name: e.target.value })} style={{ width: '100%' }} />
</div>
<div>
<label>URL logo</label>
<InputText value={layoutForm.brand_logo_url || ''} onChange={(e) => setLayoutForm({ ...layoutForm, brand_logo_url: e.target.value })} style={{ width: '100%' }} placeholder="https://..." />
</div>
<div>
<label>Colore primario</label>
<InputText value={layoutForm.brand_color_primary || ''} onChange={(e) => setLayoutForm({ ...layoutForm, brand_color_primary: e.target.value })} style={{ width: '100%' }} placeholder="#003d7a" />
</div>
<div>
<label>Colore accento</label>
<InputText value={layoutForm.brand_color_accent || ''} onChange={(e) => setLayoutForm({ ...layoutForm, brand_color_accent: e.target.value })} style={{ width: '100%' }} placeholder="#e65100" />
</div>
<Divider style={{ gridColumn: '1 / -1' }} />
<div style={{ gridColumn: '1 / -1' }}><strong>Intestazione documento</strong></div>
<div>
<label>Titolo</label>
<InputText value={layoutForm.header_title || ''} onChange={(e) => setLayoutForm({ ...layoutForm, header_title: e.target.value })} style={{ width: '100%' }} />
</div>
<div>
<label>Sottotitolo</label>
<InputText value={layoutForm.header_subtitle || ''} onChange={(e) => setLayoutForm({ ...layoutForm, header_subtitle: e.target.value })} style={{ width: '100%' }} />
</div>
<Divider style={{ gridColumn: '1 / -1' }} />
<div style={{ gridColumn: '1 / -1' }}><strong>Introduzione</strong></div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Saluto</label>
<InputText value={layoutForm.intro_salutation || ''} onChange={(e) => setLayoutForm({ ...layoutForm, intro_salutation: e.target.value })} style={{ width: '100%' }} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Testo introduttivo</label>
<InputTextarea value={layoutForm.intro_body || ''} onChange={(e) => setLayoutForm({ ...layoutForm, intro_body: e.target.value })} rows={3} style={{ width: '100%' }} />
</div>
<Divider style={{ gridColumn: '1 / -1' }} />
<div style={{ gridColumn: '1 / -1' }}><strong>Privacy</strong></div>
<div>
<label>URL informativa</label>
<InputText value={layoutForm.privacy_url || ''} onChange={(e) => setLayoutForm({ ...layoutForm, privacy_url: e.target.value })} style={{ width: '100%' }} />
</div>
<div>
<label>Testo piè di pagina</label>
<InputText value={layoutForm.privacy_body || ''} onChange={(e) => setLayoutForm({ ...layoutForm, privacy_body: e.target.value })} style={{ width: '100%' }} />
</div>
</div>
)}
{useAdvancedEditor && (
<div>
<Message severity="warn" text="Modalita avanzata: modifichi il JSON layout_config direttamente. Attenzione alla sintassi." style={{ marginBottom: 8 }} />
<InputTextarea value={layoutAdvancedJson} onChange={(e) => setLayoutAdvancedJson(e.target.value)} rows={18} style={{ width: '100%', fontFamily: 'monospace', fontSize: 12 }} />
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 14 }}>
<Button label="Annulla" severity="secondary" outlined onClick={() => setEditLayoutOpen(false)} />
<Button label="Salva layout" icon="pi pi-save" onClick={saveLayout} />
</div> </div>
</Dialog> </Dialog>
{/* DIALOG: nuova versione */} {/* Nuova versione */}
<Dialog <Dialog
header={`${__('Nuova versione variante', 'gepafin')} ${newVersionVariant}`} header={`Nuova versione ${VARIANT_LABEL[newVersionVariant] || newVersionVariant}`}
visible={newVersionOpen} visible={newVersionOpen}
onHide={() => setNewVersionOpen(false)} onHide={() => setNewVersionOpen(false)}
style={{ width: '720px', maxWidth: '95vw' }} style={{ width: '540px', maxWidth: '95vw' }}
modal modal
> >
<Message severity="info" text="La nuova versione eredita il layout grafico corrente. La versione precedente viene archiviata automaticamente se attivi subito quella nuova." style={{ marginBottom: 10 }} />
<div style={{ marginBottom: 10 }}> <div style={{ marginBottom: 10 }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>{__('Versione (semver, maggiore dell\'ACTIVE)', 'gepafin')}</label> <label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>Versione (formato semver, maggiore dell'attuale)</label>
<InputText value={newVersionData.version} onChange={(e) => setNewVersionData({ ...newVersionData, version: e.target.value })} placeholder="es. 1.1.0" style={{ width: '100%' }} /> <InputText value={newVersionData.version} onChange={(e) => setNewVersionData({ ...newVersionData, version: e.target.value })} placeholder="es. 1.1.0" style={{ width: '100%' }} />
</div> </div>
<div style={{ marginBottom: 10 }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>{__('Layout config JSON', 'gepafin')}</label>
<InputTextarea value={newVersionData.layout_config} onChange={(e) => setNewVersionData({ ...newVersionData, layout_config: e.target.value })} rows={12} style={{ width: '100%', fontFamily: 'monospace', fontSize: 12 }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<InputSwitch checked={newVersionData.activate_now} onChange={(e) => setNewVersionData({ ...newVersionData, activate_now: e.value })} /> <InputSwitch checked={newVersionData.activate_now} onChange={(e) => setNewVersionData({ ...newVersionData, activate_now: e.value })} />
<label>{__('Attiva subito (archivia versione precedente)', 'gepafin')}</label> <label>Attiva subito (archivia la versione precedente)</label>
</div> </div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 12 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 12 }}>
<Button label={__('Annulla', 'gepafin')} severity="secondary" outlined onClick={() => setNewVersionOpen(false)} /> <Button label="Annulla" severity="secondary" outlined onClick={() => setNewVersionOpen(false)} />
<Button label={__('Crea versione', 'gepafin')} icon="pi pi-plus" onClick={saveNewVersion} /> <Button label="Crea versione" icon="pi pi-plus" onClick={saveNewVersion} />
</div> </div>
</Dialog> </Dialog>
{/* DIALOG: edit pec rule */} {/* Edit regola PEC */}
<Dialog <Dialog
header={pecEditing ? __('Modifica regola PEC', 'gepafin') : __('Nuova regola PEC', 'gepafin')} header={pecEditing ? 'Modifica regola reminder' : 'Nuova regola reminder'}
visible={pecDialogOpen} visible={pecDialogOpen}
onHide={() => setPecDialogOpen(false)} onHide={() => setPecDialogOpen(false)}
style={{ width: '560px', maxWidth: '95vw' }} style={{ width: '620px', maxWidth: '95vw' }}
modal modal
> >
<div style={{ marginBottom: 10 }}> <div style={{ marginBottom: 10 }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>{__('Kind', 'gepafin')}</label> <label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>Tipo di regola</label>
<InputText value={pecDraft.kind} onChange={(e) => setPecDraft({ ...pecDraft, kind: e.target.value })} placeholder="es. AR1_REMINDER_30D" style={{ width: '100%' }} disabled={!!pecEditing} /> <Dropdown
value={pecDraft.kind}
options={PEC_KIND_OPTIONS}
onChange={(e) => setPecDraft({ ...pecDraft, kind: e.value })}
style={{ width: '100%' }}
disabled={!!pecEditing}
placeholder="Seleziona tipo…"
/>
<small style={{ color: '#888' }}>{pecEditing ? 'Il tipo non e modificabile dopo la creazione' : 'Seleziona quale evento fa partire la PEC'}</small>
</div> </div>
<div style={{ marginBottom: 10 }}> <div style={{ marginBottom: 10 }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>{__('Offset giorni (rispetto a expires_at)', 'gepafin')}</label> <label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>Quando parte la PEC</label>
<InputNumber value={pecDraft.offset_days} onValueChange={(e) => setPecDraft({ ...pecDraft, offset_days: e.value })} style={{ width: '100%' }} /> <div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<small style={{ color: '#888' }}>{__('Negativo = prima della scadenza, positivo = dopo', 'gepafin')}</small> <InputNumber value={pecDraft.offset_days} onValueChange={(e) => setPecDraft({ ...pecDraft, offset_days: e.value })} style={{ flex: 1 }} />
<span style={{ fontStyle: 'italic', color: '#555' }}>
{pecDraft.offset_days > 0 && `giorni PRIMA della scadenza`}
{pecDraft.offset_days === 0 && `giorno della scadenza`}
{pecDraft.offset_days < 0 && `giorni DOPO la scadenza`}
</span>
</div>
<small style={{ color: '#888' }}>Numero positivo = prima della scadenza; 0 = il giorno stesso; negativo = dopo la scadenza.</small>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<InputSwitch checked={pecDraft.is_recurring} onChange={(e) => setPecDraft({ ...pecDraft, is_recurring: e.value })} /> <InputSwitch checked={pecDraft.is_recurring} onChange={(e) => setPecDraft({ ...pecDraft, is_recurring: e.value })} />
<label>{__('Ricorrente', 'gepafin')}</label> <label>Ricorrente (si ripete ogni N giorni)</label>
</div> </div>
{pecDraft.is_recurring && ( {pecDraft.is_recurring && (
<div style={{ marginBottom: 10 }}> <div style={{ marginBottom: 10 }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>{__('Intervallo ricorrenza (giorni)', 'gepafin')}</label> <label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>Intervallo ricorrenza (giorni)</label>
<InputNumber value={pecDraft.recurring_interval_days} onValueChange={(e) => setPecDraft({ ...pecDraft, recurring_interval_days: e.value })} min={1} style={{ width: '100%' }} /> <InputNumber value={pecDraft.recurring_interval_days} onValueChange={(e) => setPecDraft({ ...pecDraft, recurring_interval_days: e.value })} min={1} style={{ width: '100%' }} />
<small style={{ color: '#888' }}>Esempio: 30 = la PEC si ripete ogni 30 giorni finche la dichiarazione non viene aggiornata.</small>
</div> </div>
)} )}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<InputSwitch checked={pecDraft.enabled} onChange={(e) => setPecDraft({ ...pecDraft, enabled: e.value })} /> <InputSwitch checked={pecDraft.enabled} onChange={(e) => setPecDraft({ ...pecDraft, enabled: e.value })} />
<label>{__('Attiva', 'gepafin')}</label> <label>Regola attiva</label>
</div> </div>
<div style={{ marginBottom: 10 }}> <div style={{ marginBottom: 10 }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>{__('Descrizione', 'gepafin')}</label> <label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>Descrizione (opzionale, uso interno)</label>
<InputTextarea value={pecDraft.description} onChange={(e) => setPecDraft({ ...pecDraft, description: e.target.value })} rows={2} style={{ width: '100%' }} /> <InputTextarea value={pecDraft.description} onChange={(e) => setPecDraft({ ...pecDraft, description: e.target.value })} rows={2} style={{ width: '100%' }} />
</div> </div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 12 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 12 }}>
<Button label={__('Annulla', 'gepafin')} severity="secondary" outlined onClick={() => setPecDialogOpen(false)} /> <Button label="Annulla" severity="secondary" outlined onClick={() => setPecDialogOpen(false)} />
<Button label={__('Salva', 'gepafin')} icon="pi pi-save" onClick={savePecRule} /> <Button label="Salva" icon="pi pi-save" onClick={savePecRule} />
</div> </div>
</Dialog> </Dialog>
{/* Edit testo email */}
<Dialog
header={editEmailData ? `Modifica testo: ${PEC_KIND_LABEL[editEmailData.kind] || editEmailData.kind}` : ''}
visible={editEmailOpen}
onHide={() => setEditEmailOpen(false)}
style={{ width: '900px', maxWidth: '95vw' }}
modal
maximizable
>
{editEmailData && (
<div>
<Message severity="info" style={{ marginBottom: 10 }} text="Usa le variabili nei testi con la sintassi {{nome}} (es. {{company_name}}). Il backend Gepafin le sostituira al momento dell'invio." />
<div style={{ marginBottom: 10 }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>Oggetto</label>
<InputText value={editEmailData.subject} onChange={(e) => setEditEmailData({ ...editEmailData, subject: e.target.value })} style={{ width: '100%' }} />
</div>
<div style={{ marginBottom: 10 }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>Corpo HTML</label>
<InputTextarea value={editEmailData.body_html} onChange={(e) => setEditEmailData({ ...editEmailData, body_html: e.target.value })} rows={10} style={{ width: '100%', fontFamily: 'monospace', fontSize: 12 }} />
</div>
<div style={{ marginBottom: 10 }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>Corpo testo semplice (fallback)</label>
<InputTextarea value={editEmailData.body_text} onChange={(e) => setEditEmailData({ ...editEmailData, body_text: e.target.value })} rows={5} style={{ width: '100%', fontSize: 13 }} />
<small style={{ color: '#888' }}>Usato dai client email che non supportano HTML.</small>
</div>
<div style={{ marginBottom: 10 }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>Note interne (opzionale)</label>
<InputText value={editEmailData.description || ''} onChange={(e) => setEditEmailData({ ...editEmailData, description: e.target.value })} style={{ width: '100%' }} />
</div>
<Divider />
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<Button label="Anteprima (dati di esempio)" icon="pi pi-eye" severity="info" outlined onClick={runPreview} />
</div>
{previewSubject && (
<div style={{ padding: 14, background: '#f9f9f9', border: '1px solid #ddd', borderRadius: 4, marginBottom: 10 }}>
<div style={{ marginBottom: 8 }}><strong>Oggetto:</strong> {previewSubject}</div>
<div style={{ background: 'white', padding: 10, border: '1px solid #eee' }} dangerouslySetInnerHTML={{ __html: previewHtml }} />
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 14 }}>
<Button label="Annulla" severity="secondary" outlined onClick={() => setEditEmailOpen(false)} />
<Button label="Salva testo" icon="pi pi-save" onClick={saveEmail} />
</div>
</div>
)}
</Dialog>
</div> </div>
); );
}; };

View File

@@ -208,6 +208,40 @@ const Ar1Service = {
body: JSON.stringify(payload) body: JSON.stringify(payload)
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError)); }).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
}, },
// ---------- ADMIN: document categories (per dropdown) ----------
listDocumentCategories(onSuccess, onError) {
fetch(`${BASE_URL}/admin/document-categories`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
// ---------- ADMIN: email templates (Opzione 3 — tenant-agnostic, BE Gepafin pull) ----------
listEmailTemplates(onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-email-templates`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
getEmailTemplate(kind, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-email-templates/${kind}`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
updateEmailTemplate(kind, payload, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-email-templates/${kind}`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(payload)
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
previewEmailTemplate(kind, mockVars, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-email-templates/${kind}/preview`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(mockVars || {})
}).then(r => handleResponse(r, onSuccess, onError)).catch(e => handleError(e, onError));
},
}; };
export default Ar1Service; export default Ar1Service;