feat(ar1-admin): pagina superadmin Configurazione AR1 (pattern Rendicontazione)

Seconda voce sidebar per superadmin, pattern identico a Rendicontazione:
- benef (APPLY_CALLS) -> 'Dichiarazione AR1' -> /ar1 (compilazione)
- superadmin (MANAGE_TENDERS) -> 'Configurazione AR1' -> /ar1-admin (config)

service/ar1Service.js: +11 metodi admin (adminList/Get Templates, adminUpdateLayout,
adminNewVersion, adminGet/Update Policy, CRUD PecSchedule, adminBulkRecompilation).

pages/Ar1AdminConfig.js (532 LOC): 4 tab PrimeReact TabView:
  1. Template AR1: DataTable 3 varianti, badge status ACTIVE/DRAFT/ARCHIVED,
     drawer detail con textarea JSON layout_config editabile + save,
     bottone 'nuova versione' con modale (semver regex + activate_now)
  2. Policy: form con InputNumber/InputSwitch/Checkbox per 6 campi policy
     (validity_days 30-1825, popup_dismiss_hours 1-168, popup_force_on_expired,
     auto_archive_on_company_document, company_document_category_id, allow_bulk)
  3. Regole Reminder PEC: DataTable CRUD con dialog edit, Chips, InputSwitch
  4. Invio Massivo PEC: 4 filtri (only_expired, only_missing, company_ids Chips,
     expired_before Calendar) + dry-run counter + confirm dialog + submit live

Sidebar: voce id=23 'Configurazione AR1' icon 'pi pi-cog' href '/ar1-admin'
permessi MANAGE_TENDERS (accanto a 'Rendicontazione').

Routes: /ar1-admin solo ROLE_SUPER_ADMIN, altri ruoli -> PageNotFound.

Parse check @babel/parser+JSX: 4 OK / 0 FAIL. Webpack compiled 1 warning (vecchio,
unrelated).
This commit is contained in:
BFLOWS
2026-04-23 11:06:18 +02:00
parent c407bd0b0e
commit 7c508e743b
4 changed files with 750 additions and 0 deletions

View File

