1168 lines
60 KiB
HTML
1168 lines
60 KiB
HTML
{% 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// 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 %}
|