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;