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>` : ''}`;