import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; import { canEdit, pageHeaderHTML, showToast, getRole } from '../app.js'; import { confirmAction } from '../confirm-modal.js'; import { navigate } from '../router.js'; export async function questionnairesPage() { const app = document.getElementById('app'); app.innerHTML = ` ${pageHeaderHTML('Session measures', '')}
`; if (canEdit()) { document.getElementById('headerActions').innerHTML = ` + New Questionnaire `; bindImportBundle(); } try { const [data, profilesData] = await Promise.all([ apiGet('questionnaires.php'), apiGet('scoring_profiles.php').catch(() => ({ profiles: [] })), ]); const categoryKeys = data.categoryKeys || []; renderGrid(data.questionnaires || [], categoryKeys); renderScoringProfilesSection(profilesData.profiles || [], data.questionnaires || []); renderCategoryKeysPanel(categoryKeys); bindCategoryLabelEditors(); } catch (e) { showToast(e.message, 'error'); document.getElementById('qGrid').innerHTML = `

${e.message}

`; } } function renderCategoryKeysPanel(categoryKeys) { const panel = document.getElementById('categoryKeysPanel'); if (!panel) return; if (!canEdit()) { panel.innerHTML = ''; return; } if (!categoryKeys.length) { panel.innerHTML = `

Category keys

Set a category key on a questionnaire (editor → settings) to group it on the app opening screen. German group titles can be edited below once a key is in use.

`; return; } panel.innerHTML = `

Category keys

German labels for the app opening screen. Other languages: Translations. Delete removes the key from questionnaires, all translations, and catalog defaults.

${categoryKeys.map(row => ` `).join('')}
Category key App string key German label Session measures
${esc(row.categoryKey)} ${esc(row.stringKey)} ${row.orphaned ? 'unused' : esc(String(row.questionnaireCount))}
`; panel.querySelectorAll('.delete-cat-key-btn').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const key = btn.dataset.key; const count = parseInt(btn.dataset.count || '0', 10); let msg = `Delete category key "${key}"?\n\nThis removes all translations and catalog defaults for questionnaire_group_${key}.`; if (count > 0) { msg += `\n\n${count} questionnaire(s) will have their category key cleared.`; } if (!(await confirmAction({ title: 'Delete category key', message: msg, confirmLabel: 'Delete', variant: 'danger', }))) return; btn.disabled = true; try { const result = await apiPost('questionnaires.php', { action: 'deleteCategoryKey', categoryKey: key }); const cleared = result.questionnairesCleared ?? count; showToast( cleared ? `Deleted "${key}" and cleared ${cleared} questionnaire(s)` : `Deleted "${key}"`, 'success' ); await questionnairesPage(); } catch (err) { showToast(err.message, 'error'); btn.disabled = false; } }); }); } function categoryGermanInputHTML(categoryKey, label) { if (!canEdit() || !categoryKey) { return esc(label); } return ``; } function bindCategoryLabelEditors() { if (!canEdit()) return; document.querySelectorAll('.category-german-input').forEach(input => { input.dataset.lastSaved = input.value.trim(); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } }); input.addEventListener('blur', () => saveCategoryGermanLabel(input)); }); } async function saveCategoryGermanLabel(input) { const categoryKey = input.dataset.categoryKey?.trim(); const label = input.value.trim(); if (!categoryKey) return; const prev = input.dataset.lastSaved ?? ''; if (label === prev) return; if (!label) { showToast('German label cannot be empty', 'error'); input.value = prev; return; } input.disabled = true; try { await apiPost('questionnaires.php', { action: 'updateCategoryLabel', categoryKey, germanLabel: label, }); input.dataset.lastSaved = label; syncCategoryGermanInputs(categoryKey, label); showToast('Category label saved', 'success'); } catch (err) { showToast(err.message, 'error'); input.value = prev; } finally { input.disabled = false; } } function syncCategoryGermanInputs(categoryKey, label) { document.querySelectorAll('.category-german-input').forEach(el => { if (el.dataset.categoryKey === categoryKey) { el.value = label; el.dataset.lastSaved = label; } }); } const UNCATEGORIZED = '__uncategorized__'; function groupQuestionnairesByCategory(questionnaires, categoryKeys) { const labelByKey = Object.fromEntries( (categoryKeys || []) .filter(r => !r.orphaned) .map(r => [r.categoryKey, r.germanLabel || r.categoryKey]) ); const sorted = [...questionnaires].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0)); const buckets = new Map(); for (const q of sorted) { const key = (q.categoryKey || '').trim() || UNCATEGORIZED; if (!buckets.has(key)) buckets.set(key, []); buckets.get(key).push(q); } const sections = [...buckets.entries()].map(([key, items]) => ({ categoryKey: key === UNCATEGORIZED ? '' : key, title: key === UNCATEGORIZED ? 'Uncategorized' : (labelByKey[key] || key), items, sortKey: Math.min(...items.map(q => q.orderIndex ?? 0)), })); sections.sort((a, b) => { const aUncat = a.categoryKey === ''; const bUncat = b.categoryKey === ''; if (aUncat !== bUncat) { return aUncat ? 1 : -1; } return a.sortKey - b.sortKey; }); return sections; } function renderQuestionnaireCard(q) { const stateClass = `badge-${(q.state || 'draft').toLowerCase().replace(/\s+/g, '_')}`; return `
${canEdit() ? '' : ''} ${esc(q.name)}
${esc(q.state || 'draft')}
v${esc(q.version || '—')} ${q.questionCount || 0} question${q.questionCount == 1 ? '' : 's'} #${q.orderIndex ?? '—'}
Edit Results ${getRole() === 'admin' ? `` : ''}
`; } function renderGrid(questionnaires, categoryKeys = []) { const grid = document.getElementById('qGrid'); if (!questionnaires.length) { grid.innerHTML = `

No questionnaires yet

Create your first questionnaire to get started.

`; return; } const sections = groupQuestionnairesByCategory(questionnaires, categoryKeys); grid.innerHTML = `
${sections.map(section => `

${categoryGermanInputHTML(section.categoryKey, section.title)}

${section.items.map(q => renderQuestionnaireCard(q)).join('')}
`).join('')}
`; grid.querySelectorAll('.q-card').forEach(card => { card.addEventListener('click', (e) => { if (e.target.closest('.q-card-actions') || e.target.closest('.drag-handle')) return; navigate(`#/questionnaire/${card.dataset.id}`); }); }); grid.querySelectorAll('.delete-q-btn').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); if (!(await confirmAction({ title: 'Delete questionnaire', message: 'Delete this questionnaire and all its data?', confirmLabel: 'Delete', variant: 'danger', }))) return; try { await apiDelete('questionnaires.php', { questionnaireID: btn.dataset.id }); showToast('Questionnaire deleted', 'success'); btn.closest('.q-card').remove(); } catch (err) { showToast(err.message, 'error'); } }); }); if (canEdit()) initGridDrag(questionnaires); } function collectDashboardCardOrder() { const ids = []; document.querySelectorAll('.q-category-group').forEach(section => { section.querySelectorAll('.q-category-grid .q-card').forEach(card => { ids.push(card.dataset.id); }); }); return ids; } function initGridDrag(questionnaires) { const root = document.getElementById('qGrid'); if (!root) return; let dragItem = null; let dragRow = null; root.addEventListener('dragstart', (e) => { const card = e.target.closest('.q-card'); if (!card || !e.target.closest('.drag-handle')) { e.preventDefault(); return; } dragItem = card; dragRow = card.closest('.q-category-grid'); card.style.opacity = '0.5'; e.dataTransfer.effectAllowed = 'move'; }); root.addEventListener('dragover', (e) => { e.preventDefault(); if (!dragItem || !dragRow) return; const row = e.target.closest('.q-category-grid'); if (!row || row !== dragRow) return; const cards = [...row.querySelectorAll('.q-card')].filter(c => c !== dragItem); let insertBefore = null; for (const child of cards) { const box = child.getBoundingClientRect(); if (e.clientX < box.left + box.width / 2) { insertBefore = child; break; } } if (insertBefore) row.insertBefore(dragItem, insertBefore); else row.appendChild(dragItem); }); root.addEventListener('dragend', async () => { if (!dragItem) return; dragItem.style.opacity = ''; const newOrder = collectDashboardCardOrder(); dragItem = null; dragRow = null; for (let i = 0; i < newOrder.length; i++) { const id = newOrder[i]; const q = questionnaires.find(x => x.questionnaireID === id); if (q && parseInt(q.orderIndex, 10) !== i) { try { await apiPut('questionnaires.php', { questionnaireID: id, orderIndex: i }); q.orderIndex = i; } catch (err) { showToast(err.message, 'error'); return; } } } showToast('Order saved', 'success'); }); } function normalizeProfileBands(profile) { const greenMax = profile?.greenMax ?? 12; const yellowMax = profile?.yellowMax ?? 36; return { greenMin: profile?.greenMin ?? 0, greenMax, yellowMin: profile?.yellowMin ?? (greenMax + 1), yellowMax, redMin: profile?.redMin ?? (yellowMax + 1), }; } function bandSummary(profile) { const b = normalizeProfileBands(profile || {}); return `Green ${b.greenMin}–${b.greenMax} · Yellow ${b.yellowMin}–${b.yellowMax} · Red ${b.redMin}+`; } function readBandsFromModal(overlay) { return { greenMin: parseInt(overlay.querySelector('#spGreenMin')?.value || '0', 10), greenMax: parseInt(overlay.querySelector('#spGreenMax')?.value || '0', 10), yellowMin: parseInt(overlay.querySelector('#spYellowMin')?.value || '0', 10), yellowMax: parseInt(overlay.querySelector('#spYellowMax')?.value || '0', 10), redMin: parseInt(overlay.querySelector('#spRedMin')?.value || '0', 10), }; } function validateBands(b) { if (b.greenMin > b.greenMax) return 'Green min must not exceed green max'; if (b.yellowMin > b.yellowMax) return 'Yellow min must not exceed yellow max'; if (b.greenMax >= b.yellowMin) return 'Green range must end before yellow range starts'; if (b.yellowMax >= b.redMin) return 'Yellow range must end before red range starts'; return null; } function bandPreviewHTML(b) { return `
Green: ${b.greenMin} – ${b.greenMax} Yellow: ${b.yellowMin} – ${b.yellowMax} Red: ${b.redMin}+
`; } function bandRangesEditorHTML(bands) { const b = normalizeProfileBands(bands); return `

Score bands

Set inclusive min/max for each band. Ranges must not overlap.

BandMinMax
Green
Yellow
Red
`; } function renderScoringProfilesSection(profiles, questionnaires) { const panel = document.getElementById('scoringProfilesPanel'); if (!panel) return; panel.innerHTML = `

Scoring profiles

Weighted multi-questionnaire scores with Green / Yellow / Red bands for insights.

${canEdit() ? '' : ''}
`; const list = panel.querySelector('#scoringProfilesList'); if (!profiles.length) { list.innerHTML = '

No scoring profiles yet. Create one to combine questionnaire scores for insights.

'; } else { list.innerHTML = profiles.map(p => scoringProfileCardHTML(p, questionnaires)).join(''); } if (canEdit()) { panel.querySelector('#newScoringProfileBtn')?.addEventListener('click', () => { openScoringProfileModal(null, questionnaires, profiles); }); list.querySelectorAll('.edit-profile-btn').forEach(btn => { btn.addEventListener('click', () => { const profile = profiles.find(x => x.profileID === btn.dataset.id); openScoringProfileModal(profile, questionnaires, profiles); }); }); list.querySelectorAll('.delete-profile-btn').forEach(btn => { btn.addEventListener('click', async () => { if (!(await confirmAction({ title: 'Delete scoring profile', message: `Delete scoring profile "${btn.dataset.name}"?`, confirmLabel: 'Delete', variant: 'danger', }))) return; try { await apiDelete('scoring_profiles.php', { profileID: btn.dataset.id }); showToast('Profile deleted', 'success'); await questionnairesPage(); } catch (err) { showToast(err.message, 'error'); } }); }); } } function scoringProfileCardHTML(profile, questionnaires) { const qnById = Object.fromEntries((questionnaires || []).map(q => [q.questionnaireID, q])); const members = profile.questionnaires || []; const activeBadge = profile.isActive ? 'Active' : 'Inactive'; const memberRows = members.map(m => { const q = qnById[m.questionnaireID]; const name = q?.name || m.questionnaireID; return `
${esc(name)}× ${m.weight}
`; }).join(''); return `
${esc(profile.name)} ${activeBadge}
${profile.description ? `

${esc(profile.description)}

` : ''}

${bandSummary(profile)}

${canEdit() ? `
` : ''}

${members.length} questionnaire(s)

${memberRows || '

No questionnaires linked

'}
`; } function openScoringProfileModal(profile, questionnaires, allProfiles) { const isEdit = !!profile; const members = profile?.questionnaires?.length ? [...profile.questionnaires] : []; const selectedIds = new Set(members.map(m => m.questionnaireID)); const initialBands = normalizeProfileBands(profile || {}); const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.innerHTML = ` `; document.body.appendChild(overlay); document.body.classList.add('modal-open'); const picker = overlay.querySelector('#spQnPicker'); const sortedQn = [...(questionnaires || [])].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0)); function renderPicker() { const ordered = members.length ? members.map(m => ({ ...m, q: sortedQn.find(q => q.questionnaireID === m.questionnaireID) })) : []; const available = sortedQn.filter(q => !selectedIds.has(q.questionnaireID)); picker.innerHTML = ` ${ordered.map((m, idx) => `
${esc(m.q?.name || m.questionnaireID)}
`).join('')} ${available.length ? `
` : ''}`; picker.querySelectorAll('.sp-weight').forEach((input, idx) => { input.addEventListener('change', () => { members[idx].weight = parseFloat(input.value) || 1; }); }); picker.querySelectorAll('.sp-remove').forEach(btn => { btn.addEventListener('click', () => { const row = btn.closest('.scoring-profile-q-row'); const id = row?.dataset.qn; if (!id) return; selectedIds.delete(id); const i = members.findIndex(m => m.questionnaireID === id); if (i >= 0) members.splice(i, 1); renderPicker(); }); }); picker.querySelectorAll('.sp-move-up').forEach(btn => { btn.addEventListener('click', () => { const row = btn.closest('.scoring-profile-q-row'); const id = row?.dataset.qn; const i = members.findIndex(m => m.questionnaireID === id); if (i > 0) { [members[i - 1], members[i]] = [members[i], members[i - 1]]; renderPicker(); } }); }); picker.querySelectorAll('.sp-move-down').forEach(btn => { btn.addEventListener('click', () => { const row = btn.closest('.scoring-profile-q-row'); const id = row?.dataset.qn; const i = members.findIndex(m => m.questionnaireID === id); if (i >= 0 && i < members.length - 1) { [members[i], members[i + 1]] = [members[i + 1], members[i]]; renderPicker(); } }); }); overlay.querySelector('#spAddBtn')?.addEventListener('click', () => { const sel = overlay.querySelector('#spAddSelect'); const id = sel?.value; if (!id || selectedIds.has(id)) return; selectedIds.add(id); members.push({ questionnaireID: id, weight: 1, orderIndex: members.length }); renderPicker(); }); } function updateBandPreview() { const el = overlay.querySelector('#spBandPreview'); if (!el) return; const b = readBandsFromModal(overlay); const err = validateBands(b); if (err) { el.innerHTML = `

${esc(err)}

`; return; } el.innerHTML = bandPreviewHTML(b); } renderPicker(); updateBandPreview(); ['#spGreenMin', '#spGreenMax', '#spYellowMin', '#spYellowMax', '#spRedMin'].forEach(sel => { overlay.querySelector(sel)?.addEventListener('input', updateBandPreview); }); const close = () => { overlay.remove(); document.body.classList.remove('modal-open'); }; overlay.querySelector('#spCancel')?.addEventListener('click', close); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); overlay.querySelector('#spSave')?.addEventListener('click', async () => { const name = overlay.querySelector('#spName')?.value.trim(); const description = overlay.querySelector('#spDesc')?.value.trim() || ''; const isActive = overlay.querySelector('#spActive')?.checked; const bands = readBandsFromModal(overlay); const bandErr = validateBands(bands); if (!name) { showToast('Profile name is required', 'error'); return; } if (bandErr) { showToast(bandErr, 'error'); return; } if (!members.length) { showToast('Add at least one questionnaire', 'error'); return; } const payload = { name, description, isActive, ...bands, questionnaires: members.map((m, idx) => ({ questionnaireID: m.questionnaireID, weight: parseFloat(picker.querySelector(`[data-qn="${m.questionnaireID}"] .sp-weight`)?.value) || m.weight || 1, orderIndex: idx, })), }; if (isEdit) payload.profileID = profile.profileID; const saveBtn = overlay.querySelector('#spSave'); saveBtn.disabled = true; try { if (isEdit) { await apiPut('scoring_profiles.php', payload); } else { await apiPost('scoring_profiles.php', payload); } showToast(isEdit ? 'Profile saved' : 'Profile created', 'success'); close(); await questionnairesPage(); } catch (err) { showToast(err.message, 'error'); saveBtn.disabled = false; } }); } function bindImportBundle() { const fileInput = document.getElementById('importBundleFile'); const btn = document.getElementById('importBundleBtn'); if (!fileInput || !btn) return; btn.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', async () => { const file = fileInput.files?.[0]; fileInput.value = ''; if (!file) return; let bundle; try { const text = await file.text(); bundle = JSON.parse(text); } catch { showToast('Invalid JSON file', 'error'); return; } if (!bundle?.questionnaires || !Array.isArray(bundle.questionnaires)) { showToast('Not a questionnaire bundle (missing questionnaires array)', 'error'); return; } const count = bundle.questionnaires.length; if (!(await confirmAction({ title: 'Import questionnaires', message: `Import ${count} questionnaire(s) from this file?`, confirmLabel: 'Import', }))) return; const mode = await confirmAction({ title: 'Existing questionnaire IDs', message: 'If a questionnaire ID from the file already exists, how should it be handled?', choices: [ { value: 'replace', label: 'Replace existing', description: 'Overwrite the questionnaire with that ID.', }, { value: 'copy', label: 'Import as new copy', description: 'Keep the existing questionnaire and import with a new ID.', }, ], confirmLabel: 'Continue', cancelLabel: 'Cancel import', }); if (!mode) return; const replace = mode === 'replace'; btn.disabled = true; const prev = btn.textContent; btn.textContent = 'Importing…'; try { const result = await apiPost('questionnaires.php', { action: 'importBundle', bundle, replaceIfExists: replace, }); const n = result.imported ?? count; showToast(`Imported ${n} questionnaire(s)`, 'success'); await questionnairesPage(); } catch (e) { showToast(e.message, 'error'); } finally { btn.disabled = false; btn.textContent = prev; } }); } function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }