import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js';
import { canEdit, showToast } from '../app.js';
import { navigate } from '../router.js';
import {
SOURCE_LANG,
normalizeEntry,
translationFor,
targetLanguages,
buildTranslationGroups,
sourceLanguageLabel,
translationListHeaderHTML,
renderGroupedTranslationsHTML,
escHtml,
} from '../translations-helpers.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: 'client_not_signed', label: 'Client Not Signed' },
{ 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';
// ── Entry point ──────────────────────────────────────────────────────────
export async function editorPage(params) {
const app = document.getElementById('app');
isNew = !params.id || params.id === 'new';
activeTab = 'questions';
app.innerHTML = `
`;
if (isNew) {
questionnaire = { questionnaireID: null, name: '', version: '1.0', state: 'draft',
orderIndex: 0, showPoints: 0, conditionJson: '{}' };
questions = [];
renderEditor();
} else {
try {
const [qnData, quData] = await Promise.all([
apiGet('questionnaires.php'),
apiGet(`questions.php?questionnaireID=${encodeURIComponent(params.id)}`)
]);
questionnaire = (qnData.questionnaires || []).find(q => q.questionnaireID === params.id);
if (!questionnaire) {
showToast('Questionnaire not found', 'error');
navigate('#/');
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 `
`;
}
function readNotesFromForm(prefix) {
const val = id => document.getElementById(`${prefix}_${id}`)?.value?.trim() || '';
return { noteBefore: val('noteBefore'), noteAfter: val('noteAfter') };
}
function stringSpinnerOtherEnabled(config) {
return Boolean(config.otherNextQuestionId || config.otherOptionKey);
}
function stringSpinnerNextSectionHTML(config, prefix) {
const next = config.nextQuestionId || '';
return `
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 stringSpinnerOtherSectionHTML(config, prefix) {
const enabled = stringSpinnerOtherEnabled(config);
const otherKey = config.otherOptionKey || 'other_option';
const otherNext = config.otherNextQuestionId || '';
return `
`;
}
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) {
let html = '';
switch (layout) {
case 'radio_question':
html = `
`;
break;
case 'multi_check_box_question':
html = `
`;
break;
case 'glass_scale_question': {
const scaleType = config.scaleType || 'glass';
html = `
`;
break;
}
case 'value_spinner':
case 'slider_question':
html = `
`;
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 'client_not_signed':
html = `
`;
break;
case 'last_page':
html = `
`;
break;
}
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;
break;
}
case 'glass_scale_question': {
const st = val('scaleType') || 'glass';
if (st && st !== 'glass') config.scaleType = st;
const raw = val('symptoms');
const syms = raw.split('\n').map(s => s.trim()).filter(Boolean);
if (syms.length) config.symptoms = syms;
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 'client_not_signed':
if (val('textKey1')) config.textKey1 = val('textKey1');
if (val('textKey2')) config.textKey2 = val('textKey2');
if (val('hint')) config.hint = val('hint');
break;
case 'last_page':
if (val('textKey')) config.textKey = val('textKey');
break;
}
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 ? `
Availability Condition (JSON)
` : ''}
${editable ? `
` : ''}
${!isNew ? `
Questions
${editable ? `` : ''}
` : 'Save the questionnaire first, then add questions.
'}
`;
if (editable) {
document.getElementById('saveMetaBtn').addEventListener('click', saveMeta);
}
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';
if (tab === 'translations') loadTranslationsTab();
});
});
}
function formatJson(str) {
try {
return JSON.stringify(JSON.parse(str), null, 2);
} catch {
return str;
}
}
// ── 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;
let conditionJson = '{}';
const condEl = document.getElementById('metaCondition');
if (condEl) {
try {
JSON.parse(condEl.value);
conditionJson = condEl.value.trim();
} catch {
showToast('Condition JSON is invalid', '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 });
if (data.success) {
questionnaire = data.questionnaire;
isNew = false;
showToast('Questionnaire created', 'success');
navigate(`#/questionnaire/${questionnaire.questionnaireID}`);
}
} else {
const data = await apiPut('questionnaires.php', {
questionnaireID: questionnaire.questionnaireID,
name, version, state, orderIndex, showPoints, conditionJson
});
if (data.success) {
questionnaire = { ...questionnaire, name, version, state, orderIndex, showPoints, conditionJson };
document.getElementById('editorTitle').textContent = name;
showToast('Saved', 'success');
}
}
} catch (e) {
showToast(e.message, 'error');
}
}
// ── Add question form ────────────────────────────────────────────────────
function showAddQuestionForm() {
const wrapper = document.getElementById('addQuestionFormWrapper');
if (!wrapper) return;
const pendingOptions = [];
wrapper.style.display = '';
wrapper.innerHTML = `
`;
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');
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 notes = readNotesFromForm('aq_cfg');
const configJson = readConfigFromForm(type, '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;
}
const btn = document.getElementById('aq_submit');
btn.disabled = true;
btn.textContent = 'Adding...';
try {
const qData = await apiPost('questions.php', {
questionnaireID: questionnaire.questionnaireID,
questionKey,
defaultText: text,
type,
isRequired,
configJson,
noteBefore: notes.noteBefore,
noteAfter: notes.noteAfter,
});
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) => `
`).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 ? `
` : `
`}
${OPTION_TYPES.has(layout) ? `
Answer Options (${options.length})
${options.map(o => optionItemHTML(q, o, editable)).join('')
|| '- No answer options
'}
${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);
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, {
questionKey,
defaultText: text,
type,
isRequired: isReq,
configJson: cj,
noteBefore: notes.noteBefore,
noteAfter: notes.noteAfter,
});
});
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;
document.getElementById(`eq_config_${q.questionID}`).innerHTML = configFormHTML(newLayout, {}, cfgPrefix);
wireStringSpinnerOtherToggle(cfgPrefix);
});
wireStringSpinnerOtherToggle(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 `
`;
}
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;
}