feat(ar1-admin): editor unificato template (layout L2 + struttura quadri L1)

Strada A: il superadmin puo modificare TUTTO via UI (layout grafico + struttura
dei quadri). Se tocca solo il layout -> PUT in place. Se tocca la struttura ->
il BE auto-bumpa la patch e archivia la versione precedente. I form gia compilati
continuano a usare il loro snapshot.

Nuovo componente:
  src/modules/ar1/components/QuadriStructureEditor.js (438 LOC)
  - Metadati snapshot: variant_label, legal_ref, normative_frame, variant_description
  - Lista quadri in Accordion, per ogni quadro:
      * id / title / description modificabili
      * reorder su/giu, elimina quadro, aggiungi quadro
      * Warning 'NORMATIVO' per Quadro G (is_legal_frame=true)
      * Fields normali: editor per-campo con id, label, tipo (7 types), required,
        max_length, pattern regex, options (per enum/radio), tag prefill_from
      * Row fields (row_type, Quadro B titolari): sezione separata con warning
      * Nested_full fields (Quadro C/D): sezione separata
      * Upload slots (Quadro F): tag readonly (edit avanzato tbd)
  - FIELD_TYPE_OPTIONS: text, email, date, checkbox, radio, enum, yes_no_with_note
  - Usa Accordion multi-open per navigare piu quadri, Tag per metadati visuali

Cambiamenti in Ar1AdminConfig.js:
  - Rimossi: openEditLayout, openNewVersion, saveNewVersion, stati newVersionOpen/
    Data/Variant, Dialog 'Nuova versione' manuale (user sceglieva version semver)
  - Aggiunti: openEditTemplate (carica template completo via GET detail),
    saveEditTemplate (fa diff questions_snapshot, se cambiato chiama
    createNewTemplateVersion senza version -> BE auto-bump, se invariato chiama
    updateTemplateLayout in place), questionsStructureChanged helper (deep-equal
    via JSON.stringify su clone deep fatto al load)
  - Service: + getTemplate + getNextVersion (per preview numero versione)
  - Bottoni azioni tab Template: solo 'Anteprima' + 'Modifica' (rimosso '+ Nuova vers.')
  - Dialog unificato 1100px maximizable:
      * Bar top con Tag variante/version/status + Message warning se struttura
        modificata (mostra prossima versione preview es. v1.2.968)
      * 2 tab interni (pulsanti custom): 'Layout grafico' vs 'Struttura quadri'
        con indicatore • se struttura ha modifiche
      * Sezione Layout: form come prima (brand/header/intro/privacy) + toggle JSON raw
      * Sezione Struttura: rende QuadriStructureEditor
      * Footer sticky: tag stato ('update in place' verde vs 'nuova versione' giallo)
        + bottone Salva che cambia label e severity: 'Salva layout' default vs
        'Crea versione v1.2.968' warning quando struttura cambiata
  - Dialog 'Nuova versione' rimosso (mai piu input manuale di semver)
This commit is contained in:
BFLOWS
2026-04-23 15:46:43 +02:00
parent ac1c18c737
commit ec0e7397e6
2 changed files with 694 additions and 159 deletions

View File

