1075 lines
50 KiB
JavaScript
1075 lines
50 KiB
JavaScript
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js';
|
|
import { canEdit, showToast } from '../app.js';
|
|
import { navigate } from '../router.js';
|
|
import {
|
|
OPTION_TYPES,
|
|
layoutSelectHTML,
|
|
layoutLabel,
|
|
parseConfig,
|
|
questionLocalIds,
|
|
configFormHTML,
|
|
readConfigFromForm,
|
|
esc,
|
|
} from '../editor-utils.js';
|
|
|
|
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 = `
|
|
<div class="page-header">
|
|
<h1 id="editorTitle">${isNew ? 'New Questionnaire' : 'Loading...'}</h1>
|
|
<div class="actions">
|
|
<a href="#/" class="btn">← Back</a>
|
|
${!isNew ? `<a href="#/questionnaire/${params.id}/results" class="btn">View Results</a>` : ''}
|
|
</div>
|
|
</div>
|
|
<div id="editorContent"><div class="spinner"></div></div>
|
|
`;
|
|
|
|
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 = `<p class="error-text">${esc(e.message)}</p>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Main editor render (helpers in editor-utils.js) ─────────────────────
|
|
|
|
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>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" style="display:flex;align-items:flex-end;padding-bottom:16px">
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" id="metaShowPoints" ${questionnaire.showPoints ? 'checked' : ''} ${editable ? '' : 'disabled'}> Show points
|
|
</label>
|
|
</div>
|
|
</div>
|
|
${editable ? `
|
|
<details class="condition-details" style="margin-bottom:16px">
|
|
<summary style="cursor:pointer;font-size:.85rem;font-weight:600;color:var(--text-secondary)">
|
|
Availability Condition (JSON)
|
|
</summary>
|
|
<textarea id="metaCondition" rows="6"
|
|
style="width:100%;margin-top:8px;font-family:monospace;font-size:.85rem;padding:8px 12px;border:1px solid var(--border);border-radius:6px"
|
|
>${formatJson(condJson)}</textarea>
|
|
</details>
|
|
` : ''}
|
|
${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="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>
|
|
</div>
|
|
<div id="tabContent-translations" style="display:none">
|
|
<div class="card" id="translationsCard">
|
|
<div id="translationsContent"><div class="spinner"></div></div>
|
|
</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);
|
|
}
|
|
|
|
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 = `
|
|
<div class="inline-form-card">
|
|
<h4>New Question</h4>
|
|
<div class="form-row">
|
|
<div class="form-group" style="flex:3">
|
|
<label>Question key</label>
|
|
<input type="text" id="aq_text" placeholder="e.g. consent_instruction">
|
|
</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_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:3;margin-bottom:0">
|
|
<input type="text" id="aq_opt_text" placeholder="Option key...">
|
|
</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">
|
|
<select id="aq_opt_next">
|
|
<option value="">(next in order)</option>
|
|
${questionLocalIds(questions).map(q => `<option value="${esc(q.localId)}">${esc(q.localId)} - ${esc(q.defaultText)}</option>`).join('')}
|
|
</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;
|
|
document.getElementById('aq_options_section').style.display = OPTION_TYPES.has(type) ? '' : 'none';
|
|
document.getElementById('aq_config_section').innerHTML = configFormHTML(type, {}, 'aq_cfg');
|
|
}
|
|
|
|
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">
|
|
<span class="opt-text">${esc(opt.text)}</span>
|
|
<span class="opt-points">${opt.points} pts</span>
|
|
${opt.nextQuestionId ? `<span class="opt-branch">→ ${esc(opt.nextQuestionId)}</span>` : ''}
|
|
<button class="btn-icon remove-pending-opt" data-idx="${i}" title="Remove" style="color:var(--danger)">✖</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 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 (!text) { showToast('Option key is required', 'error'); return; }
|
|
pendingOptions.push({ text, points: pts, nextQuestionId: next });
|
|
renderPendingOptions();
|
|
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_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 text = document.getElementById('aq_text').value.trim();
|
|
const type = document.getElementById('aq_type').value;
|
|
const isRequired = document.getElementById('aq_required').checked ? 1 : 0;
|
|
const configJson = readConfigFromForm(type, 'aq_cfg');
|
|
if (!text) { showToast('Question key is required', 'error'); 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,
|
|
defaultText: text, type, isRequired, configJson
|
|
});
|
|
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,
|
|
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 = `<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">☰</span>' : ''}
|
|
<span class="chevron">▶</span>
|
|
<span class="q-text">${idx + 1}. ${esc(q.defaultText)}</span>
|
|
<span class="q-type">${esc(layoutLabel(q.type))}</span>
|
|
${q.isRequired ? '<span class="badge badge-active">Required</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';
|
|
|
|
body.innerHTML = `
|
|
${editable ? `
|
|
<div class="q-edit-section">
|
|
<div class="inline-form-card" style="margin-bottom:12px">
|
|
<div class="form-row">
|
|
<div class="form-group" style="flex:3">
|
|
<label>Question key</label>
|
|
<input type="text" id="eq_text_${q.questionID}" value="${esc(q.defaultText)}">
|
|
</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>
|
|
<div id="eq_config_${q.questionID}">
|
|
${configFormHTML(layout, config, `eq_cfg_${q.questionID}`)}
|
|
</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> ${esc(q.defaultText)}</p>
|
|
<p><strong>Layout:</strong> ${esc(layoutLabel(layout))}</p>
|
|
${Object.keys(config).length ? `<p><strong>Config:</strong> <code>${esc(JSON.stringify(config))}</code></p>` : ''}
|
|
</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;
|
|
|
|
body.querySelector('.save-q-btn').addEventListener('click', () => {
|
|
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 cj = readConfigFromForm(type, `eq_cfg_${q.questionID}`);
|
|
if (!text) { showToast('Question key is required', 'error'); return; }
|
|
updateQuestion(q.questionID, { defaultText: text, type, isRequired: isReq, configJson: cj });
|
|
});
|
|
|
|
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, {}, `eq_cfg_${q.questionID}`);
|
|
});
|
|
|
|
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) {
|
|
return `
|
|
<li class="option-item" data-id="${o.answerOptionID}" draggable="${editable}">
|
|
${editable ? '<span class="drag-handle" style="font-size:.9rem">☰</span>' : ''}
|
|
<span class="opt-text">${esc(o.defaultText)}</span>
|
|
<span class="opt-points">${o.points} pts</span>
|
|
${o.nextQuestionId ? `<span class="opt-branch">→ ${esc(o.nextQuestionId)}</span>` : ''}
|
|
${editable ? `
|
|
<button class="btn-icon edit-opt-btn" data-id="${o.answerOptionID}" title="Edit">✎</button>
|
|
<button class="btn-icon del-opt-btn" data-id="${o.answerOptionID}" title="Delete" style="color:var(--danger)">✖</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(questions);
|
|
|
|
wrapper.innerHTML = `
|
|
<div class="inline-form-card" style="margin-top:8px">
|
|
<div class="form-row">
|
|
<div class="form-group" style="flex:3">
|
|
<label>Option key</label>
|
|
<input type="text" id="ao_text_${q.questionID}" placeholder="e.g. consent_signed">
|
|
</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>
|
|
${qIds.map(qi => `<option value="${esc(qi.localId)}">${esc(qi.localId)} - ${esc(qi.defaultText)}</option>`).join('')}
|
|
</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 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 (!text) { showToast('Option key 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, 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(questions);
|
|
|
|
li.innerHTML = `
|
|
<div class="inline-form-card" style="width:100%;padding:8px">
|
|
<div class="form-row">
|
|
<div class="form-group" style="flex:3;margin-bottom:8px">
|
|
<input type="text" id="eo_text_${opt.answerOptionID}" value="${esc(opt.defaultText)}">
|
|
</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">
|
|
<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(qi.localId)} - ${esc(qi.defaultText)}</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 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 (!text) { showToast('Option key is required', 'error'); return; }
|
|
await updateOption(q, opt.answerOptionID, { 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;
|
|
|
|
async function loadTranslationsTab() {
|
|
const container = document.getElementById('translationsContent');
|
|
if (!container) return;
|
|
container.innerHTML = '<div class="spinner"></div>';
|
|
|
|
try {
|
|
const data = await apiGet(`translations.php?questionnaireID=${encodeURIComponent(questionnaire.questionnaireID)}`);
|
|
transData = data;
|
|
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 entries = transData.entries || [];
|
|
const langCodes = languages.map(l => l.languageCode);
|
|
|
|
if (!entries.length) {
|
|
container.innerHTML = `
|
|
${editable ? languageManagerHTML(languages) : ''}
|
|
<p style="color:var(--text-secondary);margin-top:12px">No translatable keys found. Add questions first.</p>
|
|
`;
|
|
if (editable) bindLanguageManager(languages);
|
|
return;
|
|
}
|
|
|
|
const filterHtml = `
|
|
<div class="trans-toolbar">
|
|
<div class="trans-search-wrap">
|
|
<input type="text" id="transFilter" placeholder="Filter keys..." class="trans-search-input">
|
|
</div>
|
|
<div class="trans-filter-types">
|
|
<label class="checkbox-label"><input type="checkbox" class="trans-type-filter" value="question" checked> Questions</label>
|
|
<label class="checkbox-label"><input type="checkbox" class="trans-type-filter" value="answer_option" checked> Options</label>
|
|
<label class="checkbox-label"><input type="checkbox" class="trans-type-filter" value="string" checked> Strings</label>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const typeBadge = (type) => {
|
|
const map = { question: 'Q', answer_option: 'Opt', string: 'Str' };
|
|
const cls = { question: 'badge-q', answer_option: 'badge-opt', string: 'badge-str' };
|
|
return `<span class="trans-type-badge ${cls[type] || ''}">${map[type] || type}</span>`;
|
|
};
|
|
|
|
container.innerHTML = `
|
|
${editable ? languageManagerHTML(languages) : ''}
|
|
${filterHtml}
|
|
<div class="table-wrapper trans-table-wrapper">
|
|
<table class="data-table trans-table" id="transTable">
|
|
<thead>
|
|
<tr>
|
|
<th class="col-key">Key</th>
|
|
<th class="col-type">Type</th>
|
|
${langCodes.map(lc => `<th class="col-lang">${esc(lc.toUpperCase())}</th>`).join('')}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${entries.map((entry, i) => `
|
|
<tr data-idx="${i}" data-type="${entry.type}" data-key="${esc(entry.key)}">
|
|
<td class="cell-key" title="${esc(entry.key)}">${esc(entry.key)}</td>
|
|
<td class="cell-type">${typeBadge(entry.type)}</td>
|
|
${langCodes.map(lc => {
|
|
const val = entry.translations[lc] || '';
|
|
const missing = !val;
|
|
return `<td class="cell-trans ${missing ? 'cell-missing' : ''}">
|
|
<input type="text"
|
|
class="trans-cell-input"
|
|
data-idx="${i}"
|
|
data-lang="${esc(lc)}"
|
|
value="${esc(val)}"
|
|
${editable ? '' : 'disabled'}
|
|
placeholder="${missing ? 'missing' : ''}">
|
|
</td>`;
|
|
}).join('')}
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="trans-stats" id="transStats"></div>
|
|
`;
|
|
|
|
updateTransStats(entries, langCodes);
|
|
|
|
if (editable) {
|
|
bindLanguageManager(languages);
|
|
bindTransCellEditing(entries);
|
|
}
|
|
bindTransFiltering();
|
|
}
|
|
|
|
function languageManagerHTML(languages) {
|
|
return `
|
|
<div class="lang-manager">
|
|
<div class="lang-chips">
|
|
${languages.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">×</button>
|
|
</span>
|
|
`).join('')}
|
|
${!languages.length ? '<span style="color:var(--text-secondary);font-size:.85rem">No languages defined yet.</span>' : ''}
|
|
</div>
|
|
<div class="lang-add-row">
|
|
<input type="text" id="newLangCode" placeholder="Code (e.g. de)" class="lang-add-code">
|
|
<input type="text" id="newLangName" placeholder="Name (e.g. German)" 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 (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');
|
|
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');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function bindTransCellEditing(entries) {
|
|
const debounceTimers = {};
|
|
document.querySelectorAll('.trans-cell-input').forEach(inp => {
|
|
inp.addEventListener('input', () => {
|
|
const idx = parseInt(inp.dataset.idx, 10);
|
|
const lang = inp.dataset.lang;
|
|
const entry = entries[idx];
|
|
if (!entry) return;
|
|
|
|
entry.translations[lang] = inp.value;
|
|
|
|
const cell = inp.closest('td');
|
|
cell.classList.toggle('cell-missing', !inp.value);
|
|
|
|
const timerKey = `${idx}_${lang}`;
|
|
clearTimeout(debounceTimers[timerKey]);
|
|
debounceTimers[timerKey] = setTimeout(async () => {
|
|
try {
|
|
await apiPut('translations.php', {
|
|
type: entry.type,
|
|
id: entry.entityId,
|
|
languageCode: lang,
|
|
text: inp.value,
|
|
});
|
|
inp.classList.add('save-flash');
|
|
setTimeout(() => inp.classList.remove('save-flash'), 400);
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
}
|
|
}, 600);
|
|
});
|
|
|
|
inp.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Tab' || e.key === 'Enter') return;
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
const row = inp.closest('tr');
|
|
const cellIdx = [...row.children].indexOf(inp.closest('td'));
|
|
const targetRow = e.key === 'ArrowDown' ? row.nextElementSibling : row.previousElementSibling;
|
|
if (targetRow && !targetRow.classList.contains('trans-row-hidden')) {
|
|
const targetInput = targetRow.children[cellIdx]?.querySelector('input');
|
|
if (targetInput) targetInput.focus();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function bindTransFiltering() {
|
|
const filterInput = document.getElementById('transFilter');
|
|
const typeChecks = document.querySelectorAll('.trans-type-filter');
|
|
const tbody = document.querySelector('#transTable tbody');
|
|
if (!filterInput || !tbody) return;
|
|
|
|
function applyFilter() {
|
|
const text = filterInput.value.toLowerCase();
|
|
const activeTypes = new Set([...typeChecks].filter(c => c.checked).map(c => c.value));
|
|
let visible = 0;
|
|
tbody.querySelectorAll('tr').forEach(row => {
|
|
const key = row.dataset.key?.toLowerCase() || '';
|
|
const type = row.dataset.type || '';
|
|
const show = activeTypes.has(type) && (!text || key.includes(text));
|
|
row.classList.toggle('trans-row-hidden', !show);
|
|
if (show) visible++;
|
|
});
|
|
const stats = document.getElementById('transStats');
|
|
if (stats) stats.textContent = `Showing ${visible} of ${tbody.children.length} keys`;
|
|
}
|
|
|
|
filterInput.addEventListener('input', applyFilter);
|
|
typeChecks.forEach(c => c.addEventListener('change', applyFilter));
|
|
}
|
|
|
|
function updateTransStats(entries, langCodes) {
|
|
const stats = document.getElementById('transStats');
|
|
if (!stats) return;
|
|
const total = entries.length * langCodes.length;
|
|
let filled = 0;
|
|
for (const e of entries) {
|
|
for (const lc of langCodes) {
|
|
if (e.translations[lc]) filled++;
|
|
}
|
|
}
|
|
const pct = total ? Math.round((filled / total) * 100) : 0;
|
|
stats.innerHTML = `
|
|
<span>${entries.length} keys × ${langCodes.length} languages = ${total} cells</span>
|
|
<span class="trans-stats-progress">
|
|
<span class="trans-progress-bar"><span class="trans-progress-fill" style="width:${pct}%"></span></span>
|
|
<strong>${filled}/${total}</strong> (${pct}%) translated
|
|
</span>
|
|
`;
|
|
}
|
|
|
|
// ── 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;
|
|
}
|