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:
@@ -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',
|
||||
|
||||
532
src/modules/ar1/pages/Ar1AdminConfig.js
Normal file
532
src/modules/ar1/pages/Ar1AdminConfig.js
Normal 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;
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user