Files
aufmass-web/_aufmass_web/app/templates/custom_modules/builder.html
T

1168 lines
60 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block head %}
<style>
#builder-layout{display:grid;grid-template-columns:220px 1fr 300px;gap:12px;height:calc(100vh - 200px);min-height:500px}
#builder-toolbox{overflow-y:auto;padding:8px}
#builder-canvas{overflow-y:auto;padding:16px;background:#f4f5f7;border-radius:8px;border:2px dashed #bbb;position:relative}
#builder-props{overflow-y:auto;padding:8px}
.toolbox-item{padding:8px 10px;margin-bottom:4px;border:1px solid #ddd;border-radius:6px;cursor:grab;background:#fff;font-size:0.9rem;transition:all 0.15s}
.toolbox-item:hover{border-color:#2F5496;background:#eef3fb}
.toolbox-item:active{cursor:grabbing}
.canvas-placeholder{color:#aaa;text-align:center;padding:40px 20px;pointer-events:none}
/* ── Form Preview (WYSIWYG Canvas) ── */
#form-preview{max-width:900px;margin:0 auto;min-height:200px}
#form-preview .group-box{position:relative;border:2px solid #e8f0fe;transition:border-color 0.2s}
#form-preview .group-box.selected{border-color:#2F5496;box-shadow:0 0 0 2px rgba(47,84,150,0.12)}
#form-preview .group-box .group-edit-overlay{position:absolute;top:2px;right:2px;display:none;gap:3px;z-index:10}
#form-preview .group-box:hover .group-edit-overlay{display:flex}
#form-preview .group-box .group-edit-overlay button{background:#fff;border:1px solid #ccc;border-radius:4px;cursor:pointer;padding:2px 6px;font-size:11px;height:22px;transition:all 0.1s}
#form-preview .group-box .group-edit-overlay button:hover{background:#f0f0f0;border-color:#2F5496}
/* Field wrappers in the form */
#form-preview .field-col{position:relative;transition:all 0.2s;border-radius:4px;padding:6px}
#form-preview .field-col::before{content:'';position:absolute;inset:2px;border:2px solid transparent;border-radius:4px;pointer-events:none;transition:border-color 0.15s}
#form-preview .field-col.selected::before{border-color:#2F5496;box-shadow:0 0 0 2px rgba(47,84,150,0.1)}
#form-preview .field-col:hover::before{border-color:#d0d0d0}
#form-preview .field-col.selected:hover::before{border-color:#2F5496}
#form-preview .field-col .field{pointer-events:none;margin-bottom:0}
#form-preview .field-col .field .control{pointer-events:auto}
#form-preview .field-col .field input,#form-preview .field-col .field select,#form-preview .field-col .field textarea{pointer-events:auto}
/* Edit overlay on each field */
.field-edit-overlay{position:absolute;top:0;right:0;display:none;gap:2px;z-index:20;background:rgba(255,255,255,0.95);border:1px solid #ddd;border-radius:0 4px 0 4px;padding:1px 3px;box-shadow:0 1px 4px rgba(0,0,0,0.08)}
#form-preview .field-col:hover .field-edit-overlay,.field-col.selected .field-edit-overlay{display:flex}
.field-edit-overlay .field-name-tag{font-size:9px;color:#999;padding:2px 4px;font-family:monospace;line-height:18px}
.field-edit-overlay .btn-edit{background:transparent;border:none;cursor:pointer;padding:1px 4px;font-size:12px;line-height:18px;border-radius:3px;opacity:0.6;transition:all 0.1s}
.field-edit-overlay .btn-edit:hover{background:#eef3fb;opacity:1}
.field-edit-overlay .btn-edit.danger:hover{background:#fdecea;color:#c0392b}
/* Drag handle on fields */
.field-drag-handle{position:absolute;top:50%;left:-2px;transform:translateY(-50%);width:10px;height:30px;cursor:grab;z-index:15;opacity:0;display:flex;align-items:center;justify-content:center;transition:opacity 0.15s;border-radius:0 3px 3px 0}
.field-drag-handle::after{content:'⠿';font-size:12px;color:#bbb;line-height:1}
#form-preview .field-col:hover .field-drag-handle,.field-col.selected .field-drag-handle{opacity:1}
.field-drag-handle:hover::after{color:#2F5496}
/* Resize handle on columns */
.form-resize-handle{position:absolute;top:8px;bottom:8px;right:-3px;width:8px;cursor:col-resize;z-index:15;opacity:0;transition:opacity 0.15s;border-radius:0 4px 4px 0}
.form-resize-handle::before{content:'⋮';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:13px;color:#bbb;line-height:1}
#form-preview .field-col:hover .form-resize-handle{opacity:1}
.form-resize-handle:hover::before,.form-resize-handle.active::before{color:#2F5496}
.form-resize-handle:hover{background:rgba(47,84,150,0.06)}
.form-resize-handle.active{opacity:1;background:rgba(47,84,150,0.1)}
/* Column badges */
.form-col-badge{position:absolute;bottom:2px;right:8px;font-size:9px;color:#bbb;background:#f5f5f5;border-radius:3px;padding:0 6px;line-height:16px;letter-spacing:0.3px;z-index:3;opacity:0;transition:opacity 0.15s}
#form-preview .field-col:hover .form-col-badge,.field-col.selected .form-col-badge{opacity:1}
.form-col-badge:hover{color:#2F5496;background:#eef3fb}
/* Snap line during resize */
.resize-snap-line{position:fixed;top:0;bottom:0;width:2px;background:rgba(47,84,150,0.5);z-index:1000;pointer-events:none;display:none}
.resize-snap-line.show{display:block}
/* Structural elements (separator, label, group) */
#form-preview .structural-placeholder{position:relative;padding:4px 8px;margin:2px 0;border:2px dashed transparent;border-radius:4px;transition:all 0.15s;cursor:pointer}
#form-preview .structural-placeholder:hover{border-color:#d0d0d0;background:#fafafa}
#form-preview .structural-placeholder.selected{border-color:#2F5496;background:#f0f4ff}
#form-preview .structural-placeholder .struct-tools{position:absolute;top:2px;right:2px;display:none;gap:2px}
#form-preview .structural-placeholder:hover .struct-tools{display:flex}
#form-preview .structural-placeholder .struct-tools button{background:#fff;border:1px solid #ccc;border-radius:3px;cursor:pointer;padding:1px 5px;font-size:10px}
#form-preview .structural-placeholder .struct-tools button:hover{background:#f0f0f0}
/* Props panel */
.prop-group{margin-bottom:12px}
.prop-group label{display:block;font-size:0.8rem;font-weight:600;color:#555;margin-bottom:2px}
.prop-group input,.prop-group select,.prop-group textarea{width:100%;padding:5px 8px;border:1px solid #ddd;border-radius:4px;font-size:0.85rem}
.prop-group textarea{resize:vertical;min-height:50px}
#na-save-status{font-size:0.85rem;margin-left:12px}
/* Drop indicator */
.drop-indicator{height:3px;background:#2F5496;border-radius:2px;margin:2px 0;transition:all 0.1s;position:relative}
.drop-indicator::after{content:'';position:absolute;left:50%;top:-4px;width:8px;height:8px;background:#2F5496;border-radius:50%;transform:translateX(-50%)}
/* Inline editing */
.inline-edit-input{font-size:inherit;font-family:inherit;padding:2px 6px;border:2px solid #2F5496;border-radius:4px;background:#fff;min-width:120px;outline:none;box-shadow:0 2px 8px rgba(47,84,150,0.15)}
.inline-edit-input:focus{box-shadow:0 2px 12px rgba(47,84,150,0.25)}
/* Quick action toolbar on hover */
.field-quick-actions{position:absolute;bottom:-28px;left:50%;transform:translateX(-50%);display:none;gap:2px;z-index:30;background:rgba(255,255,255,0.98);border:1px solid #ddd;border-radius:6px;padding:2px 4px;box-shadow:0 2px 8px rgba(0,0,0,0.1);white-space:nowrap}
.field-col:hover .field-quick-actions,.field-col.selected .field-quick-actions{display:flex}
.field-quick-actions button{background:transparent;border:none;cursor:pointer;padding:3px 6px;font-size:11px;border-radius:4px;transition:all 0.1s;color:#555}
.field-quick-actions button:hover{background:#eef3fb;color:#2F5496}
.field-quick-actions button.danger:hover{background:#fdecea;color:#c0392b}
/* Label on canvas is clickable for inline edit */
#form-preview .field-col .field .label{pointer-events:auto;cursor:text;transition:background 0.1s;border-radius:3px;padding:1px 3px;margin:-1px}
#form-preview .field-col .field .label:hover{background:#f0f4ff}
#form-preview .field-col .field .label.editing{background:#fff;padding:0}
/* Rule Builder (unchanged) */
#rule-layout{display:grid;grid-template-columns:280px 1fr;gap:12px;height:calc(100vh - 200px);min-height:500px}
#rule-list{overflow-y:auto;padding:8px}
#rule-editor{overflow-y:auto;padding:12px}
.rule-card{padding:10px 12px;margin-bottom:6px;border:1px solid #ddd;border-radius:6px;cursor:pointer;background:#fff;transition:all 0.15s;position:relative}
.rule-card:hover{border-color:#2F5496}
.rule-card.selected{border-color:#2F5496;background:#eef3fb}
.rule-card .rule-name{font-weight:600;font-size:0.9rem}
.rule-card .rule-summary{font-size:0.8rem;color:#888;margin-top:2px}
.rule-card .rule-tools{position:absolute;top:6px;right:6px;display:none;gap:3px}
.rule-card:hover .rule-tools{display:flex}
.rule-card .rule-tools button{background:#fff;border:1px solid #ddd;border-radius:3px;cursor:pointer;padding:1px 5px;font-size:11px}
.rule-card .rule-tools button:hover{background:#f0f0f0}
.cond-row{display:flex;gap:6px;margin-bottom:6px;align-items:center;flex-wrap:wrap}
.cond-row select,.cond-row input{padding:4px 6px;border:1px solid #ddd;border-radius:4px;font-size:0.85rem}
.cond-row .cond-op{font-weight:600;font-size:0.8rem;color:#555;min-width:40px;text-align:center}
.act-card{border:1px solid #ddd;border-radius:6px;padding:10px;margin-bottom:8px;background:#fff;position:relative}
.act-card .act-tools{position:absolute;top:6px;right:6px;display:flex;gap:3px}
.act-card .act-tools button{background:#fff;border:1px solid #ddd;border-radius:3px;cursor:pointer;padding:1px 5px;font-size:11px}
.col-override-row{display:flex;gap:6px;margin-bottom:4px;align-items:center;flex-wrap:wrap}
.col-override-row select,.col-override-row input{padding:4px 6px;border:1px solid #ddd;border-radius:4px;font-size:0.85rem}
.col-override-row .col-val{flex:1;min-width:120px}
</style>
{% endblock %}
{% block content %}
<div class="container is-fluid mt-4">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><a href="{{ url_for('custom_modules.index') }}">Modul-Builder</a></li>
<li><a href="{{ url_for('custom_modules.edit', module_id=module.id) }}">{{ module.name }}</a></li>
<li class="is-active"><a href="#">Builder</a></li>
</ul>
</nav>
<div class="level mb-2">
<div class="level-left">
<h2 class="title is-5">{{ module.icon }} {{ module.name }}</h2>
<span id="na-save-status"></span>
</div>
<div class="level-right">
<button class="button is-success is-small" onclick="saveAll()">💾 Alles speichern</button>
<a href="{{ url_for('custom_modules.edit', module_id=module.id) }}" class="button is-light is-small">Zurück</a>
</div>
</div>
<div class="tabs is-boxed is-medium mb-3">
<ul>
<li id="tab-form" class="is-active"><a onclick="switchTab('form')">📋 Formular</a></li>
<li id="tab-rules"><a onclick="switchTab('rules')">📐 Regeln</a></li>
<li id="tab-preview"><a onclick="switchTab('preview')">🧪 Test</a></li>
</ul>
</div>
<!-- ════ Formular-Tab ════ -->
<div id="view-form">
<div id="builder-layout">
<div id="builder-toolbox" class="box">
<h3 class="title is-6 mb-2">🧰 Elemente</h3>
<div class="toolbox-item" draggable="true" data-type="text" ondragstart="onToolboxDrag(event)">📝 Text</div>
<div class="toolbox-item" draggable="true" data-type="number" ondragstart="onToolboxDrag(event)">🔢 Zahl</div>
<div class="toolbox-item" draggable="true" data-type="checkbox" ondragstart="onToolboxDrag(event)">✅ Checkbox</div>
<div class="toolbox-item" draggable="true" data-type="dropdown" ondragstart="onToolboxDrag(event)">📑 Dropdown</div>
<div class="toolbox-item" draggable="true" data-type="radio" ondragstart="onToolboxDrag(event)">🔘 Radio</div>
<div class="toolbox-item" draggable="true" data-type="separator" ondragstart="onToolboxDrag(event)"> Trenner</div>
<div class="toolbox-item" draggable="true" data-type="label" ondragstart="onToolboxDrag(event)">🏷️ Label</div>
<div class="toolbox-item" draggable="true" data-type="group_start" ondragstart="onToolboxDrag(event)">📦 Gruppen-Anfang</div>
<div class="toolbox-item" draggable="true" data-type="group_end" ondragstart="onToolboxDrag(event)">📦 Gruppen-Ende</div>
</div>
<div id="builder-canvas" ondragover="onCanvasDragOver(event)" ondrop="onCanvasDrop(event)" ondragleave="onCanvasDragLeave(event)">
<div id="js-canvas-list" style="min-height:200px">
<div class="canvas-placeholder">⬇ Elemente hierher ziehen</div>
</div>
</div>
<div id="builder-props" class="box">
<h3 class="title is-6 mb-2">⚙️ Eigenschaften</h3>
<div id="js-props-content">
<p class="has-text-grey" style="font-size:0.85rem">Klicke ein Element an</p>
</div>
</div>
</div>
</div>
<!-- ════ Regeln-Tab ════ -->
<div id="view-rules" style="display:none">
<div id="rule-layout">
<div id="rule-list" class="box">
<div class="level mb-2">
<div class="level-left"><h3 class="title is-6">📐 Regeln</h3></div>
<div class="level-right"><button class="button is-small is-primary" onclick="addRule()">+ Regel</button></div>
</div>
<div id="js-rule-list">
<p class="has-text-grey" style="font-size:0.85rem">Keine Regeln definiert.</p>
</div>
</div>
<div id="rule-editor" class="box">
<div id="js-rule-editor-content">
<p class="has-text-grey" style="font-size:0.9rem">Wähle links eine Regel aus oder erstelle eine neue.</p>
</div>
</div>
</div>
</div>
<!-- ════ Vorschau-Tab ════ -->
<div id="view-preview" style="display:none">
<div id="preview-layout">
<div class="preview-container box" id="js-preview-content" style="max-width:900px;margin:0 auto">
<p class="has-text-grey" style="font-size:0.9rem">Lade Vorschau…</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ═══════════════════════════════════════════════════════════════════
// SHARED STATE
// ═══════════════════════════════════════════════════════════════════
let formFields = [];
let rules = [];
let selectedFieldId = null;
let selectedRuleId = null;
let nextId = { fld: 1, rule: 1, act: 1 };
const MODULE_ID = {{ module.id }};
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// ═══════════════════════════════════════════════════════════════════
// TAB SWITCHING
// ═══════════════════════════════════════════════════════════════════
function switchTab(name) {
document.getElementById('view-form').style.display = (name === 'form') ? 'block' : 'none';
document.getElementById('view-rules').style.display = (name === 'rules') ? 'block' : 'none';
document.getElementById('view-preview').style.display = (name === 'preview') ? 'block' : 'none';
document.getElementById('tab-form').className = (name === 'form') ? 'is-active' : '';
document.getElementById('tab-rules').className = (name === 'rules') ? 'is-active' : '';
document.getElementById('tab-preview').className = (name === 'preview') ? 'is-active' : '';
if (name === 'rules' && rules.length > 0 && !selectedRuleId) {
selectedRuleId = rules[0].id;
renderRuleList();
renderRuleEditor();
}
if (name === 'preview') loadPreview();
}
// ═══════════════════════════════════════════════════════════════════
// SAVE / LOAD
// ═══════════════════════════════════════════════════════════════════
function updateSaveStatus(msg) {
document.getElementById('na-save-status').textContent = msg;
}
function saveAll() {
updateSaveStatus('💾 Speichere …');
Promise.all([
fetch('/custom-modules/' + MODULE_ID + '/api/form-json', {
method: 'PUT', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(formFields)
}),
fetch('/custom-modules/' + MODULE_ID + '/api/rules-json', {
method: 'PUT', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(rules)
})
]).then(([r1, r2]) => Promise.all([r1.json(), r2.json()]))
.then(([d1, d2]) => {
if (d1.ok && d2.ok) {
updateSaveStatus('✅ Alles gespeichert ' + new Date().toLocaleTimeString());
} else {
updateSaveStatus('❌ Fehler beim Speichern');
}
}).catch(() => updateSaveStatus('❌ Netzwerkfehler'));
}
function loadAll() {
Promise.all([
fetch('/custom-modules/' + MODULE_ID + '/api/form-json').then(r => r.json()),
fetch('/custom-modules/' + MODULE_ID + '/api/rules-json').then(r => r.json())
]).then(([formData, rulesData]) => {
if (Array.isArray(formData) && formData.length > 0) {
formFields = formData.map(f => {
if (!f.id) { f.id = 'fld_' + (nextId.fld++); }
return f;
});
nextId.fld = formFields.reduce((m, f) => Math.max(m, parseInt(String(f.id).replace('fld_','')) || 0), 0) + 1;
}
if (Array.isArray(rulesData) && rulesData.length > 0) {
rules = rulesData.map(r => {
if (!r.id) { r.id = 'rule_' + (nextId.rule++); }
return r;
});
nextId.rule = rules.reduce((m, r) => Math.max(m, parseInt(String(r.id).replace('rule_','')) || 0), 0) + 1;
rules.forEach(r => {
if (r.actions) {
r.actions.forEach(a => {
if (!a.id) { a.id = 'act_' + (nextId.act++); }
const n = parseInt(String(a.id).replace('act_','')) || 0;
if (n >= nextId.act) nextId.act = n + 1;
});
}
});
}
if (formFields.length > 0) selectField(formFields[0].id);
renderCanvas();
renderProperties();
renderRuleList();
if (rules.length > 0) { selectedRuleId = rules[0].id; renderRuleEditor(); }
updateSaveStatus('✅ Geladen');
}).catch(() => {
renderCanvas();
renderProperties();
renderRuleList();
});
}
// ═══════════════════════════════════════════════════════════════════
// FIELD TYPE TEMPLATES
// ═══════════════════════════════════════════════════════════════════
const FIELD_TEMPLATES = {
text: { name:'', label:'Textfeld', placeholder:'', default:'', required:false, inputmode:'', columns:'12' },
number: { name:'', label:'Zahl', placeholder:'', default:'', required:false, min:'', max:'', step:'any', columns:'12' },
checkbox: { name:'', label:'Checkbox', default:false, columns:'12' },
dropdown: { name:'', label:'Dropdown', options:[{value:'',label:''}], default:'', columns:'12' },
radio: { name:'', label:'Radio', options:[{value:'',label:''}], default:'', columns:'12' },
separator: {},
label: { text:'Label-Text', columns:'12' },
group_start:{ title:'Gruppe', collapsible:false },
group_end: {},
};
function createField(type, insertAt) {
const id = 'fld_' + (nextId.fld++);
const field = { id, type, ...JSON.parse(JSON.stringify(FIELD_TEMPLATES[type] || {})) };
if (field.name === '') field.name = id;
if (insertAt !== undefined && insertAt >= 0 && insertAt < formFields.length) {
formFields.splice(insertAt, 0, field);
} else {
formFields.push(field);
}
selectField(id);
renderCanvas();
renderProperties();
return field;
}
// ═══════════════════════════════════════════════════════════════════
// FORM BUILDER — CANVAS (LIVE FORM PREVIEW)
// ═══════════════════════════════════════════════════════════════════
function renderCanvas() {
const list = document.getElementById('js-canvas-list');
if (formFields.length === 0) {
list.innerHTML = '<div class="canvas-placeholder">⬇ Elemente aus der Toolbox hierher ziehen</div>';
return;
}
let html = '<div id="form-preview">';
let inGroup = false;
formFields.forEach((f) => {
const sel = f.id === selectedFieldId ? ' selected' : '';
const cond = f.conditional_show;
const condAttr = (cond && cond.field && cond.value) ? ` data-cond-field="${esc(cond.field)}" data-cond-value="${esc(cond.value)}" style="display:none"` : '';
const editBtns = `
<button class="btn-edit" onclick="event.stopPropagation();selectField('${f.id}')" title="Bearbeiten">✎</button>
<button class="btn-edit" onclick="event.stopPropagation();duplicateField('${f.id}')" title="Duplizieren">📋</button>
<button class="btn-edit danger" onclick="event.stopPropagation();deleteField('${f.id}')" title="Löschen">✕</button>`;
if (f.type === 'group_start') {
if (inGroup) { html += '</div></div>'; }
html += `<div class="box group-box${sel}"${condAttr} onclick="selectField('${f.id}')">
<div class="group-edit-overlay">${editBtns}</div>
<h5 class="title is-6 mb-2">📦 ${esc(f.title||'Gruppe')}</h5>
<div class="columns is-multiline mb-0">`;
inGroup = true;
return;
}
if (f.type === 'group_end') {
if (inGroup) { html += '</div></div>'; }
inGroup = false;
return;
}
if (f.type === 'separator') {
html += `<div class="structural-placeholder${sel}"${condAttr} onclick="selectField('${f.id}')">
<div class="struct-tools">${editBtns}</div>
<hr style="margin:2px 0">
</div>`;
return;
}
if (f.type === 'label') {
html += `<div class="structural-placeholder${sel}"${condAttr} onclick="selectField('${f.id}')">
<div class="struct-tools">${editBtns}</div>
<p style="margin:2px 0">${esc(f.text||'')}</p>
</div>`;
return;
}
// Regular field
const colSize = f.columns || '12';
const label = f.label || '';
const required = f.required ? ' <span class="has-text-danger">*</span>' : '';
const fieldHtml = renderFieldPreview(f);
const inner = `
<div class="field-edit-overlay">
<span class="field-name-tag" title="Feldname: doppelklick zum Editieren">${esc(f.name||f.id)}</span>
${editBtns}
</div>
<div class="field-drag-handle" title="Zum Verschieben ziehen"></div>
<div class="field">
<label class="label builder-label" data-fid="${f.id}" data-field="label" style="font-size:0.85rem" title="Doppelklick zum Editieren">${esc(label)}${required}</label>
<div class="control">${fieldHtml}</div>
</div>
<div class="field-quick-actions">
<button onclick="event.stopPropagation();duplicateField('${f.id}')" title="Duplizieren">📋 Kopie</button>
<button onclick="event.stopPropagation();deleteField('${f.id}')" class="danger" title="Löschen">✕ Löschen</button>
<button onclick="event.stopPropagation();selectField('${f.id}')" title="Eigenschaften">⚙️ Props</button>
</div>
<span class="form-col-badge">${colSize}/12</span>
<div class="form-resize-handle" data-id="${f.id}"></div>`;
if (inGroup) {
html += `<div class="column is-${colSize} field-col${sel}" data-fid="${f.id}"${condAttr} onclick="selectField('${f.id}')">${inner}</div>`;
} else {
html += `<div class="field-col${sel}" data-fid="${f.id}"${condAttr} onclick="selectField('${f.id}')">${inner}</div>`;
}
});
if (inGroup) { html += '</div></div>'; }
html += '</div>';
list.innerHTML = html;
// Setup SortableJS
setupFormSortable();
// Setup resize handles
setupResizeHandles();
// Setup inline editing
initInlineEditing();
}
let _sortingGuard = false;
function setupFormSortable() {
const preview = document.getElementById('form-preview');
if (!preview) return;
// Destroy existing instances
if (preview._sortable) preview._sortable.destroy();
preview.querySelectorAll('.group-box .columns.is-multiline').forEach(gc => {
if (gc._sortable) gc._sortable.destroy();
});
function applyNewOrder() {
if (_sortingGuard) return;
_sortingGuard = true;
// Read top-level field-cols in DOM order
const topFields = [];
preview.querySelectorAll(':scope > .field-col').forEach(el => {
const fid = el.dataset.fid;
if (fid) { const f = formFields.find(x => x.id === fid); if (f) topFields.push(f); }
});
const topFids = new Set(topFields.map(f => f.id));
// Read inner group field-cols in DOM order
const groupFields = {};
preview.querySelectorAll('.group-box .columns.is-multiline').forEach(i => {
const inner = [];
i.querySelectorAll(':scope > .field-col').forEach(el => {
const fid = el.dataset.fid;
if (fid) { const f = formFields.find(x => x.id === fid); if (f) inner.push(f); }
});
if (inner.length) {
// Find the group_start index
const gi = formFields.findIndex(x => x.id === inner[0].id);
if (gi >= 0) {
let gs = gi; while (gs > 0 && formFields[gs].type !== 'group_start') gs--;
let ge = gi; while (ge < formFields.length && formFields[ge].type !== 'group_end') ge++;
if (gs >= 0 && ge < formFields.length) groupFields[formFields[gs].id] = { gs, ge, inner };
}
}
});
// Rebuild formFields preserving structure
const result = [];
let topIdx = 0;
const topList = [...topFields];
let i = 0;
while (i < formFields.length) {
const f = formFields[i];
if (f.type === 'group_start') {
const g = groupFields[f.id];
if (g) {
result.push(f); // group_start
result.push(...g.inner); // fields in group
result.push(formFields[g.ge]); // group_end
i = g.ge + 1;
continue;
}
}
if (topFids.has(f.id)) {
result.push(topList[topIdx++]);
} else if (!Object.values(groupFields).some(g => g.inner.includes(f))) {
result.push(f);
}
i++;
}
if (result.length === formFields.length) { formFields = result; }
renderCanvas();
if (selectedFieldId) selectField(selectedFieldId);
markDirty();
_sortingGuard = false;
}
// Top level: reorder field-cols outside groups
preview._sortable = new Sortable(preview, {
animation: 150,
handle: '.field-drag-handle',
filter: '.group-box, .structural-placeholder',
onEnd: applyNewOrder
});
// Inner sortable for each group
preview.querySelectorAll('.group-box .columns.is-multiline').forEach(groupCols => {
groupCols._sortable = new Sortable(groupCols, {
animation: 150,
handle: '.field-drag-handle',
onEnd: applyNewOrder
});
});
}
function renderFieldPreview(f) {
const fid = f.id;
switch (f.type) {
case 'text':
return `<input class="input" type="text" placeholder="${esc(f.placeholder||'Text eingeben…')}" data-fid="${fid}" readonly onfocus="this.removeAttribute('readonly')" style="font-size:0.85rem;pointer-events:auto">`;
case 'number':
return `<input class="input" type="text" inputmode="decimal" placeholder="${esc(f.placeholder||'0')}" data-fid="${fid}" readonly onfocus="this.removeAttribute('readonly')" style="font-size:0.85rem;pointer-events:auto">`;
case 'checkbox':
return `<label class="checkbox" style="font-size:0.85rem;pointer-events:auto"><input type="checkbox" data-fid="${fid}" ${f.default?'checked':''}> ${esc(f.label||'')}</label>`;
case 'dropdown':
const opts = f.options||[];
return `<div class="select is-fullwidth" style="font-size:0.85rem;pointer-events:auto"><select data-fid="${fid}">${opts.map(o => '<option>'+esc(o.label||o.value)+'</option>').join('')}</select></div>`;
case 'radio':
const radOpts = f.options||[];
return radOpts.map(o => `<label class="radio" style="font-size:0.85rem;pointer-events:auto"><input type="radio" data-fid="${fid}" name="builder_${fid}"> ${esc(o.label||o.value)}</label>`).join('');
default:
return '';
}
}
function getFieldIcon(type) { return { text:'📝', number:'🔢', checkbox:'✅', dropdown:'📑', radio:'🔘', separator:'', label:'🏷️', group_start:'📦', group_end:'📦' }[type]||'📄'; }
// ═══════════════════════════════════════════════════════════════════
// RESIZE HANDLES
// ═══════════════════════════════════════════════════════════════════
let resizeState = null;
function setupResizeHandles() {
document.querySelectorAll('.form-resize-handle').forEach(h => {
h.addEventListener('mousedown', startResize);
});
}
function startResize(e) {
e.preventDefault();
e.stopPropagation();
const handle = e.currentTarget;
const fieldId = handle.dataset.id;
const colEl = handle.closest('.field-col');
const grid = document.getElementById('form-preview');
if (!grid || !colEl) return;
const gridRect = grid.getBoundingClientRect();
const colWidth = gridRect.width / 12;
resizeState = {
fieldId: fieldId,
colEl: colEl,
grid: grid,
gridRect: gridRect,
colWidth: colWidth,
startX: e.clientX,
startCols: parseInt(colEl.className.match(/is-(\d+)/)?.[1] || '12')
};
handle.classList.add('active');
document.addEventListener('mousemove', doResize);
document.addEventListener('mouseup', endResize);
}
function doResize(e) {
if (!resizeState) return;
const rs = resizeState;
const dx = e.clientX - rs.gridRect.left;
let newCols = Math.round(dx / rs.colWidth);
newCols = Math.max(1, Math.min(12, newCols));
rs.colEl.className = rs.colEl.className.replace(/is-\d+/g, '') + ' is-' + newCols;
let snapLine = document.getElementById('resize-snap');
if (!snapLine) {
snapLine = document.createElement('div');
snapLine.id = 'resize-snap';
snapLine.className = 'resize-snap-line';
document.body.appendChild(snapLine);
}
const snapX = rs.gridRect.left + newCols * rs.colWidth;
snapLine.style.left = snapX + 'px';
snapLine.classList.add('show');
const badge = rs.colEl.querySelector('.form-col-badge');
if (badge) badge.textContent = newCols + '/12';
}
function endResize(e) {
if (!resizeState) return;
const rs = resizeState;
document.removeEventListener('mousemove', doResize);
document.removeEventListener('mouseup', endResize);
const snapLine = document.getElementById('resize-snap');
if (snapLine) snapLine.classList.remove('show');
const dx = e.clientX - rs.gridRect.left;
let newCols = Math.round(dx / rs.colWidth);
newCols = Math.max(1, Math.min(12, newCols));
const f = formFields.find(x => x.id === rs.fieldId);
if (f && newCols !== rs.startCols) {
f.columns = String(newCols);
if (selectedFieldId === rs.fieldId) renderProperties();
markDirty();
}
rs.colEl.className = rs.colEl.className.replace(/is-\d+/g, '') + ' is-' + newCols;
document.querySelectorAll('.form-resize-handle.active').forEach(h => h.classList.remove('active'));
resizeState = null;
}
// ═══════════════════════════════════════════════════════════════════
// INLINE EDITING
// ═══════════════════════════════════════════════════════════════════
function initInlineEditing() {
document.querySelectorAll('.builder-label').forEach(label => {
label.removeEventListener('dblclick', handleLabelDblClick);
label.addEventListener('dblclick', handleLabelDblClick);
});
// Input fields: focus selects field, tab moves to next
document.querySelectorAll('#form-preview .control input[data-fid], #form-preview .control select[data-fid]').forEach(el => {
el.removeEventListener('focus', handleFieldFocus);
el.addEventListener('focus', handleFieldFocus);
});
}
function handleLabelDblClick(e) {
e.preventDefault();
e.stopPropagation();
const label = e.currentTarget;
const fid = label.dataset.fid;
const field = label.dataset.field || 'label';
const currentText = label.textContent.replace(' *', '').trim();
// Select the field
selectField(fid);
const input = document.createElement('input');
input.type = 'text';
input.className = 'inline-edit-input';
input.value = currentText;
input.style.width = Math.max(120, currentText.length * 10) + 'px';
label.classList.add('editing');
label.textContent = '';
label.appendChild(input);
input.focus();
input.select();
function save() {
const val = input.value.trim();
if (val && val !== currentText) {
updateFieldLabel(fid, field, val);
}
label.classList.remove('editing');
label.textContent = val || currentText;
const required = formFields.find(x => x.id === fid)?.required;
if (required) label.innerHTML = esc(val || currentText) + ' <span class="has-text-danger">*</span>';
}
input.addEventListener('blur', save);
input.addEventListener('keydown', function(ev) {
if (ev.key === 'Enter') { input.blur(); }
if (ev.key === 'Escape') { label.classList.remove('editing'); label.textContent = currentText; const req = formFields.find(x => x.id === fid)?.required; if (req) label.innerHTML = esc(currentText) + ' <span class="has-text-danger">*</span>'; }
ev.stopPropagation();
});
}
function handleFieldFocus(e) {
const el = e.currentTarget;
const fid = el.dataset.fid;
if (fid) selectField(fid);
}
function updateFieldLabel(fid, field, val) {
const f = formFields.find(x => x.id === fid);
if (!f) return;
if (field === 'label') f.label = val;
else f[field] = val;
renderProperties();
markDirty();
}
// ═══════════════════════════════════════════════════════════════════
// FORM BUILDER — PROPERTIES
// ═══════════════════════════════════════════════════════════════════
function renderProperties() {
const pane = document.getElementById('js-props-content');
const f = formFields.find(x => x.id === selectedFieldId);
if (!f) { pane.innerHTML = '<p class="has-text-grey" style="font-size:0.85rem">Klicke ein Element an</p>'; return; }
let html = `<div class="prop-group"><label>Feld-ID</label><input value="${esc(f.id)}" disabled></div>`;
if (f.type !== 'separator' && f.type !== 'group_end') {
html += propInp('name','Feld-Name',f.name);
html += propInp('label','Label',f.label);
}
if (f.type === 'text') {
html += propInp('placeholder','Platzhalter',f.placeholder);
html += propInp('default','Standardwert',f.default);
html += propChk('required','Erforderlich',f.required);
html += propInp('inputmode','Input-Mode',f.inputmode);
}
if (f.type === 'number') {
html += propInp('placeholder','Platzhalter',f.placeholder);
html += propInp('default','Standardwert',f.default);
html += propChk('required','Erforderlich',f.required);
html += propInp('min','Minimum',f.min);
html += propInp('max','Maximum',f.max);
html += propInp('step','Schrittweite',f.step);
}
if (f.type === 'checkbox') html += propChk('default','Standardmäßig aktiviert',f.default);
if (f.type === 'dropdown' || f.type === 'radio') {
html += `<div class="prop-group"><label>Optionen</label>`;
(f.options||[]).forEach((o,i) => {
html += `<div style="display:flex;gap:4px;margin-bottom:4px">
<input value="${esc(o.value)}" placeholder="Wert" style="flex:1" onchange="updOpt('${f.id}',${i},'value',this.value)">
<input value="${esc(o.label)}" placeholder="Label" style="flex:2" onchange="updOpt('${f.id}',${i},'label',this.value)">
<button class="button is-small is-danger" onclick="remOpt('${f.id}',${i})">✕</button></div>`;
});
html += `<button class="button is-small is-light" onclick="addOpt('${f.id}')">+ Option</button></div>`;
html += propInp('default','Standardwert',f.default);
}
if (f.type === 'label') {
html += `<div class="prop-group"><label>Text</label><textarea onchange="updFld('${f.id}','text',this.value)">${esc(f.text||'')}</textarea></div>`;
}
if (f.type === 'group_start') {
html += propInp('title','Gruppen-Titel',f.title);
html += propChk('collapsible','Einklappbar',f.collapsible);
}
if (!['separator','group_start','group_end'].includes(f.type)) {
const curCols = f.columns || '12';
html += `<div class="prop-group"><label>Spaltenbreite</label>
<div class="select is-fullwidth is-small"><select onchange="updFld('${f.id}','columns',this.value)">
<option value="12" ${curCols==='12'?'selected':''}>Ganzzeile (12)</option>
<option value="8" ${curCols==='8'?'selected':''}>2/3 (8)</option>
<option value="6" ${curCols==='6'?'selected':''}>Hälfte (6)</option>
<option value="4" ${curCols==='4'?'selected':''}>1/3 (4)</option>
<option value="3" ${curCols==='3'?'selected':''}>1/4 (3)</option>
<option value="2" ${curCols==='2'?'selected':''}>1/6 (2)</option>
</select></div></div>`;
}
if (f.type !== 'separator' && f.type !== 'group_end') {
html += `<hr><div class="prop-group"><label>Bedingt anzeigen (wenn Feld …)</label>
<select onchange="updFld('${f.id}','condField',this.value)" style="margin-bottom:4px">
<option value="">— immer anzeigen —</option>`;
formFields.forEach(other => {
if (other.id !== f.id && ['checkbox','dropdown','radio'].includes(other.type)) {
const sel = (f.conditional_show && f.conditional_show.field === other.name) ? 'selected' : '';
html += `<option value="${esc(other.name)}" ${sel}>${esc(other.label||other.name)}</option>`;
}
});
html += `</select><input placeholder="= Wert (z.B. 'an')" value="${esc((f.conditional_show&&f.conditional_show.value)||'')}" onchange="updFld('${f.id}','condValue',this.value)" style="font-size:0.85rem"></div>`;
}
pane.innerHTML = html;
}
function propInp(key,label,val) { return `<div class="prop-group"><label>${label}</label><input value="${esc(val||'')}" onchange="updFld('${selectedFieldId}','${key}',this.value)"></div>`; }
function propChk(key,label,val) { return `<div class="prop-group"><label class="checkbox"><input type="checkbox" ${val?'checked':''} onchange="updFld('${selectedFieldId}','${key}',this.checked)"> ${label}</label></div>`; }
function updFld(id,key,val) {
const f = formFields.find(x => x.id === id); if (!f) return;
if (key === 'condField') { if (!f.conditional_show) f.conditional_show={}; f.conditional_show.field=val||undefined; if (!f.conditional_show.field) f.conditional_show=undefined; }
else if (key === 'condValue') { if (!f.conditional_show) f.conditional_show={}; f.conditional_show.value=val||undefined; if (!f.conditional_show.value) f.conditional_show=undefined; }
else f[key]=val;
renderCanvas(); selectField(id); renderProperties(); markDirty();
}
function updOpt(fid,idx,key,val) { const f=formFields.find(x=>x.id===fid); if(!f||!f.options)return; f.options[idx][key]=val; renderCanvas(); renderProperties(); markDirty(); }
function addOpt(fid) { const f=formFields.find(x=>x.id===fid); if(!f||!f.options)return; f.options.push({value:'',label:''}); renderProperties(); markDirty(); }
function remOpt(fid,idx) { const f=formFields.find(x=>x.id===fid); if(!f||!f.options)return; f.options.splice(idx,1); renderProperties(); markDirty(); }
// ═══════════════════════════════════════════════════════════════════
// FORM BUILDER — SELECTION, CRUD, DRAG
// ═══════════════════════════════════════════════════════════════════
function selectField(id) { selectedFieldId = id; renderCanvas(); renderProperties(); }
function duplicateField(id) {
const idx = formFields.findIndex(x => x.id === id); if (idx === -1) return;
const copy = JSON.parse(JSON.stringify(formFields[idx]));
copy.id = 'fld_' + (nextId.fld++); copy.name = copy.name + '_kopie';
formFields.splice(idx + 1, 0, copy);
renderCanvas(); selectField(copy.id); renderProperties(); markDirty();
}
function deleteField(id) {
const idx = formFields.findIndex(x => x.id === id); if (idx === -1) return;
formFields.splice(idx, 1);
if (selectedFieldId === id) selectedFieldId = formFields.length > 0 ? formFields[Math.min(idx, formFields.length-1)].id : null;
renderCanvas(); renderProperties(); markDirty();
}
function onToolboxDrag(ev) { ev.dataTransfer.setData('text/plain', ev.target.dataset.type); ev.dataTransfer.effectAllowed = 'copy'; }
// Drop indicator
let _dropIndicator = null;
function getDropIndicator() {
if (!_dropIndicator || !_dropIndicator.isConnected) {
_dropIndicator = document.createElement('div');
_dropIndicator.className = 'drop-indicator';
_dropIndicator.id = 'builder-drop-indicator';
_dropIndicator.style.display = 'none';
document.getElementById('js-canvas-list')?.appendChild(_dropIndicator);
}
return _dropIndicator;
}
function onCanvasDragOver(ev) {
ev.preventDefault();
ev.dataTransfer.dropEffect = 'copy';
const preview = document.getElementById('form-preview');
if (!preview) return;
const items = preview.querySelectorAll(':scope > .field-col, :scope > .group-box, :scope > .structural-placeholder');
const ind = getDropIndicator();
ind.style.display = 'none';
items.forEach(el => {
const r = el.getBoundingClientRect();
const mid = r.top + r.height / 2;
if (ev.clientY >= r.top && ev.clientY <= r.bottom) {
const pos = ev.clientY < mid ? 'before' : 'after';
ind.style.display = 'block';
ind.style.position = 'absolute';
if (pos === 'before') {
ind.style.top = (r.top - preview.getBoundingClientRect().top - 2) + 'px';
} else {
ind.style.top = (r.bottom - preview.getBoundingClientRect().top + 2) + 'px';
}
ind.style.left = '10px';
ind.style.right = '10px';
ind.dataset.insertAfter = el.dataset.fid || '';
ind.dataset.insertPos = pos;
}
});
}
function onCanvasDragLeave(ev) {
const ind = document.getElementById('builder-drop-indicator');
if (ind) ind.style.display = 'none';
}
function onCanvasDrop(ev) {
ev.preventDefault();
const ind = getDropIndicator();
ind.style.display = 'none';
const type = ev.dataTransfer.getData('text/plain'); if (!type) return;
const preview = document.getElementById('form-preview');
if (!preview) return;
// Direct children of form-preview (field-cols, group-boxes, structural elements)
const items = preview.querySelectorAll(':scope > .field-col, :scope > .group-box, :scope > .structural-placeholder');
let insertAt = formFields.length;
items.forEach((el, i) => {
const r = el.getBoundingClientRect();
if (ev.clientY > r.top + r.height / 2) {
const fid = el.classList.contains('group-box')
? null
: (el.dataset.fid || null);
if (fid) {
const fi = formFields.findIndex(x => x.id === fid);
if (fi >= 0) insertAt = fi + 1;
} else if (el.classList.contains('group-box')) {
const firstField = el.querySelector('.field-col');
if (firstField) {
const innerId = firstField.dataset.fid;
if (innerId) {
let gi = formFields.findIndex(x => x.id === innerId);
while (gi < formFields.length && formFields[gi].type !== 'group_end') gi++;
if (gi < formFields.length) insertAt = gi + 1;
}
}
}
}
});
if (insertAt > formFields.length) insertAt = formFields.length;
createField(type, insertAt);
markDirty();
}
// ═══════════════════════════════════════════════════════════════════
// RULE BUILDER
// ═══════════════════════════════════════════════════════════════════
function getFieldNames() {
return formFields.filter(f => f.name && f.type !== 'separator' && f.type !== 'group_end' && f.type !== 'label')
.map(f => ({ name: f.name, label: f.label || f.name, type: f.type }));
}
function getFieldType(fieldName) {
const f = formFields.find(x => x.name === fieldName);
return f ? f.type : 'text';
}
function addRule() {
const id = 'rule_' + (nextId.rule++);
rules.push({ id, name: 'Neue Regel', conditions: { operator: 'and', items: [] }, actions: [] });
selectedRuleId = id;
renderRuleList(); renderRuleEditor(); markDirty();
}
function duplicateRule(id) {
const idx = rules.findIndex(r => r.id === id); if (idx === -1) return;
const copy = JSON.parse(JSON.stringify(rules[idx]));
copy.id = 'rule_' + (nextId.rule++); copy.name = copy.name + ' (Kopie)';
copy.actions.forEach(a => { a.id = 'act_' + (nextId.act++); });
rules.splice(idx + 1, 0, copy);
selectedRuleId = copy.id;
renderRuleList(); renderRuleEditor(); markDirty();
}
function deleteRule(id) {
const idx = rules.findIndex(r => r.id === id); if (idx === -1) return;
rules.splice(idx, 1);
if (selectedRuleId === id) selectedRuleId = rules.length > 0 ? rules[Math.min(idx, rules.length-1)].id : null;
renderRuleList(); renderRuleEditor(); markDirty();
}
function renderRuleList() {
const list = document.getElementById('js-rule-list');
if (rules.length === 0) {
list.innerHTML = '<p class="has-text-grey" style="font-size:0.85rem">Keine Regeln definiert.</p>';
return;
}
list.innerHTML = rules.map(r => {
const sel = r.id === selectedRuleId ? ' selected' : '';
const cnt = (r.conditions?.items?.length || 0) + ' Bedingungen, ' + (r.actions?.length || 0) + ' Aktionen';
return `<div class="rule-card${sel}" data-id="${r.id}" onclick="selectRule('${r.id}')">
<div class="rule-name">${esc(r.name)}</div>
<div class="rule-summary">${cnt}</div>
<div class="rule-tools">
<button onclick="event.stopPropagation();duplicateRule('${r.id}')" title="Duplizieren">📋</button>
<button onclick="event.stopPropagation();deleteRule('${r.id}')" title="Löschen">✕</button>
</div>
</div>`;
}).join('');
Sortable.create(document.getElementById('js-rule-list'), {
animation: 150, handle: '.rule-card',
onEnd: function(evt) {
const [item] = rules.splice(evt.oldIndex, 1);
rules.splice(evt.newIndex, 0, item);
renderRuleList();
if (selectedRuleId) renderRuleEditor();
markDirty();
}
});
}
function selectRule(id) {
selectedRuleId = id;
renderRuleList(); renderRuleEditor();
}
function renderRuleEditor() {
const pane = document.getElementById('js-rule-editor-content');
const r = rules.find(x => x.id === selectedRuleId);
if (!r) { pane.innerHTML = '<p class="has-text-grey" style="font-size:0.9rem">Wähle links eine Regel aus oder erstelle eine neue.</p>'; return; }
let html = `<div class="field"><label class="label">Regel-Name</label>
<input class="input" value="${esc(r.name)}" onchange="updRule('${r.id}','name',this.value)" style="font-size:0.9rem"></div>`;
// Conditions
html += `<h4 class="title is-6 mt-4">🎯 Bedingungen <span class="tag is-light">ALLE erfüllen (UND)</span></h4>`;
html += `<div id="js-cond-list">`;
(r.conditions?.items || []).forEach((c, i) => {
html += renderCondRow(r.id, c, i);
});
html += `</div>`;
html += `<button class="button is-small is-light mt-2" onclick="addCond('${r.id}')">+ Bedingung</button>`;
// Actions
html += `<h4 class="title is-6 mt-4">⚡ Aktionen (Positionen)</h4>`;
html += `<div id="js-act-list">`;
(r.actions || []).forEach((a, i) => {
html += renderActCard(r.id, a, i);
});
html += `</div>`;
html += `<button class="button is-small is-primary mt-2" onclick="addAction('${r.id}')">+ Aktion (LV-Position)</button>`;
pane.innerHTML = html;
}
function renderCondRow(ruleId, cond, idx) {
const fields = getFieldNames();
const ft = getFieldType(cond.field);
const opOptions = ft === 'checkbox'
? '<option value="is_checked" '+(cond.operator==='is_checked'?'selected':'')+'>ist aktiviert</option>'
: (ft === 'number'
? `<option value="gt" ${cond.operator==='gt'?'selected':''}>></option><option value="gte" ${cond.operator==='gte'?'selected':''}>>=</option><option value="lt" ${cond.operator==='lt'?'selected':''}><</option><option value="lte" ${cond.operator==='lte'?'selected':''}><=</option><option value="eq" ${cond.operator==='eq'?'selected':''}>=</option><option value="between" ${cond.operator==='between'?'selected':''}>zwischen</option>`
: `<option value="eq" ${cond.operator==='eq'?'selected':''}>=</option><option value="neq" ${cond.operator==='neq'?'selected':''}>≠</option>`);
const showVal = ft !== 'checkbox' || (cond.operator !== 'is_checked' && cond.operator !== 'is_empty');
const valInput = showVal
? (cond.operator === 'between'
? `<input class="col-val" placeholder="Von" value="${esc(cond.value||'')}" style="width:70px" onchange="updCond('${ruleId}',${idx},'value',this.value)">
<input class="col-val" placeholder="Bis" value="${esc(cond.value2||'')}" style="width:70px" onchange="updCond('${ruleId}',${idx},'value2',this.value)">`
: `<input class="col-val" placeholder="Wert" value="${esc(cond.value||'')}" onchange="updCond('${ruleId}',${idx},'value',this.value)">`)
: '';
return `<div class="cond-row">
<select onchange="updCond('${ruleId}',${idx},'field',this.value)" style="flex:1;min-width:100px">
<option value="">— Feld wählen —</option>
${fields.map(f => `<option value="${esc(f.name)}" ${f.name===cond.field?'selected':''}>${esc(f.label)}</option>`).join('')}
</select>
<select onchange="updCond('${ruleId}',${idx},'operator',this.value)">${opOptions}</select>
${valInput}
<button class="button is-small is-danger" onclick="remCond('${ruleId}',${idx})">✕</button>
</div>`;
}
function renderActCard(ruleId, act, idx) {
const colOptions = ['menge','faktor','laenge','breite','tiefe','einheit','kurztext','bemerkung','pos_nr','einzelpreis'];
const typeOptions = ['fixed','field','formula'];
const typeLabels = { fixed:'Festwert', field:'Feld', formula:'Formel' };
let html = `<div class="act-card">
<div class="act-tools">
<button onclick="remAction('${ruleId}',${idx})" title="Löschen">✕</button>
</div>
<div style="display:flex;gap:8px;margin-bottom:6px;flex-wrap:wrap">
<div style="flex:1;min-width:150px">
<label style="font-size:0.8rem;font-weight:600">LV-Positions-Nr</label>
<input class="input" value="${esc(act.pos_nr||'')}" placeholder="z.B. 1.3.02.0220" onchange="updAct('${ruleId}',${idx},'pos_nr',this.value)" style="font-size:0.85rem">
</div>
<div style="display:flex;align-items:flex-end;gap:6px">
<label class="checkbox" style="font-size:0.85rem">
<input type="checkbox" ${act.lv_lookup?'checked':''} onchange="updAct('${ruleId}',${idx},'lv_lookup',this.checked)">
Vom LV übernehmen
</label>
</div>
</div>`;
html += `<div style="margin-top:6px"><label style="font-size:0.8rem;font-weight:600">Spalten-Überschreibungen</label>`;
const cols = act.columns || {};
Object.entries(cols).forEach(([colKey, colVal]) => {
html += `<div class="col-override-row">
<select onchange="renameCol('${ruleId}',${idx},'${colKey}',this.value)" style="width:110px">
${colOptions.map(o => `<option value="${o}" ${o===colKey?'selected':''}>${o}</option>`).join('')}
</select>
<select onchange="updColType('${ruleId}',${idx},'${colKey}',this.value)" style="width:80px">
${typeOptions.map(t => `<option value="${t}" ${colVal?.type===t?'selected':''}>${typeLabels[t]}</option>`).join('')}
</select>
<input class="col-val" placeholder="Wert / Feldname / Formel" value="${esc(colVal?.value||'')}"
onchange="updColVal('${ruleId}',${idx},'${colKey}',this.value)">
<button class="button is-small is-danger" onclick="remCol('${ruleId}',${idx},'${colKey}')">✕</button>
</div>`;
});
html += `<button class="button is-small is-light mt-1" onclick="addCol('${ruleId}',${idx})">+ Spalte</button>`;
html += `</div></div>`;
return html;
}
// ── Rule CRUD helpers ──────────────────────────────────────────────
function updRule(ruleId, key, val) {
const r = rules.find(x => x.id === ruleId); if (!r) return;
r[key] = val; renderRuleList(); markDirty();
}
function addCond(ruleId) {
const r = rules.find(x => x.id === ruleId); if (!r) return;
if (!r.conditions) r.conditions = { operator: 'and', items: [] };
r.conditions.items.push({ field: '', operator: 'eq', value: '' });
renderRuleEditor(); markDirty();
}
function remCond(ruleId, idx) {
const r = rules.find(x => x.id === ruleId); if (!r || !r.conditions) return;
r.conditions.items.splice(idx, 1);
renderRuleEditor(); markDirty();
}
function updCond(ruleId, idx, key, val) {
const r = rules.find(x => x.id === ruleId); if (!r || !r.conditions) return;
r.conditions.items[idx][key] = val;
// Re-render if field changed (to update operator options)
if (key === 'field') renderRuleEditor();
markDirty();
}
function addAction(ruleId) {
const r = rules.find(x => x.id === ruleId); if (!r) return;
const id = 'act_' + (nextId.act++);
if (!r.actions) r.actions = [];
r.actions.push({ id, pos_nr: '', lv_lookup: true, columns: { menge: { type: 'field', value: '' } } });
renderRuleEditor(); markDirty();
}
function remAction(ruleId, idx) {
const r = rules.find(x => x.id === ruleId); if (!r) return;
r.actions.splice(idx, 1);
renderRuleEditor(); markDirty();
}
function updAct(ruleId, idx, key, val) {
const r = rules.find(x => x.id === ruleId); if (!r || !r.actions) return;
r.actions[idx][key] = val;
markDirty();
}
function addCol(ruleId, actIdx) {
const r = rules.find(x => x.id === ruleId); if (!r || !r.actions) return;
const a = r.actions[actIdx]; if (!a) return;
if (!a.columns) a.columns = {};
// Find first unused column
const allCols = ['menge','faktor','laenge','breite','tiefe','einheit','kurztext','bemerkung','pos_nr','einzelpreis'];
const used = Object.keys(a.columns);
const free = allCols.find(c => !used.includes(c));
a.columns[free || 'menge'] = { type: 'fixed', value: '' };
renderRuleEditor(); markDirty();
}
function remCol(ruleId, actIdx, colKey) {
const r = rules.find(x => x.id === ruleId); if (!r || !r.actions) return;
const a = r.actions[actIdx]; if (!a || !a.columns) return;
delete a.columns[colKey];
renderRuleEditor(); markDirty();
}
function updColType(ruleId, actIdx, colKey, val) {
const r = rules.find(x => x.id === ruleId); if (!r || !r.actions) return;
const a = r.actions[actIdx]; if (!a || !a.columns || !a.columns[colKey]) return;
a.columns[colKey].type = val; markDirty();
}
function updColVal(ruleId, actIdx, colKey, val) {
const r = rules.find(x => x.id === ruleId); if (!r || !r.actions) return;
const a = r.actions[actIdx]; if (!a || !a.columns || !a.columns[colKey]) return;
a.columns[colKey].value = val; markDirty();
}
function renameCol(ruleId, actIdx, oldKey, newKey) {
if (oldKey === newKey) return;
const r = rules.find(x => x.id === ruleId); if (!r || !r.actions) return;
const a = r.actions[actIdx]; if (!a || !a.columns) return;
if (a.columns[newKey] !== undefined) return; // target exists
a.columns[newKey] = a.columns[oldKey];
delete a.columns[oldKey];
renderRuleEditor(); markDirty();
}
function markDirty() { updateSaveStatus('✏️ Ungespeicherte Änderungen'); }
// ═══════════════════════════════════════════════════════════════════
// PREVIEW
// ═══════════════════════════════════════════════════════════════════
function loadPreview() {
const pane = document.getElementById('js-preview-content');
pane.innerHTML = '<p class="has-text-grey">⏳ Lade Vorschau…</p>';
fetch('/custom-modules/' + MODULE_ID + '/api/preview')
.then(r => r.text())
.then(html => { pane.innerHTML = html; })
.catch(() => { pane.innerHTML = '<div class="notification is-danger">Vorschau konnte nicht geladen werden.</div>'; });
}
// ═══════════════════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════════════════
loadAll();
</script>
{% endblock %}