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

@ -6,6 +6,7 @@ const PAGE_SIZE = 50;
let clientsList = [];
let coachesList = [];
let scoringProfileCatalog = [];
let filterSearch = '';
let filterTestData = '';
let page = 0;
@ -42,6 +43,7 @@ async function loadClients() {
apiGet('assignments.php'),
]);
clientsList = clientsData.clients || [];
scoringProfileCatalog = clientsData.scoringProfiles || [];
coachesList = assignData.coaches || [];
renderClients();
} catch (e) {
@ -65,6 +67,9 @@ function renderClients() {
return;
}
const showProfileColumn = scoringProfileCatalog.length > 0;
const profileLegend = showProfileColumn ? scoringLegendHTML() : '';
container.innerHTML = `
<div class="card">
<div class="filter-bar">
@ -76,6 +81,7 @@ function renderClients() {
</select>
<span class="data-toolbar-hint" id="clientListCount"></span>
</div>
${profileLegend}
<p class="data-toolbar-hint" style="margin:0 0 12px">
Expand a row to view questionnaire responses and upload version history.
</p>
@ -85,6 +91,7 @@ function renderClients() {
<tr>
<th>Client Code</th>
<th>Current Coach</th>
${showProfileColumn ? '<th>Profile scores</th>' : ''}
<th>Actions</th>
</tr>
</thead>
@ -132,6 +139,7 @@ function renderClientTableBody() {
const countEl = document.getElementById('clientListCount');
const filtered = getFilteredClientsList();
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const colCount = scoringProfileCatalog.length > 0 ? 4 : 3;
if (page >= totalPages) page = totalPages - 1;
const slice = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
@ -140,7 +148,7 @@ function renderClientTableBody() {
}
if (!slice.length) {
body.innerHTML = `<tr><td colspan="3" style="text-align:center;color:var(--text-secondary);padding:24px">No clients match</td></tr>`;
body.innerHTML = `<tr><td colspan="${colCount}" style="text-align:center;color:var(--text-secondary);padding:24px">No clients match</td></tr>`;
} else {
body.innerHTML = slice.map(c => clientRowHTML(c)).join('');
body.querySelectorAll('.client-expand-btn').forEach(btn => {
@ -243,9 +251,103 @@ function clientDetailPanelHTML(clientCode) {
Coach: ${esc(cl.coachUsername || '—')}
${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''}
</p>
${renderClientScoringProfiles(detail.scoringProfiles || [])}
${questionnaires.map(q => clientQnBlockHTML(clientCode, q)).join('')}`;
}
function scoringLegendHTML() {
return `
<div class="client-scoring-legend">
<p class="client-scoring-legend-title"><strong>How profile scores work</strong></p>
<p class="data-toolbar-hint client-scoring-legend-body">
Each profile adds up questionnaire points (× weight) into a <strong>weighted total</strong>,
then maps that total to <span class="band-badge band-green">green</span>
<span class="band-badge band-yellow">yellow</span>
<span class="band-badge band-red">red</span> using the thresholds on the Questionnaires page.
</p>
<dl class="client-scoring-legend-dl">
<div>
<dt>Calculated</dt>
<dd>Automatic category from answers and profile rules (shown before coach review).</dd>
</div>
<div>
<dt>Coach</dt>
<dd>Category confirmed or changed by the coach in the app (“Review scores”).</dd>
</div>
<div>
<dt>Incomplete</dt>
<dd>Not all questionnaires in that profile are completed yet.</dd>
</div>
</dl>
</div>`;
}
function bandBadgeHTML(band, { pending = false, incomplete = false, label = '' } = {}) {
const text = label || band || '';
if (incomplete) {
return '<span class="band-badge band-none">incomplete</span>';
}
if (pending) {
return '<span class="band-badge band-none">awaiting review</span>';
}
if (!band) {
return '<span class="band-badge band-none">—</span>';
}
return `<span class="band-badge band-${esc(band)}">${esc(text || band)}</span>`;
}
function profileScoreBlockHTML(p) {
const calcBand = p.calculatedBand || p.band;
if (!calcBand) {
return `
<div class="client-profile-score-block is-incomplete">
<div class="client-profile-score-block-title">${esc(p.name)}</div>
<p class="client-profile-score-hint">Profile incomplete — not all member questionnaires are done.</p>
</div>`;
}
const coachPending = p.pendingReview !== false && !p.coachBand;
const coachDiffers = p.coachBand && p.coachBand !== calcBand;
return `
<div class="client-profile-score-block${coachDiffers ? ' has-coach-override' : ''}">
<div class="client-profile-score-block-title">${esc(p.name)}</div>
<div class="client-profile-score-row">
<span class="client-profile-score-label">Calculated</span>
${bandBadgeHTML(calcBand)}
<span class="client-profile-score-total" title="Weighted total">${esc(String(p.weightedTotal))}</span>
</div>
<div class="client-profile-score-row">
<span class="client-profile-score-label">Coach</span>
${coachPending ? bandBadgeHTML(null, { pending: true }) : bandBadgeHTML(p.coachBand)}
${coachDiffers ? '<span class="client-profile-override-hint">override</span>' : ''}
</div>
</div>`;
}
function renderClientScoringProfiles(profiles) {
if (!profiles.length) return '';
return `
<div class="client-scoring-profiles-detail">
<strong>Scoring profiles</strong>
<p class="data-toolbar-hint" style="margin:4px 0 10px">
Weighted totals and calculated vs coach categories for this client.
</p>
<div class="client-profile-scores">${profiles.map(p => {
const calc = p.calculatedBand || p.band;
if (!calc) return profileScoreBlockHTML(p);
const coachPending = p.pendingReview !== false && !p.coachBand;
const computedAt = p.computedAt ? `Last computed ${esc(p.computedAt)}` : '';
const reviewedAt = p.coachReviewedAt ? `Coach reviewed ${esc(p.coachReviewedAt)}` : '';
const meta = [computedAt, reviewedAt].filter(Boolean).join(' · ');
return `
<div class="scoring-profile-detail-card">
${profileScoreBlockHTML(p)}
${meta ? `<p class="client-profile-score-meta">${meta}</p>` : ''}
${coachPending ? '<p class="client-profile-score-hint">Coach has not reviewed this profile in the app yet.</p>' : ''}
</div>`;
}).join('')}</div>
</div>`;
}
function clientQnBlockHTML(clientCode, q) {
const key = qnKey(clientCode, q.questionnaireID);
const qnExpanded = expandedQnKey === key;
@ -311,6 +413,17 @@ function clientQnBlockHTML(clientCode, q) {
</div>`;
}
function profileDotsHTML(profiles) {
if (!profiles?.length) {
return '<span class="client-profile-dots-empty">—</span>';
}
const hasAny = profiles.some(p => p.calculatedBand || p.band);
if (!hasAny) {
return `<span class="client-profile-dots-empty" title="No complete scoring profiles yet">—</span>`;
}
return `<div class="client-profile-scores">${profiles.map(p => profileScoreBlockHTML(p)).join('')}</div>`;
}
function clientHasResponseData(c) {
return Number(c.hasResponseData) === 1;
}
@ -324,6 +437,10 @@ function clientRowHTML(c) {
const expandControl = canExpand
? `<button type="button" class="btn btn-sm btn-link client-expand-btn" data-code="${esc(c.clientCode)}">${expanded ? '▼' : '▶'}</button> `
: '';
const profileCol = scoringProfileCatalog.length > 0
? `<td class="client-profile-dots-cell">${profileDotsHTML(c.scoringProfiles || [])}</td>`
: '';
const colCount = scoringProfileCatalog.length > 0 ? 4 : 3;
return `
<tr class="${testDataRowClassForClient(c.clientCode).trim()}">
@ -331,13 +448,14 @@ function clientRowHTML(c) {
${expandControl}<strong>${esc(c.clientCode)}</strong>
</td>
<td>${coach}</td>
${profileCol}
<td>
<button class="btn btn-sm btn-danger delete-client-btn" data-code="${esc(c.clientCode)}">Delete</button>
</td>
</tr>
${expanded ? `
<tr class="client-detail-row">
<td colspan="3">
<td colspan="${colCount}">
<div class="client-detail-panel">${clientDetailPanelHTML(c.clientCode)}</div>
</td>
</tr>` : ''}`;

View File

@ -33,6 +33,14 @@ const LAYOUT_TYPES = [
const OPTION_TYPES = new Set(['radio_question', 'multi_check_box_question']);
const GLASS_SCORE_LEVELS = [
{ key: 'never_glass', label: 'Never' },
{ key: 'little_glass', label: 'Little' },
{ key: 'moderate_glass', label: 'Moderate' },
{ key: 'much_glass', label: 'Much' },
{ key: 'extreme_glass', label: 'Extreme' },
];
let questionnaire = null;
let questions = [];
let isNew = false;
@ -194,10 +202,79 @@ function glassSymptomsRowsFromQuestion(q) {
return q.glassSymptoms.map(r => ({
key: (r.key || '').trim(),
labelDe: (r.labelDe || '').trim(),
scoreLevels: r.scoreLevels || defaultGlassScoreLevels(),
}));
}
const config = parseConfig(q);
return (config.symptoms || []).map(key => ({ key: String(key).trim(), labelDe: '' }));
return (config.symptoms || []).map(key => ({
key: String(key).trim(),
labelDe: '',
scoreLevels: defaultGlassScoreLevels(),
}));
}
function defaultGlassScoreLevels() {
return { never_glass: 0, little_glass: 1, moderate_glass: 2, much_glass: 3, extreme_glass: 4 };
}
function valueScoreRulesSectionHTML(prefix, config, scoreRules = []) {
const min = Number(config.range?.min ?? 0);
const max = Number(config.range?.max ?? 100);
const step = Math.max(1, Number(config.step ?? 1));
const lo = Math.min(min, max);
const hi = Math.max(min, max);
const ruleMap = {};
for (const r of scoreRules || []) {
ruleMap[r.levelKey] = r.points;
}
const values = [];
for (let v = lo; v <= hi; v += step) values.push(v);
if (!values.length) values.push(lo);
const rows = values.map(v => `
<div class="value-score-row">
<span class="value-score-label">${v}</span>
<input type="number" class="value-score-pts" data-value="${v}" value="${ruleMap[String(v)] ?? 0}" min="0" step="1">
</div>`).join('');
return `
<div class="value-score-rules" id="${prefix}_value_scores">
<label style="font-size:.85rem;font-weight:600;color:var(--text-secondary)">Points per value</label>
<p class="field-hint">Set points for each selectable value (used when the questionnaire is scored).</p>
<div class="value-score-grid">${rows}</div>
</div>`;
}
function readValueScoreRulesFromForm(prefix) {
const container = document.getElementById(`${prefix}_value_scores`);
if (!container) return [];
const rules = [];
container.querySelectorAll('.value-score-pts').forEach(inp => {
rules.push({
scopeKey: '',
levelKey: String(inp.dataset.value),
points: parseInt(inp.value || '0', 10) || 0,
});
});
return rules;
}
function wireValueScoreRules(prefix) {
const minInp = document.getElementById(`${prefix}_rangeMin`);
const maxInp = document.getElementById(`${prefix}_rangeMax`);
const stepInp = document.getElementById(`${prefix}_step`);
const rerender = () => {
const section = document.getElementById(`${prefix}_value_scores`);
if (!section) return;
const config = {
range: {
min: parseInt(minInp?.value || '0', 10),
max: parseInt(maxInp?.value || '100', 10),
},
step: parseInt(stepInp?.value || '1', 10) || 1,
};
const existing = readValueScoreRulesFromForm(prefix);
section.outerHTML = valueScoreRulesSectionHTML(prefix, config, existing);
};
[minInp, maxInp, stepInp].forEach(el => el?.addEventListener('change', rerender));
}
function suggestSymptomKey(label) {
@ -227,6 +304,13 @@ function glassSymptomsSectionHTML(rows, prefix) {
}
function glassSymptomRowHTML(prefix, row, idx) {
const levels = row.scoreLevels || defaultGlassScoreLevels();
const ptsRow = GLASS_SCORE_LEVELS.map(l => `
<div class="glass-score-cell">
<label>${esc(l.label)}</label>
<input type="number" class="glass-score-pts" data-level="${esc(l.key)}"
value="${Number(levels[l.key] ?? 0)}" min="0" step="1">
</div>`).join('');
return `
<li class="glass-symptom-row" data-idx="${idx}">
<div class="form-row" style="align-items:flex-end;margin-bottom:0">
@ -242,6 +326,7 @@ function glassSymptomRowHTML(prefix, row, idx) {
</div>
<button type="button" class="btn-icon btn-icon-danger glass-symptom-remove" title="Remove">&#x2716;</button>
</div>
<div class="glass-score-grid">${ptsRow}</div>
</li>`;
}
@ -252,7 +337,11 @@ function readGlassSymptomsFromForm(prefix) {
container.querySelectorAll('.glass-symptom-row').forEach(rowEl => {
const key = rowEl.querySelector('.glass-symptom-key')?.value?.trim() || '';
const labelDe = rowEl.querySelector('.glass-symptom-label')?.value?.trim() || '';
if (key || labelDe) rows.push({ key, labelDe });
const scoreLevels = {};
rowEl.querySelectorAll('.glass-score-pts').forEach(inp => {
scoreLevels[inp.dataset.level] = parseInt(inp.value || '0', 10) || 0;
});
if (key || labelDe) rows.push({ key, labelDe, scoreLevels });
});
return rows;
}
@ -319,7 +408,7 @@ function wireGlassSymptomsSection(prefix) {
container.querySelector('[data-action=add-glass-symptom]')?.addEventListener('click', () => {
const rows = collectRows();
rows.push({ key: '', labelDe: '' });
rows.push({ key: '', labelDe: '', scoreLevels: defaultGlassScoreLevels() });
renderList(rows);
const lastKey = container.querySelector('.glass-symptom-row:last-child .glass-symptom-label');
lastKey?.focus();
@ -477,7 +566,8 @@ function configFormHTML(layout, config, prefix, opts = {}) {
break;
}
case 'value_spinner':
case 'slider_question':
case 'slider_question': {
const scoreRules = opts.scoreRules || [];
html = `
<div class="form-row">
<div class="form-group">
@ -497,8 +587,10 @@ function configFormHTML(layout, config, prefix, opts = {}) {
<label>Unit label (optional)</label>
<input type="text" id="${prefix}_unitLabel" value="${esc(config.unitLabel || '')}" placeholder="e.g. years">
</div>` : ''}
</div>`;
</div>
${valueScoreRulesSectionHTML(prefix, config, scoreRules)}`;
break;
}
case 'date_spinner': {
const notBefore = config.constraints?.notBefore || '';
const notAfter = config.constraints?.notAfter || '';
@ -694,11 +786,6 @@ function renderEditor() {
<label>Order index</label>
<input type="number" id="metaOrder" value="${questionnaire.orderIndex ?? 0}" min="0" ${editable ? '' : 'disabled'}>
</div>
<div class="form-group" style="display:flex;align-items:flex-end;padding-bottom:16px">
<label class="checkbox-label">
<input type="checkbox" id="metaShowPoints" ${questionnaire.showPoints ? 'checked' : ''} ${editable ? '' : 'disabled'}> Show points
</label>
</div>
<div class="form-group">
<label>Category key (app grouping)</label>
<input type="text" id="metaCategoryKey" value="${esc(questionnaire.categoryKey || '')}" placeholder="e.g. health_interview" ${editable ? '' : 'disabled'}>
@ -838,7 +925,7 @@ async function saveMeta() {
const version = document.getElementById('metaVersion').value.trim();
const state = document.getElementById('metaState').value;
const orderIndex = parseInt(document.getElementById('metaOrder').value || '0', 10);
const showPoints = document.getElementById('metaShowPoints').checked ? 1 : 0;
const showPoints = 0;
const categoryKey = (document.getElementById('metaCategoryKey')?.value || '').trim();
let conditionJson = '{}';
let conditionForm = null;
@ -971,6 +1058,7 @@ function showAddQuestionForm() {
document.getElementById('aq_config_section').innerHTML = configFormHTML(type, {}, 'aq_cfg');
wireStringSpinnerOtherToggle('aq_cfg');
if (type === 'glass_scale_question') wireGlassSymptomsSection('aq_cfg');
if (type === 'slider_question' || type === 'value_spinner') wireValueScoreRules('aq_cfg');
const notesEl = document.getElementById('aq_notes_section');
if (notesEl) notesEl.innerHTML = notesSectionHTML({}, 'aq_cfg', qKey);
}
@ -1060,6 +1148,9 @@ function showAddQuestionForm() {
if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return;
postBody.glassSymptoms = glassSymptoms;
}
if (type === 'slider_question' || type === 'value_spinner') {
postBody.scoreRules = readValueScoreRulesFromForm('aq_cfg');
}
if (!validateStableKey(questionKey, 'Question key')) return;
if (!text) { showToast('German text is required', 'error'); return; }
if (type === 'string_spinner' && !validateStringSpinnerConfig('aq_cfg')) return;
@ -1203,7 +1294,10 @@ function renderQuestionBody(q) {
</label>
${notesSectionHTML(config, `eq_cfg_${q.questionID}`, qKey)}
<div id="eq_config_${q.questionID}">
${configFormHTML(layout, config, `eq_cfg_${q.questionID}`, { glassSymptoms: glassSymptomsRowsFromQuestion(q) })}
${configFormHTML(layout, config, `eq_cfg_${q.questionID}`, {
glassSymptoms: glassSymptomsRowsFromQuestion(q),
scoreRules: q.scoreRules || [],
})}
</div>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-primary btn-sm save-q-btn">Save Question</button>
@ -1271,6 +1365,9 @@ function renderQuestionBody(q) {
if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return;
payload.glassSymptoms = glassSymptoms;
}
if (type === 'slider_question' || type === 'value_spinner') {
payload.scoreRules = readValueScoreRulesFromForm(cfgPrefix);
}
if (!validateStableKey(questionKey, 'Question key')) return;
if (!text) { showToast('German text is required', 'error'); return; }
if (type === 'string_spinner' && !validateStringSpinnerConfig(cfgPrefix)) return;
@ -1291,15 +1388,19 @@ function renderQuestionBody(q) {
const newLayout = document.getElementById(`eq_type_${q.questionID}`).value;
const opts = newLayout === 'glass_scale_question'
? { glassSymptoms: glassSymptomsRowsFromQuestion(q) }
: {};
: (newLayout === 'slider_question' || newLayout === 'value_spinner')
? { scoreRules: q.scoreRules || [] }
: {};
document.getElementById(`eq_config_${q.questionID}`).innerHTML =
configFormHTML(newLayout, {}, cfgPrefix, opts);
wireStringSpinnerOtherToggle(cfgPrefix);
if (newLayout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
if (newLayout === 'slider_question' || newLayout === 'value_spinner') wireValueScoreRules(cfgPrefix);
});
wireStringSpinnerOtherToggle(cfgPrefix);
if (layout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
if (layout === 'slider_question' || layout === 'value_spinner') wireValueScoreRules(cfgPrefix);
body.querySelector('.del-q-btn').addEventListener('click', () => deleteQuestion(q));

View File

@ -48,6 +48,12 @@ function renderHomeDashboard(ov, isAdmin) {
links.push({ href: '#/dev', label: 'Admin settings', desc: 'Dev import, exports, database tools' });
}
const scoringProfiles = ov.scoringProfiles || [];
const primaryProfile = scoringProfiles[0];
const greenPct = primaryProfile && primaryProfile.clientCount
? Math.round(((primaryProfile.bands?.green ?? 0) / primaryProfile.clientCount) * 100)
: null;
el.innerHTML = `
<div class="insights-kpi-grid">
<a href="#/clients" class="insights-kpi card home-kpi-link">
@ -66,6 +72,16 @@ function renderHomeDashboard(ov, isAdmin) {
<div class="insights-kpi-value">${ov.submissionsLast30d ?? 0}</div>
<div class="insights-kpi-label">Uploads (30 days)</div>
</a>
${primaryProfile ? `
<a href="#/insights" class="insights-kpi card home-kpi-link">
<div class="insights-kpi-value">${primaryProfile.clientCount ?? 0}</div>
<div class="insights-kpi-label">${esc(primaryProfile.name)} scored</div>
</a>` : ''}
${greenPct != null ? `
<a href="#/insights" class="insights-kpi card home-kpi-link">
<div class="insights-kpi-value">${greenPct}%</div>
<div class="insights-kpi-label">Green (${esc(primaryProfile.name)})</div>
</a>` : ''}
</div>
<div class="card" style="margin-top:20px">

View File

@ -81,6 +81,35 @@ function renderInsights() {
{ suffix: '%', max: 100 }
);
const scoringProfiles = ov.scoringProfiles || [];
const scoringCharts = scoringProfiles.map(sp => {
const computed = sp.computedBands || sp.bands || {};
const coach = sp.coachBands || {};
const computedChart = horizontalBarChart([
{ label: 'Green', value: computed.green ?? 0 },
{ label: 'Yellow', value: computed.yellow ?? 0 },
{ label: 'Red', value: computed.red ?? 0 },
], { suffix: '' });
const coachChart = horizontalBarChart([
{ label: 'Green', value: coach.green ?? 0 },
{ label: 'Yellow', value: coach.yellow ?? 0 },
{ label: 'Red', value: coach.red ?? 0 },
], { suffix: '' });
return `
<div class="card insights-chart-card">
<h3>${esc(sp.name)}</h3>
<p class="insights-chart-hint">
${sp.clientCount ?? 0} client(s) with complete profile
${sp.averageTotal != null ? ` · avg ${sp.averageTotal}` : ''}
${sp.pendingReview ? ` · ${sp.pendingReview} pending coach review` : ''}
</p>
<p class="insights-chart-hint" style="margin:0 0 6px"><strong>Calculated bands</strong></p>
${computedChart}
<p class="insights-chart-hint" style="margin:12px 0 6px"><strong>Coach decisions</strong></p>
${coachChart}
</div>`;
}).join('');
const qRows = (ov.questionnaires || []).map(q => `
<tr>
<td><strong>${esc(q.name)}</strong></td>
@ -108,6 +137,12 @@ function renderInsights() {
${idleChart}
</div>
</div>
${scoringProfiles.length ? `
<div class="card" style="margin-top:20px">
<h3 style="margin:0 0 8px">Scoring profiles</h3>
<p class="insights-chart-hint" style="margin:0 0 12px">Calculated vs coach band distribution among clients with a complete weighted score</p>
<div class="insights-charts-grid">${scoringCharts}</div>
</div>` : ''}
<div class="card" style="margin-top:20px">
<h3 style="margin:0 0 8px">Completion by questionnaire</h3>
<p class="insights-chart-hint" style="margin:0 0 12px">Share of clients who completed each active questionnaire</p>

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');