import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; import { canEdit, pageHeaderHTML, showToast, getRole } from '../app.js'; import { navigate } from '../router.js'; export async function questionnairesPage() { const app = document.getElementById('app'); app.innerHTML = ` ${pageHeaderHTML('Questionnaires', '')}
`; if (canEdit()) { document.getElementById('headerActions').innerHTML = ` + New Questionnaire `; bindImportBundle(); } try { const data = await apiGet('questionnaires.php'); const categoryKeys = data.categoryKeys || []; renderGrid(data.questionnaires || [], categoryKeys); 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 Questionnaires
${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 (!confirm(msg)) 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, '_')}`; const showPts = parseInt(q.showPoints || 0, 10); return `
${canEdit() ? '' : ''} ${esc(q.name)}
${esc(q.state || 'draft')}
v${esc(q.version || '—')} ${q.questionCount || 0} question${q.questionCount == 1 ? '' : 's'} #${q.orderIndex ?? '—'} ${showPts ? 'pts' : ''}
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 (!confirm('Delete this questionnaire and all its data?')) 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 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 (!confirm(`Import ${count} questionnaire(s) from this file?`)) return; const replace = confirm( 'If a questionnaire ID from the file already exists:\n\n' + 'OK — replace that questionnaire\n' + 'Cancel — import as a new copy (new ID)' ); 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; }