@@ -41,6 +41,13 @@ const AppSidebar = () => {
id: 21,
enable: intersection(permissions, ['MANAGE_TENDERS']).length
},
{
label: __('Configurazione AR1', 'gepafin'),
icon: 'pi pi-id-card',
href: '/ar1-admin',
id: 23,
enable: intersection(permissions, ['MANAGE_TENDERS']).length
},
{
label: __('Dev: cambia utente', 'gepafin'),
icon: 'pi pi-user-edit',

View File

@@ -0,0 +1,532 @@
import React, { useEffect, useRef, useState } from 'react';
import { __ } from '@wordpress/i18n';
// primereact
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 { TabView, TabPanel } from 'primereact/tabview';
import { InputText } from 'primereact/inputtext';
import { InputNumber } from 'primereact/inputnumber';
import { InputSwitch } from 'primereact/inputswitch';
import { Dialog } from 'primereact/dialog';
import { Tag } from 'primereact/tag';
import { ConfirmDialog, confirmDialog } from 'primereact/confirmdialog';
import { Chips } from 'primereact/chips';
import { Checkbox } from 'primereact/checkbox';
import { Calendar } from 'primereact/calendar';
import { Dropdown } from 'primereact/dropdown';
import Ar1Service from '../service/ar1Service';
/**
* Configurazione AR1 (superadmin). 4 sottosezioni in TabView:
* 1. Template — lista versioni con status (ACTIVE/ARCHIVED/DRAFT), edit L2 layout_config, nuova versione
* 2. Policy — singleton (validity, popup, auto-archive, bulk flag)
* 3. Regole Reminder PEC — CRUD ar1_pec_schedule_config
* 4. Invio Massivo PEC — filtri azienda + dry-run + submit live
*
* Percorso: /ar1-admin (permessi MANAGE_TENDERS)
*/
const Ar1AdminConfig = () => {
const toast = useRef(null);
const [activeTab, setActiveTab] = useState(0);
return (
<div style={{ padding: 16 }}>
<Toast ref={toast} />
<ConfirmDialog />
<h1>{__('Configurazione AR1 — Adeguata Verifica', 'gepafin')}</h1>
<p style={{ color: '#666' }}>
{__('Gestione template, policy, regole reminder PEC e invio massivo solleciti per la dichiarazione AR1 (D.Lgs. 231/2007).', 'gepafin')}
</p>
<TabView activeIndex={activeTab} onTabChange={(e) => setActiveTab(e.index)}>
<TabPanel header={__('Template AR1', 'gepafin')} leftIcon="pi pi-file mr-2">
<TemplatesTab toast={toast} />
</TabPanel>
<TabPanel header={__('Policy', 'gepafin')} leftIcon="pi pi-cog mr-2">
<PolicyTab toast={toast} />
</TabPanel>
<TabPanel header={__('Regole Reminder PEC', 'gepafin')} leftIcon="pi pi-clock mr-2">
<PecScheduleTab toast={toast} />
</TabPanel>
<TabPanel header={__('Invio Massivo PEC', 'gepafin')} leftIcon="pi pi-send mr-2">
<BulkPecTab toast={toast} />
</TabPanel>
</TabView>
</div>
);
};
// ========== Tab 1: Template ==========
const TemplatesTab = ({ toast }) => {
const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(true);
const [detailOpen, setDetailOpen] = useState(false);
const [selectedTpl, setSelectedTpl] = useState(null);
const [layoutJson, setLayoutJson] = useState('');
const [newVersionDialog, setNewVersionDialog] = useState(false);
const [newVersionPayload, setNewVersionPayload] = useState({ version: '', layout_config: {}, activate_now: true });
const load = () => {
setLoading(true);
Ar1Service.adminListTemplates(
(resp) => {
setTemplates(resp?.items || []);
setLoading(false);
},
(err) => {
setLoading(false);
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: err?.detail || 'Impossibile caricare template' });
}
);
};
useEffect(() => { load(); /* eslint-disable-next-line */ }, []);
const openDetail = (row) => {
Ar1Service.adminGetTemplate(row.id,
(resp) => {
setSelectedTpl(resp);
setLayoutJson(JSON.stringify(resp.layout_config || {}, null, 2));
setDetailOpen(true);
},
(err) => toast.current?.show({ severity: 'error', summary: 'Errore', detail: err?.detail })
);
};
const saveLayout = () => {
let parsed;
try { parsed = JSON.parse(layoutJson); }
catch (e) {
toast.current?.show({ severity: 'error', summary: 'JSON non valido', detail: e.message });
return;
}
Ar1Service.adminUpdateLayoutConfig(selectedTpl.id, parsed,
() => {
toast.current?.show({ severity: 'success', summary: 'OK', detail: 'Layout aggiornato' });
setDetailOpen(false);
load();
},
(err) => toast.current?.show({ severity: 'error', summary: 'Errore', detail: err?.detail })
);
};
const openNewVersion = (variant) => {
const current = templates.find(t => t.variant === variant && t.status === 'ACTIVE');
setNewVersionPayload({
version: '',
layout_config: current?.layout_config || {},
activate_now: true,
_variant: variant,
});
setNewVersionDialog(true);
};
const submitNewVersion = () => {
Ar1Service.adminNewVersion(newVersionPayload._variant, {
version: newVersionPayload.version,
layout_config: newVersionPayload.layout_config,
activate_now: newVersionPayload.activate_now,
},
() => {
toast.current?.show({ severity: 'success', summary: 'OK', detail: `Nuova versione ${newVersionPayload.version} creata` });
setNewVersionDialog(false);
load();
},
(err) => toast.current?.show({ severity: 'error', summary: 'Errore', detail: err?.detail })
);
};
const statusTpl = (row) => {
const map = {
ACTIVE: { severity: 'success', icon: 'pi pi-check-circle' },
DRAFT: { severity: 'warning', icon: 'pi pi-pencil' },
ARCHIVED: { severity: 'secondary', icon: 'pi pi-history' },
};
const cfg = map[row.status] || { severity: 'info', icon: 'pi pi-circle' };
return <Tag severity={cfg.severity} icon={cfg.icon} value={row.status} />;
};
const actionsTpl = (row) => (
<div style={{ display: 'flex', gap: 4 }}>
<Button icon="pi pi-eye" rounded text tooltip={__('Dettaglio + edit layout', 'gepafin')} onClick={() => openDetail(row)} />
{row.status === 'ACTIVE' && (
<Button icon="pi pi-plus-circle" rounded text tooltip={__('Nuova versione', 'gepafin')} onClick={() => openNewVersion(row.variant)} />
)}
</div>
);
return (
<Card>
<DataTable value={templates} loading={loading} emptyMessage={__('Nessun template', 'gepafin')}>
<Column field="variant" header={__('Variante', 'gepafin')} style={{ width: 100 }} />
<Column field="variant_label" header={__('Descrizione', 'gepafin')} />
<Column field="version" header={__('Versione', 'gepafin')} style={{ width: 100 }} />
<Column field="status" header={__('Status', 'gepafin')} body={statusTpl} style={{ width: 130 }} />
<Column field="quadri_count" header={__('N. Quadri', 'gepafin')} style={{ width: 90 }} />
<Column header={__('Azioni', 'gepafin')} body={actionsTpl} style={{ width: 120 }} />
</DataTable>
{/* Dialog detail */}
<Dialog header={selectedTpl ? `${selectedTpl.variant} v${selectedTpl.version}` : ''} visible={detailOpen} onHide={() => setDetailOpen(false)} style={{ width: '780px', maxWidth: '95vw' }} modal>
{selectedTpl && (
<div>
<p><strong>{__('Status:', 'gepafin')}</strong> {selectedTpl.status}</p>
<p><strong>{__('N. Quadri:', 'gepafin')}</strong> {(selectedTpl.questions_snapshot?.quadri || []).length}</p>
<h4>{__('Layout config (L2 — editabile)', 'gepafin')}</h4>
<p style={{ color: '#888', fontSize: 12 }}>
{__('Modifica il JSON del layout (brand, header, intro, privacy, field_labels_override). Le domande normative (L1) non sono editabili da qui.', 'gepafin')}
</p>
<textarea value={layoutJson} onChange={(e) => setLayoutJson(e.target.value)} rows={16} style={{ width: '100%', fontFamily: 'monospace', fontSize: 12 }} />
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12 }}>
<Button label={__('Annulla', 'gepafin')} severity="secondary" outlined onClick={() => setDetailOpen(false)} />
<Button label={__('Salva layout', 'gepafin')} icon="pi pi-save" onClick={saveLayout} disabled={selectedTpl.status === 'ARCHIVED'} />
</div>
{selectedTpl.status === 'ARCHIVED' && (
<p style={{ color: '#b71c1c', fontSize: 12, marginTop: 8 }}>
{__('Template ARCHIVED — non modificabile. Usa "Nuova versione" per creare una variante.', 'gepafin')}
</p>
)}
</div>
)}
</Dialog>
{/* Dialog new version */}
<Dialog header={__('Nuova versione template', 'gepafin')} visible={newVersionDialog} onHide={() => setNewVersionDialog(false)} style={{ width: '560px', maxWidth: '95vw' }} modal>
<p>{__('Variante:', 'gepafin')} <strong>{newVersionPayload._variant}</strong></p>
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', marginBottom: 4 }}>{__('Versione (semver xx.yy.zz)', 'gepafin')}</label>
<InputText value={newVersionPayload.version} onChange={(e) => setNewVersionPayload({ ...newVersionPayload, version: e.target.value })} placeholder="1.1.0" style={{ width: '100%' }} />
</div>
<div style={{ marginBottom: 14, display: 'flex', alignItems: 'center', gap: 8 }}>
<Checkbox inputId="activate_now" checked={newVersionPayload.activate_now} onChange={(e) => setNewVersionPayload({ ...newVersionPayload, activate_now: e.checked })} />
<label htmlFor="activate_now">{__('Attiva subito (ARCHIVE precedente ACTIVE)', 'gepafin')}</label>
</div>
<p style={{ fontSize: 12, color: '#888' }}>
{__('Il layout_config viene pre-popolato da quello ACTIVE corrente. Puoi modificarlo dopo la creazione.', 'gepafin')}
</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label={__('Annulla', 'gepafin')} severity="secondary" outlined onClick={() => setNewVersionDialog(false)} />
<Button label={__('Crea versione', 'gepafin')} icon="pi pi-plus" onClick={submitNewVersion} disabled={!newVersionPayload.version.match(/^\d+\.\d+\.\d+$/)} />
</div>
</Dialog>
</Card>
);
};
// ========== Tab 2: Policy ==========
const PolicyTab = ({ toast }) => {
const [policy, setPolicy] = useState(null);
const [loading, setLoading] = useState(true);
const load = () => {
setLoading(true);
Ar1Service.adminGetPolicy(
(resp) => { setPolicy(resp); setLoading(false); },
(err) => {
setLoading(false);
toast.current?.show({ severity: 'error', summary: 'Errore', detail: err?.detail });
}
);
};
useEffect(() => { load(); /* eslint-disable-next-line */ }, []);
const save = () => {
if (!policy) return;
const payload = {
validity_days: policy.validity_days,
popup_dismiss_hours: policy.popup_dismiss_hours,
popup_force_on_expired: policy.popup_force_on_expired,
auto_archive_on_company_document: policy.auto_archive_on_company_document,
company_document_category_id: policy.company_document_category_id,
allow_bulk_recompilation_request: policy.allow_bulk_recompilation_request,
};
Ar1Service.adminUpdatePolicy(payload,
() => { toast.current?.show({ severity: 'success', summary: 'OK', detail: 'Policy aggiornata' }); load(); },
(err) => toast.current?.show({ severity: 'error', summary: 'Errore', detail: err?.detail })
);
};
if (loading || !policy) return <Card><p>{__('Caricamento...', 'gepafin')}</p></Card>;
return (
<Card>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
<div>
<label style={{ display: 'block', marginBottom: 6, fontWeight: 500 }}>
{__('Validità dichiarazione (giorni)', 'gepafin')}
</label>
<InputNumber value={policy.validity_days} onValueChange={(e) => setPolicy({ ...policy, validity_days: e.value })} min={30} max={1825} style={{ width: '100%' }} />
<small style={{ color: '#888' }}>{__('Range: 30-1825 giorni (5 anni max)', 'gepafin')}</small>
</div>
<div>
<label style={{ display: 'block', marginBottom: 6, fontWeight: 500 }}>
{__('Ore di dismiss popup', 'gepafin')}
</label>
<InputNumber value={policy.popup_dismiss_hours} onValueChange={(e) => setPolicy({ ...policy, popup_dismiss_hours: e.value })} min={1} max={168} style={{ width: '100%' }} />
<small style={{ color: '#888' }}>{__('Range: 1-168 ore (1 settimana max)', 'gepafin')}</small>
</div>
<div>
<label style={{ display: 'block', marginBottom: 6, fontWeight: 500 }}>
{__('Categoria documento (auto-archive)', 'gepafin')}
</label>
<InputNumber value={policy.company_document_category_id} onValueChange={(e) => setPolicy({ ...policy, company_document_category_id: e.value })} min={1} style={{ width: '100%' }} />
<small style={{ color: '#888' }}>{__('4 = ANTIRICICLAGGIO (default Gepafin)', 'gepafin')}</small>
</div>
<div></div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<InputSwitch checked={policy.popup_force_on_expired} onChange={(e) => setPolicy({ ...policy, popup_force_on_expired: e.value })} />
<label>{__('Popup bloccante se EXPIRED', 'gepafin')}</label>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<InputSwitch checked={policy.auto_archive_on_company_document} onChange={(e) => setPolicy({ ...policy, auto_archive_on_company_document: e.value })} />
<label>{__('Auto-archive in company_document', 'gepafin')}</label>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<InputSwitch checked={policy.allow_bulk_recompilation_request} onChange={(e) => setPolicy({ ...policy, allow_bulk_recompilation_request: e.value })} />
<label>{__('Abilita invio massivo PEC', 'gepafin')}</label>
</div>
</div>
<div style={{ marginTop: 20 }}>
<Button label={__('Salva policy', 'gepafin')} icon="pi pi-save" onClick={save} />
</div>
</Card>
);
};
// ========== Tab 3: Regole Reminder PEC ==========
const PecScheduleTab = ({ toast }) => {
const [rules, setRules] = useState([]);
const [loading, setLoading] = useState(true);
const [editDialog, setEditDialog] = useState(false);
const [editingRule, setEditingRule] = useState(null);
const load = () => {
setLoading(true);
Ar1Service.adminListPecSchedule(
(resp) => { setRules(resp?.items || []); setLoading(false); },
(err) => {
setLoading(false);
toast.current?.show({ severity: 'error', summary: 'Errore', detail: err?.detail });
}
);
};
useEffect(() => { load(); /* eslint-disable-next-line */ }, []);
const openNew = () => {
setEditingRule({ kind: '', offset_days: 30, is_recurring: false, recurring_interval_days: null, enabled: true, description: '' });
setEditDialog(true);
};
const openEdit = (r) => {
setEditingRule({ ...r });
setEditDialog(true);
};
const save = () => {
if (!editingRule) return;
const cb = {
onOk: () => { toast.current?.show({ severity: 'success', summary: 'OK', detail: 'Regola salvata' }); setEditDialog(false); load(); },
onErr: (err) => toast.current?.show({ severity: 'error', summary: 'Errore', detail: err?.detail }),
};
if (editingRule.id) {
Ar1Service.adminUpdatePecRule(editingRule.id, {
offset_days: editingRule.offset_days, is_recurring: editingRule.is_recurring,
recurring_interval_days: editingRule.recurring_interval_days, enabled: editingRule.enabled,
description: editingRule.description,
}, cb.onOk, cb.onErr);
} else {
Ar1Service.adminCreatePecRule(editingRule, cb.onOk, cb.onErr);
}
};
const del = (rule) => {
confirmDialog({
message: __(`Eliminare la regola "${rule.kind}"?`, 'gepafin'),
header: __('Conferma', 'gepafin'), icon: 'pi pi-exclamation-triangle',
acceptClassName: 'p-button-danger',
accept: () => {
Ar1Service.adminDeletePecRule(rule.id,
() => { toast.current?.show({ severity: 'success', summary: 'OK', detail: 'Regola eliminata' }); load(); },
(err) => toast.current?.show({ severity: 'error', summary: 'Errore', detail: err?.detail })
);
}
});
};
const enabledTpl = (r) => <Tag severity={r.enabled ? 'success' : 'secondary'} value={r.enabled ? 'ON' : 'OFF'} />;
const recurringTpl = (r) => r.is_recurring ? `ogni ${r.recurring_interval_days}gg` : '—';
const actionsTpl = (r) => (
<div style={{ display: 'flex', gap: 4 }}>
<Button icon="pi pi-pencil" rounded text onClick={() => openEdit(r)} />
<Button icon="pi pi-trash" rounded text severity="danger" onClick={() => del(r)} />
</div>
);
return (
<Card>
<div style={{ marginBottom: 12 }}>
<Button label={__('Nuova regola', 'gepafin')} icon="pi pi-plus" onClick={openNew} />
</div>
<DataTable value={rules} loading={loading} emptyMessage={__('Nessuna regola', 'gepafin')}>
<Column field="kind" header={__('Kind', 'gepafin')} />
<Column field="offset_days" header={__('Offset (gg)', 'gepafin')} style={{ width: 110 }} />
<Column header={__('Ricorrenza', 'gepafin')} body={recurringTpl} style={{ width: 130 }} />
<Column field="description" header={__('Descrizione', 'gepafin')} />
<Column header={__('Abilitata', 'gepafin')} body={enabledTpl} style={{ width: 100 }} />
<Column header={__('Azioni', 'gepafin')} body={actionsTpl} style={{ width: 110 }} />
</DataTable>
<Dialog header={editingRule?.id ? __('Modifica regola', 'gepafin') : __('Nuova regola', 'gepafin')} visible={editDialog} onHide={() => setEditDialog(false)} style={{ width: '520px', maxWidth: '95vw' }} modal>
{editingRule && (
<div>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', marginBottom: 4 }}>{__('Kind (identificatore, unique, es: AR1_REMINDER_30D)', 'gepafin')}</label>
<InputText value={editingRule.kind || ''} onChange={(e) => setEditingRule({ ...editingRule, kind: e.target.value })} disabled={!!editingRule.id} style={{ width: '100%' }} />
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', marginBottom: 4 }}>{__('Offset giorni (positivo = prima scadenza, negativo = dopo)', 'gepafin')}</label>
<InputNumber value={editingRule.offset_days} onValueChange={(e) => setEditingRule({ ...editingRule, offset_days: e.value })} style={{ width: '100%' }} />
</div>
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
<Checkbox inputId="rec" checked={!!editingRule.is_recurring} onChange={(e) => setEditingRule({ ...editingRule, is_recurring: e.checked })} />
<label htmlFor="rec">{__('Ricorrente', 'gepafin')}</label>
</div>
{editingRule.is_recurring && (
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', marginBottom: 4 }}>{__('Intervallo ripetizione (giorni)', 'gepafin')}</label>
<InputNumber value={editingRule.recurring_interval_days} onValueChange={(e) => setEditingRule({ ...editingRule, recurring_interval_days: e.value })} min={1} style={{ width: '100%' }} />
</div>
)}
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', marginBottom: 4 }}>{__('Descrizione', 'gepafin')}</label>
<InputText value={editingRule.description || ''} onChange={(e) => setEditingRule({ ...editingRule, description: e.target.value })} style={{ width: '100%' }} />
</div>
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
<InputSwitch checked={editingRule.enabled} onChange={(e) => setEditingRule({ ...editingRule, enabled: e.value })} />
<label>{__('Abilitata', 'gepafin')}</label>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label={__('Annulla', 'gepafin')} severity="secondary" outlined onClick={() => setEditDialog(false)} />
<Button label={__('Salva', 'gepafin')} icon="pi pi-save" onClick={save} />
</div>
</div>
)}
</Dialog>
</Card>
);
};
// ========== Tab 4: Invio Massivo PEC ==========
const BulkPecTab = ({ toast }) => {
const [filters, setFilters] = useState({
only_expired: false,
only_missing: false,
company_ids: [],
expired_before: null,
});
const [dryRunResult, setDryRunResult] = useState(null);
const [submitting, setSubmitting] = useState(false);
const runDryRun = () => {
setSubmitting(true);
const payload = {
only_expired: filters.only_expired,
only_missing: filters.only_missing,
company_ids: filters.company_ids.length ? filters.company_ids.map(Number) : null,
expired_before: filters.expired_before ? filters.expired_before.toISOString() : null,
dry_run: true,
};
Ar1Service.adminBulkRecompilation(payload,
(resp) => { setSubmitting(false); setDryRunResult(resp); },
(err) => { setSubmitting(false); toast.current?.show({ severity: 'error', summary: 'Errore', detail: err?.detail }); }
);
};
const runLive = () => {
confirmDialog({
message: __(`Confermi l'invio di ${dryRunResult?.matched_count || 0} PEC? L'operazione non può essere annullata.`, 'gepafin'),
header: __('Conferma invio massivo', 'gepafin'), icon: 'pi pi-exclamation-triangle',
accept: () => {
setSubmitting(true);
const payload = {
only_expired: filters.only_expired,
only_missing: filters.only_missing,
company_ids: filters.company_ids.length ? filters.company_ids.map(Number) : null,
expired_before: filters.expired_before ? filters.expired_before.toISOString() : null,
dry_run: false,
};
Ar1Service.adminBulkRecompilation(payload,
(resp) => {
setSubmitting(false);
toast.current?.show({ severity: 'success', summary: 'Inviato', detail: `Marcati ${resp.marked_count} form per PEC` });
setDryRunResult(null);
},
(err) => { setSubmitting(false); toast.current?.show({ severity: 'error', summary: 'Errore', detail: err?.detail }); }
);
}
});
};
return (
<Card>
<p style={{ color: '#666' }}>
{__('Invia una PEC di sollecito ricompilazione a un insieme filtrato di aziende. Il micro-servizio marca i form; il BE poller invia la PEC tenant-aware (PEC Massiva o Mailgun) entro 5 minuti.', 'gepafin')}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginTop: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Checkbox inputId="exp" checked={filters.only_expired} onChange={(e) => setFilters({ ...filters, only_expired: e.checked })} />
<label htmlFor="exp">{__('Solo aziende con AR1 EXPIRED', 'gepafin')}</label>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Checkbox inputId="mis" checked={filters.only_missing} onChange={(e) => setFilters({ ...filters, only_missing: e.checked })} />
<label htmlFor="mis">{__('Solo aziende senza AR1 (richiede company_ids)', 'gepafin')}</label>
</div>
<div>
<label style={{ display: 'block', marginBottom: 4 }}>{__('Company IDs (opzionale, lista)', 'gepafin')}</label>
<Chips value={filters.company_ids} onChange={(e) => setFilters({ ...filters, company_ids: e.value })} placeholder={__('es. 1, 2, 15', 'gepafin')} />
</div>
<div>
<label style={{ display: 'block', marginBottom: 4 }}>{__('Scaduto prima di (solo con EXPIRED)', 'gepafin')}</label>
<Calendar value={filters.expired_before} onChange={(e) => setFilters({ ...filters, expired_before: e.value })} dateFormat="dd/mm/yy" showIcon style={{ width: '100%' }} />
</div>
</div>
<div style={{ marginTop: 20, display: 'flex', gap: 8 }}>
<Button label={__('Dry-run (conta aziende)', 'gepafin')} icon="pi pi-search" onClick={runDryRun} loading={submitting} />
{dryRunResult && dryRunResult.matched_count > 0 && (
<Button label={__(`Invia PEC a ${dryRunResult.matched_count} aziende`, 'gepafin')} icon="pi pi-send" severity="warning" onClick={runLive} loading={submitting} />
)}
</div>
{dryRunResult && (
<div style={{ marginTop: 20, padding: 14, background: '#f5f5f5', borderRadius: 4 }}>
<p><strong>{__('Risultato dry-run', 'gepafin')}</strong></p>
<p>{__('Aziende matchate:', 'gepafin')} <strong>{dryRunResult.matched_count}</strong></p>
<p>{__('Già in coda PEC:', 'gepafin')} <strong>{dryRunResult.already_pending_count || 0}</strong></p>
{dryRunResult.sample_company_ids && dryRunResult.sample_company_ids.length > 0 && (
<p>{__('Sample company IDs (max 20):', 'gepafin')} {dryRunResult.sample_company_ids.join(', ')}</p>
)}
{dryRunResult.note && <p style={{ color: '#888', fontSize: 12 }}>{dryRunResult.note}</p>}
</div>
)}
</Card>
);
};
export default Ar1AdminConfig;

View File

@@ -153,6 +153,110 @@ const Ar1Service = {
.catch(e => handleError(e, onError));
},
// ---------- ADMIN: Templates ----------
listTemplates(onSuccess, onError, queryStr = '') {
fetch(`${BASE_URL}/admin/ar1-templates${queryStr}`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
},
getTemplateDetail(templateId, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-templates/${templateId}`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
},
updateTemplateLayout(templateId, layoutConfig, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-templates/${templateId}/layout-config`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify({ layout_config: layoutConfig })
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
},
createNewTemplateVersion(variant, payload, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-templates/${variant}/new-version`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(payload)
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
},
// ---------- ADMIN: Policy ----------
getPolicy(onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-policy`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
},
updatePolicy(payload, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-policy`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(payload)
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
},
// ---------- ADMIN: PEC Schedule Config (CRUD) ----------
listPecSchedule(onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-pec-schedule-config`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
},
createPecRule(payload, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-pec-schedule-config`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(payload)
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
},
updatePecRule(ruleId, payload, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-pec-schedule-config/${ruleId}`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(payload)
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
},
deletePecRule(ruleId, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-pec-schedule-config/${ruleId}`, {
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));
},
// ---------- ADMIN: Bulk PEC ----------
bulkRequestRecompilation(payload, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-forms/bulk-request-recompilation`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(payload)
})
.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`, {
@@ -164,3 +268,102 @@ const Ar1Service = {
};
export default Ar1Service;
// ========== ADMIN METHODS (aggiunti fase admin) ==========
Ar1Service.adminListTemplates = function (onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-templates`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
};
Ar1Service.adminGetTemplate = function (templateId, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-templates/${templateId}`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
};
Ar1Service.adminUpdateLayoutConfig = function (templateId, layoutConfig, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-templates/${templateId}/layout-config`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify({ layout_config: layoutConfig })
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
};
Ar1Service.adminNewVersion = function (variant, payload, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-templates/${variant}/new-version`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(payload)
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
};
Ar1Service.adminGetPolicy = function (onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-policy`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
};
Ar1Service.adminUpdatePolicy = function (payload, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-policy`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(payload)
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
};
Ar1Service.adminListPecSchedule = function (onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-pec-schedule-config`, {
method: 'GET', mode: 'cors', headers: buildHeaders()
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
};
Ar1Service.adminCreatePecRule = function (payload, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-pec-schedule-config`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(payload)
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
};
Ar1Service.adminUpdatePecRule = function (ruleId, payload, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-pec-schedule-config/${ruleId}`, {
method: 'PUT', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(payload)
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
};
Ar1Service.adminDeletePecRule = function (ruleId, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-pec-schedule-config/${ruleId}`, {
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));
};
Ar1Service.adminBulkRecompilation = function (payload, onSuccess, onError) {
fetch(`${BASE_URL}/admin/ar1-forms/bulk-request-recompilation`, {
method: 'POST', mode: 'cors', headers: buildHeaders(),
body: JSON.stringify(payload)
})
.then(r => handleResponse(r, onSuccess, onError))
.catch(e => handleError(e, onError));
};

View File

@@ -24,6 +24,7 @@ import IstruttoriaPratica from './modules/rendicontazione/pages/IstruttoriaPrati
import Ar1Home from './modules/ar1/pages/Ar1Home';
import Ar1Wizard from './modules/ar1/pages/Ar1Wizard';
import Ar1Signature from './modules/ar1/pages/Ar1Signature';
import Ar1AdminConfig from './modules/ar1/pages/Ar1AdminConfig';
import BandoFlowEdit from './pages/BandoFlowEdit';
import Imieibandi from './pages/Imieibandi';
import BandoApplication from './pages/BandoApplication';
@@ -187,6 +188,13 @@ const routes = ({ role, chosenCompanyId }) => {
{'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null}
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
</DefaultLayout>}/>
<Route path="/ar1-admin" element={<DefaultLayout>
{'ROLE_SUPER_ADMIN' === role ? <Ar1AdminConfig/> : <PageNotFound/>}
{'ROLE_BENEFICIARY' === role ? <PageNotFound/> : null}
{'ROLE_CONFIDI' === role ? <PageNotFound/> : null}
{'ROLE_PRE_INSTRUCTOR' === role ? <PageNotFound/> : null}
{'ROLE_INSTRUCTOR_MANAGER' === role ? <PageNotFound/> : null}
</DefaultLayout>}/>
<Route path="/rendicontazioni/:id" element={<DefaultLayout>
{'ROLE_BENEFICIARY' === role ? <PraticaRendicontazioneEdit/> : null}
{'ROLE_SUPER_ADMIN' === role ? <PraticaRendicontazioneEdit/> : null}