From 11bae269e92eaffc1995ef0bcc0f6ead26a6755a Mon Sep 17 00:00:00 2001
From: Tom Hempel
Date: Mon, 8 Jun 2026 19:22:12 +0200
Subject: [PATCH] added website overwrite for score decision
---
data/app_ui_strings.json | 14 ++++
handlers/clients.php | 55 ++++++++++++++++
website/css/style.css | 14 ++++
website/js/pages/clients.js | 127 ++++++++++++++++++++++++++++++++----
4 files changed, 199 insertions(+), 11 deletions(-)
diff --git a/data/app_ui_strings.json b/data/app_ui_strings.json
index bb2de1d..f489961 100644
--- a/data/app_ui_strings.json
+++ b/data/app_ui_strings.json
@@ -92,8 +92,10 @@
"review_scores_calculated_category",
"review_scores_coach_category",
"review_scores_empty",
+ "review_scores_pending_upload",
"review_scores_save_failed",
"review_scores_saved",
+ "review_scores_saved_pending",
"review_scores_set_category",
"review_scores_subtitle",
"review_scores_total",
@@ -108,9 +110,14 @@
"start_upload",
"upload",
"upload_failed_title",
+ "upload_item_scoring_review",
"upload_nothing_pending",
"upload_prepare_failed",
+ "upload_scoring_review_summary",
+ "upload_summary",
"upload_success_message",
+ "upload_success_questionnaires",
+ "upload_success_scoring_reviews",
"username_hint",
"year"
],
@@ -205,8 +212,10 @@
"review_scores_calculated_category": "Berechnete Kategorie",
"review_scores_coach_category": "Coach-Kategorie",
"review_scores_empty": "Noch keine vollständigen Profilwerte zum Prüfen.",
+ "review_scores_pending_upload": "Ausstehend — wird mit dem Upload gesendet",
"review_scores_save_failed": "Kategorie konnte nicht gespeichert werden.",
"review_scores_saved": "Kategorie gespeichert.",
+ "review_scores_saved_pending": "Kategorie gespeichert — wird beim Upload gesendet.",
"review_scores_set_category": "Kategorie setzen",
"review_scores_subtitle": "Prüfen Sie die berechneten Werte und bestätigen oder setzen Sie die Kategorie.",
"review_scores_total": "Gewichtete Summe",
@@ -221,9 +230,14 @@
"start_upload": "Upload starten?",
"upload": "Hochladen",
"upload_failed_title": "Upload fehlgeschlagen",
+ "upload_item_scoring_review": "Punkteprüfung: {profile}",
"upload_nothing_pending": "Es wurde nichts hochgeladen. Es liegen keine abgeschlossenen Fragebögen zum Upload vor. Bitte zuerst einen Fragebogen abschließen.",
"upload_prepare_failed": "Es wurde nichts hochgeladen. Die Daten konnten nicht für den Upload vorbereitet werden. Bitte erneut synchronisieren oder den Support kontaktieren.",
+ "upload_scoring_review_summary": "Coach: {band} · Summe {total}",
+ "upload_summary": "{qn} Fragebögen · {scores} Punkteprüfungen · {clients} Klienten",
"upload_success_message": "Hochladen: {count} erledigt.",
+ "upload_success_questionnaires": "{count} Fragebogen/Fragebögen",
+ "upload_success_scoring_reviews": "{count} Punkteprüfung(en)",
"username_hint": "Benutzername",
"year": "Jahr",
"questionnaire_group_demographics": "Demografie test",
diff --git a/handlers/clients.php b/handlers/clients.php
index 7d73629..5a7b8f4 100644
--- a/handlers/clients.php
+++ b/handlers/clients.php
@@ -98,6 +98,61 @@ case 'POST':
}
break;
+case 'PUT':
+ $body = read_json_body();
+ $clientCode = trim((string)($body['clientCode'] ?? ''));
+ $profileID = trim((string)($body['profileID'] ?? ''));
+ $coachBand = trim((string)($body['coachBand'] ?? ''));
+
+ if ($clientCode === '' || $profileID === '' || $coachBand === '') {
+ json_error('MISSING_FIELDS', 'clientCode, profileID, and coachBand are required', 400);
+ }
+
+ $calculatedBand = trim((string)($body['calculatedBand'] ?? ''));
+ $weightedTotal = isset($body['weightedTotal']) ? (float)$body['weightedTotal'] : null;
+
+ try {
+ [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
+
+ [$clause, $params] = rbac_client_filter($tokenRec, 'cl');
+ $chk = $pdo->prepare(
+ "SELECT clientCode FROM client cl WHERE cl.clientCode = :cc AND ($clause)"
+ );
+ $chk->execute(array_merge([':cc' => $clientCode], $params));
+ if (!$chk->fetch()) {
+ qdb_discard($tmpDb, $lockFp);
+ json_error('NOT_FOUND', 'Client not found or not authorized', 404);
+ }
+
+ require_once __DIR__ . '/../lib/scoring.php';
+ if (!qdb_is_valid_scoring_band($coachBand)) {
+ qdb_discard($tmpDb, $lockFp);
+ json_error('INVALID_FIELD', 'coachBand must be green, yellow, or red', 400);
+ }
+
+ $profile = qdb_save_coach_scoring_review(
+ $pdo,
+ $clientCode,
+ $profileID,
+ $coachBand,
+ (string)($tokenRec['userID'] ?? ''),
+ $weightedTotal,
+ $calculatedBand !== '' ? $calculatedBand : null,
+ );
+
+ qdb_save($tmpDb, $lockFp);
+ json_success(['profile' => $profile]);
+ } catch (InvalidArgumentException $e) {
+ qdb_discard($tmpDb ?? null, $lockFp ?? null);
+ json_error('INVALID_FIELD', $e->getMessage(), 400);
+ } catch (RuntimeException $e) {
+ qdb_discard($tmpDb ?? null, $lockFp ?? null);
+ json_error('NOT_FOUND', $e->getMessage(), 404);
+ } catch (Throwable $e) {
+ qdb_handler_fail($e, 'clients', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
+ }
+ break;
+
case 'DELETE':
$body = read_json_body();
$clientCode = trim($body['clientCode'] ?? '');
diff --git a/website/css/style.css b/website/css/style.css
index b61d0c4..23ea546 100644
--- a/website/css/style.css
+++ b/website/css/style.css
@@ -3034,6 +3034,20 @@ select:disabled {
letter-spacing: .04em;
color: #ca8a04;
}
+.client-coach-band-select {
+ min-width: 6.5rem;
+ max-width: 100%;
+ padding: 2px 6px;
+ font-size: .75rem;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: var(--surface, #fff);
+ color: var(--text);
+}
+.client-coach-band-select:disabled {
+ opacity: 0.65;
+ cursor: wait;
+}
.client-profile-score-hint,
.client-profile-score-meta {
margin: 6px 0 0;
diff --git a/website/js/pages/clients.js b/website/js/pages/clients.js
index 46f70dc..a2f7bbd 100644
--- a/website/js/pages/clients.js
+++ b/website/js/pages/clients.js
@@ -1,4 +1,4 @@
-import { apiGet, apiPost, apiDelete } from '../api.js';
+import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { pageHeaderHTML, showToast } from '../app.js';
import { isDevTestClientCode, testDataRowClassForClient } from '../test-data.js';
@@ -167,6 +167,12 @@ function renderClientTableBody() {
body.querySelectorAll('.delete-client-btn').forEach(btn => {
btn.addEventListener('click', () => deleteClient(btn.dataset.code));
});
+ body.querySelectorAll('.client-coach-band-select').forEach(select => {
+ select.addEventListener('focus', () => {
+ select.dataset.prev = select.value;
+ });
+ select.addEventListener('change', () => onCoachBandChange(select));
+ });
}
const pag = document.getElementById('clientsPagination');
@@ -251,7 +257,7 @@ function clientDetailPanelHTML(clientCode) {
Coach: ${esc(cl.coachUsername || '—')}
${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''}
- ${renderClientScoringProfiles(detail.scoringProfiles || [])}
+ ${renderClientScoringProfiles(detail.scoringProfiles || [], clientCode)}
${questionnaires.map(q => clientQnBlockHTML(clientCode, q)).join('')}`;
}
@@ -296,7 +302,39 @@ function bandBadgeHTML(band, { pending = false, incomplete = false, label = '' }
return `${esc(text || band)}`;
}
-function profileScoreBlockHTML(p) {
+const COACH_BANDS = ['green', 'yellow', 'red'];
+
+function bandLabel(band) {
+ if (!band) return '—';
+ return band.charAt(0).toUpperCase() + band.slice(1);
+}
+
+function coachBandSelectHTML(p, clientCode) {
+ const calcBand = p.calculatedBand || p.band;
+ if (!calcBand || !clientCode || !p.profileID) {
+ return '';
+ }
+ const current = p.coachBand || '';
+ const coachPending = p.pendingReview !== false && !current;
+ const placeholder = coachPending
+ ? ''
+ : '';
+ const options = COACH_BANDS.map(b => {
+ const selected = current === b ? ' selected' : '';
+ return ``;
+ }).join('');
+ return `
+ `;
+}
+
+function profileScoreBlockHTML(p, clientCode = '') {
const calcBand = p.calculatedBand || p.band;
if (!calcBand) {
return `
@@ -307,8 +345,11 @@ function profileScoreBlockHTML(p) {
}
const coachPending = p.pendingReview !== false && !p.coachBand;
const coachDiffers = p.coachBand && p.coachBand !== calcBand;
+ const coachControl = clientCode
+ ? coachBandSelectHTML(p, clientCode)
+ : (coachPending ? bandBadgeHTML(null, { pending: true }) : bandBadgeHTML(p.coachBand));
return `
-
+
${esc(p.name)}
Calculated
@@ -317,13 +358,13 @@ function profileScoreBlockHTML(p) {
Coach
- ${coachPending ? bandBadgeHTML(null, { pending: true }) : bandBadgeHTML(p.coachBand)}
+ ${coachControl}
${coachDiffers ? 'override' : ''}
`;
}
-function renderClientScoringProfiles(profiles) {
+function renderClientScoringProfiles(profiles, clientCode = '') {
if (!profiles.length) return '';
return `
@@ -333,14 +374,14 @@ function renderClientScoringProfiles(profiles) {
${profiles.map(p => {
const calc = p.calculatedBand || p.band;
- if (!calc) return profileScoreBlockHTML(p);
+ if (!calc) return profileScoreBlockHTML(p, clientCode);
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 `
- ${profileScoreBlockHTML(p)}
+ ${profileScoreBlockHTML(p, clientCode)}
${meta ? `
${meta}
` : ''}
${coachPending ? '
Coach has not reviewed this profile in the app yet.
' : ''}
`;
@@ -413,7 +454,7 @@ function clientQnBlockHTML(clientCode, q) {
`;
}
-function profileDotsHTML(profiles) {
+function profileDotsHTML(profiles, clientCode) {
if (!profiles?.length) {
return '
—';
}
@@ -421,7 +462,71 @@ function profileDotsHTML(profiles) {
if (!hasAny) {
return `
—`;
}
- return `
${profiles.map(p => profileScoreBlockHTML(p)).join('')}
`;
+ return `
${profiles.map(p => profileScoreBlockHTML(p, clientCode)).join('')}
`;
+}
+
+function patchClientScoringBand(clientCode, profileID, coachBand) {
+ const client = clientsList.find(c => c.clientCode === clientCode);
+ if (client?.scoringProfiles) {
+ const profile = client.scoringProfiles.find(p => p.profileID === profileID);
+ if (profile) {
+ profile.coachBand = coachBand;
+ profile.pendingReview = false;
+ }
+ }
+ const detail = detailByClient[clientCode];
+ if (detail?.scoringProfiles) {
+ const profile = detail.scoringProfiles.find(p => p.profileID === profileID);
+ if (profile) {
+ profile.coachBand = coachBand;
+ profile.pendingReview = false;
+ }
+ }
+}
+
+async function onCoachBandChange(select) {
+ const clientCode = select.dataset.client;
+ const profileID = select.dataset.profile;
+ const profileName = select.dataset.profileName || profileID;
+ const calculatedBand = select.dataset.calculated;
+ const weightedRaw = select.dataset.weighted;
+ const weightedTotal = weightedRaw !== '' ? Number(weightedRaw) : null;
+ const prev = select.dataset.prev || '';
+ const coachBand = select.value;
+
+ if (!coachBand) {
+ select.value = prev;
+ return;
+ }
+ if (coachBand === prev) {
+ return;
+ }
+
+ const msg = `Set coach category to "${bandLabel(coachBand)}" for ${profileName} (${clientCode})?\n\nCalculated category: ${bandLabel(calculatedBand)}.`;
+ if (!confirm(msg)) {
+ select.value = prev || '';
+ return;
+ }
+
+ select.disabled = true;
+ try {
+ await apiPut('clients.php', {
+ clientCode,
+ profileID,
+ coachBand,
+ calculatedBand,
+ weightedTotal,
+ });
+ patchClientScoringBand(clientCode, profileID, coachBand);
+ select.dataset.prev = coachBand;
+ showToast(`Coach category saved for ${profileName}`, 'success');
+ renderClientTableBody();
+ } catch (e) {
+ select.value = prev || '';
+ showToast(e.message, 'error');
+ } finally {
+ select.disabled = false;
+ }
}
function clientHasResponseData(c) {
@@ -438,7 +543,7 @@ function clientRowHTML(c) {
? `
`
: '';
const profileCol = scoringProfileCatalog.length > 0
- ? `
${profileDotsHTML(c.scoringProfiles || [])} | `
+ ? `
${profileDotsHTML(c.scoringProfiles || [], c.clientCode)} | `
: '';
const colCount = scoringProfileCatalog.length > 0 ? 4 : 3;