import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js'; import { canEdit, homeNavButton, showToast } from '../app.js'; import { navigate } from '../router.js'; import { SOURCE_LANG, normalizeEntry, translationFor, targetLanguages, buildTranslationGroups, sourceLanguageLabel, translationListHeaderHTML, renderGroupedTranslationsHTML, 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 (min–max)' }, { 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 / Coach Code' }, { value: 'last_page', label: 'Last Page' }, ]; const OPTION_TYPES = new Set(['radio_question', 'multi_check_box_question']); let questionnaire = null; let questions = []; 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 = `
`; 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)}`); questionnaire = allQuestionnaires.find(q => q.questionnaireID === params.id); if (!questionnaire) { showToast('Questionnaire not found', 'error'); navigate('#/questionnaires'); return; } questions = quData.questions || []; document.getElementById('editorTitle').textContent = questionnaire.name; renderEditor(); } catch (e) { showToast(e.message, 'error'); document.getElementById('editorContent').innerHTML = `

${esc(e.message)}

`; } } } // ── Layout helpers ─────────────────────────────────────────────────────── function layoutSelectHTML(selected = 'radio_question', id = '') { return ``; } 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 => `` ).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 `

Notes (German)

Key: ${esc(nbKey)}
Key: ${esc(naKey)}
`; } 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(), })); } const config = parseConfig(q); return (config.symptoms || []).map(key => ({ key: String(key).trim(), labelDe: '' })); } 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 `

Each row is one table line in the app. German text is saved with the question; translate other languages on the Translations tab.

