Files
nat-as-server/website/js/pages/questionnaires.js
Tom Hempel 5b5cc52133
Some checks failed
PHPUnit / test (push) Has been cancelled
Updated UI strings and references from "coach" to "counselor"
2026-06-12 13:41:42 +02:00

797 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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="scoringProfilesPanel"></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, 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 = `<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, '_')}`;
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">&#x2630;</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>
</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 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 `
<div class="scoring-band-strips">
<span class="band-badge band-green">Green: ${b.greenMin} ${b.greenMax}</span>
<span class="band-badge band-yellow">Yellow: ${b.yellowMin} ${b.yellowMax}</span>
<span class="band-badge band-red">Red: ${b.redMin}+</span>
</div>`;
}
function bandRangesEditorHTML(bands) {
const b = normalizeProfileBands(bands);
return `
<h4 class="scoring-modal-section-title">Score bands</h4>
<p class="data-toolbar-hint" style="margin:0 0 10px">Set inclusive min/max for each band. Ranges must not overlap.</p>
<div class="scoring-band-ranges-table">
<div class="scoring-band-range-row scoring-band-range-header">
<span>Band</span><span>Min</span><span>Max</span>
</div>
<div class="scoring-band-range-row">
<span class="band-badge band-green">Green</span>
<input type="number" id="spGreenMin" min="0" value="${b.greenMin}">
<input type="number" id="spGreenMax" min="0" value="${b.greenMax}">
</div>
<div class="scoring-band-range-row">
<span class="band-badge band-yellow">Yellow</span>
<input type="number" id="spYellowMin" min="0" value="${b.yellowMin}">
<input type="number" id="spYellowMax" min="0" value="${b.yellowMax}">
</div>
<div class="scoring-band-range-row">
<span class="band-badge band-red">Red</span>
<input type="number" id="spRedMin" min="0" value="${b.redMin}">
<span class="scoring-band-max-placeholder">∞</span>
</div>
</div>
<div id="spBandPreview" class="scoring-band-labels"></div>`;
}
function renderScoringProfilesSection(profiles, questionnaires) {
const panel = document.getElementById('scoringProfilesPanel');
if (!panel) return;
panel.innerHTML = `
<div class="scoring-profiles-section">
<div class="card scoring-profiles-card">
<div class="scoring-profile-header">
<div>
<h3 class="scoring-section-title">Scoring profiles</h3>
<p class="scoring-section-desc">
Weighted multi-questionnaire scores with Green / Yellow / Red bands for insights.
</p>
</div>
${canEdit() ? '<button type="button" class="btn btn-primary btn-sm" id="newScoringProfileBtn">+ New profile</button>' : ''}
</div>
<div id="scoringProfilesList" class="scoring-profiles-list"></div>
</div>
</div>`;
const list = panel.querySelector('#scoringProfilesList');
if (!profiles.length) {
list.innerHTML = '<p class="scoring-empty-hint">No scoring profiles yet. Create one to combine questionnaire scores for insights.</p>';
} 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 (!confirm(`Delete scoring profile "${btn.dataset.name}"?`)) 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
? '<span class="badge badge-active">Active</span>'
: '<span class="badge badge-draft">Inactive</span>';
const memberRows = members.map(m => {
const q = qnById[m.questionnaireID];
const name = q?.name || m.questionnaireID;
return `<div class="scoring-profile-q-row"><span class="scoring-profile-q-name">${esc(name)}</span><span class="scoring-profile-q-weight">× ${m.weight}</span></div>`;
}).join('');
return `
<div class="scoring-profile-card">
<div class="scoring-profile-card-top">
<div class="scoring-profile-card-main">
<div class="scoring-profile-name-row">
<strong>${esc(profile.name)}</strong>
${activeBadge}
</div>
${profile.description ? `<p class="scoring-profile-desc">${esc(profile.description)}</p>` : ''}
<p class="scoring-profile-bands">${bandSummary(profile)}</p>
</div>
${canEdit() ? `
<div class="scoring-profile-card-actions">
<button type="button" class="btn btn-sm edit-profile-btn" data-id="${esc(profile.profileID)}">Edit</button>
<button type="button" class="btn btn-sm btn-danger delete-profile-btn"
data-id="${esc(profile.profileID)}" data-name="${esc(profile.name)}">Delete</button>
</div>` : ''}
</div>
<p class="scoring-profile-member-count">${members.length} questionnaire(s)</p>
<div class="scoring-profile-members">${memberRows || '<p class="scoring-empty-hint">No questionnaires linked</p>'}</div>
</div>`;
}
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 = `
<div class="modal-dialog modal-dialog-wide">
<h2>${isEdit ? 'Edit scoring profile' : 'New scoring profile'}</h2>
<p class="modal-subtitle">Combine questionnaire totals with weights, then map the sum to Green / Yellow / Red bands.</p>
<div class="form-group">
<label for="spName">Name</label>
<input type="text" id="spName" value="${esc(profile?.name || '')}" placeholder="e.g. RHS Index">
</div>
<div class="form-group">
<label for="spDesc">Description</label>
<textarea id="spDesc" rows="2" placeholder="Optional note for counselors and admins">${esc(profile?.description || '')}</textarea>
</div>
<div class="form-group scoring-profile-active-row">
<label class="checkbox-inline">
<input type="checkbox" id="spActive" ${profile?.isActive !== 0 ? 'checked' : ''}>
Active (shown in insights)
</label>
</div>
${bandRangesEditorHTML(initialBands)}
<h4 class="scoring-modal-section-title">Questionnaires</h4>
<p class="data-toolbar-hint" style="margin:0 0 10px">Add questionnaires and set weight. List order is the display order.</p>
<div id="spQnPicker" class="scoring-profile-picker"></div>
<div class="modal-actions">
<button type="button" class="btn btn-sm" id="spCancel">Cancel</button>
<button type="button" class="btn btn-primary btn-sm" id="spSave">${isEdit ? 'Save' : 'Create'}</button>
</div>
</div>`;
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) => `
<div class="scoring-profile-q-row" data-qn="${esc(m.questionnaireID)}">
<span class="scoring-profile-q-name">${esc(m.q?.name || m.questionnaireID)}</span>
<span class="scoring-profile-q-actions">
<label class="sp-weight-wrap">Weight
<input type="number" class="sp-weight" step="0.1" min="0.1"
value="${m.weight ?? 1}">
</label>
<button type="button" class="btn btn-sm sp-move-up" ${idx === 0 ? 'disabled' : ''} title="Move up">↑</button>
<button type="button" class="btn btn-sm sp-move-down" ${idx === ordered.length - 1 ? 'disabled' : ''} title="Move down">↓</button>
<button type="button" class="btn btn-sm btn-danger sp-remove" title="Remove">Remove</button>
</span>
</div>
`).join('')}
${available.length ? `
<div class="scoring-profile-add-row">
<select id="spAddSelect">
<option value="">Add questionnaire…</option>
${available.map(q => `<option value="${esc(q.questionnaireID)}">${esc(q.name)}</option>`).join('')}
</select>
<button type="button" class="btn btn-sm" id="spAddBtn">Add</button>
</div>` : ''}`;
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 = `<p class="error-text" style="margin:0">${esc(err)}</p>`;
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 (!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;
}