added website overwrite for score decision
Some checks failed
PHPUnit / test (push) Has been cancelled
Some checks failed
PHPUnit / test (push) Has been cancelled
This commit is contained in:
@ -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",
|
||||
|
||||
@ -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'] ?? '');
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user