From ec0e7397e67bb9b6805dd82c30f912d790c141bc Mon Sep 17 00:00:00 2001 From: BFLOWS Date: Thu, 23 Apr 2026 15:46:43 +0200 Subject: [PATCH] feat(ar1-admin): editor unificato template (layout L2 + struttura quadri L1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ar1/components/QuadriStructureEditor.js | 438 ++++++++++++++++++ src/modules/ar1/pages/Ar1AdminConfig.js | 415 ++++++++++------- 2 files changed, 694 insertions(+), 159 deletions(-) create mode 100644 src/modules/ar1/components/QuadriStructureEditor.js diff --git a/src/modules/ar1/components/QuadriStructureEditor.js b/src/modules/ar1/components/QuadriStructureEditor.js new file mode 100644 index 0000000..13c8190 --- /dev/null +++ b/src/modules/ar1/components/QuadriStructureEditor.js @@ -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 ( +
+
+
+ + onUpdate({ id: e.target.value.replace(/\s+/g, '_') })} + style={{ width: '100%', fontFamily: 'monospace', fontSize: 12 }} /> +
+
+ + onUpdate({ label: e.target.value })} + style={{ width: '100%' }} /> +
+
+ + onUpdate({ type: e.value })} + style={{ width: '100%' }} /> +
+
+
+
+ +
+
+ onUpdate({ required: e.checked })} /> + +
+ + {field.type === 'text' && ( + <> +
+ + onUpdate({ max_length: e.value })} + style={{ width: 90 }} inputStyle={{ fontSize: 12 }} /> +
+
+ + onUpdate({ pattern: e.target.value || undefined })} + style={{ width: 180, fontFamily: 'monospace', fontSize: 11 }} + placeholder="es. ^[0-9]{11}$" /> +
+ + )} + + {field.prefill_from && ( + + )} +
+ + {isEnum && ( +
+ + 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 }} /> +
+ )} +
+ ); + }; + + // ----- render ----- + return ( +
+ + + {/* Metadati */} + +
+
+ + updateMeta('variant_label', e.target.value)} style={{ width: '100%' }} /> +
+
+ + updateMeta('legal_ref', e.target.value)} style={{ width: '100%' }} + placeholder="es. D.Lgs. 231/2007" /> +
+
+ + updateMeta('variant_description', e.target.value)} style={{ width: '100%' }} /> +
+
+ + updateMeta('normative_frame', e.target.value)} + style={{ width: '100%', fontSize: 12 }} /> +
+
+
+ + {/* Quadri */} +
+

Quadri ({quadri.length})

+
+ + + {quadri.map((q, qIdx) => ( + + + {q.title || '(senza titolo)'} + {q.is_legal_frame && } + {q.row_type && } + {q.nested_full && } + + {(q.fields || []).length} campi + {q.row_fields ? ` +${q.row_fields.length} riga` : ''} + {q.nested_full?.fields ? ` +${q.nested_full.fields.length} nidif.` : ''} + +
+ }> +
+
+ + updateQuadro(qIdx, { id: e.target.value.toUpperCase() })} + style={{ width: '100%', fontFamily: 'monospace' }} maxLength={4} /> +
+
+ + updateQuadro(qIdx, { title: e.target.value })} + style={{ width: '100%' }} /> +
+
+
+
+ +
+ + updateQuadro(qIdx, { description: e.target.value })} + style={{ width: '100%' }} /> +
+ + {q.is_legal_frame && ( + + )} + + {/* Fields normali */} + {(q.fields !== undefined) && ( + <> +
+ Campi ({(q.fields || []).length}) +
+ {(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 && ( +

Nessun campo. Aggiungine uno.

+ )} + + )} + + {/* Row fields (Quadro B titolari) */} + {q.row_type && ( + <> + +
+ Campi riga (per ogni {q.row_type}): ({(q.row_fields || []).length}) +
+ + {(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 && ( + <> + +
+ Campi annidati: ({(q.nested_full.fields || []).length}) +
+ {(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 && ( + <> + + Slot upload: +
+ {q.upload_slots.map((slot, i) => ( + + ))} +
+ Gli slot upload sono gestiti dal modello: modifica avanzata non ancora via UI. + + )} + + ))} + + + ); +}; + +export default QuadriStructureEditor; diff --git a/src/modules/ar1/pages/Ar1AdminConfig.js b/src/modules/ar1/pages/Ar1AdminConfig.js index 3d12427..1c5bfa7 100644 --- a/src/modules/ar1/pages/Ar1AdminConfig.js +++ b/src/modules/ar1/pages/Ar1AdminConfig.js @@ -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 = () => { } }} />