440 lines
16 KiB
JavaScript
440 lines
16 KiB
JavaScript
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', '<span id="headerActions"></span>')}
|
|
<div id="qGrid" class="q-dashboard-root"><div class="spinner"></div></div>
|
|
<div id="categoryKeysPanel"></div>
|
|
`;
|
|
|
|
if (canEdit()) {
|
|
document.getElementById('headerActions').innerHTML = `
|
|
<input type="file" id="importBundleFile" accept=".json,application/json" hidden>
|
|
<button type="button" class="btn" id="importBundleBtn">Import questionnaires</button>
|
|
<a href="#/questionnaire/new" class="btn btn-primary">+ New Questionnaire</a>
|
|
`;
|
|
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 = `<p class="error-text">${e.message}</p>`;
|
|
}
|
|
}
|
|
|
|
function renderCategoryKeysPanel(categoryKeys) {
|
|
const panel = document.getElementById('categoryKeysPanel');
|
|
if (!panel) return;
|
|
|
|
if (!canEdit()) {
|
|
panel.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
if (!categoryKeys.length) {
|
|
panel.innerHTML = `
|
|
<div class="card category-keys-card" style="margin-top:16px">
|
|
<h3 style="margin:0 0 8px;font-size:1rem">Category keys</h3>
|
|
<p class="data-toolbar-hint" style="margin:0">
|
|
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.
|
|
</p>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
panel.innerHTML = `
|
|
<div class="card category-keys-card" style="margin-top:16px">
|
|
<h3 style="margin:0 0 8px;font-size:1rem">Category keys</h3>
|
|
<p class="data-toolbar-hint" style="margin:0 0 12px">
|
|
German labels for the app opening screen. Other languages: <a href="#/translations">Translations</a>.
|
|
Delete removes the key from questionnaires, all translations, and catalog defaults.
|
|
</p>
|
|
<div class="table-wrapper" style="max-height:none">
|
|
<table class="data-table category-keys-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Category key</th>
|
|
<th>App string key</th>
|
|
<th>German label</th>
|
|
<th>Questionnaires</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${categoryKeys.map(row => `
|
|
<tr class="${row.orphaned ? 'row-orphan-category' : ''}">
|
|
<td><code>${esc(row.categoryKey)}</code></td>
|
|
<td><code class="data-toolbar-hint">${esc(row.stringKey)}</code></td>
|
|
<td class="category-label-cell">
|
|
<input type="text" class="category-german-input"
|
|
data-category-key="${esc(row.categoryKey)}"
|
|
value="${esc(row.germanLabel || '')}"
|
|
placeholder="German label"
|
|
aria-label="German label for ${esc(row.categoryKey)}">
|
|
</td>
|
|
<td>${row.orphaned
|
|
? '<span class="data-toolbar-hint">unused</span>'
|
|
: esc(String(row.questionnaireCount))}</td>
|
|
<td style="text-align:right;white-space:nowrap">
|
|
<button type="button" class="btn btn-sm btn-danger delete-cat-key-btn"
|
|
data-key="${esc(row.categoryKey)}"
|
|
data-count="${row.questionnaireCount}">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>`;
|
|
|
|
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 `<input type="text" class="category-german-input category-title-input"
|
|
data-category-key="${esc(categoryKey)}"
|
|
value="${esc(label)}"
|
|
aria-label="German category title">`;
|
|
}
|
|
|
|
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 `
|
|
<div class="q-card" data-id="${q.questionnaireID}" draggable="${canEdit()}">
|
|
<div class="q-card-header">
|
|
<div class="q-card-title">
|
|
${canEdit() ? '<span class="drag-handle" title="Drag to reorder" aria-label="Drag to reorder">☰</span>' : ''}
|
|
${esc(q.name)}
|
|
</div>
|
|
<span class="badge ${stateClass}">${esc(q.state || 'draft')}</span>
|
|
</div>
|
|
<div class="q-card-meta">
|
|
<span>v${esc(q.version || '—')}</span>
|
|
<span>${q.questionCount || 0} question${q.questionCount == 1 ? '' : 's'}</span>
|
|
<span>#${q.orderIndex ?? '—'}</span>
|
|
${showPts ? '<span class="badge badge-active" style="font-size:.7rem;padding:1px 6px">pts</span>' : ''}
|
|
</div>
|
|
<div class="q-card-actions">
|
|
<a href="#/questionnaire/${q.questionnaireID}" class="btn btn-sm">Edit</a>
|
|
<a href="#/questionnaire/${q.questionnaireID}/results" class="btn btn-sm">Results</a>
|
|
${getRole() === 'admin' ? `<button class="btn btn-sm btn-danger delete-q-btn" data-id="${q.questionnaireID}">Delete</button>` : ''}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderGrid(questionnaires, categoryKeys = []) {
|
|
const grid = document.getElementById('qGrid');
|
|
|
|
if (!questionnaires.length) {
|
|
grid.innerHTML = `
|
|
<div class="empty-state">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 12h6m-3-3v6m-7 4h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
|
<h3>No questionnaires yet</h3>
|
|
<p>Create your first questionnaire to get started.</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const sections = groupQuestionnairesByCategory(questionnaires, categoryKeys);
|
|
|
|
grid.innerHTML = `
|
|
<div class="q-dashboard-groups">
|
|
${sections.map(section => `
|
|
<section class="q-category-group" data-category="${esc(section.categoryKey)}">
|
|
<h2 class="q-category-title">${categoryGermanInputHTML(section.categoryKey, section.title)}</h2>
|
|
<div class="q-category-grid">
|
|
${section.items.map(q => renderQuestionnaireCard(q)).join('')}
|
|
</div>
|
|
</section>
|
|
`).join('')}
|
|
</div>`;
|
|
|
|
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;
|
|
}
|