implemented scoring system
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-06-08 19:10:23 +02:00
parent 4589be119c
commit 63adef79df
19 changed files with 2873 additions and 36 deletions

View File

@ -7,6 +7,7 @@ export async function questionnairesPage() {
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>
`;
@ -20,9 +21,13 @@ export async function questionnairesPage() {
}
try {
const data = await apiGet('questionnaires.php');
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) {
@ -232,7 +237,6 @@ function groupQuestionnairesByCategory(questionnaires, categoryKeys) {
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">
@ -246,7 +250,6 @@ function renderQuestionnaireCard(q) {
<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>
@ -377,6 +380,360 @@ function initGridDrag(questionnaires) {
});
}
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 coaches 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');