Files
nat-as-server/website/js/pages/editor.js
Tom Hempel 339091e13f
Some checks failed
PHPUnit / test (push) Has been cancelled
adding process completion logic
2026-07-01 12:07:41 +02:00

2116 lines
94 KiB
JavaScript
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.

import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js';
import { canEdit, homeNavButton, showToast } from '../app.js';
import { confirmAction } from '../confirm-modal.js';
import { navigate } from '../router.js';
import {
SOURCE_LANG,
normalizeEntry,
translationFor,
targetLanguages,
buildTranslationGroups,
sourceLanguageLabel,
translationListHeaderHTML,
renderCollapsibleGroupedTranslationsHTML,
escHtml,
} from '../translations-helpers.js';
import {
mountConditionEditor,
parseConditionForm,
} from '../condition-editor.js';
import { mountQuestionnairePreview } from '../questionnaire-preview.js';
const LAYOUT_TYPES = [
{ value: 'radio_question', label: 'Radio (Single Choice)' },
{ value: 'multi_check_box_question', label: 'Checkbox (Multiple Choice)' },
{ value: 'glass_scale_question', label: 'Glass Scale' },
{ value: 'value_spinner', label: 'Value Spinner' },
{ value: 'slider_question', label: 'Slider (minmax)' },
{ value: 'date_spinner', label: 'Date Spinner' },
{ value: 'string_spinner', label: 'String Spinner' },
{ value: 'free_text', label: 'Free Text' },
{ value: 'client_coach_code_question', label: 'Client / Counselor code' },
{ value: 'last_page', label: 'Last Page' },
];
const OPTION_TYPES = new Set(['radio_question', 'multi_check_box_question']);
const GLASS_SCORE_LEVELS = [
{ key: 'never_glass', label: 'Never' },
{ key: 'little_glass', label: 'Little' },
{ key: 'moderate_glass', label: 'Moderate' },
{ key: 'much_glass', label: 'Much' },
{ key: 'extreme_glass', label: 'Extreme' },
];
let questionnaire = null;
let questions = [];
let retiredQuestions = [];
let isNew = false;
let activeTab = 'questions';
let allQuestionnaires = [];
let questionsByQuestionnaire = {};
let conditionEditorApi = null;
// ── Entry point ──────────────────────────────────────────────────────────
export async function editorPage(params) {
const app = document.getElementById('app');
isNew = !params.id || params.id === 'new';
activeTab = 'questions';
app.innerHTML = `
<div class="page-header">
<div class="page-header-start">
${homeNavButton()}
<div class="page-header-titles">
<h1 id="editorTitle">${isNew ? 'New Questionnaire' : 'Loading...'}</h1>
</div>
</div>
<div class="actions">
<a href="#/questionnaires" class="btn">Questionnaires</a>
${!isNew ? `<a href="#/questionnaire/${params.id}/results" class="btn">View Results</a>` : ''}
</div>
</div>
<div id="editorContent"><div class="spinner"></div></div>
`;
try {
const qnList = await apiGet('questionnaires.php');
allQuestionnaires = qnList.questionnaires || [];
} catch {
allQuestionnaires = [];
}
if (isNew) {
questionnaire = { questionnaireID: null, name: '', version: '1.0', state: 'draft',
orderIndex: 0, showPoints: 0, conditionJson: '{}' };
questions = [];
renderEditor();
} else {
try {
const quData = await apiGet(
`questions.php?questionnaireID=${encodeURIComponent(params.id)}&includeRetired=1`
);
questionnaire = allQuestionnaires.find(q => q.questionnaireID === params.id);
if (!questionnaire) {
showToast('Questionnaire not found', 'error');
navigate('#/questionnaires');
return;
}
questionnaire.structureRevision = quData.structureRevision ?? questionnaire.structureRevision ?? 1;
const allQs = quData.questions || [];
questions = allQs.filter(q => !q.retired);
retiredQuestions = allQs.filter(q => q.retired);
document.getElementById('editorTitle').textContent = questionnaire.name;
renderEditor();
} catch (e) {
showToast(e.message, 'error');
document.getElementById('editorContent').innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
}
}
}
// ── Layout helpers ───────────────────────────────────────────────────────
function layoutSelectHTML(selected = 'radio_question', id = '') {
return `<select ${id ? `id="${id}"` : ''} class="type-select">
${LAYOUT_TYPES.map(t =>
`<option value="${t.value}" ${selected === t.value ? 'selected' : ''}>${t.label}</option>`
).join('')}
</select>`;
}
function layoutLabel(value) {
const t = LAYOUT_TYPES.find(t => t.value === value);
return t ? t.label : value || 'unknown';
}
const STABLE_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/;
function parseConfig(q) {
try { return JSON.parse(q.configJson || '{}'); } catch { return {}; }
}
function questionKeyOf(q) {
const cfg = typeof q === 'object' && q.configJson !== undefined ? parseConfig(q) : (q.config || q);
return (q.questionKey || cfg.questionKey || '').trim();
}
function questionLocalIds() {
return questions.map(q => {
const parts = q.questionID.split('__');
const localId = parts[parts.length - 1];
return {
questionID: q.questionID,
localId,
defaultText: q.defaultText,
questionKey: questionKeyOf(q),
};
});
}
function branchTargetLabel(q) {
const key = questionKeyOf(q) || q.localId;
return `${q.localId} · ${key}`;
}
function branchTargetOptionsHTML(selected = '') {
return questionLocalIds().map(q =>
`<option value="${esc(q.localId)}" ${selected === q.localId ? 'selected' : ''}>${esc(branchTargetLabel(q))}</option>`
).join('');
}
function validateStableKey(key, label = 'Key') {
if (!STABLE_KEY_RE.test(key)) {
showToast(`${label} must match [a-zA-Z][a-zA-Z0-9_]*`, 'error');
return false;
}
return true;
}
function optionKeyOf(o) {
if (o.optionKey) return o.optionKey;
const dt = (o.defaultText || '').trim();
return STABLE_KEY_RE.test(dt) ? dt : '';
}
function optionGermanLabel(o) {
return (o.labelGerman || '').trim() || (STABLE_KEY_RE.test(o.defaultText || '') ? '' : o.defaultText) || '';
}
function notesSectionHTML(config, prefix, questionKey) {
const nbKey = questionKey ? `${questionKey}_note_before` : '—';
const naKey = questionKey ? `${questionKey}_note_after` : '—';
return `
<div class="notes-section" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
<h4 style="font-size:.85rem;margin-bottom:8px;color:var(--text-secondary)">Notes (German)</h4>
<div class="form-row">
<div class="form-group" style="flex:1">
<label>Note before question</label>
<input type="text" id="${prefix}_noteBefore" value="${esc(config.noteBefore || '')}" placeholder="Optional text shown above the question">
<span class="field-hint">Key: <code>${esc(nbKey)}</code></span>
</div>
<div class="form-group" style="flex:1">
<label>Note after question</label>
<input type="text" id="${prefix}_noteAfter" value="${esc(config.noteAfter || '')}" placeholder="Optional text shown below the question">
<span class="field-hint">Key: <code>${esc(naKey)}</code></span>
</div>
</div>
</div>`;
}
function readNotesFromForm(prefix) {
const val = id => document.getElementById(`${prefix}_${id}`)?.value?.trim() || '';
return { noteBefore: val('noteBefore'), noteAfter: val('noteAfter') };
}
function glassSymptomsRowsFromQuestion(q) {
if (Array.isArray(q?.glassSymptoms) && q.glassSymptoms.length) {
return q.glassSymptoms.map(r => ({
key: (r.key || '').trim(),
labelDe: (r.labelDe || '').trim(),
scoreLevels: r.scoreLevels || defaultGlassScoreLevels(),
}));
}
const config = parseConfig(q);
return (config.symptoms || []).map(key => ({
key: String(key).trim(),
labelDe: '',
scoreLevels: defaultGlassScoreLevels(),
}));
}
function defaultGlassScoreLevels() {
return { never_glass: 0, little_glass: 1, moderate_glass: 2, much_glass: 3, extreme_glass: 4 };
}
function valueScoreRulesSectionHTML(prefix, config, scoreRules = []) {
const min = Number(config.range?.min ?? 0);
const max = Number(config.range?.max ?? 100);
const step = Math.max(1, Number(config.step ?? 1));
const lo = Math.min(min, max);
const hi = Math.max(min, max);
const ruleMap = {};
for (const r of scoreRules || []) {
ruleMap[r.levelKey] = r.points;
}
const values = [];
for (let v = lo; v <= hi; v += step) values.push(v);
if (!values.length) values.push(lo);
const rows = values.map(v => `
<div class="value-score-row">
<span class="value-score-label">${v}</span>
<input type="number" class="value-score-pts" data-value="${v}" value="${ruleMap[String(v)] ?? 0}" min="0" step="1">
</div>`).join('');
return `
<div class="value-score-rules" id="${prefix}_value_scores">
<label style="font-size:.85rem;font-weight:600;color:var(--text-secondary)">Points per value</label>
<p class="field-hint">Set points for each selectable value (used when the questionnaire is scored).</p>
<div class="value-score-grid">${rows}</div>
</div>`;
}
function readValueScoreRulesFromForm(prefix) {
const container = document.getElementById(`${prefix}_value_scores`);
if (!container) return [];
const rules = [];
container.querySelectorAll('.value-score-pts').forEach(inp => {
rules.push({
scopeKey: '',
levelKey: String(inp.dataset.value),
points: parseInt(inp.value || '0', 10) || 0,
});
});
return rules;
}
function wireValueScoreRules(prefix) {
const minInp = document.getElementById(`${prefix}_rangeMin`);
const maxInp = document.getElementById(`${prefix}_rangeMax`);
const stepInp = document.getElementById(`${prefix}_step`);
const rerender = () => {
const section = document.getElementById(`${prefix}_value_scores`);
if (!section) return;
const config = {
range: {
min: parseInt(minInp?.value || '0', 10),
max: parseInt(maxInp?.value || '100', 10),
},
step: parseInt(stepInp?.value || '1', 10) || 1,
};
const existing = readValueScoreRulesFromForm(prefix);
section.outerHTML = valueScoreRulesSectionHTML(prefix, config, existing);
};
[minInp, maxInp, stepInp].forEach(el => el?.addEventListener('change', rerender));
}
function suggestSymptomKey(label) {
let slug = (label || '').trim().toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
if (!slug) return '';
if (!/^[a-z]/.test(slug)) slug = `sym_${slug}`;
if (!STABLE_KEY_RE.test(slug)) slug = `sym_${slug.replace(/^[^a-z]+/, '')}`;
return STABLE_KEY_RE.test(slug) ? slug : '';
}
function glassSymptomsSectionHTML(rows, prefix) {
const list = rows.length ? rows : [];
return `
<div class="glass-symptoms-section" id="${prefix}_glass_symptoms">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<label style="font-size:.85rem;font-weight:600;color:var(--text-secondary)">Symptoms</label>
<button type="button" class="btn btn-sm" data-action="add-glass-symptom">+ Add symptom</button>
</div>
<p class="field-hint" style="margin-bottom:10px">Each row is one table line in the app. German text is saved with the question; translate other languages on the Translations tab.</p>
<ul class="glass-symptoms-list">
${list.length ? list.map((row, i) => glassSymptomRowHTML(prefix, row, i)).join('')
: '<li class="glass-symptoms-empty">No symptoms yet.</li>'}
</ul>
</div>`;
}
function glassSymptomRowHTML(prefix, row, idx) {
const levels = row.scoreLevels || defaultGlassScoreLevels();
const ptsRow = GLASS_SCORE_LEVELS.map(l => `
<div class="glass-score-cell">
<label>${esc(l.label)}</label>
<input type="number" class="glass-score-pts" data-level="${esc(l.key)}"
value="${Number(levels[l.key] ?? 0)}" min="0" step="1">
</div>`).join('');
return `
<li class="glass-symptom-row" data-idx="${idx}">
<div class="form-row" style="align-items:flex-end;margin-bottom:0">
<div class="form-group" style="flex:1;min-width:140px;margin-bottom:0">
<label style="font-size:.75rem">Symptom key <span class="required-mark">*</span></label>
<input type="text" class="glass-symptom-key" value="${esc(row.key || '')}"
placeholder="e.g. q10_reexperience_trauma" pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off">
</div>
<div class="form-group" style="flex:2;margin-bottom:0">
<label style="font-size:.75rem">German label <span class="required-mark">*</span></label>
<input type="text" class="glass-symptom-label" value="${esc(row.labelDe || '')}"
placeholder="Text shown in the app for this row">
</div>
<button type="button" class="btn-icon btn-icon-danger glass-symptom-remove" title="Remove">&#x2716;</button>
</div>
<div class="glass-score-grid">${ptsRow}</div>
</li>`;
}
function readGlassSymptomsFromForm(prefix) {
const container = document.getElementById(`${prefix}_glass_symptoms`);
if (!container) return [];
const rows = [];
container.querySelectorAll('.glass-symptom-row').forEach(rowEl => {
const key = rowEl.querySelector('.glass-symptom-key')?.value?.trim() || '';
const labelDe = rowEl.querySelector('.glass-symptom-label')?.value?.trim() || '';
const scoreLevels = {};
rowEl.querySelectorAll('.glass-score-pts').forEach(inp => {
scoreLevels[inp.dataset.level] = parseInt(inp.value || '0', 10) || 0;
});
if (key || labelDe) rows.push({ key, labelDe, scoreLevels });
});
return rows;
}
function validateGlassSymptoms(rows) {
const seen = new Set();
for (const row of rows) {
if (!row.key) {
showToast('Each symptom needs a key', 'error');
return false;
}
if (!validateStableKey(row.key, 'Symptom key')) return false;
if (!row.labelDe) {
showToast(`German label is required for symptom "${row.key}"`, 'error');
return false;
}
if (seen.has(row.key)) {
showToast(`Duplicate symptom key "${row.key}"`, 'error');
return false;
}
seen.add(row.key);
}
return true;
}
function wireGlassSymptomsSection(prefix) {
const container = document.getElementById(`${prefix}_glass_symptoms`);
if (!container) return;
const renderList = (rows) => {
const list = container.querySelector('.glass-symptoms-list');
if (!list) return;
if (!rows.length) {
list.innerHTML = '<li class="glass-symptoms-empty">No symptoms yet.</li>';
return;
}
list.innerHTML = rows.map((row, i) => glassSymptomRowHTML(prefix, row, i)).join('');
bindRowEvents();
};
const collectRows = () => readGlassSymptomsFromForm(prefix);
const bindRowEvents = () => {
container.querySelectorAll('.glass-symptom-remove').forEach(btn => {
btn.addEventListener('click', () => {
const rowEl = btn.closest('.glass-symptom-row');
rowEl?.remove();
const list = container.querySelector('.glass-symptoms-list');
if (list && !list.querySelector('.glass-symptom-row')) {
list.innerHTML = '<li class="glass-symptoms-empty">No symptoms yet.</li>';
}
});
});
container.querySelectorAll('.glass-symptom-label').forEach(inp => {
inp.addEventListener('blur', () => {
const rowEl = inp.closest('.glass-symptom-row');
const keyInp = rowEl?.querySelector('.glass-symptom-key');
if (!keyInp || keyInp.value.trim()) return;
const suggested = suggestSymptomKey(inp.value);
if (suggested) keyInp.value = suggested;
});
});
};
container.querySelector('[data-action=add-glass-symptom]')?.addEventListener('click', () => {
const rows = collectRows();
rows.push({ key: '', labelDe: '', scoreLevels: defaultGlassScoreLevels() });
renderList(rows);
const lastKey = container.querySelector('.glass-symptom-row:last-child .glass-symptom-label');
lastKey?.focus();
});
bindRowEvents();
}
function stringSpinnerOtherEnabled(config) {
return Boolean(config.otherNextQuestionId || config.otherOptionKey);
}
/** Question types that use config.nextQuestionId (not per-option branching). */
const QUESTION_LEVEL_NEXT_LAYOUTS = new Set([
'value_spinner',
'slider_question',
'free_text',
'date_spinner',
'multi_check_box_question',
'glass_scale_question',
'client_coach_code_question',
'last_page',
]);
function questionNextSectionHTML(config, prefix, { label, hint } = {}) {
const next = config.nextQuestionId || '';
return `
<div class="form-group question-next-field" style="margin-top:12px">
<label>${label || 'Next question'}</label>
<select id="${prefix}_nextQuestionId">
<option value="">(next in list order)</option>
${branchTargetOptionsHTML(next)}
</select>
<span class="field-hint">${hint || 'Where to go after this question. Leave empty to continue in list order.'}</span>
</div>`;
}
function stringSpinnerNextSectionHTML(config, prefix) {
return questionNextSectionHTML(config, prefix, {
label: 'Next question (list selection)',
hint: 'Where to go after a normal spinner choice. <strong>Required</strong> when &ldquo;Other&rdquo; targets a question that sits next in order (so list picks can skip it).',
});
}
function readQuestionNextFromForm(prefix, config) {
const next = document.getElementById(`${prefix}_nextQuestionId`)?.value?.trim() || '';
if (next) config.nextQuestionId = next;
}
function stringSpinnerOtherSectionHTML(config, prefix) {
const enabled = stringSpinnerOtherEnabled(config);
const otherKey = config.otherOptionKey || 'other_option';
const otherNext = config.otherNextQuestionId || '';
return `
<div class="form-group" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
<label class="checkbox-label" style="display:inline-flex;margin-bottom:8px">
<input type="checkbox" id="${prefix}_otherEnabled" ${enabled ? 'checked' : ''}>
Show &ldquo;Other&rdquo; option (branch to free-text question)
</label>
<div id="${prefix}_otherFields" style="${enabled ? '' : 'display:none'}">
<div class="form-row">
<div class="form-group" style="flex:1">
<label>Label translation key</label>
<input type="text" id="${prefix}_otherOptionKey" value="${esc(otherKey)}"
placeholder="e.g. other_country" pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off">
<span class="field-hint">Appends this label to the spinner. Translate under <strong>Questionnaire UI strings</strong> (or App strings if listed in catalog).</span>
</div>
<div class="form-group" style="flex:1">
<label>Free-text question when &ldquo;Other&rdquo; is chosen</label>
<select id="${prefix}_otherNextQuestionId">
<option value="">— select question —</option>
${branchTargetOptionsHTML(otherNext)}
</select>
<span class="field-hint">Use layout <strong>Free text</strong> for the follow-up (e.g. country name).</span>
</div>
</div>
</div>
</div>`;
}
function wireStringSpinnerOtherToggle(prefix) {
const cb = document.getElementById(`${prefix}_otherEnabled`);
const panel = document.getElementById(`${prefix}_otherFields`);
if (!cb || !panel) return;
const sync = () => { panel.style.display = cb.checked ? '' : 'none'; };
cb.addEventListener('change', sync);
sync();
}
function validateStringSpinnerConfig(prefix) {
const defaultNext = document.getElementById(`${prefix}_nextQuestionId`)?.value.trim() || '';
const enabled = document.getElementById(`${prefix}_otherEnabled`)?.checked;
if (enabled) {
const key = document.getElementById(`${prefix}_otherOptionKey`)?.value.trim() || '';
const otherNext = document.getElementById(`${prefix}_otherNextQuestionId`)?.value.trim() || '';
if (!key) {
showToast('Other label translation key is required', 'error');
return false;
}
if (!validateStableKey(key, 'Other label key')) return false;
if (!otherNext) {
showToast('Select the free-text question for “Other”', 'error');
return false;
}
const target = questions.find(q => (q.localId || q.questionID.split('__').pop()) === otherNext);
if (target && target.type !== 'free_text') {
showToast('“Other” should branch to a Free text question', 'error');
return false;
}
if (!defaultNext) {
showToast('Set “Next question (list selection)” so normal choices do not fall through to the free-text step', 'error');
return false;
}
if (defaultNext === otherNext) {
showToast('“Next question” and “Other” branch cannot target the same question', 'error');
return false;
}
}
return true;
}
// ── Config form HTML for each layout type ────────────────────────────────
function configFormHTML(layout, config, prefix, opts = {}) {
let html = '';
switch (layout) {
case 'radio_question':
html = `
<div class="form-group">
<label>Extra text key (optional)</label>
<input type="text" id="${prefix}_textKey" value="${esc(config.textKey || '')}" placeholder="e.g. resilience_reflection_prompt">
</div>
<p class="field-hint" style="margin-top:8px">Branching: set <strong>Next question</strong> on each answer option below.</p>`;
break;
case 'multi_check_box_question':
html = `
<div class="form-group">
<label>Min selections</label>
<input type="number" id="${prefix}_minSelection" value="${config.minSelection || 0}" min="0">
</div>
${stringSpinnerOtherSectionHTML(config, prefix)}`;
break;
case 'glass_scale_question': {
const scaleType = config.scaleType || 'glass';
const rows = opts.glassSymptoms || (config.symptoms || []).map(k => ({ key: k, labelDe: '' }));
html = `
<div class="form-group">
<label>Scale display</label>
<select id="${prefix}_scaleType">
<option value="glass" ${scaleType === 'glass' ? 'selected' : ''}>Glass (pain scale)</option>
<option value="thermometer" ${scaleType === 'thermometer' ? 'selected' : ''}>Thermometer</option>
</select>
</div>
${glassSymptomsSectionHTML(rows, prefix)}`;
break;
}
case 'value_spinner':
case 'slider_question': {
const scoreRules = opts.scoreRules || [];
html = `
<div class="form-row">
<div class="form-group">
<label>Min</label>
<input type="number" id="${prefix}_rangeMin" value="${config.range?.min ?? 0}">
</div>
<div class="form-group">
<label>Max</label>
<input type="number" id="${prefix}_rangeMax" value="${config.range?.max ?? 100}">
</div>
${layout === 'slider_question' ? `
<div class="form-group">
<label>Step</label>
<input type="number" id="${prefix}_step" value="${config.step ?? 1}" min="1">
</div>
<div class="form-group">
<label>Unit label (optional)</label>
<input type="text" id="${prefix}_unitLabel" value="${esc(config.unitLabel || '')}" placeholder="e.g. years">
</div>` : ''}
</div>
${valueScoreRulesSectionHTML(prefix, config, scoreRules)}`;
break;
}
case 'date_spinner': {
const notBefore = config.constraints?.notBefore || '';
const notAfter = config.constraints?.notAfter || '';
const precision = config.precision || 'full';
html = `
<div class="form-group">
<label>Date precision</label>
<select id="${prefix}_precision">
<option value="full" ${precision === 'full' ? 'selected' : ''}>Year, month, and day</option>
<option value="year_month" ${precision === 'year_month' ? 'selected' : ''}>Year and month</option>
<option value="year" ${precision === 'year' ? 'selected' : ''}>Year only</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label>Not before (question key)</label>
<input type="text" id="${prefix}_notBefore" value="${esc(notBefore)}" placeholder="e.g. departure_country">
</div>
<div class="form-group">
<label>Not after (question key)</label>
<input type="text" id="${prefix}_notAfter" value="${esc(notAfter)}" placeholder="e.g. since_in_germany">
</div>
</div>`;
break;
}
case 'string_spinner':
html = `
<div class="form-group">
<label>Static options (one per line)</label>
<textarea id="${prefix}_stringOptions" rows="8" style="font-family:monospace;font-size:.85rem">${(config.options || []).join('\n')}</textarea>
<span class="field-hint">Do not include the &ldquo;Other&rdquo; row here; enable it below.</span>
</div>
${stringSpinnerNextSectionHTML(config, prefix)}
${stringSpinnerOtherSectionHTML(config, prefix)}`;
break;
case 'free_text':
html = `
<div class="form-row">
<div class="form-group">
<label>Hint key (optional)</label>
<input type="text" id="${prefix}_hint" value="${esc(config.hint || '')}" placeholder="e.g. enter_text_to_continue">
</div>
<div class="form-group">
<label>Max length</label>
<input type="number" id="${prefix}_maxLength" value="${config.maxLength ?? 500}" min="1" max="10000">
</div>
</div>
<div class="form-group">
<label>Extra text key (optional)</label>
<input type="text" id="${prefix}_textKey" value="${esc(config.textKey || '')}" placeholder="e.g. additional_notes_label">
</div>`;
break;
case 'client_coach_code_question':
html = `
<div class="form-row">
<div class="form-group">
<label>Hint 1 key</label>
<input type="text" id="${prefix}_hint1" value="${esc(config.hint1 || '')}" placeholder="client_code">
</div>
<div class="form-group">
<label>Hint 2 key</label>
<input type="text" id="${prefix}_hint2" value="${esc(config.hint2 || '')}" placeholder="berater_code">
</div>
</div>`;
break;
case 'last_page':
html = `
<div class="form-group">
<label>Text key</label>
<input type="text" id="${prefix}_textKey" value="${esc(config.textKey || '')}" placeholder="finish_data_entry">
</div>`;
break;
}
if (QUESTION_LEVEL_NEXT_LAYOUTS.has(layout)) {
html += questionNextSectionHTML(config, prefix);
}
return html ? `<div class="config-section">${html}</div>` : '';
}
function readConfigFromForm(layout, prefix) {
const config = {};
const val = id => document.getElementById(`${prefix}_${id}`)?.value?.trim() || '';
switch (layout) {
case 'radio_question':
if (val('textKey')) config.textKey = val('textKey');
break;
case 'multi_check_box_question': {
const ms = parseInt(val('minSelection') || '0', 10);
if (ms > 0) config.minSelection = ms;
const otherEnabled = document.getElementById(`${prefix}_otherEnabled`)?.checked;
if (otherEnabled) {
const ok = val('otherOptionKey') || 'other_option';
const on = val('otherNextQuestionId');
if (ok) config.otherOptionKey = ok;
if (on) config.otherNextQuestionId = on;
}
break;
}
case 'glass_scale_question': {
const st = val('scaleType') || 'glass';
if (st && st !== 'glass') config.scaleType = st;
break;
}
case 'value_spinner':
case 'slider_question':
config.range = {
min: parseInt(val('rangeMin') || '0', 10),
max: parseInt(val('rangeMax') || '100', 10),
};
if (layout === 'slider_question') {
const step = parseInt(val('step') || '1', 10);
if (step > 0) config.step = step;
if (val('unitLabel')) config.unitLabel = val('unitLabel');
}
break;
case 'date_spinner': {
const prec = val('precision') || 'full';
if (prec && prec !== 'full') config.precision = prec;
const nb = val('notBefore');
const na = val('notAfter');
if (nb || na) {
config.constraints = {};
if (nb) config.constraints.notBefore = nb;
if (na) config.constraints.notAfter = na;
}
break;
}
case 'string_spinner': {
const raw = val('stringOptions');
const opts = raw.split('\n').map(s => s.trim()).filter(Boolean);
if (opts.length) config.options = opts;
const otherEnabled = document.getElementById(`${prefix}_otherEnabled`)?.checked;
if (otherEnabled) {
const ok = val('otherOptionKey') || 'other_option';
const on = val('otherNextQuestionId');
if (ok) config.otherOptionKey = ok;
if (on) config.otherNextQuestionId = on;
}
break;
}
case 'free_text': {
if (val('textKey')) config.textKey = val('textKey');
if (val('hint')) config.hint = val('hint');
const ml = parseInt(val('maxLength') || '500', 10);
if (!Number.isNaN(ml) && ml > 0) config.maxLength = Math.min(ml, 10000);
break;
}
case 'client_coach_code_question':
if (val('hint1')) config.hint1 = val('hint1');
if (val('hint2')) config.hint2 = val('hint2');
break;
case 'last_page':
if (val('textKey')) config.textKey = val('textKey');
break;
}
if (QUESTION_LEVEL_NEXT_LAYOUTS.has(layout) || layout === 'string_spinner') {
readQuestionNextFromForm(prefix, config);
}
return JSON.stringify(config);
}
// ── Main editor render ───────────────────────────────────────────────────
function renderEditor() {
const container = document.getElementById('editorContent');
const editable = canEdit();
const condJson = questionnaire.conditionJson || '{}';
container.innerHTML = `
<div class="card" style="margin-bottom:20px">
<h3 style="margin-bottom:12px">Questionnaire Details</h3>
<div class="meta-form">
<div class="form-group">
<label>Name</label>
<input type="text" id="metaName" value="${esc(questionnaire.name)}" ${editable ? '' : 'disabled'}>
</div>
<div class="form-group">
<label>Version</label>
<input type="text" id="metaVersion" value="${esc(questionnaire.version)}" ${editable ? '' : 'disabled'}>
</div>
<div class="form-group">
<label>Structure revision</label>
<input type="text" value="${esc(String(questionnaire.structureRevision ?? 1))}" disabled
title="Auto-incremented when questions or options change structurally">
</div>
<div class="form-group">
<label>State</label>
<select id="metaState" ${editable ? '' : 'disabled'}>
${['draft','active','archived'].map(s =>
`<option value="${s}" ${questionnaire.state === s ? 'selected' : ''}>${s}</option>`
).join('')}
</select>
</div>
</div>
<div class="meta-form" style="margin-top:0">
<div class="form-group">
<label>Order index</label>
<input type="number" id="metaOrder" value="${questionnaire.orderIndex ?? 0}" min="0" ${editable ? '' : 'disabled'}>
</div>
<div class="form-group">
<label>Category key (app grouping)</label>
<input type="text" id="metaCategoryKey" value="${esc(questionnaire.categoryKey || '')}" placeholder="e.g. health_interview" ${editable ? '' : 'disabled'}>
</div>
</div>
<div id="conditionEditorMount" style="margin:16px 0"></div>
${editable ? `<button class="btn btn-primary" id="saveMetaBtn">${isNew ? 'Create Questionnaire' : 'Save Changes'}</button>` : ''}
</div>
${!isNew ? `
<div class="tab-bar" id="editorTabs">
<button class="active" data-tab="questions">Questions (${questions.length})</button>
<button data-tab="preview">Preview</button>
<button data-tab="translations">Translations</button>
</div>
<div id="tabContent-questions">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3>Questions</h3>
${editable ? `<button class="btn btn-primary btn-sm" id="addQuestionBtn">+ Add Question</button>` : ''}
</div>
<div id="addQuestionFormWrapper" style="display:none"></div>
<ul class="question-list" id="questionList"></ul>
<div id="retiredQuestionsSection" style="display:none;margin-top:20px">
<details class="retired-questions-panel">
<summary>Retired questions (<span id="retiredCount">0</span>)</summary>
<p class="field-hint">Retired questions stay in upload history; Counselors on older forms can still submit.</p>
<ul class="question-list retired-question-list" id="retiredQuestionList"></ul>
</details>
</div>
</div>
</div>
<div id="tabContent-translations" style="display:none">
<div class="card" id="translationsCard">
<div id="translationsContent"><div class="spinner"></div></div>
</div>
</div>
<div id="tabContent-preview" style="display:none">
<div id="previewContent"></div>
</div>
` : '<p style="color:var(--text-secondary);margin-top:12px">Save the questionnaire first, then add questions.</p>'}
`;
if (editable) {
document.getElementById('saveMetaBtn').addEventListener('click', saveMeta);
}
initConditionEditor(condJson);
if (!isNew) {
initTabs();
renderQuestions();
renderRetiredQuestions();
if (editable) {
document.getElementById('addQuestionBtn').addEventListener('click', showAddQuestionForm);
initDragReorder();
}
}
}
function applyStructureRevision(rev) {
if (rev == null) return;
questionnaire.structureRevision = rev;
const input = document.querySelector('.meta-form input[disabled][title*="Auto-incremented"]');
if (input) input.value = String(rev);
}
function notifyStructureBump(data) {
const rev = data?.structureRevision;
if (rev != null) {
applyStructureRevision(rev);
showToast(`Structure revision bumped to ${rev}.`, 'info');
}
}
function initTabs() {
const tabBar = document.getElementById('editorTabs');
if (!tabBar) return;
tabBar.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
if (tab === activeTab) return;
activeTab = tab;
tabBar.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
document.getElementById('tabContent-questions').style.display = tab === 'questions' ? '' : 'none';
document.getElementById('tabContent-translations').style.display = tab === 'translations' ? '' : 'none';
document.getElementById('tabContent-preview').style.display = tab === 'preview' ? '' : 'none';
if (tab === 'translations') loadTranslationsTab();
if (tab === 'preview') loadPreviewTab();
});
});
}
function loadPreviewTab() {
const mount = document.getElementById('previewContent');
if (!mount) return;
mountQuestionnairePreview(mount, {
questionnaireName: questionnaire?.name || '',
questions,
});
}
async function fetchAppStringGerman(messageKey) {
if (!messageKey) return '';
try {
const data = await apiGet(
`translations.php?type=app_string&id=${encodeURIComponent(messageKey)}`
);
const row = (data.translations || []).find(
tr => tr.languageCode === SOURCE_LANG
);
return row?.text?.trim() || '';
} catch {
return '';
}
}
async function loadQuestionsByQuestionnaire() {
const ids = allQuestionnaires.map(q => q.questionnaireID).filter(Boolean);
if (!ids.length) {
questionsByQuestionnaire = {};
return;
}
const pairs = await Promise.all(ids.map(async id => {
try {
const data = await apiGet(
`questions.php?questionnaireID=${encodeURIComponent(id)}`
);
return [id, data.questions || []];
} catch {
return [id, []];
}
}));
questionsByQuestionnaire = Object.fromEntries(pairs);
}
async function initConditionEditor(condJson) {
const mount = document.getElementById('conditionEditorMount');
if (!mount) return;
mount.innerHTML = '<div class="spinner"></div>';
await loadQuestionsByQuestionnaire();
let scoringProfiles = [];
try {
const data = await apiGet('scoring_profiles.php');
scoringProfiles = (data.profiles || []).filter(p => p.isActive !== 0 && p.isActive !== false);
} catch {
scoringProfiles = [];
}
const qid = questionnaire?.questionnaireID || 'new';
const parsed = parseConditionForm(condJson, qid);
const germanLabel = parsed.messageKey
? await fetchAppStringGerman(parsed.messageKey)
: '';
conditionEditorApi = mountConditionEditor(mount, {
questionnaireId: qid,
allQuestionnaires,
questionsByQuestionnaire,
scoringProfiles,
conditionJson: condJson,
germanLabel,
editable: canEdit(),
});
}
// ── Save questionnaire meta ──────────────────────────────────────────────
async function saveMeta() {
const name = document.getElementById('metaName').value.trim();
const version = document.getElementById('metaVersion').value.trim();
const state = document.getElementById('metaState').value;
const orderIndex = parseInt(document.getElementById('metaOrder').value || '0', 10);
const showPoints = 0;
const categoryKey = (document.getElementById('metaCategoryKey')?.value || '').trim();
let conditionJson = '{}';
let conditionForm = null;
if (conditionEditorApi) {
try {
conditionJson = conditionEditorApi.buildJson();
conditionForm = conditionEditorApi.readForm();
} catch (e) {
showToast(e.message || 'Invalid availability settings', 'error');
return;
}
}
if (!name) { showToast('Name is required', 'error'); return; }
try {
if (isNew) {
const data = await apiPost('questionnaires.php', { name, version, state, orderIndex, showPoints, conditionJson, categoryKey });
if (data.success) {
questionnaire = data.questionnaire;
isNew = false;
await saveConditionMessageLabel(conditionForm);
showToast('Questionnaire created', 'success');
navigate(`#/questionnaire/${questionnaire.questionnaireID}`);
}
} else {
const data = await apiPut('questionnaires.php', {
questionnaireID: questionnaire.questionnaireID,
name, version, state, orderIndex, showPoints, conditionJson, categoryKey
});
if (data.success) {
questionnaire = { ...questionnaire, name, version, state, orderIndex, showPoints, conditionJson, categoryKey };
document.getElementById('editorTitle').textContent = name;
await saveConditionMessageLabel(conditionForm);
showToast('Saved', 'success');
}
}
} catch (e) {
showToast(e.message, 'error');
}
}
async function saveConditionMessageLabel(form) {
if (!form || form.mode === 'always') return;
const messageKey = (form.messageKey || '').trim();
const germanLabel = (form.germanLabel || '').trim();
if (!messageKey || !germanLabel) return;
try {
await apiPost('questionnaires.php', {
action: 'updateConditionMessageLabel',
messageKey,
germanLabel,
});
} catch (e) {
showToast(`Saved questionnaire; message label failed: ${e.message}`, 'error');
}
}
// ── Add question form ────────────────────────────────────────────────────
function showAddQuestionForm() {
const wrapper = document.getElementById('addQuestionFormWrapper');
if (!wrapper) return;
const pendingOptions = [];
wrapper.style.display = '';
wrapper.innerHTML = `
<div class="inline-form-card">
<h4>New Question</h4>
<div class="form-row">
<div class="form-group" style="flex:1;min-width:180px">
<label>Question key <span class="required-mark">*</span></label>
<input type="text" id="aq_questionKey" placeholder="e.g. consent_instruction" required pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off">
</div>
<div class="form-group" style="flex:2">
<label>German text <span class="required-mark">*</span></label>
<input type="text" id="aq_text" placeholder="e.g. Haben Sie zugestimmt?" required>
</div>
<div class="form-group" style="flex:1;min-width:200px">
<label>Layout type</label>
${layoutSelectHTML('radio_question', 'aq_type')}
</div>
</div>
<label class="checkbox-label" style="margin-bottom:12px;display:inline-flex">
<input type="checkbox" id="aq_required"> Required
</label>
<div id="aq_config_section"></div>
<div id="aq_notes_section">${notesSectionHTML({}, 'aq_cfg', '')}</div>
<div id="aq_options_section">
<div class="options-builder">
<label style="font-size:.85rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:8px">
Answer Options
</label>
<ul class="pending-options-list" id="aq_pending_options"></ul>
<div style="display:flex;gap:8px;margin-top:6px;align-items:flex-end">
<div class="form-group" style="flex:1;min-width:140px;margin-bottom:0">
<input type="text" id="aq_opt_key" placeholder="option_key" pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off">
</div>
<div class="form-group" style="flex:2;margin-bottom:0">
<input type="text" id="aq_opt_text" placeholder="German label…" required>
</div>
<div class="form-group" style="width:90px;margin-bottom:0">
<input type="number" id="aq_opt_pts" value="0" min="0" placeholder="Pts">
</div>
<div class="form-group" style="width:160px;margin-bottom:0">
<label style="font-size:.75rem;color:var(--text-secondary)">Next question</label>
<select id="aq_opt_next">
<option value="">(next in order)</option>
${branchTargetOptionsHTML()}
</select>
</div>
<button class="btn btn-sm" id="aq_opt_add" style="flex-shrink:0;white-space:nowrap">+ Add</button>
</div>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:14px">
<button class="btn btn-primary btn-sm" id="aq_submit">Add Question</button>
<button class="btn btn-sm" id="aq_cancel">Cancel</button>
</div>
</div>
`;
function updateTypeUI() {
const type = document.getElementById('aq_type').value;
const qKey = document.getElementById('aq_questionKey')?.value.trim() || '';
document.getElementById('aq_options_section').style.display = OPTION_TYPES.has(type) ? '' : 'none';
document.getElementById('aq_config_section').innerHTML = configFormHTML(type, {}, 'aq_cfg');
wireStringSpinnerOtherToggle('aq_cfg');
if (type === 'glass_scale_question') wireGlassSymptomsSection('aq_cfg');
if (type === 'slider_question' || type === 'value_spinner') wireValueScoreRules('aq_cfg');
const notesEl = document.getElementById('aq_notes_section');
if (notesEl) notesEl.innerHTML = notesSectionHTML({}, 'aq_cfg', qKey);
}
function renderPendingOptions() {
const list = document.getElementById('aq_pending_options');
if (!list) return;
if (!pendingOptions.length) {
list.innerHTML = `<li class="pending-options-empty">No options yet.</li>`;
return;
}
list.innerHTML = pendingOptions.map((opt, i) => `
<li class="pending-option-item">
<code class="opt-key">${esc(opt.optionKey)}</code>
<span class="opt-text">${esc(opt.text)}</span>
<span class="opt-points">${opt.points} pts</span>
${opt.nextQuestionId ? `<span class="opt-branch">&rarr; ${esc(opt.nextQuestionId)}</span>` : ''}
<button class="btn-icon btn-icon-danger remove-pending-opt" data-idx="${i}" title="Remove">&#x2716;</button>
</li>
`).join('');
list.querySelectorAll('.remove-pending-opt').forEach(btn => {
btn.addEventListener('click', () => {
pendingOptions.splice(parseInt(btn.dataset.idx, 10), 1);
renderPendingOptions();
});
});
}
function addPendingOption() {
const optionKey = document.getElementById('aq_opt_key').value.trim();
const text = document.getElementById('aq_opt_text').value.trim();
const pts = parseInt(document.getElementById('aq_opt_pts').value || '0', 10);
const next = document.getElementById('aq_opt_next').value;
if (!validateStableKey(optionKey, 'Option key')) return;
if (!text) { showToast('German label is required', 'error'); return; }
pendingOptions.push({ optionKey, text, points: pts, nextQuestionId: next });
renderPendingOptions();
document.getElementById('aq_opt_key').value = '';
document.getElementById('aq_opt_text').value = '';
document.getElementById('aq_opt_pts').value = '0';
document.getElementById('aq_opt_next').value = '';
document.getElementById('aq_opt_text').focus();
}
updateTypeUI();
renderPendingOptions();
document.getElementById('aq_text').focus();
document.getElementById('aq_type').addEventListener('change', updateTypeUI);
document.getElementById('aq_questionKey').addEventListener('input', () => {
const notesEl = document.getElementById('aq_notes_section');
if (notesEl) {
const nb = document.getElementById('aq_cfg_noteBefore')?.value ?? '';
const na = document.getElementById('aq_cfg_noteAfter')?.value ?? '';
notesEl.innerHTML = notesSectionHTML(
{ noteBefore: nb, noteAfter: na },
'aq_cfg',
document.getElementById('aq_questionKey').value.trim()
);
}
});
document.getElementById('aq_opt_add').addEventListener('click', addPendingOption);
document.getElementById('aq_opt_text').addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); addPendingOption(); }
});
document.getElementById('aq_submit').addEventListener('click', async () => {
const questionKey = document.getElementById('aq_questionKey').value.trim();
const text = document.getElementById('aq_text').value.trim();
const type = document.getElementById('aq_type').value;
const isRequired = document.getElementById('aq_required').checked ? 1 : 0;
const btn = document.getElementById('aq_submit');
const notes = readNotesFromForm('aq_cfg');
const configJson = readConfigFromForm(type, 'aq_cfg');
const postBody = {
questionnaireID: questionnaire.questionnaireID,
questionKey,
defaultText: text,
type,
isRequired,
configJson,
noteBefore: notes.noteBefore,
noteAfter: notes.noteAfter,
};
if (type === 'glass_scale_question') {
const glassSymptoms = readGlassSymptomsFromForm('aq_cfg');
if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return;
postBody.glassSymptoms = glassSymptoms;
}
if (type === 'slider_question' || type === 'value_spinner') {
postBody.scoreRules = readValueScoreRulesFromForm('aq_cfg');
}
if (!validateStableKey(questionKey, 'Question key')) return;
if (!text) { showToast('German text is required', 'error'); return; }
if (type === 'string_spinner' && !validateStringSpinnerConfig('aq_cfg')) return;
if (OPTION_TYPES.has(type) && pendingOptions.length === 0) {
showToast('Add at least one answer option', 'error');
return;
}
btn.disabled = true;
btn.textContent = 'Adding...';
try {
const qData = await apiPost('questions.php', postBody);
if (!qData.success) throw new Error(qData.error || 'Failed');
const newQ = qData.question;
newQ.answerOptions = [];
for (const opt of pendingOptions) {
const oData = await apiPost('answer_options.php', {
questionID: newQ.questionID,
optionKey: opt.optionKey,
defaultText: opt.text,
points: opt.points,
nextQuestionId: opt.nextQuestionId || '',
});
if (oData.success) newQ.answerOptions.push(oData.answerOption);
}
questions.push(newQ);
renderQuestions();
notifyStructureBump(qData);
showToast('Question added', 'success');
document.getElementById('aq_text').value = '';
pendingOptions.length = 0;
renderPendingOptions();
updateTypeUI();
document.getElementById('aq_text').focus();
} catch (e) {
showToast(e.message, 'error');
} finally {
if (document.getElementById('aq_submit')) {
btn.disabled = false;
btn.textContent = 'Add Question';
}
}
});
document.getElementById('aq_text').addEventListener('keydown', (e) => {
if (e.key === 'Escape') hideAddQuestionForm();
});
document.getElementById('aq_cancel').addEventListener('click', hideAddQuestionForm);
}
function hideAddQuestionForm() {
const wrapper = document.getElementById('addQuestionFormWrapper');
if (wrapper) { wrapper.style.display = 'none'; wrapper.innerHTML = ''; }
}
// ── Questions list ───────────────────────────────────────────────────────
function renderRetiredQuestions() {
const section = document.getElementById('retiredQuestionsSection');
const list = document.getElementById('retiredQuestionList');
const countEl = document.getElementById('retiredCount');
if (!section || !list) return;
if (!retiredQuestions.length) {
section.style.display = 'none';
list.innerHTML = '';
if (countEl) countEl.textContent = '0';
return;
}
section.style.display = '';
if (countEl) countEl.textContent = String(retiredQuestions.length);
list.innerHTML = retiredQuestions.map((q, idx) => `
<li class="question-item retired-question-item">
<div class="question-header">
<span class="q-text">${idx + 1}. <code>${esc(questionKeyOf(q) || '?')}</code> · ${esc((q.defaultText || '').slice(0, 48))}</span>
<span class="badge badge-archived">Retired</span>
${q.retiredInRevision ? `<span class="badge">rev ${q.retiredInRevision}</span>` : ''}
</div>
</li>
`).join('');
}
function renderQuestions() {
const list = document.getElementById('questionList');
if (!list) return;
const editable = canEdit();
// update tab label
const tabBtn = document.querySelector('#editorTabs button[data-tab="questions"]');
if (tabBtn) tabBtn.textContent = `Questions (${questions.length})`;
renderRetiredQuestions();
if (!questions.length) {
list.innerHTML = `<li class="empty-state" style="padding:30px"><h3>No questions yet</h3><p>Add your first question above.</p></li>`;
return;
}
list.innerHTML = questions.map((q, idx) => `
<li class="question-item" data-id="${q.questionID}" draggable="${editable}">
<div class="question-header">
${editable ? '<span class="drag-handle" title="Drag to reorder">&#x2630;</span>' : ''}
<span class="chevron">&#x25B6;</span>
<span class="q-text">${idx + 1}. <code>${esc(questionKeyOf(q) || '?')}</code> · ${esc((q.defaultText || '').slice(0, 48))}${(q.defaultText || '').length > 48 ? '…' : ''}</span>
<span class="q-type">${esc(layoutLabel(q.type))}</span>
${!questionKeyOf(q) ? '<span class="badge badge-warn">No key</span>' : ''}
${q.isRequired ? '<span class="badge badge-active">Required</span>' : ''}
${(() => {
const c = parseConfig(q);
const parts = [];
if (c.nextQuestionId) parts.push(`${esc(c.nextQuestionId)}`);
if (c.otherNextQuestionId) parts.push(`Other→${esc(c.otherNextQuestionId)}`);
return parts.length
? `<span class="badge" title="Branches">${esc(parts.join(' · '))}</span>` : '';
})()}
</div>
<div class="question-body" id="qbody-${q.questionID}"></div>
</li>
`).join('');
list.querySelectorAll('.question-header').forEach((hdr, idx) => {
hdr.addEventListener('click', (e) => {
if (e.target.closest('.drag-handle')) return;
const item = hdr.closest('.question-item');
const wasOpen = item.classList.contains('open');
item.classList.toggle('open');
if (!wasOpen) renderQuestionBody(questions[idx]);
});
});
}
// ── Question body (expanded) ─────────────────────────────────────────────
function renderQuestionBody(q) {
const body = document.getElementById(`qbody-${q.questionID}`);
if (!body) return;
const editable = canEdit();
const options = q.answerOptions || [];
const config = parseConfig(q);
const layout = q.type || 'radio_question';
const qKey = questionKeyOf(q);
const localId = q.localId || q.questionID.split('__').pop();
body.innerHTML = `
${editable ? `
<div class="q-edit-section">
<div class="inline-form-card" style="margin-bottom:12px">
<p class="field-hint" style="margin-bottom:10px"><strong>Local ID:</strong> <code>${esc(localId)}</code></p>
<div class="form-row">
<div class="form-group" style="flex:1;min-width:180px">
<label>Question key <span class="required-mark">*</span></label>
<input type="text" id="eq_key_${q.questionID}" value="${esc(qKey)}" required pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off">
</div>
<div class="form-group" style="flex:2">
<label>German text <span class="required-mark">*</span></label>
<input type="text" id="eq_text_${q.questionID}" value="${esc(q.defaultText)}" required>
</div>
<div class="form-group" style="flex:1;min-width:200px">
<label>Layout type</label>
${layoutSelectHTML(layout, `eq_type_${q.questionID}`)}
</div>
</div>
<label class="checkbox-label" style="margin-bottom:12px;display:inline-flex">
<input type="checkbox" id="eq_req_${q.questionID}" ${q.isRequired ? 'checked' : ''}> Required
</label>
${notesSectionHTML(config, `eq_cfg_${q.questionID}`, qKey)}
<div id="eq_config_${q.questionID}">
${configFormHTML(layout, config, `eq_cfg_${q.questionID}`, {
glassSymptoms: glassSymptomsRowsFromQuestion(q),
scoreRules: q.scoreRules || [],
})}
</div>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-primary btn-sm save-q-btn">Save Question</button>
<button class="btn btn-sm btn-danger del-q-btn">Delete Question</button>
</div>
</div>
</div>
` : `
<div class="inline-form-card" style="margin-bottom:12px">
<p><strong>Key:</strong> <code>${esc(qKey || '—')}</code> · <strong>ID:</strong> <code>${esc(localId)}</code></p>
<p><strong>German:</strong> ${esc(q.defaultText)}</p>
<p><strong>Layout:</strong> ${esc(layoutLabel(layout))}</p>
${config.nextQuestionId || config.otherNextQuestionId
? `<p><strong>Branches:</strong>
${config.nextQuestionId ? `→ <code>${esc(config.nextQuestionId)}</code>` : ''}
${config.otherNextQuestionId ? `${config.nextQuestionId ? ' · ' : ''}Other (<code>${esc(config.otherOptionKey || 'other_option')}</code>) → <code>${esc(config.otherNextQuestionId)}</code>` : ''}
</p>`
: ''}
${Object.keys(config).length ? `<p><strong>Config:</strong> <code>${esc(JSON.stringify(config))}</code></p>` : ''}
${layout === 'glass_scale_question' && glassSymptomsRowsFromQuestion(q).length ? `
<p><strong>Symptoms:</strong></p>
<ul class="option-list">
${glassSymptomsRowsFromQuestion(q).map(r =>
`<li><code>${esc(r.key)}</code> · ${esc(r.labelDe || '—')}</li>`
).join('')}
</ul>` : ''}
</div>
`}
${OPTION_TYPES.has(layout) ? `
<h4 style="font-size:.85rem;color:var(--text-secondary);margin-bottom:6px">Answer Options (${options.length})</h4>
<ul class="option-list" id="opts-${q.questionID}">
${options.map(o => optionItemHTML(q, o, editable)).join('')
|| '<li style="color:var(--text-secondary);padding:8px;font-size:.85rem">No answer options</li>'}
</ul>
${editable ? `
<div id="addOptForm-${q.questionID}" style="display:none"></div>
<button class="btn btn-sm" id="addOpt-${q.questionID}" style="margin-top:6px">+ Add Option</button>
` : ''}
` : ''}
`;
if (!editable) return;
const cfgPrefix = `eq_cfg_${q.questionID}`;
body.querySelector('.save-q-btn').addEventListener('click', () => {
const questionKey = document.getElementById(`eq_key_${q.questionID}`).value.trim();
const text = document.getElementById(`eq_text_${q.questionID}`).value.trim();
const type = document.getElementById(`eq_type_${q.questionID}`).value;
const isReq = document.getElementById(`eq_req_${q.questionID}`).checked ? 1 : 0;
const notes = readNotesFromForm(cfgPrefix);
const cj = readConfigFromForm(type, cfgPrefix);
const payload = {
questionKey,
defaultText: text,
type,
isRequired: isReq,
configJson: cj,
noteBefore: notes.noteBefore,
noteAfter: notes.noteAfter,
};
if (type === 'glass_scale_question') {
const glassSymptoms = readGlassSymptomsFromForm(cfgPrefix);
if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return;
payload.glassSymptoms = glassSymptoms;
}
if (type === 'slider_question' || type === 'value_spinner') {
payload.scoreRules = readValueScoreRulesFromForm(cfgPrefix);
}
if (!validateStableKey(questionKey, 'Question key')) return;
if (!text) { showToast('German text is required', 'error'); return; }
if (type === 'string_spinner' && !validateStringSpinnerConfig(cfgPrefix)) return;
updateQuestion(q.questionID, payload);
});
document.getElementById(`eq_key_${q.questionID}`).addEventListener('input', () => {
const nb = document.getElementById(`${cfgPrefix}_noteBefore`)?.value ?? '';
const na = document.getElementById(`${cfgPrefix}_noteAfter`)?.value ?? '';
const key = document.getElementById(`eq_key_${q.questionID}`).value.trim();
const notesBlock = body.querySelector('.notes-section');
if (notesBlock) {
notesBlock.outerHTML = notesSectionHTML({ noteBefore: nb, noteAfter: na }, cfgPrefix, key);
}
});
document.getElementById(`eq_type_${q.questionID}`).addEventListener('change', () => {
const newLayout = document.getElementById(`eq_type_${q.questionID}`).value;
const opts = newLayout === 'glass_scale_question'
? { glassSymptoms: glassSymptomsRowsFromQuestion(q) }
: (newLayout === 'slider_question' || newLayout === 'value_spinner')
? { scoreRules: q.scoreRules || [] }
: {};
document.getElementById(`eq_config_${q.questionID}`).innerHTML =
configFormHTML(newLayout, {}, cfgPrefix, opts);
wireStringSpinnerOtherToggle(cfgPrefix);
if (newLayout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
if (newLayout === 'slider_question' || newLayout === 'value_spinner') wireValueScoreRules(cfgPrefix);
});
wireStringSpinnerOtherToggle(cfgPrefix);
if (layout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
if (layout === 'slider_question' || layout === 'value_spinner') wireValueScoreRules(cfgPrefix);
body.querySelector('.del-q-btn').addEventListener('click', () => deleteQuestion(q));
body.querySelectorAll('.edit-opt-btn').forEach(btn => {
btn.addEventListener('click', () => {
const opt = options.find(o => o.answerOptionID === btn.dataset.id);
if (opt) showEditOptionForm(q, opt);
});
});
body.querySelectorAll('.del-opt-btn').forEach(btn => {
btn.addEventListener('click', () => deleteOption(q, btn.dataset.id));
});
const addOptBtn = document.getElementById(`addOpt-${q.questionID}`);
if (addOptBtn) addOptBtn.addEventListener('click', () => showAddOptionForm(q));
if (OPTION_TYPES.has(layout)) initOptionDrag(q);
}
function optionItemHTML(q, o, editable) {
const ok = optionKeyOf(o);
const label = optionGermanLabel(o) || o.defaultText;
return `
<li class="option-item" data-id="${o.answerOptionID}" draggable="${editable}">
${editable ? '<span class="drag-handle" style="font-size:.9rem">&#x2630;</span>' : ''}
<code class="opt-key">${esc(ok || '?')}</code>
<span class="opt-text">${esc(label)}</span>
<span class="opt-points">${o.points} pts</span>
${o.nextQuestionId ? `<span class="opt-branch">&rarr; ${esc(o.nextQuestionId)}</span>` : ''}
${editable ? `
<button class="btn-icon edit-opt-btn" data-id="${o.answerOptionID}" title="Edit">&#x270E;</button>
<button class="btn-icon btn-icon-danger del-opt-btn" data-id="${o.answerOptionID}" title="Delete">&#x2716;</button>
` : ''}
</li>
`;
}
// ── Add option form ──────────────────────────────────────────────────────
function showAddOptionForm(q) {
const wrapper = document.getElementById(`addOptForm-${q.questionID}`);
const toggleBtn = document.getElementById(`addOpt-${q.questionID}`);
if (!wrapper) return;
toggleBtn.style.display = 'none';
wrapper.style.display = '';
const qIds = questionLocalIds();
wrapper.innerHTML = `
<div class="inline-form-card" style="margin-top:8px">
<div class="form-row">
<div class="form-group" style="flex:1;min-width:140px">
<label>Option key <span class="required-mark">*</span></label>
<input type="text" id="ao_key_${q.questionID}" placeholder="e.g. consent_signed" pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off" required>
</div>
<div class="form-group" style="flex:2">
<label>German label <span class="required-mark">*</span></label>
<input type="text" id="ao_text_${q.questionID}" placeholder="e.g. Ja, ich stimme zu" required>
</div>
<div class="form-group" style="flex:1;min-width:100px">
<label>Points</label>
<input type="number" id="ao_pts_${q.questionID}" value="0" min="0">
</div>
<div class="form-group" style="flex:1;min-width:160px">
<label>Next question</label>
<select id="ao_next_${q.questionID}">
<option value="">(next in order)</option>
${branchTargetOptionsHTML()}
</select>
</div>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-primary btn-sm" id="ao_submit_${q.questionID}">Add Option</button>
<button class="btn btn-sm" id="ao_cancel_${q.questionID}">Cancel</button>
</div>
</div>
`;
document.getElementById(`ao_text_${q.questionID}`).focus();
document.getElementById(`ao_submit_${q.questionID}`).addEventListener('click', async () => {
const optionKey = document.getElementById(`ao_key_${q.questionID}`).value.trim();
const text = document.getElementById(`ao_text_${q.questionID}`).value.trim();
const points = parseInt(document.getElementById(`ao_pts_${q.questionID}`).value || '0', 10);
const nextQ = document.getElementById(`ao_next_${q.questionID}`).value;
if (!validateStableKey(optionKey, 'Option key')) return;
if (!text) { showToast('German label is required', 'error'); return; }
const btn = document.getElementById(`ao_submit_${q.questionID}`);
btn.disabled = true;
btn.textContent = 'Adding...';
try {
const data = await apiPost('answer_options.php', {
questionID: q.questionID,
optionKey,
defaultText: text,
points,
nextQuestionId: nextQ,
});
if (data.success) {
if (!q.answerOptions) q.answerOptions = [];
q.answerOptions.push(data.answerOption);
renderQuestionBody(q);
showToast('Option added', 'success');
}
} catch (e) {
showToast(e.message, 'error');
btn.disabled = false;
btn.textContent = 'Add Option';
}
});
document.getElementById(`ao_text_${q.questionID}`).addEventListener('keydown', (e) => {
if (e.key === 'Enter') document.getElementById(`ao_submit_${q.questionID}`).click();
if (e.key === 'Escape') hideAddOptionForm(q);
});
document.getElementById(`ao_cancel_${q.questionID}`).addEventListener('click', () => hideAddOptionForm(q));
}
function hideAddOptionForm(q) {
const wrapper = document.getElementById(`addOptForm-${q.questionID}`);
const toggleBtn = document.getElementById(`addOpt-${q.questionID}`);
if (wrapper) { wrapper.style.display = 'none'; wrapper.innerHTML = ''; }
if (toggleBtn) toggleBtn.style.display = '';
}
// ── Edit option form ─────────────────────────────────────────────────────
function showEditOptionForm(q, opt) {
const li = document.querySelector(`#opts-${q.questionID} .option-item[data-id="${opt.answerOptionID}"]`);
if (!li) return;
const qIds = questionLocalIds();
li.innerHTML = `
<div class="inline-form-card" style="width:100%;padding:8px">
<div class="form-row">
<div class="form-group" style="flex:1;min-width:120px;margin-bottom:8px">
<label style="font-size:.8rem">Option key <span class="required-mark">*</span></label>
<input type="text" id="eo_key_${opt.answerOptionID}" value="${esc(optionKeyOf(opt))}" pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off" required>
</div>
<div class="form-group" style="flex:2;margin-bottom:8px">
<label style="font-size:.8rem">German label <span class="required-mark">*</span></label>
<input type="text" id="eo_text_${opt.answerOptionID}" value="${esc(optionGermanLabel(opt))}" required>
</div>
<div class="form-group" style="flex:1;min-width:80px;margin-bottom:8px">
<input type="number" id="eo_pts_${opt.answerOptionID}" value="${opt.points}" min="0">
</div>
<div class="form-group" style="flex:1;min-width:160px;margin-bottom:8px">
<label style="font-size:.8rem">Next question</label>
<select id="eo_next_${opt.answerOptionID}">
<option value="">(next in order)</option>
${qIds.map(qi => `<option value="${esc(qi.localId)}" ${opt.nextQuestionId === qi.localId ? 'selected' : ''}>${esc(branchTargetLabel(qi))}</option>`).join('')}
</select>
</div>
</div>
<div style="display:flex;gap:6px">
<button class="btn btn-primary btn-sm" id="eo_save_${opt.answerOptionID}">Save</button>
<button class="btn btn-sm" id="eo_cancel_${opt.answerOptionID}">Cancel</button>
</div>
</div>
`;
li.draggable = false;
document.getElementById(`eo_text_${opt.answerOptionID}`).focus();
document.getElementById(`eo_save_${opt.answerOptionID}`).addEventListener('click', async () => {
const optionKey = document.getElementById(`eo_key_${opt.answerOptionID}`).value.trim();
const text = document.getElementById(`eo_text_${opt.answerOptionID}`).value.trim();
const points = parseInt(document.getElementById(`eo_pts_${opt.answerOptionID}`).value || '0', 10);
const nextQ = document.getElementById(`eo_next_${opt.answerOptionID}`).value;
if (!validateStableKey(optionKey, 'Option key')) return;
if (!text) { showToast('German label is required', 'error'); return; }
await updateOption(q, opt.answerOptionID, { optionKey, defaultText: text, points, nextQuestionId: nextQ });
});
document.getElementById(`eo_text_${opt.answerOptionID}`).addEventListener('keydown', (e) => {
if (e.key === 'Enter') document.getElementById(`eo_save_${opt.answerOptionID}`).click();
if (e.key === 'Escape') renderQuestionBody(q);
});
document.getElementById(`eo_cancel_${opt.answerOptionID}`).addEventListener('click', () => renderQuestionBody(q));
}
// ── Question CRUD ────────────────────────────────────────────────────────
async function updateQuestion(questionID, fields) {
try {
const data = await apiPut('questions.php', { questionID, ...fields });
if (data.success) {
const idx = questions.findIndex(q => q.questionID === questionID);
if (idx >= 0) {
questions[idx] = { ...questions[idx], ...data.question,
answerOptions: questions[idx].answerOptions };
}
renderQuestions();
showToast('Question updated', 'success');
}
} catch (e) {
showToast(e.message, 'error');
}
}
async function deleteQuestion(q) {
if (q.retired) {
showToast('This question is already retired', 'error');
return;
}
const msg = 'Remove this question? If client data exists it will be retired (not deleted) so upload history stays intact.';
if (!(await confirmAction({
title: 'Remove question',
message: msg,
confirmLabel: 'Remove',
variant: 'danger',
}))) return;
try {
const data = await apiDelete('questions.php', { questionID: q.questionID });
questions = questions.filter(x => x.questionID !== q.questionID);
if (data.retired) {
retiredQuestions.push({ ...q, retired: true, retiredAt: Date.now() / 1000 | 0 });
showToast('Question retired', 'success');
} else {
showToast('Question deleted', 'success');
}
notifyStructureBump(data);
renderQuestions();
} catch (e) {
showToast(e.message, 'error');
}
}
// ── Answer option CRUD ───────────────────────────────────────────────────
async function updateOption(q, answerOptionID, fields) {
try {
const data = await apiPut('answer_options.php', { answerOptionID, ...fields });
if (data.success) {
const idx = q.answerOptions.findIndex(o => o.answerOptionID === answerOptionID);
if (idx >= 0) q.answerOptions[idx] = { ...q.answerOptions[idx], ...data.answerOption };
renderQuestionBody(q);
showToast('Option updated', 'success');
}
} catch (e) {
showToast(e.message, 'error');
}
}
async function deleteOption(q, answerOptionID) {
if (!(await confirmAction({
title: 'Remove answer option',
message: 'Remove this answer option?',
confirmLabel: 'Remove',
variant: 'danger',
}))) return;
try {
const data = await apiDelete('answer_options.php', { answerOptionID });
if (!data.retired) {
q.answerOptions = (q.answerOptions || []).filter(o => o.answerOptionID !== answerOptionID);
}
renderQuestionBody(q);
notifyStructureBump(data);
showToast(data.retired ? 'Option retired' : 'Option deleted', 'success');
} catch (e) {
showToast(e.message, 'error');
}
}
// ── Translations tab ─────────────────────────────────────────────────────
let transData = null;
let editorTransLang = '';
let editorTransSaveTimers = {};
async function loadTranslationsTab() {
const container = document.getElementById('translationsContent');
if (!container) return;
container.innerHTML = '<div class="spinner"></div>';
try {
try {
await apiPut('translations.php', { type: 'language', languageCode: SOURCE_LANG, name: 'German' });
} catch (_) { /* already exists */ }
const data = await apiGet(`translations.php?questionnaireID=${encodeURIComponent(questionnaire.questionnaireID)}`);
transData = data;
transData.entries = (transData.entries || []).map(normalizeEntry);
renderTranslationsTab();
} catch (e) {
container.innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
}
}
function renderTranslationsTab() {
const container = document.getElementById('translationsContent');
if (!container || !transData) return;
const editable = canEdit();
const languages = transData.languages || [];
const qnEntries = transData.entries || [];
const targets = targetLanguages(languages);
if (!qnEntries.length) {
container.innerHTML = `
${editable ? languageManagerHTML(languages) : ''}
<p style="color:var(--text-secondary);margin-top:12px">No translatable content yet. Add questions with German text first.</p>
`;
if (editable) bindLanguageManager(languages);
return;
}
if (!editorTransLang || !targets.some(l => l.languageCode === editorTransLang)) {
editorTransLang = targets[0]?.languageCode || '';
}
const langSelectHtml = targets.length
? `<label class="trans-control" style="max-width:280px;margin-bottom:14px">
<span>Translate into</span>
<select id="editorTransLangSelect" class="trans-select">
${targets.map(l => {
const label = l.name ? `${l.name} (${l.languageCode})` : l.languageCode;
return `<option value="${escHtml(l.languageCode)}" ${l.languageCode === editorTransLang ? 'selected' : ''}>${escHtml(label)}</option>`;
}).join('')}
</select>
</label>`
: `<p class="text-muted" style="margin-bottom:14px">Add a language below (e.g. English) to translate into.</p>`;
container.innerHTML = `
${editable ? languageManagerHTML(languages) : ''}
${langSelectHtml}
${targets.length && editorTransLang ? renderEditorTransList(qnEntries, editable) : ''}
`;
if (editable) bindLanguageManager(languages);
document.getElementById('editorTransLangSelect')?.addEventListener('change', (e) => {
editorTransLang = e.target.value;
renderTranslationsTab();
});
if (targets.length && editorTransLang) {
bindEditorTransEditing(qnEntries, editable);
bindEditorTransFiltering(qnEntries);
}
}
function renderEditorTransList(qnEntries, editable) {
const targets = targetLanguages(transData.languages || []);
const langLabel = targets.find(l => l.languageCode === editorTransLang)?.name || editorTransLang.toUpperCase();
const sourceLabel = sourceLanguageLabel(transData.languages || []);
const missingCount = qnEntries.filter(e => !translationFor(e, editorTransLang).trim()).length;
const pct = qnEntries.length ? Math.round(((qnEntries.length - missingCount) / qnEntries.length) * 100) : 0;
const qnGroups = buildTranslationGroups(qnEntries, editorTransLang, 0);
const bodyHtml = renderCollapsibleGroupedTranslationsHTML(qnGroups, editorTransLang, sourceLabel, editable, {
openGroupsWithMissing: true,
});
return `
<div class="trans-toolbar">
<input type="search" id="transFilter" class="trans-search-input" placeholder="Search…">
<select id="transTypeFilter" class="trans-select trans-type-select">
<option value="">All types</option>
<option value="string">UI strings</option>
<option value="question">Questions</option>
<option value="answer_option">Answer options</option>
</select>
</div>
<div class="trans-sheet">
${translationListHeaderHTML(langLabel, sourceLabel)}
<div id="transGroupedRoot" class="trans-sheet-body">
<div class="trans-scope-block">${bodyHtml}</div>
</div>
</div>
<div class="trans-stats">
<span id="transStats">${missingCount ? `${missingCount} missing · ` : ''}${qnEntries.length} keys</span>
<span class="trans-stats-progress">
<span class="trans-progress-bar"><span class="trans-progress-fill" style="width:${pct}%"></span></span>
<span class="trans-progress-label">${pct}% in ${escHtml(editorTransLang.toUpperCase())}</span>
</span>
</div>
`;
}
function bindEditorTransEditing(entries, editable) {
if (!editable) return;
editorTransSaveTimers = {};
document.querySelectorAll('#transGroupedRoot .trans-cell-input').forEach(inp => {
inp.addEventListener('input', () => {
const idx = parseInt(inp.dataset.idx, 10);
const entry = entries[idx];
if (!entry) return;
const isGerman = inp.dataset.field === 'german';
if (!entry.translations || typeof entry.translations !== 'object') entry.translations = {};
if (isGerman) {
entry.germanText = inp.value;
entry.translations[SOURCE_LANG] = inp.value;
} else {
entry.translations[editorTransLang] = inp.value;
const missing = !inp.value.trim();
inp.classList.toggle('trans-input-missing', missing);
const row = inp.closest('.trans-list-item');
if (row) {
row.classList.toggle('trans-list-item-missing', missing);
row.dataset.missing = missing ? '1' : '0';
}
}
const timerKey = isGerman ? `${idx}_de` : `${idx}_${editorTransLang}`;
clearTimeout(editorTransSaveTimers[timerKey]);
editorTransSaveTimers[timerKey] = setTimeout(() => saveEditorTranslation(entry, inp, isGerman), 500);
});
});
}
async function saveEditorTranslation(entry, inp, isGerman) {
try {
await apiPut('translations.php', {
type: entry.type,
id: entry.entityId,
languageCode: isGerman ? SOURCE_LANG : editorTransLang,
text: inp.value,
});
inp.classList.add('save-flash');
setTimeout(() => inp.classList.remove('save-flash'), 400);
} catch (e) {
showToast(e.message, 'error');
}
}
function bindEditorTransFiltering(entries) {
const filterInput = document.getElementById('transFilter');
const typeFilter = document.getElementById('transTypeFilter');
const root = document.getElementById('transGroupedRoot');
if (!filterInput || !root) return;
function applyFilter() {
const text = filterInput.value.toLowerCase();
const type = typeFilter?.value || '';
let visible = 0;
let visibleMissing = 0;
root.querySelectorAll('.trans-list-item').forEach(row => {
const key = row.dataset.key?.toLowerCase() || '';
const label = row.dataset.label?.toLowerCase() || '';
const rowType = row.dataset.type || '';
const german = row.querySelector('.trans-source-text')?.textContent?.toLowerCase()
|| row.querySelector('[data-field="german"]')?.value?.toLowerCase() || '';
const val = row.querySelector('[data-field="translation"]')?.value?.toLowerCase() || '';
const show = (!type || rowType === type) &&
(!text || key.includes(text) || label.includes(text) || german.includes(text) || val.includes(text));
row.classList.toggle('trans-row-hidden', !show);
if (show) {
visible++;
if (row.dataset.missing === '1') visibleMissing++;
}
});
root.querySelectorAll('.trans-group-details').forEach(group => {
const anyVisible = [...group.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
group.classList.toggle('trans-group-hidden', !anyVisible);
});
root.querySelectorAll('.trans-scope-block').forEach(block => {
const anyVisible = [...block.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
block.classList.toggle('trans-qn-hidden', !anyVisible);
});
const stats = document.getElementById('transStats');
if (stats) {
stats.textContent = text || type
? `Showing ${visible} (${visibleMissing} missing)`
: `${[...root.querySelectorAll('.trans-list-item')].filter(r => r.dataset.missing === '1').length} missing · ${entries.length} keys`;
}
}
filterInput.addEventListener('input', applyFilter);
typeFilter?.addEventListener('change', applyFilter);
}
function languageManagerHTML(languages) {
const others = targetLanguages(languages);
return `
<div class="lang-manager">
<p style="font-size:.85rem;color:var(--text-secondary);margin:0 0 10px">
German source text is edited under Questions. Add other languages here.
</p>
<div class="lang-chips">
<span class="lang-chip lang-chip-fixed">
<strong>DE</strong>
<span class="lang-chip-name">German (source)</span>
</span>
${others.map(l => `
<span class="lang-chip">
<strong>${esc(l.languageCode.toUpperCase())}</strong>
${l.name ? `<span class="lang-chip-name">${esc(l.name)}</span>` : ''}
<button class="lang-chip-remove" data-lc="${esc(l.languageCode)}" title="Remove language">&times;</button>
</span>
`).join('')}
</div>
<div class="lang-add-row">
<input type="text" id="newLangCode" placeholder="Code (en)" class="lang-add-code">
<input type="text" id="newLangName" placeholder="Name (English)" class="lang-add-name">
<button class="btn btn-sm btn-primary" id="addLangBtn">+ Add Language</button>
</div>
</div>
`;
}
function bindLanguageManager(languages) {
document.getElementById('addLangBtn')?.addEventListener('click', async () => {
const code = document.getElementById('newLangCode').value.trim().toLowerCase();
const name = document.getElementById('newLangName').value.trim();
if (!code) { showToast('Language code is required', 'error'); return; }
if (code === SOURCE_LANG) {
showToast('German (de) is always available as the source language', 'error');
return;
}
if (languages.some(l => l.languageCode === code)) {
showToast('Language already exists', 'error');
return;
}
try {
await apiPut('translations.php', { type: 'language', languageCode: code, name });
showToast(`Language "${code}" added`, 'success');
editorTransLang = code;
loadTranslationsTab();
} catch (e) {
showToast(e.message, 'error');
}
});
document.querySelectorAll('.lang-chip-remove').forEach(btn => {
btn.addEventListener('click', async () => {
const lc = btn.dataset.lc;
if (!(await confirmAction({
title: 'Remove language',
message: `Remove language "${lc}" and ALL its translations?`,
confirmLabel: 'Remove',
variant: 'danger',
}))) return;
try {
await apiDelete('translations.php', { type: 'language', languageCode: lc });
showToast(`Language "${lc}" removed`, 'success');
loadTranslationsTab();
} catch (e) {
showToast(e.message, 'error');
}
});
});
}
// ── Drag & drop (questions) ──────────────────────────────────────────────
function initDragReorder() {
const list = document.getElementById('questionList');
if (!list) return;
let dragItem = null;
list.addEventListener('dragstart', (e) => {
const item = e.target.closest('.question-item');
if (!item || !e.target.closest('.drag-handle')) { e.preventDefault(); return; }
dragItem = item;
item.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
list.addEventListener('dragover', (e) => {
e.preventDefault();
const afterEl = getDragAfterElement(list, e.clientY, '.question-item');
if (afterEl) list.insertBefore(dragItem, afterEl);
else list.appendChild(dragItem);
});
list.addEventListener('dragend', async () => {
if (!dragItem) return;
dragItem.classList.remove('dragging');
const newOrder = [...list.querySelectorAll('.question-item')].map(el => el.dataset.id);
dragItem = null;
const map = {};
questions.forEach(q => map[q.questionID] = q);
questions = newOrder.map(id => map[id]).filter(Boolean);
try {
await apiPatch('questions.php', {
questionnaireID: questionnaire.questionnaireID,
order: newOrder
});
showToast('Order saved', 'success');
} catch (e) {
showToast(e.message, 'error');
}
});
}
function initOptionDrag(q) {
const list = document.getElementById(`opts-${q.questionID}`);
if (!list) return;
let dragItem = null;
list.addEventListener('dragstart', (e) => {
const item = e.target.closest('.option-item');
if (!item || !e.target.closest('.drag-handle')) { e.preventDefault(); return; }
dragItem = item;
item.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
list.addEventListener('dragover', (e) => {
e.preventDefault();
const afterEl = getDragAfterElement(list, e.clientY, '.option-item');
if (afterEl) list.insertBefore(dragItem, afterEl);
else list.appendChild(dragItem);
});
list.addEventListener('dragend', async () => {
if (!dragItem) return;
dragItem.classList.remove('dragging');
const newOrder = [...list.querySelectorAll('.option-item')].map(el => el.dataset.id);
dragItem = null;
const map = {};
(q.answerOptions || []).forEach(o => map[o.answerOptionID] = o);
q.answerOptions = newOrder.map(id => map[id]).filter(Boolean);
try {
await apiPatch('answer_options.php', {
questionID: q.questionID,
order: newOrder
});
showToast('Option order saved', 'success');
} catch (e) {
showToast(e.message, 'error');
}
});
}
function getDragAfterElement(container, y, selector) {
const elements = [...container.querySelectorAll(`${selector}:not(.dragging)`)];
return elements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) return { offset, element: child };
return closest;
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}