@@ -0,0 +1,438 @@
import React, { useState } from 'react';
// prime
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { InputText } from 'primereact/inputtext';
import { InputTextarea } from 'primereact/inputtextarea';
import { Dropdown } from 'primereact/dropdown';
import { InputNumber } from 'primereact/inputnumber';
import { Checkbox } from 'primereact/checkbox';
import { Tag } from 'primereact/tag';
import { Divider } from 'primereact/divider';
import { Message } from 'primereact/message';
import { Accordion, AccordionTab } from 'primereact/accordion';
import { confirmDialog } from 'primereact/confirmdialog';
const FIELD_TYPE_OPTIONS = [
{ label: 'Testo libero', value: 'text' },
{ label: 'Email', value: 'email' },
{ label: 'Data', value: 'date' },
{ label: 'Checkbox (si/no)', value: 'checkbox' },
{ label: 'Scelta singola (radio)', value: 'radio' },
{ label: 'Menu a tendina (enum)', value: 'enum' },
{ label: 'Si/No con nota', value: 'yes_no_with_note' }
];
/**
* Editor della struttura questions_snapshot di un template AR1.
*
* Props:
* value: { quadri: [...], variant, legal_ref, ... }
* onChange: (newValue) => void
*
* Permette:
* - Modificare metadati snapshot (variant_label, variant_description, legal_ref, normative_frame)
* - Modificare per ogni quadro: id, title, description
* - Aggiungere/rimuovere/modificare fields con tipo, label, required, pattern, max_length, options, prefill_from
* - Per Quadro G (is_legal_frame=true): warning visuale + editor del description
* - Per Quadri B (row_type=titolare): edit row_fields separato
* - Per Quadri C/D (nested_full): edit fields nested
* - Aggiungere/rimuovere quadri interi
*/
const QuadriStructureEditor = ({ value, onChange }) => {
const qs = value || { quadri: [] };
const quadri = qs.quadri || [];
const update = (newQs) => onChange(newQs);
// --- metadati snapshot ---
const updateMeta = (key, v) => update({ ...qs, [key]: v });
// --- quadri ---
const updateQuadro = (idx, partial) => {
const newQuadri = quadri.map((q, i) => i === idx ? { ...q, ...partial } : q);
update({ ...qs, quadri: newQuadri });
};
const moveQuadro = (idx, direction) => {
const newIdx = idx + direction;
if (newIdx < 0 || newIdx >= quadri.length) return;
const newQuadri = [...quadri];
[newQuadri[idx], newQuadri[newIdx]] = [newQuadri[newIdx], newQuadri[idx]];
update({ ...qs, quadri: newQuadri });
};
const removeQuadro = (idx) => {
const q = quadri[idx];
confirmDialog({
message: `Eliminare il quadro "${q.id} - ${q.title}"? Sara creata una nuova versione.`,
header: 'Conferma eliminazione quadro',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Elimina',
rejectLabel: 'Annulla',
acceptClassName: 'p-button-danger',
accept: () => {
const newQuadri = quadri.filter((_, i) => i !== idx);
update({ ...qs, quadri: newQuadri });
}
});
};
const addQuadro = () => {
const nextLetter = String.fromCharCode(65 + quadri.length); // A, B, C...
const newQuadro = {
id: nextLetter,
title: `Quadro ${nextLetter} - Nuovo quadro`,
description: '',
fields: []
};
update({ ...qs, quadri: [...quadri, newQuadro] });
};
// --- fields dentro quadro ---
const updateField = (qIdx, fIdx, partial) => {
const q = quadri[qIdx];
const newFields = (q.fields || []).map((f, i) => i === fIdx ? { ...f, ...partial } : f);
updateQuadro(qIdx, { fields: newFields });
};
const addField = (qIdx) => {
const q = quadri[qIdx];
const fields = q.fields || [];
const newField = {
id: `campo_${fields.length + 1}`,
type: 'text',
label: 'Nuovo campo',
required: false
};
updateQuadro(qIdx, { fields: [...fields, newField] });
};
const removeField = (qIdx, fIdx) => {
const q = quadri[qIdx];
const newFields = (q.fields || []).filter((_, i) => i !== fIdx);
updateQuadro(qIdx, { fields: newFields });
};
const moveField = (qIdx, fIdx, direction) => {
const q = quadri[qIdx];
const fields = q.fields || [];
const newIdx = fIdx + direction;
if (newIdx < 0 || newIdx >= fields.length) return;
const newFields = [...fields];
[newFields[fIdx], newFields[newIdx]] = [newFields[newIdx], newFields[fIdx]];
updateQuadro(qIdx, { fields: newFields });
};
// --- row_fields (es. Quadro B titolari) ---
const updateRowField = (qIdx, fIdx, partial) => {
const q = quadri[qIdx];
const newRowFields = (q.row_fields || []).map((f, i) => i === fIdx ? { ...f, ...partial } : f);
updateQuadro(qIdx, { row_fields: newRowFields });
};
const addRowField = (qIdx) => {
const q = quadri[qIdx];
const rowFields = q.row_fields || [];
updateQuadro(qIdx, {
row_fields: [...rowFields, { id: `campo_${rowFields.length + 1}`, type: 'text', label: 'Nuovo campo', required: false }]
});
};
const removeRowField = (qIdx, fIdx) => {
const q = quadri[qIdx];
updateQuadro(qIdx, { row_fields: (q.row_fields || []).filter((_, i) => i !== fIdx) });
};
// --- nested fields (es. Quadro C/D) ---
const updateNestedField = (qIdx, fIdx, partial) => {
const q = quadri[qIdx];
const nested = q.nested_full || {};
const newNestedFields = (nested.fields || []).map((f, i) => i === fIdx ? { ...f, ...partial } : f);
updateQuadro(qIdx, { nested_full: { ...nested, fields: newNestedFields } });
};
const addNestedField = (qIdx) => {
const q = quadri[qIdx];
const nested = q.nested_full || { fields: [] };
const fields = nested.fields || [];
updateQuadro(qIdx, {
nested_full: {
...nested,
fields: [...fields, { id: `campo_${fields.length + 1}`, type: 'text', label: 'Nuovo campo', required: false }]
}
});
};
const removeNestedField = (qIdx, fIdx) => {
const q = quadri[qIdx];
const nested = q.nested_full || {};
updateQuadro(qIdx, {
nested_full: { ...nested, fields: (nested.fields || []).filter((_, i) => i !== fIdx) }
});
};
// ----- render field editor (atom) -----
const renderFieldRow = (field, idx, onUpdate, onRemove, onMoveUp, onMoveDown, total) => {
const isEnum = field.type === 'enum' || field.type === 'radio';
return (
<div key={idx} style={{
border: '1px solid #ddd', borderRadius: 4, padding: 10,
marginBottom: 8, background: '#fafafa'
}}>
<div style={{ display: 'grid', gridTemplateColumns: '160px 1fr 160px auto', gap: 8, alignItems: 'end' }}>
<div>
<label style={{ fontSize: 11, color: '#555' }}>ID (kebab, no spazi)</label>
<InputText value={field.id || ''}
onChange={(e) => onUpdate({ id: e.target.value.replace(/\s+/g, '_') })}
style={{ width: '100%', fontFamily: 'monospace', fontSize: 12 }} />
</div>
<div>
<label style={{ fontSize: 11, color: '#555' }}>Label visualizzato</label>
<InputText value={field.label || ''}
onChange={(e) => onUpdate({ label: e.target.value })}
style={{ width: '100%' }} />
</div>
<div>
<label style={{ fontSize: 11, color: '#555' }}>Tipo</label>
<Dropdown value={field.type || 'text'} options={FIELD_TYPE_OPTIONS}
onChange={(e) => onUpdate({ type: e.value })}
style={{ width: '100%' }} />
</div>
<div style={{ display: 'flex', gap: 4 }}>
<Button icon="pi pi-arrow-up" rounded outlined size="small"
disabled={idx === 0} onClick={() => onMoveUp()} tooltip="Sposta su" />
<Button icon="pi pi-arrow-down" rounded outlined size="small"
disabled={idx === total - 1} onClick={() => onMoveDown && onMoveDown()} tooltip="Sposta giu" />
<Button icon="pi pi-trash" rounded outlined size="small" severity="danger"
onClick={() => onRemove()} tooltip="Elimina campo" />
</div>
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Checkbox inputId={`req_${idx}_${field.id}`} checked={!!field.required}
onChange={(e) => onUpdate({ required: e.checked })} />
<label htmlFor={`req_${idx}_${field.id}`} style={{ fontSize: 12 }}>Obbligatorio</label>
</div>
{field.type === 'text' && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<label style={{ fontSize: 11, color: '#555' }}>Max lunghezza:</label>
<InputNumber value={field.max_length || null}
onValueChange={(e) => onUpdate({ max_length: e.value })}
style={{ width: 90 }} inputStyle={{ fontSize: 12 }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<label style={{ fontSize: 11, color: '#555' }}>Pattern regex:</label>
<InputText value={field.pattern || ''}
onChange={(e) => onUpdate({ pattern: e.target.value || undefined })}
style={{ width: 180, fontFamily: 'monospace', fontSize: 11 }}
placeholder="es. ^[0-9]{11}$" />
</div>
</>
)}
{field.prefill_from && (
<Tag severity="info" value={`prefill: ${field.prefill_from}`} style={{ fontSize: 11 }} />
)}
</div>
{isEnum && (
<div style={{ marginTop: 8 }}>
<label style={{ fontSize: 11, color: '#555', display: 'block', marginBottom: 2 }}>
Opzioni (una per riga)
</label>
<InputTextarea rows={3}
value={(field.options || []).map(o => typeof o === 'string' ? o : (o.label || o.value || '')).join('\n')}
onChange={(e) => {
const opts = e.target.value.split('\n').filter(Boolean);
onUpdate({ options: opts });
}}
style={{ width: '100%', fontSize: 12 }} />
</div>
)}
</div>
);
};
// ----- render -----
return (
<div>
<Message severity="warn" style={{ marginBottom: 14 }}
text="Attenzione: modifiche alla struttura dei quadri generano automaticamente una nuova versione del template. I form gia compilati continuano a usare la versione precedente grazie allo snapshot." />
{/* Metadati */}
<Card title="Metadati template" style={{ marginBottom: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div>
<label style={{ fontSize: 11, color: '#555' }}>Etichetta variante</label>
<InputText value={qs.variant_label || ''}
onChange={(e) => updateMeta('variant_label', e.target.value)} style={{ width: '100%' }} />
</div>
<div>
<label style={{ fontSize: 11, color: '#555' }}>Riferimento normativo</label>
<InputText value={qs.legal_ref || ''}
onChange={(e) => updateMeta('legal_ref', e.target.value)} style={{ width: '100%' }}
placeholder="es. D.Lgs. 231/2007" />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label style={{ fontSize: 11, color: '#555' }}>Descrizione variante</label>
<InputTextarea rows={2} value={qs.variant_description || ''}
onChange={(e) => updateMeta('variant_description', e.target.value)} style={{ width: '100%' }} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label style={{ fontSize: 11, color: '#555' }}>Cornice normativa (testo completo)</label>
<InputTextarea rows={3} value={qs.normative_frame || ''}
onChange={(e) => updateMeta('normative_frame', e.target.value)}
style={{ width: '100%', fontSize: 12 }} />
</div>
</div>
</Card>
{/* Quadri */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<h3 style={{ margin: 0 }}>Quadri ({quadri.length})</h3>
<Button label="Aggiungi quadro" icon="pi pi-plus" size="small" outlined onClick={addQuadro} />
</div>
<Accordion multiple>
{quadri.map((q, qIdx) => (
<AccordionTab key={qIdx}
header={
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<Tag value={q.id || '?'} severity={q.is_legal_frame ? 'warning' : 'info'} />
<strong style={{ flex: 1 }}>{q.title || '(senza titolo)'}</strong>
{q.is_legal_frame && <Tag value="NORMATIVO" severity="warning" />}
{q.row_type && <Tag value={`ripetuto (${q.row_type})`} severity="secondary" />}
{q.nested_full && <Tag value="annidato" severity="secondary" />}
<small style={{ color: '#888' }}>
{(q.fields || []).length} campi
{q.row_fields ? ` +${q.row_fields.length} riga` : ''}
{q.nested_full?.fields ? ` +${q.nested_full.fields.length} nidif.` : ''}
</small>
</div>
}>
<div style={{ display: 'grid', gridTemplateColumns: '140px 1fr 1fr auto', gap: 8, alignItems: 'end', marginBottom: 10 }}>
<div>
<label style={{ fontSize: 11, color: '#555' }}>ID quadro</label>
<InputText value={q.id || ''}
onChange={(e) => updateQuadro(qIdx, { id: e.target.value.toUpperCase() })}
style={{ width: '100%', fontFamily: 'monospace' }} maxLength={4} />
</div>
<div style={{ gridColumn: 'span 2' }}>
<label style={{ fontSize: 11, color: '#555' }}>Titolo</label>
<InputText value={q.title || ''}
onChange={(e) => updateQuadro(qIdx, { title: e.target.value })}
style={{ width: '100%' }} />
</div>
<div style={{ display: 'flex', gap: 4 }}>
<Button icon="pi pi-arrow-up" rounded outlined size="small"
disabled={qIdx === 0} onClick={() => moveQuadro(qIdx, -1)} tooltip="Su" />
<Button icon="pi pi-arrow-down" rounded outlined size="small"
disabled={qIdx === quadri.length - 1} onClick={() => moveQuadro(qIdx, 1)} tooltip="Giu" />
<Button icon="pi pi-trash" rounded outlined size="small" severity="danger"
onClick={() => removeQuadro(qIdx)} tooltip="Elimina quadro" />
</div>
</div>
<div style={{ marginBottom: 10 }}>
<label style={{ fontSize: 11, color: '#555' }}>Descrizione</label>
<InputTextarea rows={2} value={q.description || ''}
onChange={(e) => updateQuadro(qIdx, { description: e.target.value })}
style={{ width: '100%' }} />
</div>
{q.is_legal_frame && (
<Message severity="error" style={{ marginBottom: 10 }}
text="Quadro normativo (D.Lgs. 231/2007). Il testo dichiara responsabilita legali del firmatario. Modifica con massima cautela: il contenuto viene inserito nel PDF firmato dal beneficiario." />
)}
{/* Fields normali */}
{(q.fields !== undefined) && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<strong style={{ fontSize: 13 }}>Campi ({(q.fields || []).length})</strong>
<Button label="Aggiungi campo" icon="pi pi-plus" size="small" outlined
onClick={() => addField(qIdx)} />
</div>
{(q.fields || []).map((f, fIdx) =>
renderFieldRow(f, fIdx,
(p) => updateField(qIdx, fIdx, p),
() => removeField(qIdx, fIdx),
() => moveField(qIdx, fIdx, -1),
() => moveField(qIdx, fIdx, 1),
(q.fields || []).length
)
)}
{(!q.fields || q.fields.length === 0) && !q.row_type && !q.nested_full && (
<p style={{ color: '#888', fontStyle: 'italic', textAlign: 'center' }}>Nessun campo. Aggiungine uno.</p>
)}
</>
)}
{/* Row fields (Quadro B titolari) */}
{q.row_type && (
<>
<Divider />
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<strong style={{ fontSize: 13 }}>Campi riga (per ogni {q.row_type}): ({(q.row_fields || []).length})</strong>
<Button label="Aggiungi campo riga" icon="pi pi-plus" size="small" outlined
onClick={() => addRowField(qIdx)} />
</div>
<Message severity="info" style={{ marginBottom: 8, fontSize: 12 }}
text={`Questi campi si ripetono per ogni ${q.row_type} aggiunto dal beneficiario. Non eliminare il row_type se ci sono form gia compilati.`} />
{(q.row_fields || []).map((f, fIdx) =>
renderFieldRow(f, fIdx,
(p) => updateRowField(qIdx, fIdx, p),
() => removeRowField(qIdx, fIdx),
() => {}, () => {},
(q.row_fields || []).length
)
)}
</>
)}
{/* Nested full (Quadro C/D rappresentante/esecutore) */}
{q.nested_full && (
<>
<Divider />
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<strong style={{ fontSize: 13 }}>Campi annidati: ({(q.nested_full.fields || []).length})</strong>
<Button label="Aggiungi campo annidato" icon="pi pi-plus" size="small" outlined
onClick={() => addNestedField(qIdx)} />
</div>
{(q.nested_full.fields || []).map((f, fIdx) =>
renderFieldRow(f, fIdx,
(p) => updateNestedField(qIdx, fIdx, p),
() => removeNestedField(qIdx, fIdx),
() => {}, () => {},
(q.nested_full.fields || []).length
)
)}
</>
)}
{/* Upload slots info (Quadro F) */}
{q.upload_slots && (
<>
<Divider />
<strong style={{ fontSize: 13 }}>Slot upload:</strong>
<div style={{ marginTop: 4 }}>
{q.upload_slots.map((slot, i) => (
<Tag key={i} value={slot.label || slot.id} severity="secondary" style={{ marginRight: 4 }} />
))}
</div>
<small style={{ color: '#888' }}>Gli slot upload sono gestiti dal modello: modifica avanzata non ancora via UI.</small>
</>
)}
</AccordionTab>
))}
</Accordion>
</div>
);
};
export default QuadriStructureEditor;

View File

@@ -23,6 +23,9 @@ import { ConfirmDialog, confirmDialog } from 'primereact/confirmdialog';
// service
import Ar1Service from '../service/ar1Service';
// components
import QuadriStructureEditor from '../components/QuadriStructureEditor';
// ==================================================
// Mappe label in italiano — SINGLE SOURCE OF TRUTH
// ==================================================
@@ -142,14 +145,20 @@ const Ar1AdminConfig = () => {
// ========= TEMPLATES =========
const [templates, setTemplates] = useState([]);
const [loadingTpl, setLoadingTpl] = useState(false);
const [editLayoutOpen, setEditLayoutOpen] = useState(false);
const [editLayoutTpl, setEditLayoutTpl] = useState(null);
const [layoutForm, setLayoutForm] = useState({}); // form strutturato
const [layoutAdvancedJson, setLayoutAdvancedJson] = useState(''); // modalita JSON raw
const [editOpen, setEditOpen] = useState(false);
const [editTpl, setEditTpl] = useState(null); // template completo (caricato via GET /admin/ar1-templates/:id)
const [editLoading, setEditLoading] = useState(false);
const [editSection, setEditSection] = useState('layout'); // 'layout' | 'struttura'
// Layout form fields
const [layoutForm, setLayoutForm] = useState({});
const [layoutAdvancedJson, setLayoutAdvancedJson] = useState('');
const [useAdvancedEditor, setUseAdvancedEditor] = useState(false);
const [newVersionOpen, setNewVersionOpen] = useState(false);
const [newVersionVariant, setNewVersionVariant] = useState('A1');
const [newVersionData, setNewVersionData] = useState({ version: '', activate_now: true });
// Questions snapshot editor
const [questionsSnapshot, setQuestionsSnapshot] = useState(null);
const [originalQuestionsSnapshot, setOriginalQuestionsSnapshot] = useState(null);
// Prossima versione auto-bump (preview)
const [nextVersion, setNextVersion] = useState(null);
const [saving, setSaving] = useState(false);
// ========= POLICY =========
const [policy, setPolicy] = useState(null);
@@ -278,25 +287,51 @@ const Ar1AdminConfig = () => {
const activeTemplates = templates.filter(t => t.status === 'ACTIVE' || t.status === 'DRAFT');
const archivedTemplates = templates.filter(t => t.status === 'ARCHIVED');
const openEditLayout = (tpl) => {
setEditLayoutTpl(tpl);
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);
const openEditTemplate = (tplListRow) => {
// carico il template completo (questions_snapshot incluso) via GET detail
setEditOpen(true);
setEditLoading(true);
setEditTpl(null);
setQuestionsSnapshot(null);
setOriginalQuestionsSnapshot(null);
setNextVersion(null);
setEditSection('layout');
Ar1Service.getTemplate(tplListRow.id,
(tpl) => {
setEditTpl(tpl);
const lc = tpl.layout_config || {};
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);
const qs = tpl.questions_snapshot || { quadri: [] };
setQuestionsSnapshot(qs);
setOriginalQuestionsSnapshot(JSON.parse(JSON.stringify(qs))); // deep clone per diff
setEditLoading(false);
// carico anche la prossima versione possibile (preview)
Ar1Service.getNextVersion(tpl.variant,
(resp) => setNextVersion(resp.next_version),
() => {}
);
},
(err) => {
setEditLoading(false);
setEditOpen(false);
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: formatErrorDetail(err?.detail, 'Caricamento template fallito') });
}
);
};
const buildLayoutFromForm = () => ({
@@ -320,46 +355,64 @@ const Ar1AdminConfig = () => {
}
});
const saveLayout = () => {
let payload;
// Deep-equal semplice via JSON.stringify con key order (sufficiente perche le key provengono dalla stessa struttura)
const questionsStructureChanged = () => {
if (!originalQuestionsSnapshot || !questionsSnapshot) return false;
return JSON.stringify(questionsSnapshot) !== JSON.stringify(originalQuestionsSnapshot);
};
const saveEditTemplate = () => {
if (!editTpl) return;
let layoutConfig;
if (useAdvancedEditor) {
try { payload = JSON.parse(layoutAdvancedJson); }
try { layoutConfig = JSON.parse(layoutAdvancedJson); }
catch (e) { if (toast.current) toast.current.show({ severity: 'error', summary: 'JSON non valido', detail: e.message }); return; }
} else {
payload = buildLayoutFromForm();
layoutConfig = buildLayoutFromForm();
}
Ar1Service.updateTemplateLayout(editLayoutTpl.id, payload,
() => {
if (toast.current) toast.current.show({ severity: 'success', summary: 'Salvato', detail: 'Layout aggiornato' });
setEditLayoutOpen(false);
loadTemplates();
},
(err) => { if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: formatErrorDetail(err?.detail, 'Salvataggio fallito') }); }
);
};
const openNewVersion = (variant) => {
setNewVersionVariant(variant);
setNewVersionData({ version: '', activate_now: true });
setNewVersionOpen(true);
};
const structureChanged = questionsStructureChanged();
const saveNewVersion = () => {
// Eredita layout_config dalla versione ACTIVE corrente
const active = templates.find(t => t.variant === newVersionVariant && t.status === 'ACTIVE');
const layoutConfig = active?.layout_config || {};
Ar1Service.createNewTemplateVersion(newVersionVariant, {
version: newVersionData.version,
layout_config: layoutConfig,
activate_now: newVersionData.activate_now
},
() => {
if (toast.current) toast.current.show({ severity: 'success', summary: 'Creata', detail: `Nuova versione ${newVersionVariant} v${newVersionData.version}` });
setNewVersionOpen(false);
loadTemplates();
setSaving(true);
if (structureChanged) {
// Nuova versione: il BE auto-bumpa la patch
Ar1Service.createNewTemplateVersion(editTpl.variant, {
// version omessa -> BE auto-bump
layout_config: layoutConfig,
questions_snapshot: questionsSnapshot,
activate_now: true
},
(err) => { if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: formatErrorDetail(err?.detail, 'Creazione fallita') }); }
);
(resp) => {
setSaving(false);
if (toast.current) toast.current.show({
severity: 'success',
summary: `Nuova versione creata: v${resp.version}`,
detail: `La versione precedente e stata archiviata. I form gia compilati continuano a usare quella. Nuove bozze useranno v${resp.version}.`,
life: 6000
});
setEditOpen(false);
loadTemplates();
},
(err) => {
setSaving(false);
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore creazione versione', detail: formatErrorDetail(err?.detail, 'Creazione fallita') });
}
);
} else {
// Solo layout cambiato -> PUT in place sulla versione corrente
Ar1Service.updateTemplateLayout(editTpl.id, layoutConfig,
() => {
setSaving(false);
if (toast.current) toast.current.show({ severity: 'success', summary: 'Layout aggiornato', detail: `Template ${editTpl.variant} v${editTpl.version}` });
setEditOpen(false);
loadTemplates();
},
(err) => {
setSaving(false);
if (toast.current) toast.current.show({ severity: 'error', summary: 'Errore', detail: formatErrorDetail(err?.detail, 'Salvataggio fallito') });
}
);
}
};
// ==================================================
@@ -541,11 +594,8 @@ const Ar1AdminConfig = () => {
}
}} />
<Button icon="pi pi-pencil" rounded outlined size="small"
tooltip="Modifica layout grafico" tooltipOptions={{ position: 'top' }}
onClick={() => openEditLayout(row)} disabled={row.status === 'ARCHIVED'} />
<Button icon="pi pi-plus" rounded outlined size="small" severity="warning"
tooltip="Nuova versione" tooltipOptions={{ position: 'top' }}
onClick={() => openNewVersion(row.variant)} />
tooltip="Modifica (layout + struttura quadri)" tooltipOptions={{ position: 'top' }}
onClick={() => openEditTemplate(row)} disabled={row.status === 'ARCHIVED'} />
</div>
);
@@ -787,108 +837,155 @@ const Ar1AdminConfig = () => {
{/* ==================== DIALOGS ==================== */}
{/* Edit layout template */}
{/* Edit template unificato: layout + struttura quadri */}
<Dialog
header={editLayoutTpl ? `Modifica layout ${editLayoutTpl.variant} v${editLayoutTpl.version}` : ''}
visible={editLayoutOpen}
onHide={() => setEditLayoutOpen(false)}
style={{ width: '780px', maxWidth: '95vw' }}
header={editTpl ? `Modifica template ${editTpl.variant} v${editTpl.version}` : 'Caricamento...'}
visible={editOpen}
onHide={() => setEditOpen(false)}
style={{ width: '1100px', maxWidth: '95vw', height: '90vh' }}
contentStyle={{ maxHeight: 'calc(90vh - 100px)', overflow: 'auto' }}
modal
maximizable
>
<div style={{ marginBottom: 10, display: 'flex', alignItems: 'center', gap: 10 }}>
<InputSwitch checked={useAdvancedEditor} onChange={(e) => setUseAdvancedEditor(e.value)} />
<label>Modalita avanzata (JSON raw)</label>
</div>
{editLoading && <p>Caricamento template...</p>}
{!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 && (
{!editLoading && editTpl && (
<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 }} />
{/* Bar info + cambio sezione */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14, padding: '8px 12px', background: '#f5f5f5', borderRadius: 4 }}>
<div>
<Tag value={editTpl.variant} severity="info" />{' '}
<Tag value={`v${editTpl.version}`} severity="secondary" />{' '}
<Tag value={TEMPLATE_STATUS_LABEL[editTpl.status] || editTpl.status} severity={editTpl.status === 'ACTIVE' ? 'success' : 'warning'} />
</div>
{questionsStructureChanged() && nextVersion && (
<Message severity="warn" text={`Struttura modificata → verra creata la versione v${nextVersion}`} />
)}
</div>
{/* Tab switch: Layout vs Struttura */}
<div style={{ display: 'flex', gap: 0, borderBottom: '2px solid #e0e0e0', marginBottom: 16 }}>
<Button
label="Layout grafico"
icon="pi pi-palette"
text={editSection !== 'layout'}
onClick={() => setEditSection('layout')}
style={{ borderRadius: 0, borderBottom: editSection === 'layout' ? '3px solid var(--primary-color)' : 'none' }}
/>
<Button
label={'Struttura quadri' + (questionsStructureChanged() ? ' •' : '')}
icon="pi pi-list"
text={editSection !== 'struttura'}
severity={questionsStructureChanged() ? 'warning' : undefined}
onClick={() => setEditSection('struttura')}
style={{ borderRadius: 0, borderBottom: editSection === 'struttura' ? '3px solid var(--primary-color)' : 'none' }}
/>
</div>
{/* SEZIONE LAYOUT */}
{editSection === 'layout' && (
<div>
<Message severity="info" style={{ marginBottom: 10 }}
text="Modifiche al layout grafico (brand, colori, testi introduttivi) si applicano in place alla versione corrente, senza creare una nuova versione." />
<div style={{ marginBottom: 10, display: 'flex', alignItems: 'center', gap: 10 }}>
<InputSwitch checked={useAdvancedEditor} onChange={(e) => setUseAdvancedEditor(e.value)} />
<label>Modalita avanzata (JSON raw)</label>
</div>
{!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={4} 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 pie 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={20} style={{ width: '100%', fontFamily: 'monospace', fontSize: 12 }} />
</div>
)}
</div>
)}
{/* SEZIONE STRUTTURA QUADRI */}
{editSection === 'struttura' && questionsSnapshot && (
<QuadriStructureEditor
value={questionsSnapshot}
onChange={setQuestionsSnapshot}
/>
)}
{/* Footer azioni */}
<div style={{ position: 'sticky', bottom: 0, background: 'white', paddingTop: 14, borderTop: '1px solid #eee', marginTop: 20, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
{questionsStructureChanged() && nextVersion && (
<Tag severity="warning" value={`Al salvataggio: nuova versione v${nextVersion}`} />
)}
{!questionsStructureChanged() && (
<Tag severity="info" value={`Al salvataggio: update in place su v${editTpl.version}`} />
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button label="Annulla" severity="secondary" outlined onClick={() => setEditOpen(false)} disabled={saving} />
<Button label={questionsStructureChanged() ? `Crea versione v${nextVersion || '...'}` : 'Salva layout'}
icon="pi pi-save"
severity={questionsStructureChanged() ? 'warning' : undefined}
onClick={saveEditTemplate}
loading={saving} />
</div>
</div>
</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>
</Dialog>
{/* Nuova versione */}
<Dialog
header={`Nuova versione ${VARIANT_LABEL[newVersionVariant] || newVersionVariant}`}
visible={newVersionOpen}
onHide={() => setNewVersionOpen(false)}
style={{ width: '540px', maxWidth: '95vw' }}
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 }}>
<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%' }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<InputSwitch checked={newVersionData.activate_now} onChange={(e) => setNewVersionData({ ...newVersionData, activate_now: e.value })} />
<label>Attiva subito (archivia la versione precedente)</label>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 12 }}>
<Button label="Annulla" severity="secondary" outlined onClick={() => setNewVersionOpen(false)} />
<Button label="Crea versione" icon="pi pi-plus" onClick={saveNewVersion} />
</div>
</Dialog>
{/* Edit regola PEC */}