`; } function glassSymptomRowHTML(prefix, row, idx) { return `
  • `; } 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() || ''; if (key || labelDe) rows.push({ key, labelDe }); }); 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 = '
  • No symptoms yet.
  • '; 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 = '
  • No symptoms yet.
  • '; } }); }); 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: '' }); 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 `
    ${hint || 'Where to go after this question. Leave empty to continue in list order.'}
    `; } function stringSpinnerNextSectionHTML(config, prefix) { return questionNextSectionHTML(config, prefix, { label: 'Next question (list selection)', hint: 'Where to go after a normal spinner choice. Required when “Other” 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 `
    Appends this label to the spinner. Translate under Questionnaire UI strings (or App strings if listed in catalog).
    Use layout Free text for the follow-up (e.g. country name).
    `; } 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 = `

    Branching: set Next question on each answer option below.

    `; break; case 'multi_check_box_question': html = `
    ${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 = `
    ${glassSymptomsSectionHTML(rows, prefix)}`; break; } case 'value_spinner': case 'slider_question': html = `
    ${layout === 'slider_question' ? `
    ` : ''}
    `; break; case 'date_spinner': { const notBefore = config.constraints?.notBefore || ''; const notAfter = config.constraints?.notAfter || ''; const precision = config.precision || 'full'; html = `
    `; break; } case 'string_spinner': html = `
    Do not include the “Other” row here; enable it below.
    ${stringSpinnerNextSectionHTML(config, prefix)} ${stringSpinnerOtherSectionHTML(config, prefix)}`; break; case 'free_text': html = `
    `; break; case 'client_coach_code_question': html = `
    `; break; case 'last_page': html = `
    `; break; } if (QUESTION_LEVEL_NEXT_LAYOUTS.has(layout)) { html += questionNextSectionHTML(config, prefix); } return html ? `
    ${html}
    ` : ''; } 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 = `

    Questionnaire Details

    ${editable ? `` : ''}
    ${!isNew ? `

    Questions

    ${editable ? `` : ''}
    ` : '

    Save the questionnaire first, then add questions.

    '} `; if (editable) { document.getElementById('saveMetaBtn').addEventListener('click', saveMeta); } initConditionEditor(condJson); if (!isNew) { initTabs(); renderQuestions(); if (editable) { document.getElementById('addQuestionBtn').addEventListener('click', showAddQuestionForm); initDragReorder(); } } } 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 = '
    '; await loadQuestionsByQuestionnaire(); 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, 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 = document.getElementById('metaShowPoints').checked ? 1 : 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 = `

    New Question

    ${layoutSelectHTML('radio_question', 'aq_type')}
    ${notesSectionHTML({}, 'aq_cfg', '')}
      `; 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'); 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 = `
    • No options yet.
    • `; return; } list.innerHTML = pendingOptions.map((opt, i) => `
    • ${esc(opt.optionKey)} ${esc(opt.text)} ${opt.points} pts ${opt.nextQuestionId ? `→ ${esc(opt.nextQuestionId)}` : ''}
    • `).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 (!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(); 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 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})`; if (!questions.length) { list.innerHTML = `
    • No questions yet

      Add your first question above.

    • `; return; } list.innerHTML = questions.map((q, idx) => `
    • ${editable ? '' : ''} ${idx + 1}. ${esc(questionKeyOf(q) || '?')} · ${esc((q.defaultText || '').slice(0, 48))}${(q.defaultText || '').length > 48 ? '…' : ''} ${esc(layoutLabel(q.type))} ${!questionKeyOf(q) ? 'No key' : ''} ${q.isRequired ? 'Required' : ''} ${(() => { 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 ? `${esc(parts.join(' · '))}` : ''; })()}
    • `).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 ? `

      Local ID: ${esc(localId)}

      ${layoutSelectHTML(layout, `eq_type_${q.questionID}`)}
      ${notesSectionHTML(config, `eq_cfg_${q.questionID}`, qKey)}
      ${configFormHTML(layout, config, `eq_cfg_${q.questionID}`, { glassSymptoms: glassSymptomsRowsFromQuestion(q) })}
      ` : `

      Key: ${esc(qKey || '—')} · ID: ${esc(localId)}

      German: ${esc(q.defaultText)}

      Layout: ${esc(layoutLabel(layout))}

      ${config.nextQuestionId || config.otherNextQuestionId ? `

      Branches: ${config.nextQuestionId ? `→ ${esc(config.nextQuestionId)}` : ''} ${config.otherNextQuestionId ? `${config.nextQuestionId ? ' · ' : ''}Other (${esc(config.otherOptionKey || 'other_option')}) → ${esc(config.otherNextQuestionId)}` : ''}

      ` : ''} ${Object.keys(config).length ? `

      Config: ${esc(JSON.stringify(config))}

      ` : ''} ${layout === 'glass_scale_question' && glassSymptomsRowsFromQuestion(q).length ? `

      Symptoms:

      ` : ''}
      `} ${OPTION_TYPES.has(layout) ? `

      Answer Options (${options.length})

      ${editable ? ` ` : ''} ` : ''} `; 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 (!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) } : {}; document.getElementById(`eq_config_${q.questionID}`).innerHTML = configFormHTML(newLayout, {}, cfgPrefix, opts); wireStringSpinnerOtherToggle(cfgPrefix); if (newLayout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix); }); wireStringSpinnerOtherToggle(cfgPrefix); if (layout === 'glass_scale_question') wireGlassSymptomsSection(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 `
    • ${editable ? '' : ''} ${esc(ok || '?')} ${esc(label)} ${o.points} pts ${o.nextQuestionId ? `→ ${esc(o.nextQuestionId)}` : ''} ${editable ? ` ` : ''}
    • `; } // ── 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 = `
      `; 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 = `
      `; 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 (!confirm(`Delete "${q.defaultText}" and all its answers?`)) return; try { await apiDelete('questions.php', { questionID: q.questionID }); questions = questions.filter(x => x.questionID !== q.questionID); renderQuestions(); showToast('Question deleted', 'success'); } 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 (!confirm('Delete this answer option?')) return; try { await apiDelete('answer_options.php', { answerOptionID }); q.answerOptions = (q.answerOptions || []).filter(o => o.answerOptionID !== answerOptionID); renderQuestionBody(q); showToast('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 = '
      '; 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 = `

      ${esc(e.message)}

      `; } } 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) : ''}

      No translatable content yet. Add questions with German text first.

      `; if (editable) bindLanguageManager(languages); return; } if (!editorTransLang || !targets.some(l => l.languageCode === editorTransLang)) { editorTransLang = targets[0]?.languageCode || ''; } const langSelectHtml = targets.length ? `` : `

      Add a language below (e.g. English) to translate into.

      `; 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 qnName = transData.questionnaire?.name || questionnaire?.name || 'Questionnaire'; const bodyHtml = `

      ${escHtml(qnName)}

      ${renderGroupedTranslationsHTML(qnGroups, editorTransLang, sourceLabel, editable, { showColumnHeader: false })}
      `; return `
      ${translationListHeaderHTML(langLabel, sourceLabel)}
      ${bodyHtml}
      ${missingCount ? `${missingCount} missing · ` : ''}${qnEntries.length} keys ${pct}% in ${escHtml(editorTransLang.toUpperCase())}
      `; } 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').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-qn-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 `

      German source text is edited under Questions. Add other languages here.

      DE German (source) ${others.map(l => ` ${esc(l.languageCode.toUpperCase())} ${l.name ? `${esc(l.name)}` : ''} `).join('')}
      `; } 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 (!confirm(`Remove language "${lc}" and ALL its translations?`)) 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; }