added website overwrite for score decision
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-06-08 19:22:12 +02:00
parent 63adef79df
commit 11bae269e9
4 changed files with 199 additions and 11 deletions

View File

@ -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",

View File

@ -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'] ?? '');

View File

@ -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;

View File

@ -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)}` : ''}
</p>
${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 `<span class="band-badge band-${esc(band)}">${esc(text || band)}</span>`;
}
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
? '<option value="" selected disabled>Awaiting review</option>'
: '';
const options = COACH_BANDS.map(b => {
const selected = current === b ? ' selected' : '';
return `<option value="${esc(b)}"${selected}>${esc(bandLabel(b))}</option>`;
}).join('');
return `
<select class="client-coach-band-select"
data-client="${esc(clientCode)}"
data-profile="${esc(p.profileID)}"
data-profile-name="${esc(p.name || p.profileID)}"
data-calculated="${esc(calcBand)}"
data-weighted="${esc(String(p.weightedTotal ?? ''))}"
data-prev="${esc(current)}"
title="Set coach category">${placeholder}${options}</select>`;
}
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 `
<div class="client-profile-score-block${coachDiffers ? ' has-coach-override' : ''}">
<div class="client-profile-score-block${coachDiffers ? ' has-coach-override' : ''}" data-profile-id="${esc(p.profileID || '')}">
<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>
@ -317,13 +358,13 @@ function profileScoreBlockHTML(p) {
</div>
<div class="client-profile-score-row">
<span class="client-profile-score-label">Coach</span>
${coachPending ? bandBadgeHTML(null, { pending: true }) : bandBadgeHTML(p.coachBand)}
${coachControl}
${coachDiffers ? '<span class="client-profile-override-hint">override</span>' : ''}
</div>
</div>`;
}
function renderClientScoringProfiles(profiles) {
function renderClientScoringProfiles(profiles, clientCode = '') {
if (!profiles.length) return '';
return `
<div class="client-scoring-profiles-detail">
@ -333,14 +374,14 @@ function renderClientScoringProfiles(profiles) {
</p>
<div class="client-profile-scores">${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 `
<div class="scoring-profile-detail-card">
${profileScoreBlockHTML(p)}
${profileScoreBlockHTML(p, clientCode)}
${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>`;
@ -413,7 +454,7 @@ function clientQnBlockHTML(clientCode, q) {
</div>`;
}
function profileDotsHTML(profiles) {
function profileDotsHTML(profiles, clientCode) {
if (!profiles?.length) {
return '<span class="client-profile-dots-empty">—</span>';
}
@ -421,7 +462,71 @@ function profileDotsHTML(profiles) {
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>`;
return `<div class="client-profile-scores">${profiles.map(p => profileScoreBlockHTML(p, clientCode)).join('')}</div>`;
}
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) {
? `<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>`
? `<td class="client-profile-dots-cell">${profileDotsHTML(c.scoringProfiles || [], c.clientCode)}</td>`
: '';
const colCount = scoringProfileCatalog.length > 0 ? 4 : 3;