Adds domain selection (cascaded from debitor) to handover creation and edit, bulk delete with confirmation dialog, and various UI improvements to the handover wizard and list page. Includes migration for domain columns, domain-select AJAX endpoint, and updated tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
412 lines
15 KiB
JavaScript
412 lines
15 KiB
JavaScript
/**
|
|
* Handover protocol schema editor.
|
|
*
|
|
* Renders a structured field list from JSON and serializes changes
|
|
* back to a hidden input on form submit.
|
|
*/
|
|
|
|
const FIELD_TYPES = ['heading', 'paragraph', 'text', 'textarea', 'number', 'date', 'checkbox', 'select'];
|
|
const DISPLAY_ONLY_TYPES = ['heading', 'paragraph'];
|
|
|
|
/** @type {HTMLElement|null} */
|
|
const container = document.getElementById('handover-schema-editor');
|
|
if (container) {
|
|
init(container);
|
|
}
|
|
|
|
/**
|
|
* Remove all child nodes from an element (safe alternative to setting markup).
|
|
* @param {HTMLElement} el
|
|
*/
|
|
function clearElement(el) {
|
|
while (el.firstChild) {
|
|
el.removeChild(el.firstChild);
|
|
}
|
|
}
|
|
|
|
function init(root) {
|
|
const t = JSON.parse(root.dataset.translations || '{}');
|
|
const initialFields = JSON.parse(root.dataset.initialFields || '[]');
|
|
const hiddenInput = document.getElementById('handover-schema-json');
|
|
const previewContainer = document.getElementById('handover-schema-preview');
|
|
const form = hiddenInput?.closest('form');
|
|
|
|
/** @type {Array<object>} */
|
|
let fields = initialFields.map((f) => ({ ...f, options: f.options ? f.options.map((o) => ({ ...o })) : [] }));
|
|
|
|
render();
|
|
renderPreview();
|
|
syncHiddenInput();
|
|
|
|
if (form) {
|
|
form.addEventListener('submit', () => syncHiddenInput());
|
|
}
|
|
|
|
function render() {
|
|
clearElement(root);
|
|
|
|
if (fields.length === 0) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'handover-schema-empty-state';
|
|
const emptyText = document.createElement('p');
|
|
emptyText.textContent = t.no_fields || 'No fields defined yet.';
|
|
empty.appendChild(emptyText);
|
|
const addButton = document.createElement('button');
|
|
addButton.type = 'button';
|
|
addButton.className = 'secondary outline';
|
|
addButton.appendChild(createIcon('bi-plus-lg'));
|
|
addButton.appendChild(document.createTextNode(' ' + (t.add_field || 'Add field')));
|
|
addButton.addEventListener('click', () => {
|
|
fields.push({ type: 'text', key: '', label: '', required: false, options: [] });
|
|
render();
|
|
notifyChange();
|
|
});
|
|
empty.appendChild(addButton);
|
|
root.appendChild(empty);
|
|
} else {
|
|
const list = document.createElement('div');
|
|
list.className = 'handover-schema-list';
|
|
fields.forEach((field, index) => {
|
|
if (index > 0) {
|
|
list.appendChild(buildInsertDivider(index));
|
|
}
|
|
list.appendChild(buildFieldRow(field, index));
|
|
});
|
|
root.appendChild(list);
|
|
root.appendChild(buildInsertDivider(fields.length));
|
|
}
|
|
}
|
|
|
|
function buildInsertDivider(insertIndex) {
|
|
const divider = document.createElement('div');
|
|
divider.className = 'handover-schema-insert-divider';
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'secondary outline small handover-schema-insert-button';
|
|
button.setAttribute('data-tooltip', t.add_field || 'Add field');
|
|
button.setAttribute('aria-label', t.add_field || 'Add field');
|
|
button.appendChild(createIcon('bi-plus'));
|
|
button.addEventListener('click', () => {
|
|
fields.splice(insertIndex, 0, { type: 'text', key: '', label: '', required: false, options: [] });
|
|
render();
|
|
notifyChange();
|
|
});
|
|
divider.appendChild(button);
|
|
return divider;
|
|
}
|
|
|
|
function buildFieldRow(field, index) {
|
|
const isDisplayOnly = DISPLAY_ONLY_TYPES.includes(field.type);
|
|
const isSelect = field.type === 'select';
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'handover-schema-row';
|
|
row.dataset.fieldIndex = index;
|
|
row.addEventListener('mouseenter', () => highlightPreview(index, true));
|
|
row.addEventListener('mouseleave', () => highlightPreview(index, false));
|
|
|
|
// --- Header: "Feld #N" title + action icons ---
|
|
const header = document.createElement('div');
|
|
header.className = 'handover-schema-row-header';
|
|
|
|
const title = document.createElement('span');
|
|
title.className = 'handover-schema-row-title';
|
|
title.textContent = (t.field || 'Field') + ' #' + (index + 1);
|
|
header.appendChild(title);
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'handover-schema-row-actions';
|
|
|
|
if (index > 0) {
|
|
actions.appendChild(createIconButton(
|
|
'bi-arrow-up', 'secondary outline small',
|
|
() => { [fields[index - 1], fields[index]] = [fields[index], fields[index - 1]]; render(); notifyChange(); },
|
|
(t.move_up || 'Move up') + ' ' + (t.field_label || 'field') + ' ' + (index + 1),
|
|
));
|
|
}
|
|
if (index < fields.length - 1) {
|
|
actions.appendChild(createIconButton(
|
|
'bi-arrow-down', 'secondary outline small',
|
|
() => { [fields[index], fields[index + 1]] = [fields[index + 1], fields[index]]; render(); notifyChange(); },
|
|
(t.move_down || 'Move down') + ' ' + (t.field_label || 'field') + ' ' + (index + 1),
|
|
));
|
|
}
|
|
actions.appendChild(createIconButton(
|
|
'bi-copy', 'secondary outline small',
|
|
() => {
|
|
const clone = { ...field, label: field.label, key: field.key || '' };
|
|
if (field.options) { clone.options = field.options.map((o) => ({ ...o })); }
|
|
fields.splice(index + 1, 0, clone);
|
|
render();
|
|
notifyChange();
|
|
},
|
|
(t.duplicate_field || 'Duplicate field') + ' ' + (index + 1),
|
|
));
|
|
actions.appendChild(createIconButton(
|
|
'bi-trash', 'danger outline small',
|
|
() => { fields.splice(index, 1); render(); notifyChange(); },
|
|
(t.remove_field || 'Remove field') + ' ' + (index + 1),
|
|
));
|
|
|
|
header.appendChild(actions);
|
|
row.appendChild(header);
|
|
|
|
// --- Type select (full width) ---
|
|
const typeGroup = createGroup(t.field_type || 'Field type');
|
|
const typeSelect = document.createElement('select');
|
|
typeSelect.setAttribute('aria-label', (t.field_type || 'Field type') + ' ' + (index + 1));
|
|
FIELD_TYPES.forEach((ft) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = ft;
|
|
opt.textContent = t['type_' + ft] || ft;
|
|
if (ft === field.type) opt.selected = true;
|
|
typeSelect.appendChild(opt);
|
|
});
|
|
typeSelect.addEventListener('change', () => {
|
|
field.type = typeSelect.value;
|
|
if (DISPLAY_ONLY_TYPES.includes(field.type)) {
|
|
delete field.key;
|
|
delete field.required;
|
|
} else {
|
|
if (!field.key) field.key = '';
|
|
if (field.required === undefined) field.required = false;
|
|
}
|
|
if (field.type === 'select' && (!field.options || field.options.length === 0)) {
|
|
field.options = [{ value: '', label: '' }];
|
|
}
|
|
render();
|
|
notifyChange();
|
|
});
|
|
typeGroup.appendChild(typeSelect);
|
|
row.appendChild(typeGroup);
|
|
|
|
// --- Body: label input ---
|
|
const body = document.createElement('div');
|
|
body.className = 'handover-schema-row-body';
|
|
|
|
const labelKey = field.type === 'paragraph' ? (t.text_content || 'Text content') : (t.field_label || 'Label');
|
|
const labelGroup = createGroup(labelKey);
|
|
const labelInput = document.createElement(field.type === 'paragraph' ? 'textarea' : 'input');
|
|
if (field.type !== 'paragraph') {
|
|
labelInput.type = 'text';
|
|
} else {
|
|
labelInput.rows = 2;
|
|
}
|
|
labelInput.value = field.label || '';
|
|
labelInput.setAttribute('aria-label', labelKey + ' ' + (index + 1));
|
|
labelInput.addEventListener('input', () => { field.label = labelInput.value; notifyChange(); });
|
|
labelGroup.appendChild(labelInput);
|
|
body.appendChild(labelGroup);
|
|
|
|
row.appendChild(body);
|
|
|
|
// --- Required toggle — own row below key/label ---
|
|
if (!isDisplayOnly) {
|
|
const reqRow = document.createElement('div');
|
|
reqRow.className = 'handover-schema-row-required';
|
|
const reqLabel = document.createElement('label');
|
|
reqLabel.className = 'handover-schema-checkbox-label';
|
|
const reqInput = document.createElement('input');
|
|
reqInput.type = 'checkbox';
|
|
reqInput.checked = !!field.required;
|
|
reqInput.addEventListener('change', () => { field.required = reqInput.checked; notifyChange(); });
|
|
reqLabel.appendChild(reqInput);
|
|
reqLabel.appendChild(document.createTextNode(' ' + (t.required || 'Required')));
|
|
reqRow.appendChild(reqLabel);
|
|
row.appendChild(reqRow);
|
|
}
|
|
|
|
// --- Select options ---
|
|
if (isSelect) {
|
|
const optionsSection = document.createElement('div');
|
|
optionsSection.className = 'handover-schema-options';
|
|
|
|
const optionsHeader = document.createElement('div');
|
|
optionsHeader.className = 'handover-schema-options-header';
|
|
const optionsTitle = document.createElement('strong');
|
|
optionsTitle.textContent = t.options || 'Options';
|
|
optionsHeader.appendChild(optionsTitle);
|
|
|
|
const addOptButton = document.createElement('button');
|
|
addOptButton.type = 'button';
|
|
addOptButton.className = 'secondary outline small';
|
|
addOptButton.setAttribute('data-tooltip', t.add_option || 'Add option');
|
|
addOptButton.setAttribute('aria-label', t.add_option || 'Add option');
|
|
addOptButton.appendChild(createIcon('bi-plus-lg'));
|
|
addOptButton.addEventListener('click', () => {
|
|
field.options.push({ value: '', label: '' });
|
|
render();
|
|
notifyChange();
|
|
});
|
|
optionsHeader.appendChild(addOptButton);
|
|
optionsSection.appendChild(optionsHeader);
|
|
|
|
if (!field.options) field.options = [];
|
|
|
|
field.options.forEach((opt, optIndex) => {
|
|
const optRow = document.createElement('div');
|
|
optRow.className = 'handover-schema-option-row';
|
|
|
|
const lblInput = document.createElement('input');
|
|
lblInput.type = 'text';
|
|
lblInput.value = opt.label || '';
|
|
lblInput.placeholder = (t.option_label || 'Label') + ' ' + (optIndex + 1);
|
|
lblInput.setAttribute('aria-label', (t.option_label || 'Label') + ' ' + (optIndex + 1));
|
|
lblInput.addEventListener('input', () => { opt.label = lblInput.value; notifyChange(); });
|
|
optRow.appendChild(lblInput);
|
|
|
|
optRow.appendChild(createIconButton(
|
|
'bi-x-lg', 'danger outline small',
|
|
() => { field.options.splice(optIndex, 1); render(); notifyChange(); },
|
|
(t.remove_option || 'Remove option') + ' ' + (optIndex + 1),
|
|
));
|
|
|
|
optionsSection.appendChild(optRow);
|
|
});
|
|
|
|
row.appendChild(optionsSection);
|
|
}
|
|
|
|
return row;
|
|
}
|
|
|
|
function createGroup(labelText) {
|
|
const group = document.createElement('div');
|
|
group.className = 'handover-schema-field-group';
|
|
if (labelText) {
|
|
const label = document.createElement('span');
|
|
label.className = 'handover-schema-field-label';
|
|
label.textContent = labelText;
|
|
group.appendChild(label);
|
|
}
|
|
return group;
|
|
}
|
|
|
|
function createIcon(iconClass) {
|
|
const icon = document.createElement('i');
|
|
icon.className = 'bi ' + iconClass;
|
|
return icon;
|
|
}
|
|
|
|
function createIconButton(iconClass, buttonClass, onClick, ariaLabel) {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = buttonClass;
|
|
button.setAttribute('aria-label', ariaLabel);
|
|
button.appendChild(createIcon(iconClass));
|
|
button.addEventListener('click', onClick);
|
|
return button;
|
|
}
|
|
|
|
function renderPreview() {
|
|
if (!previewContainer) return;
|
|
clearElement(previewContainer);
|
|
|
|
if (fields.length === 0) {
|
|
const empty = document.createElement('p');
|
|
empty.className = 'handover-schema-preview-empty';
|
|
empty.textContent = t.preview_empty || 'Add fields to see a live preview.';
|
|
previewContainer.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
const formEl = document.createElement('div');
|
|
formEl.className = 'handover-schema-preview-form';
|
|
|
|
fields.forEach((field, index) => {
|
|
if (field.type === 'heading') {
|
|
const h = document.createElement('h3');
|
|
h.dataset.fieldIndex = index;
|
|
h.textContent = field.label || '';
|
|
formEl.appendChild(h);
|
|
return;
|
|
}
|
|
|
|
if (field.type === 'paragraph') {
|
|
const p = document.createElement('p');
|
|
p.dataset.fieldIndex = index;
|
|
p.textContent = field.label || '';
|
|
formEl.appendChild(p);
|
|
return;
|
|
}
|
|
|
|
const group = document.createElement('div');
|
|
group.className = 'handover-schema-preview-field';
|
|
group.dataset.fieldIndex = index;
|
|
|
|
if (field.type === 'checkbox') {
|
|
const input = document.createElement('input');
|
|
input.type = 'checkbox';
|
|
input.addEventListener('click', (e) => e.preventDefault());
|
|
group.appendChild(input);
|
|
const label = document.createElement('label');
|
|
label.textContent = field.label || '';
|
|
if (field.required) {
|
|
const star = document.createElement('span');
|
|
star.className = 'handover-schema-preview-required';
|
|
star.textContent = ' *';
|
|
label.appendChild(star);
|
|
}
|
|
group.appendChild(label);
|
|
} else {
|
|
const label = document.createElement('label');
|
|
label.textContent = field.label || '';
|
|
if (field.required) {
|
|
const star = document.createElement('span');
|
|
star.className = 'handover-schema-preview-required';
|
|
star.textContent = ' *';
|
|
label.appendChild(star);
|
|
}
|
|
group.appendChild(label);
|
|
|
|
let input;
|
|
if (field.type === 'textarea') {
|
|
input = document.createElement('textarea');
|
|
input.rows = 3;
|
|
} else if (field.type === 'select') {
|
|
input = document.createElement('select');
|
|
const placeholder = document.createElement('option');
|
|
placeholder.value = '';
|
|
placeholder.textContent = '';
|
|
input.appendChild(placeholder);
|
|
(field.options || []).forEach((opt) => {
|
|
const option = document.createElement('option');
|
|
option.textContent = opt.label || '';
|
|
input.appendChild(option);
|
|
});
|
|
} else {
|
|
input = document.createElement('input');
|
|
input.type = field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text';
|
|
}
|
|
input.disabled = true;
|
|
group.appendChild(input);
|
|
}
|
|
|
|
formEl.appendChild(group);
|
|
});
|
|
|
|
previewContainer.appendChild(formEl);
|
|
}
|
|
|
|
function highlightPreview(index, active) {
|
|
if (!previewContainer) return;
|
|
const el = previewContainer.querySelector('[data-field-index="' + index + '"]');
|
|
if (el) {
|
|
el.classList.toggle('handover-schema-preview-highlight', active);
|
|
}
|
|
}
|
|
|
|
function syncHiddenInput() {
|
|
if (!hiddenInput) return;
|
|
hiddenInput.value = fields.length > 0 ? JSON.stringify(fields) : '';
|
|
}
|
|
|
|
function notifyChange() {
|
|
syncHiddenInput();
|
|
renderPreview();
|
|
if (hiddenInput) {
|
|
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
}
|
|
}
|