initial prototype based on nat-as-server
Some checks failed
PHPUnit / test (push) Has been cancelled
Some checks failed
PHPUnit / test (push) Has been cancelled
This commit is contained in:
832
website/js/pages/questionnaires.js
Normal file
832
website/js/pages/questionnaires.js
Normal file
@ -0,0 +1,832 @@
|
||||
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', '<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>Session measures</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 (!(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 `<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">☰</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 (!(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 `
|
||||
<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 (!(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
|
||||
? '<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">Session measures</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 (!(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;
|
||||
}
|
||||
Reference in New Issue
Block a user