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

@ -49,6 +49,7 @@ $routes = [
'results' => __DIR__ . '/../handlers/results.php', 'results' => __DIR__ . '/../handlers/results.php',
'export' => __DIR__ . '/../handlers/export.php', 'export' => __DIR__ . '/../handlers/export.php',
'analytics' => __DIR__ . '/../handlers/analytics.php', 'analytics' => __DIR__ . '/../handlers/analytics.php',
'scoring_profiles' => __DIR__ . '/../handlers/scoring_profiles.php',
'coaches' => __DIR__ . '/../handlers/coaches.php', 'coaches' => __DIR__ . '/../handlers/coaches.php',
'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php', 'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php',
'logout' => __DIR__ . '/../handlers/logout.php', 'logout' => __DIR__ . '/../handlers/logout.php',

View File

@ -1728,6 +1728,7 @@ function qdb_translation_row_label(array $e): string {
/** Export one questionnaire with structure + all translations (portable JSON). */ /** Export one questionnaire with structure + all translations (portable JSON). */
function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array { function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array {
require_once __DIR__ . '/lib/scoring.php';
$qn = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id'); $qn = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id');
$qn->execute([':id' => $qnID]); $qn->execute([':id' => $qnID]);
$qnRow = $qn->fetch(PDO::FETCH_ASSOC); $qnRow = $qn->fetch(PDO::FETCH_ASSOC);
@ -1779,6 +1780,7 @@ function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array {
'isRequired' => (int)$dbQ['isRequired'], 'isRequired' => (int)$dbQ['isRequired'],
'config' => $config, 'config' => $config,
'answerOptions' => $optionsOut, 'answerOptions' => $optionsOut,
'scoreRules' => qdb_get_score_rules_for_question($pdo, $dbQ['questionID']),
'translations' => qdb_fetch_translation_map($pdo, 'question', $dbQ['questionID']), 'translations' => qdb_fetch_translation_map($pdo, 'question', $dbQ['questionID']),
]; ];
} }
@ -1823,14 +1825,21 @@ function qdb_export_all_questionnaires_bundle(PDO $pdo): array {
} }
} }
return [ return [
'exportVersion' => 1, 'exportVersion' => 2,
'exportedAt' => time(), 'exportedAt' => time(),
'sourceLanguage' => QDB_SOURCE_LANGUAGE, 'sourceLanguage' => QDB_SOURCE_LANGUAGE,
'questionnaireCount' => count($items), 'questionnaireCount' => count($items),
'questionnaires' => $items, 'questionnaires' => $items,
'scoringProfiles' => qdb_export_scoring_profiles_bundle($pdo),
]; ];
} }
/** Export all scoring profiles for bundle portability. */
function qdb_export_scoring_profiles_bundle(PDO $pdo): array {
require_once __DIR__ . '/lib/scoring.php';
return qdb_list_scoring_profiles($pdo);
}
/** Remove questionnaire and related rows (no client_answer cleanup for other qns). */ /** Remove questionnaire and related rows (no client_answer cleanup for other qns). */
function qdb_delete_questionnaire_cascade(PDO $pdo, string $qnID): void { function qdb_delete_questionnaire_cascade(PDO $pdo, string $qnID): void {
$qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id'); $qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id');
@ -1957,6 +1966,11 @@ function qdb_import_questionnaire_bundle(PDO $pdo, array $item, bool $replaceIfE
qdb_apply_translation_map($pdo, 'question', $questionID, is_array($qTr) ? $qTr : []); qdb_apply_translation_map($pdo, 'question', $questionID, is_array($qTr) ? $qTr : []);
qdb_sync_question_note_strings($pdo, $questionKey, $config); qdb_sync_question_note_strings($pdo, $questionKey, $config);
if (!empty($q['scoreRules']) && is_array($q['scoreRules'])) {
require_once __DIR__ . '/lib/scoring.php';
qdb_sync_question_score_rules($pdo, $questionID, $q['scoreRules']);
}
foreach ($q['answerOptions'] ?? [] as $opt) { foreach ($q['answerOptions'] ?? [] as $opt) {
$optKey = trim((string)($opt['optionKey'] ?? '')); $optKey = trim((string)($opt['optionKey'] ?? ''));
$optGerman = trim($opt['defaultText'] ?? ''); $optGerman = trim($opt['defaultText'] ?? '');
@ -2213,12 +2227,102 @@ function qdb_import_questionnaires_bundle(PDO $pdo, array $bundle, bool $replace
foreach ($items as $item) { foreach ($items as $item) {
$results[] = qdb_import_questionnaire_bundle($pdo, $item, $replaceIfExists); $results[] = qdb_import_questionnaire_bundle($pdo, $item, $replaceIfExists);
} }
if (!empty($bundle['scoringProfiles']) && is_array($bundle['scoringProfiles'])) {
qdb_import_scoring_profiles_bundle($pdo, $bundle['scoringProfiles'], $replaceIfExists);
}
return [ return [
'imported' => count($results), 'imported' => count($results),
'results' => $results, 'results' => $results,
]; ];
} }
/** Import scoring profiles from a questionnaire bundle. */
function qdb_import_scoring_profiles_bundle(PDO $pdo, array $profiles, bool $replaceIfExists = false): void {
require_once __DIR__ . '/lib/scoring.php';
foreach ($profiles as $profile) {
if (!is_array($profile)) {
continue;
}
$wantedId = trim($profile['profileID'] ?? '');
$name = trim($profile['name'] ?? '');
if ($name === '') {
continue;
}
$exists = false;
if ($wantedId !== '') {
$chk = $pdo->prepare('SELECT 1 FROM scoring_profile WHERE profileID = :id');
$chk->execute([':id' => $wantedId]);
$exists = (bool)$chk->fetch();
}
if ($exists && $replaceIfExists) {
$pdo->prepare('DELETE FROM scoring_profile WHERE profileID = :id')->execute([':id' => $wantedId]);
$profileID = $wantedId;
} elseif ($exists) {
$profileID = bin2hex(random_bytes(16));
} else {
$profileID = $wantedId !== '' ? $wantedId : bin2hex(random_bytes(16));
}
$now = time();
$pdo->prepare(
'INSERT INTO scoring_profile (profileID, name, description, isActive,
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
VALUES (:id, :name, :desc, :act, :gmin, :gmax, :ymin, :ymax, :rmin, :ca, :ua)'
)->execute([
':id' => $profileID,
':name' => $name,
':desc' => trim($profile['description'] ?? ''),
':act' => !empty($profile['isActive']) ? 1 : 0,
':gmin' => (int)($profile['greenMin'] ?? 0),
':gmax' => (int)($profile['greenMax'] ?? 12),
':ymin' => (int)($profile['yellowMin'] ?? ((int)($profile['greenMax'] ?? 12) + 1)),
':ymax' => (int)($profile['yellowMax'] ?? 36),
':rmin' => (int)($profile['redMin'] ?? ((int)($profile['yellowMax'] ?? 36) + 1)),
':ca' => (int)($profile['createdAt'] ?? $now),
':ua' => (int)($profile['updatedAt'] ?? $now),
]);
$members = [];
foreach ($profile['questionnaires'] ?? [] as $idx => $row) {
if (!is_array($row)) {
continue;
}
$qnID = trim((string)($row['questionnaireID'] ?? ''));
if ($qnID === '') {
continue;
}
$chk = $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id');
$chk->execute([':id' => $qnID]);
if (!$chk->fetch()) {
continue;
}
$members[] = [
'questionnaireID' => $qnID,
'weight' => (float)($row['weight'] ?? 1.0),
'orderIndex' => (int)($row['orderIndex'] ?? $idx),
];
}
if ($members !== []) {
qdb_sync_profile_questionnaire_members($pdo, $profileID, $members);
}
}
}
function qdb_sync_profile_questionnaire_members(PDO $pdo, string $profileID, array $members): void {
$pdo->prepare('DELETE FROM scoring_profile_questionnaire WHERE profileID = :pid')
->execute([':pid' => $profileID]);
$ins = $pdo->prepare(
'INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
VALUES (:pid, :qn, :w, :o)'
);
foreach ($members as $m) {
$ins->execute([
':pid' => $profileID,
':qn' => $m['questionnaireID'],
':w' => $m['weight'],
':o' => $m['orderIndex'],
]);
}
}
// --- AES-256-CBC: IV(16) + CIPHERTEXT --- // --- AES-256-CBC: IV(16) + CIPHERTEXT ---
function aes256_cbc_encrypt_bytes(string $plain, string $key): string { function aes256_cbc_encrypt_bytes(string $plain, string $key): string {
$key = str_pad(substr($key, 0, 32), 32, "\0"); $key = str_pad(substr($key, 0, 32), 32, "\0");

View File

@ -1,6 +1,6 @@
{ {
"version": 2, "version": 2,
"description": "Coach-visible app UI strings only (excludes dev settings / database tools).", "description": "Coach-visible app UI strings only (excludes dev settings \/ database tools).",
"keys": [ "keys": [
"all_clients", "all_clients",
"answer", "answer",
@ -10,6 +10,9 @@
"auth_subtitle_change_password", "auth_subtitle_change_password",
"auth_subtitle_login", "auth_subtitle_login",
"auth_title", "auth_title",
"category_green",
"category_red",
"category_yellow",
"cancel", "cancel",
"choose_answer", "choose_answer",
"choose_more_elements", "choose_more_elements",
@ -84,6 +87,16 @@
"question", "question",
"questions_filled", "questions_filled",
"refresh", "refresh",
"review_scores",
"review_scores_agree",
"review_scores_calculated_category",
"review_scores_coach_category",
"review_scores_empty",
"review_scores_save_failed",
"review_scores_saved",
"review_scores_set_category",
"review_scores_subtitle",
"review_scores_total",
"save", "save",
"save_password_btn", "save_password_btn",
"select_one_answer", "select_one_answer",
@ -111,6 +124,9 @@
"auth_subtitle_login": "Melden Sie sich mit Ihrem Coach-Konto an.", "auth_subtitle_login": "Melden Sie sich mit Ihrem Coach-Konto an.",
"auth_title": "BW Schützt", "auth_title": "BW Schützt",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"category_green": "Grün",
"category_red": "Rot",
"category_yellow": "Gelb",
"choose_answer": "Antwort wählen", "choose_answer": "Antwort wählen",
"choose_more_elements": "Es müssen mehr Elemenete ausgewählt werden.", "choose_more_elements": "Es müssen mehr Elemenete ausgewählt werden.",
"client": "Klient", "client": "Klient",
@ -184,6 +200,16 @@
"question": "Frage", "question": "Frage",
"questions_filled": "Antworten", "questions_filled": "Antworten",
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
"review_scores": "Punkte prüfen",
"review_scores_agree": "Berechnete Kategorie bestätigen",
"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_save_failed": "Kategorie konnte nicht gespeichert werden.",
"review_scores_saved": "Kategorie gespeichert.",
"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",
"save": "Speichern", "save": "Speichern",
"save_password_btn": "Passwort speichern", "save_password_btn": "Passwort speichern",
"select_one_answer": "Bitte wählen Sie eine Antwort aus!", "select_one_answer": "Bitte wählen Sie eine Antwort aus!",
@ -200,7 +226,7 @@
"upload_success_message": "Hochladen: {count} erledigt.", "upload_success_message": "Hochladen: {count} erledigt.",
"username_hint": "Benutzername", "username_hint": "Benutzername",
"year": "Jahr", "year": "Jahr",
"questionnaire_group_demographics": "Demografie", "questionnaire_group_demographics": "Demografie test",
"questionnaire_group_rhs": "Gesundheitsinterview" "questionnaire_group_rhs": "Gesundheitsinterview"
} }
} }

View File

@ -13,7 +13,7 @@ if (defined('QDB_TEST_UPLOADS')) {
define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock'); define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock');
} }
define('QDB_SCHEMA', __DIR__ . '/schema.sql'); define('QDB_SCHEMA', __DIR__ . '/schema.sql');
define('QDB_VERSION', 6); define('QDB_VERSION', 9);
function qdb_table_exists(PDO $pdo, string $table): bool { function qdb_table_exists(PDO $pdo, string $table): bool {
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
@ -126,6 +126,92 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
$changed = true; $changed = true;
} }
if (!qdb_table_exists($pdo, 'question_score_rule')) {
$pdo->exec("
CREATE TABLE question_score_rule (
ruleID TEXT NOT NULL PRIMARY KEY,
questionID TEXT NOT NULL,
scopeKey TEXT NOT NULL DEFAULT '',
levelKey TEXT NOT NULL,
points INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(questionID) REFERENCES question(questionID) ON DELETE CASCADE,
UNIQUE(questionID, scopeKey, levelKey)
)
");
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_score_rule_question ON question_score_rule(questionID)');
$changed = true;
}
if (!qdb_table_exists($pdo, 'scoring_profile')) {
$pdo->exec("
CREATE TABLE scoring_profile (
profileID TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
isActive INTEGER NOT NULL DEFAULT 1,
greenMin INTEGER NOT NULL DEFAULT 0,
greenMax INTEGER NOT NULL DEFAULT 12,
yellowMin INTEGER NOT NULL DEFAULT 13,
yellowMax INTEGER NOT NULL DEFAULT 36,
redMin INTEGER NOT NULL DEFAULT 37,
createdAt INTEGER NOT NULL DEFAULT 0,
updatedAt INTEGER NOT NULL DEFAULT 0
)
");
$changed = true;
}
if (!qdb_table_exists($pdo, 'scoring_profile_questionnaire')) {
$pdo->exec("
CREATE TABLE scoring_profile_questionnaire (
profileID TEXT NOT NULL,
questionnaireID TEXT NOT NULL,
weight REAL NOT NULL DEFAULT 1.0,
orderIndex INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (profileID, questionnaireID),
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE,
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
)
");
$changed = true;
}
if (!qdb_table_exists($pdo, 'client_scoring_profile_result')) {
$pdo->exec("
CREATE TABLE client_scoring_profile_result (
clientCode TEXT NOT NULL,
profileID TEXT NOT NULL,
weightedTotal REAL NOT NULL DEFAULT 0,
band TEXT NOT NULL DEFAULT '',
computedAt INTEGER NOT NULL DEFAULT 0,
questionnaireSnapshot TEXT NOT NULL DEFAULT '{}',
coachBand TEXT NOT NULL DEFAULT '',
coachReviewedAt INTEGER NOT NULL DEFAULT 0,
coachReviewedByUserID TEXT NOT NULL DEFAULT '',
PRIMARY KEY (clientCode, profileID),
FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE,
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE
)
");
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_client_profile_result_profile ON client_scoring_profile_result(profileID)');
$changed = true;
}
if (qdb_table_exists($pdo, 'client_scoring_profile_result')) {
if (!qdb_column_exists($pdo, 'client_scoring_profile_result', 'coachBand')) {
$pdo->exec("ALTER TABLE client_scoring_profile_result ADD COLUMN coachBand TEXT NOT NULL DEFAULT ''");
$changed = true;
}
if (!qdb_column_exists($pdo, 'client_scoring_profile_result', 'coachReviewedAt')) {
$pdo->exec('ALTER TABLE client_scoring_profile_result ADD COLUMN coachReviewedAt INTEGER NOT NULL DEFAULT 0');
$changed = true;
}
if (!qdb_column_exists($pdo, 'client_scoring_profile_result', 'coachReviewedByUserID')) {
$pdo->exec("ALTER TABLE client_scoring_profile_result ADD COLUMN coachReviewedByUserID TEXT NOT NULL DEFAULT ''");
$changed = true;
}
}
if (qdb_table_exists($pdo, 'questionnaire_submission')) { if (qdb_table_exists($pdo, 'questionnaire_submission')) {
require_once __DIR__ . '/lib/submissions.php'; require_once __DIR__ . '/lib/submissions.php';
if (qdb_backfill_submissions_from_live($pdo)) { if (qdb_backfill_submissions_from_live($pdo)) {
@ -133,6 +219,43 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
} }
} }
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
if ($currentVersion < 7 && qdb_table_exists($pdo, 'question_score_rule')) {
require_once __DIR__ . '/lib/scoring.php';
if (qdb_backfill_glass_score_rules($pdo) > 0) {
$changed = true;
}
if (qdb_recompute_all_questionnaire_scores($pdo) > 0) {
$changed = true;
}
if (qdb_seed_default_rhs_scoring_profile($pdo) !== null) {
$changed = true;
}
}
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
if ($currentVersion < 8 && qdb_table_exists($pdo, 'scoring_profile')) {
if (!qdb_column_exists($pdo, 'scoring_profile', 'greenMin')) {
$pdo->exec('ALTER TABLE scoring_profile ADD COLUMN greenMin INTEGER NOT NULL DEFAULT 0');
$changed = true;
}
if (!qdb_column_exists($pdo, 'scoring_profile', 'yellowMin')) {
$pdo->exec('ALTER TABLE scoring_profile ADD COLUMN yellowMin INTEGER NOT NULL DEFAULT 13');
$changed = true;
}
if (!qdb_column_exists($pdo, 'scoring_profile', 'redMin')) {
$pdo->exec('ALTER TABLE scoring_profile ADD COLUMN redMin INTEGER NOT NULL DEFAULT 37');
$changed = true;
}
$pdo->exec(
'UPDATE scoring_profile SET
greenMin = COALESCE(greenMin, 0),
yellowMin = greenMax + 1,
redMin = yellowMax + 1'
);
$changed = true;
}
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn(); $currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
if ($currentVersion < 6 && qdb_table_exists($pdo, 'questionnaire_submission')) { if ($currentVersion < 6 && qdb_table_exists($pdo, 'questionnaire_submission')) {
$pdo->exec( $pdo->exec(

View File

@ -7,6 +7,9 @@
* GET ?clients=1 -> list of clients assigned to the authenticated coach * GET ?clients=1 -> list of clients assigned to the authenticated coach
* GET ?clientCode=X&answers=1 -> all completed questionnaire answers for one client (encrypted) * GET ?clientCode=X&answers=1 -> all completed questionnaire answers for one client (encrypted)
* GET ?answersBulk=1 -> all assigned clients' answers in one payload (coach, encrypted) * GET ?answersBulk=1 -> all assigned clients' answers in one payload (coach, encrypted)
* GET ?scoringProfiles=1 -> active scoring profile definitions (encrypted)
* GET ?scoringReview=1 -> coach review state (coachBand only; computed on device)
* POST action=coachScoringReview -> coach agrees with or overrides calculated band (encrypted)
* POST -> submit interview answers for a client (coach / supervisor / admin). * POST -> submit interview answers for a client (coach / supervisor / admin).
* Re-posting the same clientCode + questionnaireID updates answers in place (re-edit). * Re-posting the same clientCode + questionnaireID updates answers in place (re-edit).
* Sensitive POST/GET ?clients=1 bodies use encrypted payloads (HKDF from Bearer token). * Sensitive POST/GET ?clients=1 bodies use encrypted payloads (HKDF from Bearer token).
@ -27,6 +30,58 @@ if ($method === 'POST') {
require_role(['admin', 'supervisor', 'coach'], $tokenRec); require_role(['admin', 'supervisor', 'coach'], $tokenRec);
$body = read_encrypted_json_body($tokenRec['_token']); $body = read_encrypted_json_body($tokenRec['_token']);
if (($body['action'] ?? '') === 'coachScoringReview') {
require_fields($body, ['clientCode', 'profileID', 'coachBand']);
$clientCode = trim((string)$body['clientCode']);
$profileID = trim((string)$body['profileID']);
$coachBand = trim((string)($body['coachBand'] ?? ''));
$calculatedBand = trim((string)($body['calculatedBand'] ?? ''));
$weightedTotal = isset($body['weightedTotal']) ? (float)$body['weightedTotal'] : null;
try {
$opened = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$clStmt = $pdo->prepare(
"SELECT cl.clientCode FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)"
);
$clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
if (!$clStmt->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, 'Coach scoring review', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
}
require_fields($body, ['questionnaireID', 'clientCode', 'answers']); require_fields($body, ['questionnaireID', 'clientCode', 'answers']);
$qnID = trim($body['questionnaireID']); $qnID = trim($body['questionnaireID']);
@ -126,7 +181,6 @@ if ($method === 'POST') {
$pdo->beginTransaction(); $pdo->beginTransaction();
$sumPoints = 0;
$glassByParent = []; $glassByParent = [];
$submittedQuestionIds = []; $submittedQuestionIds = [];
$answerInsert = $pdo->prepare(" $answerInsert = $pdo->prepare("
@ -183,19 +237,11 @@ if ($method === 'POST') {
} }
} }
// Resolve option key to answerOptionID and accumulate points // Resolve option key to answerOptionID (points computed by scoring engine after save)
$optionKey = $a['answerOptionKey'] ?? null; $optionKey = $a['answerOptionKey'] ?? null;
if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) { if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) {
$opt = $optionMap[$fullQID][(string)$optionKey]; $opt = $optionMap[$fullQID][(string)$optionKey];
$answerOptionID = $opt['answerOptionID']; $answerOptionID = $opt['answerOptionID'];
$sumPoints += $opt['points'];
} elseif (($shortIdToType[$shortId] ?? '') === 'multi_check_box_question'
&& $freeTextValue !== null && $freeTextValue !== '') {
foreach (qdb_decode_multi_check_values($freeTextValue) as $mk) {
if (isset($optionMap[$fullQID][$mk])) {
$sumPoints += $optionMap[$fullQID][$mk]['points'];
}
}
} }
$hasValue = $answerOptionID !== null $hasValue = $answerOptionID !== null
@ -247,6 +293,9 @@ if ($method === 'POST') {
} }
// Upsert completed_questionnaire record // Upsert completed_questionnaire record
require_once __DIR__ . '/../lib/scoring.php';
$sumPoints = qdb_compute_questionnaire_score($pdo, $clientCode, $qnID);
$pdo->prepare(" $pdo->prepare("
INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints) INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints)
VALUES (:cc, :qn, :abc, 'completed', :sa, :ca, :sp) VALUES (:cc, :qn, :abc, 'completed', :sa, :ca, :sp)
@ -277,6 +326,8 @@ if ($method === 'POST') {
$assignedByCoach $assignedByCoach
); );
qdb_recompute_profile_scores_for_client($pdo, $clientCode, $qnID);
$pdo->commit(); $pdo->commit();
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
@ -298,8 +349,50 @@ $translations = $_GET['translations'] ?? '';
$fetchClients = $_GET['clients'] ?? ''; $fetchClients = $_GET['clients'] ?? '';
$fetchAnswers = $_GET['answers'] ?? ''; $fetchAnswers = $_GET['answers'] ?? '';
$fetchAnswersBulk = $_GET['answersBulk'] ?? ''; $fetchAnswersBulk = $_GET['answersBulk'] ?? '';
$fetchScoringProfiles = $_GET['scoringProfiles'] ?? '';
$fetchScoringReview = $_GET['scoringReview'] ?? '';
$clientCode = trim($_GET['clientCode'] ?? ''); $clientCode = trim($_GET['clientCode'] ?? '');
if ($fetchScoringProfiles) {
require_role(['coach'], $tokenRec);
require_once __DIR__ . '/../lib/scoring.php';
$profiles = array_values(array_filter(
qdb_list_scoring_profiles($pdo),
static fn(array $p): bool => (int)($p['isActive'] ?? 0) === 1
));
qdb_discard($tmpDb, $lockFp);
json_success_sensitive(['profiles' => $profiles], $tokenRec['_token']);
}
if ($fetchScoringReview) {
require_role(['coach'], $tokenRec);
require_once __DIR__ . '/../lib/scoring.php';
$coachID = $tokenRec['entityID'] ?? '';
if ($clientCode !== '') {
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$clStmt = $pdo->prepare(
"SELECT cl.clientCode FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)"
);
$clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
if (!$clStmt->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
}
$clientCodes = [$clientCode];
} else {
$stmt = $pdo->prepare(
'SELECT clientCode FROM client WHERE coachID = :cid ORDER BY clientCode'
);
$stmt->execute([':cid' => $coachID]);
$clientCodes = $stmt->fetchAll(PDO::FETCH_COLUMN);
}
$clients = qdb_app_scoring_review_for_clients($pdo, $clientCodes);
qdb_discard($tmpDb, $lockFp);
json_success_sensitive(['clients' => $clients], $tokenRec['_token']);
}
if ($fetchAnswersBulk) { if ($fetchAnswersBulk) {
require_role(['coach'], $tokenRec); require_role(['coach'], $tokenRec);
require_once __DIR__ . '/../lib/app_answers.php'; require_once __DIR__ . '/../lib/app_answers.php';
@ -441,6 +534,14 @@ if ($qnID) {
$q['options'] = $config['valueOptions']; $q['options'] = $config['valueOptions'];
} }
if (in_array($layout, ['glass_scale_question', 'slider_question', 'value_spinner'], true)) {
require_once __DIR__ . '/../lib/scoring.php';
$ruleMap = qdb_score_rule_map_for_question($pdo, $dbQ['questionID']);
if ($ruleMap !== []) {
$q['scoreRuleMap'] = $ruleMap;
}
}
$ao = $pdo->prepare(" $ao = $pdo->prepare("
SELECT defaultText, points, nextQuestionId SELECT defaultText, points, nextQuestionId
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex FROM answer_option WHERE questionID = :qid ORDER BY orderIndex

View File

@ -36,8 +36,21 @@ case 'GET':
$stmt->execute(); $stmt->execute();
$clients = $stmt->fetchAll(PDO::FETCH_ASSOC); $clients = $stmt->fetchAll(PDO::FETCH_ASSOC);
require_once __DIR__ . '/../lib/submissions.php';
$scoringSummary = qdb_clients_scoring_summary_for_list($pdo, $tokenRec);
foreach ($clients as &$client) {
$client['scoringProfiles'] = qdb_client_scoring_dots_for_client(
$client['clientCode'],
$scoringSummary
);
}
unset($client);
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_success(['clients' => $clients]); json_success([
'clients' => $clients,
'scoringProfiles' => $scoringSummary['profiles'],
]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_handler_fail($e, 'clients', null, $tmpDb ?? null, $lockFp ?? null); qdb_handler_fail($e, 'clients', null, $tmpDb ?? null, $lockFp ?? null);
} }

View File

@ -1,5 +1,7 @@
<?php <?php
require_once __DIR__ . '/../lib/scoring.php';
$tokenRec = require_valid_token_web(); $tokenRec = require_valid_token_web();
switch ($method) { switch ($method) {
@ -39,7 +41,9 @@ case 'GET':
} }
unset($opt); unset($opt);
if (($q['type'] ?? '') === 'glass_scale_question') { if (($q['type'] ?? '') === 'glass_scale_question') {
$q['glassSymptoms'] = qdb_glass_symptoms_with_labels($pdo, $cfg); $q['glassSymptoms'] = qdb_glass_symptoms_with_score_rules($pdo, $q['questionID'], $cfg);
} elseif (in_array($q['type'] ?? '', ['slider_question', 'value_spinner'], true)) {
$q['scoreRules'] = qdb_get_score_rules_for_question($pdo, $q['questionID']);
} }
$tr = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :qid"); $tr = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :qid");
$tr->execute([':qid' => $q['questionID']]); $tr->execute([':qid' => $q['questionID']]);
@ -111,6 +115,10 @@ case 'POST':
if ($type === 'glass_scale_question') { if ($type === 'glass_scale_question') {
qdb_sync_glass_symptom_strings($pdo, $glassSymptoms); qdb_sync_glass_symptom_strings($pdo, $glassSymptoms);
} }
$scoreRules = qdb_parse_score_rules_body($body, $type, $config);
if ($scoreRules !== [] || in_array($type, ['slider_question', 'value_spinner', 'glass_scale_question'], true)) {
qdb_sync_question_score_rules($pdo, $id, $scoreRules);
}
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success(['question' => [ json_success(['question' => [
'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text, 'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text,
@ -118,7 +126,9 @@ case 'POST':
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
'configJson' => $configJson, 'answerOptions' => [], 'translations' => [], 'configJson' => $configJson, 'answerOptions' => [], 'translations' => [],
'glassSymptoms' => $type === 'glass_scale_question' 'glassSymptoms' => $type === 'glass_scale_question'
? qdb_glass_symptoms_with_labels($pdo, $config) : [], ? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [],
'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true)
? qdb_get_score_rules_for_question($pdo, $id) : [],
]]); ]]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null); qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
@ -172,6 +182,10 @@ case 'PUT':
if ($type === 'glass_scale_question') { if ($type === 'glass_scale_question') {
qdb_sync_glass_symptom_strings($pdo, $glassSymptoms); qdb_sync_glass_symptom_strings($pdo, $glassSymptoms);
} }
$scoreRules = qdb_parse_score_rules_body($body, $type, $config);
if ($scoreRules !== [] || in_array($type, ['slider_question', 'value_spinner', 'glass_scale_question'], true)) {
qdb_sync_question_score_rules($pdo, $id, $scoreRules);
}
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success(['question' => [ json_success(['question' => [
'questionID' => $id, 'questionnaireID' => $row['questionnaireID'], 'questionID' => $id, 'questionnaireID' => $row['questionnaireID'],
@ -179,7 +193,9 @@ case 'PUT':
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
'configJson' => $configJson, 'configJson' => $configJson,
'glassSymptoms' => $type === 'glass_scale_question' 'glassSymptoms' => $type === 'glass_scale_question'
? qdb_glass_symptoms_with_labels($pdo, $config) : [], ? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [],
'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true)
? qdb_get_score_rules_for_question($pdo, $id) : [],
]]); ]]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null); qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
@ -203,6 +219,7 @@ case 'DELETE':
$pdo->prepare("DELETE FROM answer_option_translation WHERE answerOptionID = :id")->execute([':id' => $aoid]); $pdo->prepare("DELETE FROM answer_option_translation WHERE answerOptionID = :id")->execute([':id' => $aoid]);
} }
$pdo->prepare("DELETE FROM answer_option WHERE questionID = :id")->execute([':id' => $id]); $pdo->prepare("DELETE FROM answer_option WHERE questionID = :id")->execute([':id' => $id]);
$pdo->prepare("DELETE FROM question_score_rule WHERE questionID = :id")->execute([':id' => $id]);
$pdo->prepare("DELETE FROM question_translation WHERE questionID = :id")->execute([':id' => $id]); $pdo->prepare("DELETE FROM question_translation WHERE questionID = :id")->execute([':id' => $id]);
$pdo->prepare("DELETE FROM client_answer WHERE questionID = :id")->execute([':id' => $id]); $pdo->prepare("DELETE FROM client_answer WHERE questionID = :id")->execute([':id' => $id]);
$pdo->prepare("DELETE FROM question WHERE questionID = :id")->execute([':id' => $id]); $pdo->prepare("DELETE FROM question WHERE questionID = :id")->execute([':id' => $id]);

View File

@ -0,0 +1,203 @@
<?php
/**
* Scoring profiles CRUD (weighted multi-questionnaire Green/Yellow/Red bands).
*/
require_once __DIR__ . '/../lib/scoring.php';
$tokenRec = require_valid_token_web();
switch ($method) {
case 'GET':
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
$profileID = trim($_GET['id'] ?? '');
if ($profileID !== '') {
$profile = qdb_get_scoring_profile($pdo, $profileID);
qdb_discard($tmpDb, $lockFp);
if (!$profile) {
json_error('NOT_FOUND', 'Scoring profile not found', 404);
}
json_success(['profile' => $profile]);
}
json_success(['profiles' => qdb_list_scoring_profiles($pdo)]);
qdb_discard($tmpDb, $lockFp);
} catch (Throwable $e) {
qdb_handler_fail($e, 'List scoring profiles', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'POST':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
$name = trim($body['name'] ?? '');
if ($name === '') {
json_error('BAD_REQUEST', 'name is required', 400);
}
$bands = qdb_parse_scoring_bands_body($body);
$bandErr = qdb_validate_scoring_bands($bands);
if ($bandErr !== null) {
json_error('INVALID_FIELD', $bandErr, 400);
}
$members = qdb_parse_profile_questionnaires($body['questionnaires'] ?? []);
if ($members === []) {
json_error('INVALID_FIELD', 'At least one questionnaire is required', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$profileID = bin2hex(random_bytes(16));
$now = time();
$pdo->prepare(
'INSERT INTO scoring_profile (profileID, name, description, isActive,
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
VALUES (:id, :name, :desc, :act, :gmin, :gmax, :ymin, :ymax, :rmin, :ca, :ua)'
)->execute([
':id' => $profileID,
':name' => $name,
':desc' => trim($body['description'] ?? ''),
':act' => !empty($body['isActive']) ? 1 : 0,
':gmin' => $bands['greenMin'],
':gmax' => $bands['greenMax'],
':ymin' => $bands['yellowMin'],
':ymax' => $bands['yellowMax'],
':rmin' => $bands['redMin'],
':ca' => $now,
':ua' => $now,
]);
qdb_sync_profile_questionnaires($pdo, $profileID, $members);
qdb_recompute_all_profiles_after_change($pdo);
qdb_save($tmpDb, $lockFp);
json_success(['profile' => qdb_get_scoring_profile($pdo, $profileID)]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Create scoring profile', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
$profileID = trim($body['profileID'] ?? '');
if ($profileID === '') {
json_error('BAD_REQUEST', 'profileID is required', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$existing = qdb_get_scoring_profile($pdo, $profileID);
if (!$existing) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Scoring profile not found', 404);
}
$name = trim($body['name'] ?? $existing['name']);
$bands = qdb_normalize_scoring_bands(array_merge($existing, $body));
$bandErr = qdb_validate_scoring_bands($bands);
if ($bandErr !== null) {
qdb_discard($tmpDb, $lockFp);
json_error('INVALID_FIELD', $bandErr, 400);
}
$members = isset($body['questionnaires'])
? qdb_parse_profile_questionnaires($body['questionnaires'])
: null;
if ($members !== null && $members === []) {
qdb_discard($tmpDb, $lockFp);
json_error('INVALID_FIELD', 'At least one questionnaire is required', 400);
}
$pdo->prepare(
'UPDATE scoring_profile SET name = :name, description = :desc, isActive = :act,
greenMin = :gmin, greenMax = :gmax, yellowMin = :ymin, yellowMax = :ymax, redMin = :rmin,
updatedAt = :ua WHERE profileID = :id'
)->execute([
':name' => $name,
':desc' => trim($body['description'] ?? $existing['description']),
':act' => isset($body['isActive']) ? (!empty($body['isActive']) ? 1 : 0) : $existing['isActive'],
':gmin' => $bands['greenMin'],
':gmax' => $bands['greenMax'],
':ymin' => $bands['yellowMin'],
':ymax' => $bands['yellowMax'],
':rmin' => $bands['redMin'],
':ua' => time(),
':id' => $profileID,
]);
if ($members !== null) {
qdb_sync_profile_questionnaires($pdo, $profileID, $members);
}
qdb_recompute_all_profiles_after_change($pdo, $profileID);
qdb_save($tmpDb, $lockFp);
json_success(['profile' => qdb_get_scoring_profile($pdo, $profileID)]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Update scoring profile', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'DELETE':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
$profileID = trim($body['profileID'] ?? '');
if ($profileID === '') {
json_error('BAD_REQUEST', 'profileID is required', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$pdo->prepare('DELETE FROM scoring_profile WHERE profileID = :id')->execute([':id' => $profileID]);
qdb_save($tmpDb, $lockFp);
json_success(['deleted' => true]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete scoring profile', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
/**
* @return list<array{questionnaireID: string, weight: float, orderIndex: int}>
*/
function qdb_parse_profile_questionnaires(array $rows): array {
$out = [];
$idx = 0;
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$qnID = trim((string)($row['questionnaireID'] ?? ''));
if ($qnID === '') {
continue;
}
$weight = (float)($row['weight'] ?? 1.0);
if ($weight <= 0) {
$weight = 1.0;
}
$out[] = [
'questionnaireID' => $qnID,
'weight' => $weight,
'orderIndex' => (int)($row['orderIndex'] ?? $idx),
];
$idx++;
}
return $out;
}
function qdb_sync_profile_questionnaires(PDO $pdo, string $profileID, array $members): void {
$pdo->prepare('DELETE FROM scoring_profile_questionnaire WHERE profileID = :pid')
->execute([':pid' => $profileID]);
$ins = $pdo->prepare(
'INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
VALUES (:pid, :qn, :w, :o)'
);
foreach ($members as $m) {
$ins->execute([
':pid' => $profileID,
':qn' => $m['questionnaireID'],
':w' => $m['weight'],
':o' => $m['orderIndex'],
]);
}
}
function qdb_recompute_all_profiles_after_change(PDO $pdo, ?string $profileID = null): void {
$clients = $pdo->query('SELECT DISTINCT clientCode FROM completed_questionnaire WHERE status = \'completed\'')
->fetchAll(PDO::FETCH_COLUMN);
foreach ($clients as $clientCode) {
qdb_recompute_profile_scores_for_client($pdo, (string)$clientCode);
}
}

View File

@ -101,9 +101,77 @@ function qdb_analytics_overview(PDO $pdo, array $tokenRec): array {
'submissionsLast30d' => $submissions30d, 'submissionsLast30d' => $submissions30d,
'questionnaires' => $perQuestionnaire, 'questionnaires' => $perQuestionnaire,
'submissionsByDay' => qdb_analytics_submissions_by_day($pdo, $tokenRec, 14), 'submissionsByDay' => qdb_analytics_submissions_by_day($pdo, $tokenRec, 14),
'scoringProfiles' => qdb_analytics_scoring_profiles($pdo, $tokenRec),
]; ];
} }
/**
* Band distribution and averages per active scoring profile (RBAC-scoped).
*
* @return list<array<string, mixed>>
*/
function qdb_analytics_scoring_profiles(PDO $pdo, array $tokenRec): array {
require_once __DIR__ . '/scoring.php';
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$profiles = $pdo->query(
"SELECT profileID, name, description,
greenMin, greenMax, yellowMin, yellowMax, redMin, isActive
FROM scoring_profile WHERE isActive = 1 ORDER BY name"
)->fetchAll(PDO::FETCH_ASSOC);
$out = [];
foreach ($profiles as $p) {
$profileID = $p['profileID'];
$stmt = $pdo->prepare(
"SELECT r.band, r.coachBand, r.weightedTotal
FROM client_scoring_profile_result r
INNER JOIN client cl ON cl.clientCode = r.clientCode
WHERE r.profileID = :pid AND ($rbacClause)"
);
$stmt->execute(array_merge([':pid' => $profileID], $rbacParams));
$computedBands = ['green' => 0, 'yellow' => 0, 'red' => 0];
$coachBands = ['green' => 0, 'yellow' => 0, 'red' => 0];
$pendingReview = 0;
$totalSum = 0.0;
$count = 0;
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$calcBand = $row['band'] ?? '';
if (isset($computedBands[$calcBand])) {
$computedBands[$calcBand]++;
}
$coachBand = trim((string)($row['coachBand'] ?? ''));
if ($coachBand === '') {
$pendingReview++;
} elseif (isset($coachBands[$coachBand])) {
$coachBands[$coachBand]++;
}
$totalSum += (float)$row['weightedTotal'];
$count++;
}
$members = qdb_scoring_profile_members($pdo, $profileID);
$bandRanges = qdb_normalize_scoring_bands($p);
$out[] = [
'profileID' => $profileID,
'name' => $p['name'],
'description' => $p['description'] ?? '',
'greenMin' => $bandRanges['greenMin'],
'greenMax' => $bandRanges['greenMax'],
'yellowMin' => $bandRanges['yellowMin'],
'yellowMax' => $bandRanges['yellowMax'],
'redMin' => $bandRanges['redMin'],
'questionnaires' => $members,
'clientCount' => $count,
'bands' => $computedBands,
'computedBands' => $computedBands,
'coachBands' => $coachBands,
'pendingReview' => $pendingReview,
'averageTotal' => $count > 0 ? round($totalSum / $count, 2) : null,
];
}
return $out;
}
function qdb_analytics_stale_clients(PDO $pdo, array $tokenRec): array { function qdb_analytics_stale_clients(PDO $pdo, array $tokenRec): array {
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$now = time(); $now = time();

757
lib/scoring.php Normal file
View File

@ -0,0 +1,757 @@
<?php
/**
* Questionnaire scoring engine and scoring-profile aggregation.
*/
/** Default glass-scale level keys and implicit points (04). */
function qdb_glass_scale_level_keys(): array {
return ['never_glass', 'little_glass', 'moderate_glass', 'much_glass', 'extreme_glass'];
}
function qdb_default_glass_level_points(): array {
return [
'never_glass' => 0,
'little_glass' => 1,
'moderate_glass' => 2,
'much_glass' => 3,
'extreme_glass' => 4,
];
}
function qdb_make_score_rule_id(string $questionID, string $scopeKey, string $levelKey): string {
return substr(hash('sha256', $questionID . "\0" . $scopeKey . "\0" . $levelKey), 0, 32);
}
/**
* @return list<array{scopeKey: string, levelKey: string, points: int}>
*/
function qdb_get_score_rules_for_question(PDO $pdo, string $questionID): array {
$stmt = $pdo->prepare(
'SELECT scopeKey, levelKey, points FROM question_score_rule WHERE questionID = :qid ORDER BY scopeKey, levelKey'
);
$stmt->execute([':qid' => $questionID]);
$rows = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
$rows[] = [
'scopeKey' => (string)($r['scopeKey'] ?? ''),
'levelKey' => (string)($r['levelKey'] ?? ''),
'points' => (int)($r['points'] ?? 0),
];
}
return $rows;
}
/**
* @param list<array{scopeKey?: string, levelKey: string, points: int}> $rules
*/
function qdb_sync_question_score_rules(PDO $pdo, string $questionID, array $rules): void {
$pdo->prepare('DELETE FROM question_score_rule WHERE questionID = :qid')
->execute([':qid' => $questionID]);
if ($rules === []) {
return;
}
$ins = $pdo->prepare(
'INSERT INTO question_score_rule (ruleID, questionID, scopeKey, levelKey, points)
VALUES (:rid, :qid, :sk, :lk, :pts)'
);
foreach ($rules as $rule) {
$scopeKey = trim((string)($rule['scopeKey'] ?? ''));
$levelKey = trim((string)($rule['levelKey'] ?? ''));
if ($levelKey === '') {
continue;
}
$ins->execute([
':rid' => qdb_make_score_rule_id($questionID, $scopeKey, $levelKey),
':qid' => $questionID,
':sk' => $scopeKey,
':lk' => $levelKey,
':pts' => (int)($rule['points'] ?? 0),
]);
}
}
/**
* Build nested map: scopeKey -> levelKey -> points.
*
* @return array<string, array<string, int>>
*/
function qdb_score_rule_map_for_question(PDO $pdo, string $questionID): array {
$map = [];
foreach (qdb_get_score_rules_for_question($pdo, $questionID) as $rule) {
$map[$rule['scopeKey']][$rule['levelKey']] = $rule['points'];
}
return $map;
}
/**
* Attach scoreRules to glass symptom rows for editor API.
*
* @return list<array{key: string, labelDe: string, scoreLevels: array<string, int>}>
*/
function qdb_glass_symptoms_with_score_rules(PDO $pdo, string $questionID, array $config): array {
$ruleMap = $questionID !== '' ? qdb_score_rule_map_for_question($pdo, $questionID) : [];
$defaults = qdb_default_glass_level_points();
$rows = [];
foreach (qdb_glass_symptoms_with_labels($pdo, $config) as $row) {
$key = $row['key'];
$levels = [];
foreach (qdb_glass_scale_level_keys() as $levelKey) {
$levels[$levelKey] = $ruleMap[$key][$levelKey]
?? $ruleMap[''][$levelKey]
?? $defaults[$levelKey]
?? 0;
}
$rows[] = [
'key' => $key,
'labelDe' => $row['labelDe'],
'scoreLevels' => $levels,
];
}
return $rows;
}
/**
* Parse scoreRules from question save body.
*
* @return list<array{scopeKey: string, levelKey: string, points: int}>
*/
function qdb_parse_score_rules_body(array $body, string $questionType, array $config): array {
if (!isset($body['scoreRules']) || !is_array($body['scoreRules'])) {
if ($questionType === 'glass_scale_question' && isset($body['glassSymptoms']) && is_array($body['glassSymptoms'])) {
return qdb_score_rules_from_glass_symptoms($body['glassSymptoms']);
}
return [];
}
$rules = [];
foreach ($body['scoreRules'] as $row) {
if (!is_array($row)) {
continue;
}
$levelKey = trim((string)($row['levelKey'] ?? ''));
if ($levelKey === '') {
continue;
}
$rules[] = [
'scopeKey' => trim((string)($row['scopeKey'] ?? '')),
'levelKey' => $levelKey,
'points' => (int)($row['points'] ?? 0),
];
}
return $rules;
}
/**
* @param list<array{key?: string, scoreLevels?: array<string, int>}> $glassSymptoms
* @return list<array{scopeKey: string, levelKey: string, points: int}>
*/
function qdb_score_rules_from_glass_symptoms(array $glassSymptoms): array {
$rules = [];
$defaults = qdb_default_glass_level_points();
foreach ($glassSymptoms as $row) {
if (!is_array($row)) {
continue;
}
$scopeKey = trim((string)($row['key'] ?? ''));
if ($scopeKey === '') {
continue;
}
$levels = is_array($row['scoreLevels'] ?? null) ? $row['scoreLevels'] : $defaults;
foreach (qdb_glass_scale_level_keys() as $levelKey) {
$rules[] = [
'scopeKey' => $scopeKey,
'levelKey' => $levelKey,
'points' => (int)($levels[$levelKey] ?? $defaults[$levelKey] ?? 0),
];
}
}
return $rules;
}
function qdb_normalize_scoring_bands(array $row): array {
$greenMax = (int)($row['greenMax'] ?? 12);
$yellowMax = (int)($row['yellowMax'] ?? 36);
return [
'greenMin' => (int)($row['greenMin'] ?? 0),
'greenMax' => $greenMax,
'yellowMin' => (int)($row['yellowMin'] ?? ($greenMax + 1)),
'yellowMax' => $yellowMax,
'redMin' => (int)($row['redMin'] ?? ($yellowMax + 1)),
];
}
/**
* @return string|null Error message, or null if valid.
*/
function qdb_validate_scoring_bands(array $bands): ?string {
if ($bands['greenMin'] > $bands['greenMax']) {
return 'Green min must not exceed green max';
}
if ($bands['yellowMin'] > $bands['yellowMax']) {
return 'Yellow min must not exceed yellow max';
}
if ($bands['greenMax'] >= $bands['yellowMin']) {
return 'Green range must end before yellow range starts';
}
if ($bands['yellowMax'] >= $bands['redMin']) {
return 'Yellow range must end before red range starts';
}
return null;
}
/**
* @param array<string, int>|int $bandsOrGreenMax Band map or legacy greenMax
*/
function qdb_band_for_total(float $total, array|int $bandsOrGreenMax, ?int $yellowMax = null): string {
$bands = is_array($bandsOrGreenMax)
? qdb_normalize_scoring_bands($bandsOrGreenMax)
: qdb_normalize_scoring_bands(['greenMax' => $bandsOrGreenMax, 'yellowMax' => $yellowMax ?? 36]);
if ($total >= $bands['greenMin'] && $total <= $bands['greenMax']) {
return 'green';
}
if ($total >= $bands['yellowMin'] && $total <= $bands['yellowMax']) {
return 'yellow';
}
if ($total >= $bands['redMin']) {
return 'red';
}
if ($total < $bands['greenMin']) {
return 'green';
}
if ($total < $bands['yellowMin']) {
return 'yellow';
}
return 'red';
}
function qdb_parse_scoring_bands_body(array $body): array {
$greenMax = (int)($body['greenMax'] ?? 12);
$yellowMax = (int)($body['yellowMax'] ?? 36);
return qdb_normalize_scoring_bands([
'greenMin' => (int)($body['greenMin'] ?? 0),
'greenMax' => $greenMax,
'yellowMin' => (int)($body['yellowMin'] ?? ($greenMax + 1)),
'yellowMax' => $yellowMax,
'redMin' => (int)($body['redMin'] ?? ($yellowMax + 1)),
]);
}
function qdb_scoring_bands_to_api(array $row): array {
$bands = qdb_normalize_scoring_bands($row);
return $bands;
}
function qdb_compute_questionnaire_score(PDO $pdo, string $clientCode, string $questionnaireID): int {
$qStmt = $pdo->prepare(
'SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn ORDER BY orderIndex'
);
$qStmt->execute([':qn' => $questionnaireID]);
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
$aoStmt = $pdo->prepare(
'SELECT ao.questionID, ao.defaultText, ao.points
FROM answer_option ao
JOIN question q ON q.questionID = ao.questionID
WHERE q.questionnaireID = :qn'
);
$aoStmt->execute([':qn' => $questionnaireID]);
$optionPoints = [];
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
$optionPoints[$ao['questionID']][$ao['defaultText']] = (int)$ao['points'];
}
$aStmt = $pdo->prepare(
'SELECT ca.questionID, ca.answerOptionID, ca.freeTextValue, ca.numericValue,
ao.defaultText AS optionKey
FROM client_answer ca
LEFT JOIN answer_option ao ON ao.answerOptionID = ca.answerOptionID
JOIN question q ON q.questionID = ca.questionID
WHERE ca.clientCode = :cc AND q.questionnaireID = :qn'
);
$aStmt->execute([':cc' => $clientCode, ':qn' => $questionnaireID]);
$answersByQuestion = [];
foreach ($aStmt->fetchAll(PDO::FETCH_ASSOC) as $a) {
$answersByQuestion[$a['questionID']] = $a;
}
$total = 0;
foreach ($questions as $qRow) {
$total += qdb_score_points_for_question(
$pdo,
$qRow,
$answersByQuestion[$qRow['questionID']] ?? null,
$optionPoints[$qRow['questionID']] ?? []
);
}
return $total;
}
function qdb_score_points_for_question(
PDO $pdo,
array $questionRow,
?array $answerRow,
array $optionPointsMap,
): int {
if ($answerRow === null) {
return 0;
}
$type = (string)($questionRow['type'] ?? '');
$questionID = (string)($questionRow['questionID'] ?? '');
$config = json_decode($questionRow['configJson'] ?? '{}', true) ?: [];
$ruleMap = qdb_score_rule_map_for_question($pdo, $questionID);
switch ($type) {
case 'radio_question':
case 'string_spinner':
$key = trim((string)($answerRow['optionKey'] ?? $answerRow['freeTextValue'] ?? ''));
return $key !== '' ? (int)($optionPointsMap[$key] ?? 0) : 0;
case 'multi_check_box_question':
$raw = trim((string)($answerRow['freeTextValue'] ?? ''));
if ($raw === '') {
return 0;
}
$sum = 0;
foreach (qdb_decode_multi_check_values($raw) as $mk) {
$sum += (int)($optionPointsMap[$mk] ?? 0);
}
return $sum;
case 'glass_scale_question':
$json = trim((string)($answerRow['freeTextValue'] ?? ''));
if ($json === '') {
return 0;
}
$decoded = json_decode($json, true);
if (!is_array($decoded)) {
return 0;
}
$defaults = qdb_default_glass_level_points();
$sum = 0;
foreach ($decoded as $symptomKey => $levelKey) {
$sk = trim((string)$symptomKey);
$lk = trim((string)$levelKey);
if ($sk === '' || $lk === '') {
continue;
}
$sum += (int)($ruleMap[$sk][$lk]
?? $ruleMap[''][$lk]
?? $defaults[$lk]
?? 0);
}
return $sum;
case 'slider_question':
case 'value_spinner':
$value = $answerRow['numericValue'];
if ($value === null || $value === '') {
$value = $answerRow['freeTextValue'];
}
if ($value === null || $value === '') {
return 0;
}
$levelKey = is_numeric($value)
? (string)(int)round((float)$value)
: trim((string)$value);
return (int)($ruleMap[''][$levelKey] ?? 0);
default:
return 0;
}
}
function qdb_recompute_profile_scores_for_client(PDO $pdo, string $clientCode, ?string $triggerQuestionnaireID = null): void {
$profileSql = 'SELECT profileID, name, greenMin, greenMax, yellowMin, yellowMax, redMin FROM scoring_profile WHERE isActive = 1';
$params = [];
if ($triggerQuestionnaireID !== null && $triggerQuestionnaireID !== '') {
$profileSql = '
SELECT sp.profileID, sp.name, sp.greenMin, sp.greenMax, sp.yellowMin, sp.yellowMax, sp.redMin
FROM scoring_profile sp
JOIN scoring_profile_questionnaire spq ON spq.profileID = sp.profileID
WHERE sp.isActive = 1 AND spq.questionnaireID = :qn';
$params[':qn'] = $triggerQuestionnaireID;
}
$pStmt = $pdo->prepare($profileSql);
$pStmt->execute($params);
$profiles = $pStmt->fetchAll(PDO::FETCH_ASSOC);
$deleteOrphan = $pdo->prepare(
'DELETE FROM client_scoring_profile_result WHERE clientCode = :cc AND profileID = :pid'
);
$upsert = $pdo->prepare(
'INSERT INTO client_scoring_profile_result
(clientCode, profileID, weightedTotal, band, computedAt, questionnaireSnapshot)
VALUES (:cc, :pid, :wt, :band, :at, :snap)
ON CONFLICT(clientCode, profileID) DO UPDATE SET
weightedTotal = excluded.weightedTotal,
band = excluded.band,
computedAt = excluded.computedAt,
questionnaireSnapshot = excluded.questionnaireSnapshot'
);
foreach ($profiles as $profile) {
$profileID = $profile['profileID'];
$members = qdb_scoring_profile_members($pdo, $profileID);
if ($members === []) {
continue;
}
$snapshot = [];
$weightedTotal = 0.0;
$allComplete = true;
foreach ($members as $member) {
$qnID = $member['questionnaireID'];
$weight = (float)$member['weight'];
$cq = $pdo->prepare(
"SELECT status, sumPoints FROM completed_questionnaire
WHERE clientCode = :cc AND questionnaireID = :qn"
);
$cq->execute([':cc' => $clientCode, ':qn' => $qnID]);
$row = $cq->fetch(PDO::FETCH_ASSOC);
if (!$row || ($row['status'] ?? '') !== 'completed') {
$allComplete = false;
break;
}
$sumPoints = (int)($row['sumPoints'] ?? 0);
$contribution = $sumPoints * $weight;
$weightedTotal += $contribution;
$snapshot[$qnID] = [
'sumPoints' => $sumPoints,
'weight' => $weight,
'contribution' => round($contribution, 4),
];
}
if (!$allComplete) {
$deleteOrphan->execute([':cc' => $clientCode, ':pid' => $profileID]);
continue;
}
$band = qdb_band_for_total($weightedTotal, qdb_normalize_scoring_bands($profile));
$upsert->execute([
':cc' => $clientCode,
':pid' => $profileID,
':wt' => round($weightedTotal, 4),
':band' => $band,
':at' => time(),
':snap' => json_encode($snapshot, JSON_UNESCAPED_UNICODE),
]);
}
}
/**
* @return list<array{questionnaireID: string, weight: float, orderIndex: int, name?: string}>
*/
function qdb_scoring_profile_members(PDO $pdo, string $profileID): array {
$stmt = $pdo->prepare(
'SELECT spq.questionnaireID, spq.weight, spq.orderIndex, q.name
FROM scoring_profile_questionnaire spq
JOIN questionnaire q ON q.questionnaireID = spq.questionnaireID
WHERE spq.profileID = :pid
ORDER BY spq.orderIndex, spq.questionnaireID'
);
$stmt->execute([':pid' => $profileID]);
$rows = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
$rows[] = [
'questionnaireID' => $r['questionnaireID'],
'weight' => (float)$r['weight'],
'orderIndex' => (int)$r['orderIndex'],
'name' => $r['name'] ?? '',
];
}
return $rows;
}
/**
* @return list<array<string, mixed>>
*/
function qdb_list_scoring_profiles(PDO $pdo): array {
$stmt = $pdo->query(
'SELECT profileID, name, description, isActive,
greenMin, greenMax, yellowMin, yellowMax, redMin,
createdAt, updatedAt
FROM scoring_profile ORDER BY name'
);
$profiles = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$row['isActive'] = (int)$row['isActive'];
$bands = qdb_normalize_scoring_bands($row);
$row = array_merge($row, $bands);
$row['questionnaires'] = qdb_scoring_profile_members($pdo, $row['profileID']);
$profiles[] = $row;
}
return $profiles;
}
function qdb_get_scoring_profile(PDO $pdo, string $profileID): ?array {
$stmt = $pdo->prepare(
'SELECT profileID, name, description, isActive,
greenMin, greenMax, yellowMin, yellowMax, redMin,
createdAt, updatedAt
FROM scoring_profile WHERE profileID = :pid'
);
$stmt->execute([':pid' => $profileID]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
$row['isActive'] = (int)$row['isActive'];
$row = array_merge($row, qdb_normalize_scoring_bands($row));
$row['questionnaires'] = qdb_scoring_profile_members($pdo, $profileID);
return $row;
}
/**
* Backfill default glass score rules (04) for all glass questions missing rules.
*/
function qdb_backfill_glass_score_rules(PDO $pdo): int {
$stmt = $pdo->query("SELECT questionID, configJson FROM question WHERE type = 'glass_scale_question'");
$count = 0;
$defaults = qdb_default_glass_level_points();
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$existing = qdb_get_score_rules_for_question($pdo, $row['questionID']);
if ($existing !== []) {
continue;
}
$config = json_decode($row['configJson'] ?? '{}', true) ?: [];
$rules = [];
foreach ($config['symptoms'] ?? [] as $symptomKey) {
$sk = trim((string)$symptomKey);
if ($sk === '') {
continue;
}
foreach ($defaults as $levelKey => $pts) {
$rules[] = ['scopeKey' => $sk, 'levelKey' => $levelKey, 'points' => $pts];
}
}
if ($rules !== []) {
qdb_sync_question_score_rules($pdo, $row['questionID'], $rules);
$count++;
}
}
return $count;
}
function qdb_recompute_all_questionnaire_scores(PDO $pdo): int {
$stmt = $pdo->query(
"SELECT clientCode, questionnaireID FROM completed_questionnaire WHERE status = 'completed'"
);
$n = 0;
$upd = $pdo->prepare(
'UPDATE completed_questionnaire SET sumPoints = :sp WHERE clientCode = :cc AND questionnaireID = :qn'
);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$score = qdb_compute_questionnaire_score($pdo, $row['clientCode'], $row['questionnaireID']);
$upd->execute([
':sp' => $score,
':cc' => $row['clientCode'],
':qn' => $row['questionnaireID'],
]);
qdb_recompute_profile_scores_for_client($pdo, $row['clientCode']);
$n++;
}
return $n;
}
function qdb_seed_default_rhs_scoring_profile(PDO $pdo): ?string {
foreach (qdb_list_scoring_profiles($pdo) as $p) {
if ($p['name'] === 'RHS Index') {
return $p['profileID'];
}
}
$rhsId = 'questionnaire_2_rhs';
$chk = $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id');
$chk->execute([':id' => $rhsId]);
if (!$chk->fetch()) {
return null;
}
$profileID = bin2hex(random_bytes(16));
$now = time();
$pdo->prepare(
'INSERT INTO scoring_profile (profileID, name, description, isActive,
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
VALUES (:id, :name, :desc, 1, 0, 12, 13, 36, 37, :ca, :ua)'
)->execute([
':id' => $profileID,
':name' => 'RHS Index',
':desc' => 'Legacy RHS thresholds (green 012, yellow 1336, red 37+)',
':ca' => $now,
':ua' => $now,
]);
$pdo->prepare(
'INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
VALUES (:pid, :qn, 1.0, 0)'
)->execute([':pid' => $profileID, ':qn' => $rhsId]);
return $profileID;
}
function qdb_is_valid_scoring_band(?string $band): bool {
return in_array($band, ['green', 'yellow', 'red'], true);
}
/**
* Coach decision when set; otherwise the server-computed band.
*/
function qdb_effective_scoring_band(array $row): string {
$coach = trim((string)($row['coachBand'] ?? ''));
if ($coach !== '' && qdb_is_valid_scoring_band($coach)) {
return $coach;
}
return (string)($row['band'] ?? '');
}
function qdb_scoring_review_row_to_api(array $row, bool $includeComputed = true): array {
$coachBand = trim((string)($row['coachBand'] ?? ''));
$out = [
'profileID' => (string)($row['profileID'] ?? ''),
'name' => (string)($row['name'] ?? ''),
'coachBand' => $coachBand !== '' ? $coachBand : null,
'pendingReview' => $coachBand === '',
];
if ($includeComputed) {
$out['weightedTotal'] = (float)($row['weightedTotal'] ?? 0);
$out['calculatedBand'] = (string)($row['band'] ?? '');
$out['computedAt'] = !empty($row['computedAt']) ? date('Y-m-d H:i', (int)$row['computedAt']) : '';
}
return $out;
}
/**
* @param list<string> $clientCodes
* @return list<array{clientCode: string, profiles: list<array<string, mixed>>}>
*/
function qdb_app_scoring_review_for_clients(PDO $pdo, array $clientCodes): array {
if ($clientCodes === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($clientCodes), '?'));
$stmt = $pdo->prepare(
"SELECT r.clientCode, r.profileID, r.coachBand, sp.name
FROM client_scoring_profile_result r
JOIN scoring_profile sp ON sp.profileID = r.profileID AND sp.isActive = 1
WHERE r.clientCode IN ($placeholders)
ORDER BY r.clientCode, sp.name"
);
$stmt->execute(array_values($clientCodes));
$byClient = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$cc = (string)$row['clientCode'];
$byClient[$cc][] = qdb_scoring_review_row_to_api($row, false);
}
$out = [];
foreach ($clientCodes as $code) {
$out[] = [
'clientCode' => $code,
'profiles' => $byClient[$code] ?? [],
];
}
return $out;
}
/**
* @return array<string, mixed>
*/
/**
* Store coach band; create a result row from app-local computed values when none exists yet.
*
* @return array<string, mixed>
*/
function qdb_save_coach_scoring_review(
PDO $pdo,
string $clientCode,
string $profileID,
string $coachBand,
string $userID,
?float $weightedTotal = null,
?string $calculatedBand = null,
): array {
if (!qdb_is_valid_scoring_band($coachBand)) {
throw new InvalidArgumentException('coachBand must be green, yellow, or red');
}
$stmt = $pdo->prepare(
'SELECT r.band, r.coachBand, r.weightedTotal, r.computedAt, sp.name
FROM client_scoring_profile_result r
JOIN scoring_profile sp ON sp.profileID = r.profileID
WHERE r.clientCode = :cc AND r.profileID = :pid'
);
$stmt->execute([':cc' => $clientCode, ':pid' => $profileID]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
if ($weightedTotal === null || $calculatedBand === null || !qdb_is_valid_scoring_band($calculatedBand)) {
throw new RuntimeException('Scoring profile result not found; calculatedBand and weightedTotal required');
}
$profile = qdb_get_scoring_profile($pdo, $profileID);
if (!$profile || (int)($profile['isActive'] ?? 0) !== 1) {
throw new RuntimeException('Scoring profile not found');
}
$now = time();
$pdo->prepare(
'INSERT INTO client_scoring_profile_result
(clientCode, profileID, weightedTotal, band, computedAt, questionnaireSnapshot,
coachBand, coachReviewedAt, coachReviewedByUserID)
VALUES (:cc, :pid, :wt, :band, :at, :snap, :cb, :rat, :uid)'
)->execute([
':cc' => $clientCode,
':pid' => $profileID,
':wt' => round($weightedTotal, 4),
':band' => $calculatedBand,
':at' => $now,
':snap' => '{}',
':cb' => $coachBand,
':rat' => $now,
':uid' => $userID,
]);
return qdb_scoring_review_row_to_api([
'profileID' => $profileID,
'name' => $profile['name'],
'weightedTotal' => $weightedTotal,
'band' => $calculatedBand,
'coachBand' => $coachBand,
'computedAt' => $now,
]);
}
return qdb_set_coach_scoring_band($pdo, $clientCode, $profileID, $coachBand, $userID);
}
function qdb_set_coach_scoring_band(
PDO $pdo,
string $clientCode,
string $profileID,
string $coachBand,
string $userID
): array {
if (!qdb_is_valid_scoring_band($coachBand)) {
throw new InvalidArgumentException('coachBand must be green, yellow, or red');
}
$stmt = $pdo->prepare(
'SELECT r.band, r.coachBand, r.weightedTotal, r.computedAt, sp.name
FROM client_scoring_profile_result r
JOIN scoring_profile sp ON sp.profileID = r.profileID
WHERE r.clientCode = :cc AND r.profileID = :pid'
);
$stmt->execute([':cc' => $clientCode, ':pid' => $profileID]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new RuntimeException('Scoring profile result not found or profile incomplete');
}
$now = time();
$pdo->prepare(
'UPDATE client_scoring_profile_result
SET coachBand = :cb, coachReviewedAt = :at, coachReviewedByUserID = :uid
WHERE clientCode = :cc AND profileID = :pid'
)->execute([
':cb' => $coachBand,
':at' => $now,
':uid' => $userID,
':cc' => $clientCode,
':pid' => $profileID,
]);
$row['coachBand'] = $coachBand;
$row['profileID'] = $profileID;
return qdb_scoring_review_row_to_api($row);
}

View File

@ -732,5 +732,116 @@ function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array
'supervisorUsername' => $client['supervisorUsername'] ?? '', 'supervisorUsername' => $client['supervisorUsername'] ?? '',
], ],
'questionnaires' => $questionnaires, 'questionnaires' => $questionnaires,
'scoringProfiles' => qdb_client_scoring_profile_results($pdo, $clientCode),
]; ];
} }
function qdb_client_scoring_profile_results(PDO $pdo, string $clientCode): array {
require_once __DIR__ . '/scoring.php';
$stmt = $pdo->prepare(
'SELECT r.profileID, r.weightedTotal, r.band, r.computedAt, r.questionnaireSnapshot,
r.coachBand, r.coachReviewedAt, r.coachReviewedByUserID,
sp.name, sp.greenMin, sp.greenMax, sp.yellowMin, sp.yellowMax, sp.redMin
FROM client_scoring_profile_result r
JOIN scoring_profile sp ON sp.profileID = r.profileID
WHERE r.clientCode = :cc
ORDER BY sp.name'
);
$stmt->execute([':cc' => $clientCode]);
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$bands = qdb_normalize_scoring_bands($row);
$coachBand = trim((string)($row['coachBand'] ?? ''));
$out[] = [
'profileID' => $row['profileID'],
'name' => $row['name'],
'weightedTotal' => (float)$row['weightedTotal'],
'band' => $row['band'],
'calculatedBand' => $row['band'],
'coachBand' => $coachBand !== '' ? $coachBand : null,
'effectiveBand' => qdb_effective_scoring_band($row),
'pendingReview' => $coachBand === '',
'greenMin' => $bands['greenMin'],
'greenMax' => $bands['greenMax'],
'yellowMin' => $bands['yellowMin'],
'yellowMax' => $bands['yellowMax'],
'redMin' => $bands['redMin'],
'computedAt' => $row['computedAt'] ? date('Y-m-d H:i', (int)$row['computedAt']) : '',
'coachReviewedAt' => !empty($row['coachReviewedAt'])
? date('Y-m-d H:i', (int)$row['coachReviewedAt']) : '',
'snapshot' => json_decode($row['questionnaireSnapshot'] ?? '{}', true) ?: [],
];
}
return $out;
}
/**
* Active scoring profiles + per-client results for the clients list (RBAC-scoped).
*
* @return array{profiles: list<array{profileID: string, name: string}>, byClient: array<string, array<string, array{band: string, weightedTotal: float, name: string}>>}
*/
function qdb_clients_scoring_summary_for_list(PDO $pdo, array $tokenRec): array {
require_once __DIR__ . '/scoring.php';
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$profiles = $pdo->query(
"SELECT profileID, name FROM scoring_profile WHERE isActive = 1 ORDER BY name"
)->fetchAll(PDO::FETCH_ASSOC);
if ($profiles === []) {
return ['profiles' => [], 'byClient' => []];
}
$stmt = $pdo->prepare(
"SELECT r.clientCode, r.profileID, r.band, r.coachBand, r.weightedTotal, sp.name
FROM client_scoring_profile_result r
INNER JOIN scoring_profile sp ON sp.profileID = r.profileID AND sp.isActive = 1
INNER JOIN client cl ON cl.clientCode = r.clientCode
WHERE ($rbacClause)"
);
$stmt->execute($rbacParams);
$byClient = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$cc = (string)$row['clientCode'];
$coachBand = trim((string)($row['coachBand'] ?? ''));
$byClient[$cc][$row['profileID']] = [
'band' => (string)$row['band'],
'calculatedBand' => (string)$row['band'],
'coachBand' => $coachBand !== '' ? $coachBand : null,
'effectiveBand' => qdb_effective_scoring_band($row),
'pendingReview' => $coachBand === '',
'weightedTotal' => (float)$row['weightedTotal'],
'name' => (string)$row['name'],
];
}
return [
'profiles' => array_map(static fn(array $p) => [
'profileID' => (string)$p['profileID'],
'name' => (string)$p['name'],
], $profiles),
'byClient' => $byClient,
];
}
/**
* @return list<array{profileID: string, name: string, band: ?string, weightedTotal: ?float}>
*/
function qdb_client_scoring_dots_for_client(string $clientCode, array $summary): array {
$out = [];
foreach ($summary['profiles'] as $profile) {
$result = $summary['byClient'][$clientCode][$profile['profileID']] ?? null;
$out[] = [
'profileID' => $profile['profileID'],
'name' => $profile['name'],
'band' => $result['calculatedBand'] ?? null,
'calculatedBand' => $result['calculatedBand'] ?? null,
'coachBand' => $result['coachBand'] ?? null,
'effectiveBand' => $result['effectiveBand'] ?? null,
'pendingReview' => $result['pendingReview'] ?? false,
'weightedTotal' => $result !== null ? $result['weightedTotal'] : null,
];
}
return $out;
}

View File

@ -184,3 +184,58 @@ CREATE TABLE IF NOT EXISTS client_followup_note (
FOREIGN KEY(clientCode) REFERENCES client(clientCode) FOREIGN KEY(clientCode) REFERENCES client(clientCode)
); );
CREATE TABLE IF NOT EXISTS question_score_rule (
ruleID TEXT NOT NULL PRIMARY KEY,
questionID TEXT NOT NULL,
scopeKey TEXT NOT NULL DEFAULT '',
levelKey TEXT NOT NULL,
points INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(questionID) REFERENCES question(questionID) ON DELETE CASCADE,
UNIQUE(questionID, scopeKey, levelKey)
);
CREATE INDEX IF NOT EXISTS idx_score_rule_question
ON question_score_rule(questionID);
CREATE TABLE IF NOT EXISTS scoring_profile (
profileID TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
isActive INTEGER NOT NULL DEFAULT 1,
greenMin INTEGER NOT NULL DEFAULT 0,
greenMax INTEGER NOT NULL DEFAULT 12,
yellowMin INTEGER NOT NULL DEFAULT 13,
yellowMax INTEGER NOT NULL DEFAULT 36,
redMin INTEGER NOT NULL DEFAULT 37,
createdAt INTEGER NOT NULL DEFAULT 0,
updatedAt INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS scoring_profile_questionnaire (
profileID TEXT NOT NULL,
questionnaireID TEXT NOT NULL,
weight REAL NOT NULL DEFAULT 1.0,
orderIndex INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (profileID, questionnaireID),
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE,
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS client_scoring_profile_result (
clientCode TEXT NOT NULL,
profileID TEXT NOT NULL,
weightedTotal REAL NOT NULL DEFAULT 0,
band TEXT NOT NULL DEFAULT '',
computedAt INTEGER NOT NULL DEFAULT 0,
questionnaireSnapshot TEXT NOT NULL DEFAULT '{}',
coachBand TEXT NOT NULL DEFAULT '',
coachReviewedAt INTEGER NOT NULL DEFAULT 0,
coachReviewedByUserID TEXT NOT NULL DEFAULT '',
PRIMARY KEY (clientCode, profileID),
FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE,
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_client_profile_result_profile
ON client_scoring_profile_result(profileID);

211
tests/Unit/ScoringTest.php Normal file
View File

@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PDO;
use PHPUnit\Framework\TestCase;
final class ScoringTest extends TestCase
{
private PDO $pdo;
protected function setUp(): void
{
require_once dirname(__DIR__, 2) . '/common.php';
require_once dirname(__DIR__, 2) . '/lib/scoring.php';
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->exec('PRAGMA foreign_keys = ON');
$schema = file_get_contents(dirname(__DIR__, 2) . '/schema.sql');
$this->pdo->exec($schema);
}
public function testBandForTotal(): void
{
$legacy = ['greenMax' => 12, 'yellowMax' => 36];
$this->assertSame('green', qdb_band_for_total(0, $legacy));
$this->assertSame('green', qdb_band_for_total(12, $legacy));
$this->assertSame('yellow', qdb_band_for_total(13, $legacy));
$this->assertSame('yellow', qdb_band_for_total(36, $legacy));
$this->assertSame('red', qdb_band_for_total(37, $legacy));
$custom = [
'greenMin' => 5,
'greenMax' => 15,
'yellowMin' => 16,
'yellowMax' => 30,
'redMin' => 31,
];
$this->assertSame('green', qdb_band_for_total(5, $custom));
$this->assertSame('green', qdb_band_for_total(15, $custom));
$this->assertSame('yellow', qdb_band_for_total(16, $custom));
$this->assertSame('red', qdb_band_for_total(31, $custom));
}
public function testValidateScoringBands(): void
{
$this->assertNull(qdb_validate_scoring_bands(qdb_normalize_scoring_bands(['greenMax' => 12, 'yellowMax' => 36])));
$this->assertNotNull(qdb_validate_scoring_bands([
'greenMin' => 0, 'greenMax' => 20,
'yellowMin' => 15, 'yellowMax' => 36,
'redMin' => 37,
]));
}
public function testGlassScoreRulesFromSymptoms(): void
{
$rules = qdb_score_rules_from_glass_symptoms([
['key' => 'pain', 'scoreLevels' => ['never_glass' => 0, 'extreme_glass' => 5]],
]);
$this->assertCount(5, $rules);
$painExtreme = array_values(array_filter(
$rules,
fn ($r) => $r['scopeKey'] === 'pain' && $r['levelKey'] === 'extreme_glass'
));
$this->assertSame(5, $painExtreme[0]['points'] ?? null);
}
private function seedCoachChain(): void
{
$this->pdo->exec("INSERT INTO supervisor (supervisorID, username) VALUES ('sup1', 'sup')");
$this->pdo->exec("INSERT INTO coach (coachID, supervisorID, username) VALUES ('coach1', 'sup1', 'coach')");
}
public function testRadioAndGlassScoring(): void
{
$this->seedCoachChain();
$qnID = 'qn_test';
$this->pdo->exec("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
VALUES ('$qnID', 'Test', '1', 'active', 0, 0, '{}', '')");
$radioQ = "{$qnID}__radio";
$this->pdo->exec("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES ('$radioQ', '$qnID', 'Pick one', 'radio_question', 0, 1, '{}')");
$this->pdo->exec("INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
VALUES ('ao1', '$radioQ', 'yes', 3, 0, '')");
$glassQ = "{$qnID}__glass";
$this->pdo->exec("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES ('$glassQ', '$qnID', 'Symptoms', 'glass_scale_question', 1, 1, '{\"symptoms\":[\"pain\"]}')");
qdb_sync_question_score_rules($this->pdo, $glassQ, [
['scopeKey' => 'pain', 'levelKey' => 'moderate_glass', 'points' => 2],
]);
$client = 'CLIENT-001';
$this->pdo->exec("INSERT INTO client (clientCode, coachID) VALUES ('$client', 'coach1')");
$this->pdo->exec("INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue)
VALUES ('$client', '$radioQ', 'ao1', NULL, NULL)");
$this->pdo->exec("INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue)
VALUES ('$client', '$glassQ', NULL, '{\"pain\":\"moderate_glass\"}', NULL)");
$score = qdb_compute_questionnaire_score($this->pdo, $client, $qnID);
$this->assertSame(5, $score);
}
public function testIncompleteProfileProducesNoResult(): void
{
$this->seedCoachChain();
$qnA = 'qn_a';
$qnB = 'qn_b';
foreach ([$qnA, $qnB] as $id) {
$this->pdo->exec("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
VALUES ('$id', '$id', '1', 'active', 0, 0, '{}', '')");
}
$profileID = 'profile1';
$this->pdo->exec("INSERT INTO scoring_profile (profileID, name, description, isActive,
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
VALUES ('$profileID', 'Combo', '', 1, 0, 10, 11, 20, 21, 0, 0)");
$this->pdo->exec("INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
VALUES ('$profileID', '$qnA', 1.0, 0), ('$profileID', '$qnB', 1.0, 1)");
$client = 'CLIENT-002';
$this->pdo->exec("INSERT INTO client (clientCode, coachID) VALUES ('$client', 'coach1')");
$this->pdo->exec("INSERT INTO completed_questionnaire (clientCode, questionnaireID, status, sumPoints, completedAt)
VALUES ('$client', '$qnA', 'completed', 5, 1)");
qdb_recompute_profile_scores_for_client($this->pdo, $client);
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM client_scoring_profile_result WHERE clientCode = :cc');
$stmt->execute([':cc' => $client]);
$this->assertSame(0, (int)$stmt->fetchColumn());
}
public function testCoachScoringReviewPreservesComputedBand(): void
{
$this->seedCoachChain();
$qnID = 'qn_review';
$this->pdo->exec("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
VALUES ('$qnID', 'Review', '1', 'active', 0, 0, '{}', '')");
$profileID = 'profile_review';
$this->pdo->exec("INSERT INTO scoring_profile (profileID, name, description, isActive,
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
VALUES ('$profileID', 'Review profile', '', 1, 0, 10, 11, 20, 21, 0, 0)");
$this->pdo->exec("INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
VALUES ('$profileID', '$qnID', 1.0, 0)");
$client = 'CLIENT-REVIEW';
$this->pdo->exec("INSERT INTO client (clientCode, coachID) VALUES ('$client', 'coach1')");
$this->pdo->exec("INSERT INTO completed_questionnaire (clientCode, questionnaireID, status, sumPoints, completedAt)
VALUES ('$client', '$qnID', 'completed', 15, 1)");
qdb_recompute_profile_scores_for_client($this->pdo, $client);
$row = $this->pdo->query(
"SELECT band, coachBand FROM client_scoring_profile_result WHERE clientCode = '$client'"
)->fetch(PDO::FETCH_ASSOC);
$this->assertSame('yellow', $row['band']);
$this->assertSame('', $row['coachBand']);
qdb_set_coach_scoring_band($this->pdo, $client, $profileID, 'green', 'user1');
$row = $this->pdo->query(
"SELECT band, coachBand FROM client_scoring_profile_result WHERE clientCode = '$client'"
)->fetch(PDO::FETCH_ASSOC);
$this->assertSame('yellow', $row['band']);
$this->assertSame('green', $row['coachBand']);
qdb_recompute_profile_scores_for_client($this->pdo, $client);
$row = $this->pdo->query(
"SELECT band, coachBand FROM client_scoring_profile_result WHERE clientCode = '$client'"
)->fetch(PDO::FETCH_ASSOC);
$this->assertSame('yellow', $row['band']);
$this->assertSame('green', $row['coachBand']);
}
public function testSaveCoachReviewFromAppBeforeServerRecompute(): void
{
$this->seedCoachChain();
$qnID = 'qn_pre';
$this->pdo->exec("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
VALUES ('$qnID', 'Pre', '1', 'active', 0, 0, '{}', '')");
$profileID = 'profile_pre';
$this->pdo->exec("INSERT INTO scoring_profile (profileID, name, description, isActive,
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
VALUES ('$profileID', 'Pre profile', '', 1, 0, 10, 11, 20, 21, 0, 0)");
$this->pdo->exec("INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
VALUES ('$profileID', '$qnID', 1.0, 0)");
$client = 'CLIENT-PRE';
$this->pdo->exec("INSERT INTO client (clientCode, coachID) VALUES ('$client', 'coach1')");
qdb_save_coach_scoring_review(
$this->pdo,
$client,
$profileID,
'yellow',
'user1',
15.0,
'yellow'
);
$row = $this->pdo->query(
"SELECT band, coachBand, weightedTotal FROM client_scoring_profile_result WHERE clientCode = '$client'"
)->fetch(PDO::FETCH_ASSOC);
$this->assertSame('yellow', $row['band']);
$this->assertSame('yellow', $row['coachBand']);
$this->assertSame(15.0, (float)$row['weightedTotal']);
}
}

View File

@ -2635,3 +2635,423 @@ select:disabled {
font-size: .85rem; font-size: .85rem;
font-style: italic; font-style: italic;
} }
.glass-score-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--border);
}
.glass-score-cell label {
display: block;
font-size: .7rem;
color: var(--text-secondary);
margin-bottom: 2px;
}
.glass-score-cell input {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 4px;
}
.value-score-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
max-height: 200px;
overflow-y: auto;
}
.value-score-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
font-size: .85rem;
}
.value-score-label {
min-width: 2rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.value-score-pts {
width: 4rem;
padding: 4px 6px;
border: 1px solid var(--border);
border-radius: 4px;
}
.modal-dialog-wide {
max-width: 640px;
}
/* Scoring profiles on questionnaire dashboard */
#scoringProfilesPanel {
width: 100%;
margin-top: 28px;
}
.scoring-profiles-section {
margin-top: 0;
}
.scoring-profiles-card {
padding: 24px;
}
.scoring-section-title {
margin: 0 0 6px;
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
}
.scoring-section-desc,
.scoring-empty-hint {
margin: 0;
font-size: .875rem;
line-height: 1.5;
color: var(--text-secondary);
}
.scoring-empty-hint {
padding: 8px 0;
}
.scoring-profiles-list {
margin-top: 18px;
display: flex;
flex-direction: column;
gap: 12px;
}
.scoring-profile-card {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
background: var(--surface-muted);
color: var(--text);
}
.scoring-profile-card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.scoring-profile-card-main {
min-width: 0;
flex: 1;
}
.scoring-profile-name-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.scoring-profile-name-row strong {
font-size: 1rem;
color: var(--text);
}
.scoring-profile-desc {
margin: 0 0 8px;
font-size: .875rem;
line-height: 1.45;
color: var(--text-secondary);
}
.scoring-profile-bands {
margin: 0;
font-size: .85rem;
color: var(--primary-text);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.scoring-profile-member-count {
margin: 12px 0 8px;
font-size: .8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--text-secondary);
}
.scoring-profile-members {
border-top: 1px solid var(--border);
padding-top: 4px;
}
.scoring-profile-card-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.scoring-profile-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.scoring-modal-section-title {
margin: 18px 0 0;
font-size: .95rem;
color: var(--text);
}
.scoring-profile-active-row {
margin-bottom: 12px;
}
.checkbox-inline {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: .9rem;
color: var(--text);
cursor: pointer;
}
.scoring-profile-picker {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
background: var(--surface-muted);
}
.scoring-profile-picker input,
.scoring-profile-picker select {
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: .875rem;
font-family: var(--font);
background: var(--surface);
color: var(--text);
}
.scoring-profile-picker input:focus,
.scoring-profile-picker select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.scoring-profile-add-row {
display: flex;
gap: 8px;
align-items: center;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.scoring-profile-add-row select {
flex: 1;
min-width: 0;
}
.scoring-band-labels {
margin: 4px 0 8px;
min-height: 1.5rem;
}
.scoring-band-ranges-table {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
.scoring-band-range-row {
display: grid;
grid-template-columns: 100px 1fr 1fr;
gap: 10px;
align-items: center;
}
.scoring-band-range-row.scoring-band-range-header {
font-size: .75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--text-secondary);
padding-bottom: 4px;
border-bottom: 1px solid var(--border);
}
.scoring-band-range-row input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: .875rem;
font-family: var(--font);
background: var(--surface);
color: var(--text);
}
.scoring-band-range-row input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.scoring-band-max-placeholder {
color: var(--text-secondary);
font-size: 1.1rem;
padding: 8px 10px;
}
.scoring-band-strips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.scoring-profile-q-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--border);
color: var(--text);
}
.scoring-profile-q-row:last-child { border-bottom: none; }
.scoring-profile-q-name {
flex: 1;
min-width: 120px;
font-weight: 500;
color: var(--text);
}
.scoring-profile-q-weight {
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.scoring-profile-q-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.sp-weight-wrap {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: .8rem;
color: var(--text-secondary);
}
.sp-weight-wrap input {
width: 72px;
}
.band-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
font-size: .75rem;
font-weight: 600;
letter-spacing: .02em;
}
.band-badge.band-green { background: #14532d; color: #bbf7d0; border: 1px solid #166534; }
.band-badge.band-yellow { background: #713f12; color: #fef08a; border: 1px solid #854d0e; }
.band-badge.band-red { background: #7f1d1d; color: #fecaca; border: 1px solid #991b1b; }
.band-badge.band-none { background: #334155; color: #cbd5e1; border: 1px solid #475569; }
/* Client list — scoring profiles */
.client-scoring-legend {
margin: 0 0 14px;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-elevated, rgba(0, 0, 0, 0.02));
}
.client-scoring-legend-title {
margin: 0 0 6px;
}
.client-scoring-legend-body {
margin: 0 0 10px;
line-height: 1.45;
}
.client-scoring-legend-body .band-badge {
margin: 0 2px;
vertical-align: middle;
}
.client-scoring-legend-dl {
display: grid;
gap: 8px 16px;
margin: 0;
font-size: .8125rem;
}
@media (min-width: 640px) {
.client-scoring-legend-dl {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.client-scoring-legend-dl dt {
font-weight: 600;
color: var(--text);
margin: 0;
}
.client-scoring-legend-dl dd {
margin: 2px 0 0;
color: var(--text-secondary);
line-height: 1.4;
}
.client-profile-scores {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.client-profile-score-block {
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-elevated, rgba(0, 0, 0, 0.02));
font-size: .8125rem;
line-height: 1.35;
}
.client-profile-score-block.is-incomplete {
opacity: 0.85;
}
.client-profile-score-block.has-coach-override {
border-color: rgba(251, 191, 36, 0.45);
}
.client-profile-score-block-title {
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
}
.client-profile-score-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 8px;
margin-top: 4px;
}
.client-profile-score-row:first-of-type {
margin-top: 0;
}
.client-profile-score-label {
flex: 0 0 4.75rem;
color: var(--text-secondary);
font-size: .75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .03em;
}
.client-profile-score-total {
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
font-size: .75rem;
}
.client-profile-score-total::before {
content: "total ";
opacity: 0.75;
}
.client-profile-override-hint {
font-size: .7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
color: #ca8a04;
}
.client-profile-score-hint,
.client-profile-score-meta {
margin: 6px 0 0;
font-size: .75rem;
color: var(--text-secondary);
line-height: 1.4;
}
.client-scoring-profiles-detail {
margin-bottom: 12px;
}
.scoring-profile-detail-card {
margin-top: 8px;
}
.client-profile-dots-cell {
min-width: 200px;
max-width: 320px;
vertical-align: top;
}
.client-profile-dots-empty {
color: var(--text-secondary);
}

View File

@ -6,6 +6,7 @@ const PAGE_SIZE = 50;
let clientsList = []; let clientsList = [];
let coachesList = []; let coachesList = [];
let scoringProfileCatalog = [];
let filterSearch = ''; let filterSearch = '';
let filterTestData = ''; let filterTestData = '';
let page = 0; let page = 0;
@ -42,6 +43,7 @@ async function loadClients() {
apiGet('assignments.php'), apiGet('assignments.php'),
]); ]);
clientsList = clientsData.clients || []; clientsList = clientsData.clients || [];
scoringProfileCatalog = clientsData.scoringProfiles || [];
coachesList = assignData.coaches || []; coachesList = assignData.coaches || [];
renderClients(); renderClients();
} catch (e) { } catch (e) {
@ -65,6 +67,9 @@ function renderClients() {
return; return;
} }
const showProfileColumn = scoringProfileCatalog.length > 0;
const profileLegend = showProfileColumn ? scoringLegendHTML() : '';
container.innerHTML = ` container.innerHTML = `
<div class="card"> <div class="card">
<div class="filter-bar"> <div class="filter-bar">
@ -76,6 +81,7 @@ function renderClients() {
</select> </select>
<span class="data-toolbar-hint" id="clientListCount"></span> <span class="data-toolbar-hint" id="clientListCount"></span>
</div> </div>
${profileLegend}
<p class="data-toolbar-hint" style="margin:0 0 12px"> <p class="data-toolbar-hint" style="margin:0 0 12px">
Expand a row to view questionnaire responses and upload version history. Expand a row to view questionnaire responses and upload version history.
</p> </p>
@ -85,6 +91,7 @@ function renderClients() {
<tr> <tr>
<th>Client Code</th> <th>Client Code</th>
<th>Current Coach</th> <th>Current Coach</th>
${showProfileColumn ? '<th>Profile scores</th>' : ''}
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@ -132,6 +139,7 @@ function renderClientTableBody() {
const countEl = document.getElementById('clientListCount'); const countEl = document.getElementById('clientListCount');
const filtered = getFilteredClientsList(); const filtered = getFilteredClientsList();
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const colCount = scoringProfileCatalog.length > 0 ? 4 : 3;
if (page >= totalPages) page = totalPages - 1; if (page >= totalPages) page = totalPages - 1;
const slice = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); const slice = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
@ -140,7 +148,7 @@ function renderClientTableBody() {
} }
if (!slice.length) { 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 { } else {
body.innerHTML = slice.map(c => clientRowHTML(c)).join(''); body.innerHTML = slice.map(c => clientRowHTML(c)).join('');
body.querySelectorAll('.client-expand-btn').forEach(btn => { body.querySelectorAll('.client-expand-btn').forEach(btn => {
@ -243,9 +251,103 @@ function clientDetailPanelHTML(clientCode) {
Coach: ${esc(cl.coachUsername || '—')} Coach: ${esc(cl.coachUsername || '—')}
${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''} ${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''}
</p> </p>
${renderClientScoringProfiles(detail.scoringProfiles || [])}
${questionnaires.map(q => clientQnBlockHTML(clientCode, q)).join('')}`; ${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) { function clientQnBlockHTML(clientCode, q) {
const key = qnKey(clientCode, q.questionnaireID); const key = qnKey(clientCode, q.questionnaireID);
const qnExpanded = expandedQnKey === key; const qnExpanded = expandedQnKey === key;
@ -311,6 +413,17 @@ function clientQnBlockHTML(clientCode, q) {
</div>`; </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) { function clientHasResponseData(c) {
return Number(c.hasResponseData) === 1; return Number(c.hasResponseData) === 1;
} }
@ -324,6 +437,10 @@ function clientRowHTML(c) {
const expandControl = canExpand const expandControl = canExpand
? `<button type="button" class="btn btn-sm btn-link client-expand-btn" data-code="${esc(c.clientCode)}">${expanded ? '▼' : '▶'}</button> ` ? `<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 ` return `
<tr class="${testDataRowClassForClient(c.clientCode).trim()}"> <tr class="${testDataRowClassForClient(c.clientCode).trim()}">
@ -331,13 +448,14 @@ function clientRowHTML(c) {
${expandControl}<strong>${esc(c.clientCode)}</strong> ${expandControl}<strong>${esc(c.clientCode)}</strong>
</td> </td>
<td>${coach}</td> <td>${coach}</td>
${profileCol}
<td> <td>
<button class="btn btn-sm btn-danger delete-client-btn" data-code="${esc(c.clientCode)}">Delete</button> <button class="btn btn-sm btn-danger delete-client-btn" data-code="${esc(c.clientCode)}">Delete</button>
</td> </td>
</tr> </tr>
${expanded ? ` ${expanded ? `
<tr class="client-detail-row"> <tr class="client-detail-row">
<td colspan="3"> <td colspan="${colCount}">
<div class="client-detail-panel">${clientDetailPanelHTML(c.clientCode)}</div> <div class="client-detail-panel">${clientDetailPanelHTML(c.clientCode)}</div>
</td> </td>
</tr>` : ''}`; </tr>` : ''}`;

View File

@ -33,6 +33,14 @@ const LAYOUT_TYPES = [
const OPTION_TYPES = new Set(['radio_question', 'multi_check_box_question']); const OPTION_TYPES = new Set(['radio_question', 'multi_check_box_question']);
const GLASS_SCORE_LEVELS = [
{ key: 'never_glass', label: 'Never' },
{ key: 'little_glass', label: 'Little' },
{ key: 'moderate_glass', label: 'Moderate' },
{ key: 'much_glass', label: 'Much' },
{ key: 'extreme_glass', label: 'Extreme' },
];
let questionnaire = null; let questionnaire = null;
let questions = []; let questions = [];
let isNew = false; let isNew = false;
@ -194,10 +202,79 @@ function glassSymptomsRowsFromQuestion(q) {
return q.glassSymptoms.map(r => ({ return q.glassSymptoms.map(r => ({
key: (r.key || '').trim(), key: (r.key || '').trim(),
labelDe: (r.labelDe || '').trim(), labelDe: (r.labelDe || '').trim(),
scoreLevels: r.scoreLevels || defaultGlassScoreLevels(),
})); }));
} }
const config = parseConfig(q); const config = parseConfig(q);
return (config.symptoms || []).map(key => ({ key: String(key).trim(), labelDe: '' })); return (config.symptoms || []).map(key => ({
key: String(key).trim(),
labelDe: '',
scoreLevels: defaultGlassScoreLevels(),
}));
}
function defaultGlassScoreLevels() {
return { never_glass: 0, little_glass: 1, moderate_glass: 2, much_glass: 3, extreme_glass: 4 };
}
function valueScoreRulesSectionHTML(prefix, config, scoreRules = []) {
const min = Number(config.range?.min ?? 0);
const max = Number(config.range?.max ?? 100);
const step = Math.max(1, Number(config.step ?? 1));
const lo = Math.min(min, max);
const hi = Math.max(min, max);
const ruleMap = {};
for (const r of scoreRules || []) {
ruleMap[r.levelKey] = r.points;
}
const values = [];
for (let v = lo; v <= hi; v += step) values.push(v);
if (!values.length) values.push(lo);
const rows = values.map(v => `
<div class="value-score-row">
<span class="value-score-label">${v}</span>
<input type="number" class="value-score-pts" data-value="${v}" value="${ruleMap[String(v)] ?? 0}" min="0" step="1">
</div>`).join('');
return `
<div class="value-score-rules" id="${prefix}_value_scores">
<label style="font-size:.85rem;font-weight:600;color:var(--text-secondary)">Points per value</label>
<p class="field-hint">Set points for each selectable value (used when the questionnaire is scored).</p>
<div class="value-score-grid">${rows}</div>
</div>`;
}
function readValueScoreRulesFromForm(prefix) {
const container = document.getElementById(`${prefix}_value_scores`);
if (!container) return [];
const rules = [];
container.querySelectorAll('.value-score-pts').forEach(inp => {
rules.push({
scopeKey: '',
levelKey: String(inp.dataset.value),
points: parseInt(inp.value || '0', 10) || 0,
});
});
return rules;
}
function wireValueScoreRules(prefix) {
const minInp = document.getElementById(`${prefix}_rangeMin`);
const maxInp = document.getElementById(`${prefix}_rangeMax`);
const stepInp = document.getElementById(`${prefix}_step`);
const rerender = () => {
const section = document.getElementById(`${prefix}_value_scores`);
if (!section) return;
const config = {
range: {
min: parseInt(minInp?.value || '0', 10),
max: parseInt(maxInp?.value || '100', 10),
},
step: parseInt(stepInp?.value || '1', 10) || 1,
};
const existing = readValueScoreRulesFromForm(prefix);
section.outerHTML = valueScoreRulesSectionHTML(prefix, config, existing);
};
[minInp, maxInp, stepInp].forEach(el => el?.addEventListener('change', rerender));
} }
function suggestSymptomKey(label) { function suggestSymptomKey(label) {
@ -227,6 +304,13 @@ function glassSymptomsSectionHTML(rows, prefix) {
} }
function glassSymptomRowHTML(prefix, row, idx) { function glassSymptomRowHTML(prefix, row, idx) {
const levels = row.scoreLevels || defaultGlassScoreLevels();
const ptsRow = GLASS_SCORE_LEVELS.map(l => `
<div class="glass-score-cell">
<label>${esc(l.label)}</label>
<input type="number" class="glass-score-pts" data-level="${esc(l.key)}"
value="${Number(levels[l.key] ?? 0)}" min="0" step="1">
</div>`).join('');
return ` return `
<li class="glass-symptom-row" data-idx="${idx}"> <li class="glass-symptom-row" data-idx="${idx}">
<div class="form-row" style="align-items:flex-end;margin-bottom:0"> <div class="form-row" style="align-items:flex-end;margin-bottom:0">
@ -242,6 +326,7 @@ function glassSymptomRowHTML(prefix, row, idx) {
</div> </div>
<button type="button" class="btn-icon btn-icon-danger glass-symptom-remove" title="Remove">&#x2716;</button> <button type="button" class="btn-icon btn-icon-danger glass-symptom-remove" title="Remove">&#x2716;</button>
</div> </div>
<div class="glass-score-grid">${ptsRow}</div>
</li>`; </li>`;
} }
@ -252,7 +337,11 @@ function readGlassSymptomsFromForm(prefix) {
container.querySelectorAll('.glass-symptom-row').forEach(rowEl => { container.querySelectorAll('.glass-symptom-row').forEach(rowEl => {
const key = rowEl.querySelector('.glass-symptom-key')?.value?.trim() || ''; const key = rowEl.querySelector('.glass-symptom-key')?.value?.trim() || '';
const labelDe = rowEl.querySelector('.glass-symptom-label')?.value?.trim() || ''; const labelDe = rowEl.querySelector('.glass-symptom-label')?.value?.trim() || '';
if (key || labelDe) rows.push({ key, labelDe }); const scoreLevels = {};
rowEl.querySelectorAll('.glass-score-pts').forEach(inp => {
scoreLevels[inp.dataset.level] = parseInt(inp.value || '0', 10) || 0;
});
if (key || labelDe) rows.push({ key, labelDe, scoreLevels });
}); });
return rows; return rows;
} }
@ -319,7 +408,7 @@ function wireGlassSymptomsSection(prefix) {
container.querySelector('[data-action=add-glass-symptom]')?.addEventListener('click', () => { container.querySelector('[data-action=add-glass-symptom]')?.addEventListener('click', () => {
const rows = collectRows(); const rows = collectRows();
rows.push({ key: '', labelDe: '' }); rows.push({ key: '', labelDe: '', scoreLevels: defaultGlassScoreLevels() });
renderList(rows); renderList(rows);
const lastKey = container.querySelector('.glass-symptom-row:last-child .glass-symptom-label'); const lastKey = container.querySelector('.glass-symptom-row:last-child .glass-symptom-label');
lastKey?.focus(); lastKey?.focus();
@ -477,7 +566,8 @@ function configFormHTML(layout, config, prefix, opts = {}) {
break; break;
} }
case 'value_spinner': case 'value_spinner':
case 'slider_question': case 'slider_question': {
const scoreRules = opts.scoreRules || [];
html = ` html = `
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
@ -497,8 +587,10 @@ function configFormHTML(layout, config, prefix, opts = {}) {
<label>Unit label (optional)</label> <label>Unit label (optional)</label>
<input type="text" id="${prefix}_unitLabel" value="${esc(config.unitLabel || '')}" placeholder="e.g. years"> <input type="text" id="${prefix}_unitLabel" value="${esc(config.unitLabel || '')}" placeholder="e.g. years">
</div>` : ''} </div>` : ''}
</div>`; </div>
${valueScoreRulesSectionHTML(prefix, config, scoreRules)}`;
break; break;
}
case 'date_spinner': { case 'date_spinner': {
const notBefore = config.constraints?.notBefore || ''; const notBefore = config.constraints?.notBefore || '';
const notAfter = config.constraints?.notAfter || ''; const notAfter = config.constraints?.notAfter || '';
@ -694,11 +786,6 @@ function renderEditor() {
<label>Order index</label> <label>Order index</label>
<input type="number" id="metaOrder" value="${questionnaire.orderIndex ?? 0}" min="0" ${editable ? '' : 'disabled'}> <input type="number" id="metaOrder" value="${questionnaire.orderIndex ?? 0}" min="0" ${editable ? '' : 'disabled'}>
</div> </div>
<div class="form-group" style="display:flex;align-items:flex-end;padding-bottom:16px">
<label class="checkbox-label">
<input type="checkbox" id="metaShowPoints" ${questionnaire.showPoints ? 'checked' : ''} ${editable ? '' : 'disabled'}> Show points
</label>
</div>
<div class="form-group"> <div class="form-group">
<label>Category key (app grouping)</label> <label>Category key (app grouping)</label>
<input type="text" id="metaCategoryKey" value="${esc(questionnaire.categoryKey || '')}" placeholder="e.g. health_interview" ${editable ? '' : 'disabled'}> <input type="text" id="metaCategoryKey" value="${esc(questionnaire.categoryKey || '')}" placeholder="e.g. health_interview" ${editable ? '' : 'disabled'}>
@ -838,7 +925,7 @@ async function saveMeta() {
const version = document.getElementById('metaVersion').value.trim(); const version = document.getElementById('metaVersion').value.trim();
const state = document.getElementById('metaState').value; const state = document.getElementById('metaState').value;
const orderIndex = parseInt(document.getElementById('metaOrder').value || '0', 10); const orderIndex = parseInt(document.getElementById('metaOrder').value || '0', 10);
const showPoints = document.getElementById('metaShowPoints').checked ? 1 : 0; const showPoints = 0;
const categoryKey = (document.getElementById('metaCategoryKey')?.value || '').trim(); const categoryKey = (document.getElementById('metaCategoryKey')?.value || '').trim();
let conditionJson = '{}'; let conditionJson = '{}';
let conditionForm = null; let conditionForm = null;
@ -971,6 +1058,7 @@ function showAddQuestionForm() {
document.getElementById('aq_config_section').innerHTML = configFormHTML(type, {}, 'aq_cfg'); document.getElementById('aq_config_section').innerHTML = configFormHTML(type, {}, 'aq_cfg');
wireStringSpinnerOtherToggle('aq_cfg'); wireStringSpinnerOtherToggle('aq_cfg');
if (type === 'glass_scale_question') wireGlassSymptomsSection('aq_cfg'); if (type === 'glass_scale_question') wireGlassSymptomsSection('aq_cfg');
if (type === 'slider_question' || type === 'value_spinner') wireValueScoreRules('aq_cfg');
const notesEl = document.getElementById('aq_notes_section'); const notesEl = document.getElementById('aq_notes_section');
if (notesEl) notesEl.innerHTML = notesSectionHTML({}, 'aq_cfg', qKey); if (notesEl) notesEl.innerHTML = notesSectionHTML({}, 'aq_cfg', qKey);
} }
@ -1060,6 +1148,9 @@ function showAddQuestionForm() {
if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return; if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return;
postBody.glassSymptoms = glassSymptoms; postBody.glassSymptoms = glassSymptoms;
} }
if (type === 'slider_question' || type === 'value_spinner') {
postBody.scoreRules = readValueScoreRulesFromForm('aq_cfg');
}
if (!validateStableKey(questionKey, 'Question key')) return; if (!validateStableKey(questionKey, 'Question key')) return;
if (!text) { showToast('German text is required', 'error'); return; } if (!text) { showToast('German text is required', 'error'); return; }
if (type === 'string_spinner' && !validateStringSpinnerConfig('aq_cfg')) return; if (type === 'string_spinner' && !validateStringSpinnerConfig('aq_cfg')) return;
@ -1203,7 +1294,10 @@ function renderQuestionBody(q) {
</label> </label>
${notesSectionHTML(config, `eq_cfg_${q.questionID}`, qKey)} ${notesSectionHTML(config, `eq_cfg_${q.questionID}`, qKey)}
<div id="eq_config_${q.questionID}"> <div id="eq_config_${q.questionID}">
${configFormHTML(layout, config, `eq_cfg_${q.questionID}`, { glassSymptoms: glassSymptomsRowsFromQuestion(q) })} ${configFormHTML(layout, config, `eq_cfg_${q.questionID}`, {
glassSymptoms: glassSymptomsRowsFromQuestion(q),
scoreRules: q.scoreRules || [],
})}
</div> </div>
<div style="display:flex;gap:8px;margin-top:8px"> <div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-primary btn-sm save-q-btn">Save Question</button> <button class="btn btn-primary btn-sm save-q-btn">Save Question</button>
@ -1271,6 +1365,9 @@ function renderQuestionBody(q) {
if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return; if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return;
payload.glassSymptoms = glassSymptoms; payload.glassSymptoms = glassSymptoms;
} }
if (type === 'slider_question' || type === 'value_spinner') {
payload.scoreRules = readValueScoreRulesFromForm(cfgPrefix);
}
if (!validateStableKey(questionKey, 'Question key')) return; if (!validateStableKey(questionKey, 'Question key')) return;
if (!text) { showToast('German text is required', 'error'); return; } if (!text) { showToast('German text is required', 'error'); return; }
if (type === 'string_spinner' && !validateStringSpinnerConfig(cfgPrefix)) return; if (type === 'string_spinner' && !validateStringSpinnerConfig(cfgPrefix)) return;
@ -1291,15 +1388,19 @@ function renderQuestionBody(q) {
const newLayout = document.getElementById(`eq_type_${q.questionID}`).value; const newLayout = document.getElementById(`eq_type_${q.questionID}`).value;
const opts = newLayout === 'glass_scale_question' const opts = newLayout === 'glass_scale_question'
? { glassSymptoms: glassSymptomsRowsFromQuestion(q) } ? { glassSymptoms: glassSymptomsRowsFromQuestion(q) }
: {}; : (newLayout === 'slider_question' || newLayout === 'value_spinner')
? { scoreRules: q.scoreRules || [] }
: {};
document.getElementById(`eq_config_${q.questionID}`).innerHTML = document.getElementById(`eq_config_${q.questionID}`).innerHTML =
configFormHTML(newLayout, {}, cfgPrefix, opts); configFormHTML(newLayout, {}, cfgPrefix, opts);
wireStringSpinnerOtherToggle(cfgPrefix); wireStringSpinnerOtherToggle(cfgPrefix);
if (newLayout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix); if (newLayout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
if (newLayout === 'slider_question' || newLayout === 'value_spinner') wireValueScoreRules(cfgPrefix);
}); });
wireStringSpinnerOtherToggle(cfgPrefix); wireStringSpinnerOtherToggle(cfgPrefix);
if (layout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix); if (layout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
if (layout === 'slider_question' || layout === 'value_spinner') wireValueScoreRules(cfgPrefix);
body.querySelector('.del-q-btn').addEventListener('click', () => deleteQuestion(q)); body.querySelector('.del-q-btn').addEventListener('click', () => deleteQuestion(q));

View File

@ -48,6 +48,12 @@ function renderHomeDashboard(ov, isAdmin) {
links.push({ href: '#/dev', label: 'Admin settings', desc: 'Dev import, exports, database tools' }); links.push({ href: '#/dev', label: 'Admin settings', desc: 'Dev import, exports, database tools' });
} }
const scoringProfiles = ov.scoringProfiles || [];
const primaryProfile = scoringProfiles[0];
const greenPct = primaryProfile && primaryProfile.clientCount
? Math.round(((primaryProfile.bands?.green ?? 0) / primaryProfile.clientCount) * 100)
: null;
el.innerHTML = ` el.innerHTML = `
<div class="insights-kpi-grid"> <div class="insights-kpi-grid">
<a href="#/clients" class="insights-kpi card home-kpi-link"> <a href="#/clients" class="insights-kpi card home-kpi-link">
@ -66,6 +72,16 @@ function renderHomeDashboard(ov, isAdmin) {
<div class="insights-kpi-value">${ov.submissionsLast30d ?? 0}</div> <div class="insights-kpi-value">${ov.submissionsLast30d ?? 0}</div>
<div class="insights-kpi-label">Uploads (30 days)</div> <div class="insights-kpi-label">Uploads (30 days)</div>
</a> </a>
${primaryProfile ? `
<a href="#/insights" class="insights-kpi card home-kpi-link">
<div class="insights-kpi-value">${primaryProfile.clientCount ?? 0}</div>
<div class="insights-kpi-label">${esc(primaryProfile.name)} scored</div>
</a>` : ''}
${greenPct != null ? `
<a href="#/insights" class="insights-kpi card home-kpi-link">
<div class="insights-kpi-value">${greenPct}%</div>
<div class="insights-kpi-label">Green (${esc(primaryProfile.name)})</div>
</a>` : ''}
</div> </div>
<div class="card" style="margin-top:20px"> <div class="card" style="margin-top:20px">

View File

@ -81,6 +81,35 @@ function renderInsights() {
{ suffix: '%', max: 100 } { suffix: '%', max: 100 }
); );
const scoringProfiles = ov.scoringProfiles || [];
const scoringCharts = scoringProfiles.map(sp => {
const computed = sp.computedBands || sp.bands || {};
const coach = sp.coachBands || {};
const computedChart = horizontalBarChart([
{ label: 'Green', value: computed.green ?? 0 },
{ label: 'Yellow', value: computed.yellow ?? 0 },
{ label: 'Red', value: computed.red ?? 0 },
], { suffix: '' });
const coachChart = horizontalBarChart([
{ label: 'Green', value: coach.green ?? 0 },
{ label: 'Yellow', value: coach.yellow ?? 0 },
{ label: 'Red', value: coach.red ?? 0 },
], { suffix: '' });
return `
<div class="card insights-chart-card">
<h3>${esc(sp.name)}</h3>
<p class="insights-chart-hint">
${sp.clientCount ?? 0} client(s) with complete profile
${sp.averageTotal != null ? ` · avg ${sp.averageTotal}` : ''}
${sp.pendingReview ? ` · ${sp.pendingReview} pending coach review` : ''}
</p>
<p class="insights-chart-hint" style="margin:0 0 6px"><strong>Calculated bands</strong></p>
${computedChart}
<p class="insights-chart-hint" style="margin:12px 0 6px"><strong>Coach decisions</strong></p>
${coachChart}
</div>`;
}).join('');
const qRows = (ov.questionnaires || []).map(q => ` const qRows = (ov.questionnaires || []).map(q => `
<tr> <tr>
<td><strong>${esc(q.name)}</strong></td> <td><strong>${esc(q.name)}</strong></td>
@ -108,6 +137,12 @@ function renderInsights() {
${idleChart} ${idleChart}
</div> </div>
</div> </div>
${scoringProfiles.length ? `
<div class="card" style="margin-top:20px">
<h3 style="margin:0 0 8px">Scoring profiles</h3>
<p class="insights-chart-hint" style="margin:0 0 12px">Calculated vs coach band distribution among clients with a complete weighted score</p>
<div class="insights-charts-grid">${scoringCharts}</div>
</div>` : ''}
<div class="card" style="margin-top:20px"> <div class="card" style="margin-top:20px">
<h3 style="margin:0 0 8px">Completion by questionnaire</h3> <h3 style="margin:0 0 8px">Completion by questionnaire</h3>
<p class="insights-chart-hint" style="margin:0 0 12px">Share of clients who completed each active questionnaire</p> <p class="insights-chart-hint" style="margin:0 0 12px">Share of clients who completed each active questionnaire</p>

View File

@ -7,6 +7,7 @@ export async function questionnairesPage() {
app.innerHTML = ` app.innerHTML = `
${pageHeaderHTML('Questionnaires', '<span id="headerActions"></span>')} ${pageHeaderHTML('Questionnaires', '<span id="headerActions"></span>')}
<div id="qGrid" class="q-dashboard-root"><div class="spinner"></div></div> <div id="qGrid" class="q-dashboard-root"><div class="spinner"></div></div>
<div id="scoringProfilesPanel"></div>
<div id="categoryKeysPanel"></div> <div id="categoryKeysPanel"></div>
`; `;
@ -20,9 +21,13 @@ export async function questionnairesPage() {
} }
try { try {
const data = await apiGet('questionnaires.php'); const [data, profilesData] = await Promise.all([
apiGet('questionnaires.php'),
apiGet('scoring_profiles.php').catch(() => ({ profiles: [] })),
]);
const categoryKeys = data.categoryKeys || []; const categoryKeys = data.categoryKeys || [];
renderGrid(data.questionnaires || [], categoryKeys); renderGrid(data.questionnaires || [], categoryKeys);
renderScoringProfilesSection(profilesData.profiles || [], data.questionnaires || []);
renderCategoryKeysPanel(categoryKeys); renderCategoryKeysPanel(categoryKeys);
bindCategoryLabelEditors(); bindCategoryLabelEditors();
} catch (e) { } catch (e) {
@ -232,7 +237,6 @@ function groupQuestionnairesByCategory(questionnaires, categoryKeys) {
function renderQuestionnaireCard(q) { function renderQuestionnaireCard(q) {
const stateClass = `badge-${(q.state || 'draft').toLowerCase().replace(/\s+/g, '_')}`; const stateClass = `badge-${(q.state || 'draft').toLowerCase().replace(/\s+/g, '_')}`;
const showPts = parseInt(q.showPoints || 0, 10);
return ` return `
<div class="q-card" data-id="${q.questionnaireID}" draggable="${canEdit()}"> <div class="q-card" data-id="${q.questionnaireID}" draggable="${canEdit()}">
<div class="q-card-header"> <div class="q-card-header">
@ -246,7 +250,6 @@ function renderQuestionnaireCard(q) {
<span>v${esc(q.version || '—')}</span> <span>v${esc(q.version || '—')}</span>
<span>${q.questionCount || 0} question${q.questionCount == 1 ? '' : 's'}</span> <span>${q.questionCount || 0} question${q.questionCount == 1 ? '' : 's'}</span>
<span>#${q.orderIndex ?? '—'}</span> <span>#${q.orderIndex ?? '—'}</span>
${showPts ? '<span class="badge badge-active" style="font-size:.7rem;padding:1px 6px">pts</span>' : ''}
</div> </div>
<div class="q-card-actions"> <div class="q-card-actions">
<a href="#/questionnaire/${q.questionnaireID}" class="btn btn-sm">Edit</a> <a href="#/questionnaire/${q.questionnaireID}" class="btn btn-sm">Edit</a>
@ -377,6 +380,360 @@ function initGridDrag(questionnaires) {
}); });
} }
function normalizeProfileBands(profile) {
const greenMax = profile?.greenMax ?? 12;
const yellowMax = profile?.yellowMax ?? 36;
return {
greenMin: profile?.greenMin ?? 0,
greenMax,
yellowMin: profile?.yellowMin ?? (greenMax + 1),
yellowMax,
redMin: profile?.redMin ?? (yellowMax + 1),
};
}
function bandSummary(profile) {
const b = normalizeProfileBands(profile || {});
return `Green ${b.greenMin}${b.greenMax} · Yellow ${b.yellowMin}${b.yellowMax} · Red ${b.redMin}+`;
}
function readBandsFromModal(overlay) {
return {
greenMin: parseInt(overlay.querySelector('#spGreenMin')?.value || '0', 10),
greenMax: parseInt(overlay.querySelector('#spGreenMax')?.value || '0', 10),
yellowMin: parseInt(overlay.querySelector('#spYellowMin')?.value || '0', 10),
yellowMax: parseInt(overlay.querySelector('#spYellowMax')?.value || '0', 10),
redMin: parseInt(overlay.querySelector('#spRedMin')?.value || '0', 10),
};
}
function validateBands(b) {
if (b.greenMin > b.greenMax) return 'Green min must not exceed green max';
if (b.yellowMin > b.yellowMax) return 'Yellow min must not exceed yellow max';
if (b.greenMax >= b.yellowMin) return 'Green range must end before yellow range starts';
if (b.yellowMax >= b.redMin) return 'Yellow range must end before red range starts';
return null;
}
function bandPreviewHTML(b) {
return `
<div class="scoring-band-strips">
<span class="band-badge band-green">Green: ${b.greenMin} ${b.greenMax}</span>
<span class="band-badge band-yellow">Yellow: ${b.yellowMin} ${b.yellowMax}</span>
<span class="band-badge band-red">Red: ${b.redMin}+</span>
</div>`;
}
function bandRangesEditorHTML(bands) {
const b = normalizeProfileBands(bands);
return `
<h4 class="scoring-modal-section-title">Score bands</h4>
<p class="data-toolbar-hint" style="margin:0 0 10px">Set inclusive min/max for each band. Ranges must not overlap.</p>
<div class="scoring-band-ranges-table">
<div class="scoring-band-range-row scoring-band-range-header">
<span>Band</span><span>Min</span><span>Max</span>
</div>
<div class="scoring-band-range-row">
<span class="band-badge band-green">Green</span>
<input type="number" id="spGreenMin" min="0" value="${b.greenMin}">
<input type="number" id="spGreenMax" min="0" value="${b.greenMax}">
</div>
<div class="scoring-band-range-row">
<span class="band-badge band-yellow">Yellow</span>
<input type="number" id="spYellowMin" min="0" value="${b.yellowMin}">
<input type="number" id="spYellowMax" min="0" value="${b.yellowMax}">
</div>
<div class="scoring-band-range-row">
<span class="band-badge band-red">Red</span>
<input type="number" id="spRedMin" min="0" value="${b.redMin}">
<span class="scoring-band-max-placeholder">∞</span>
</div>
</div>
<div id="spBandPreview" class="scoring-band-labels"></div>`;
}
function renderScoringProfilesSection(profiles, questionnaires) {
const panel = document.getElementById('scoringProfilesPanel');
if (!panel) return;
panel.innerHTML = `
<div class="scoring-profiles-section">
<div class="card scoring-profiles-card">
<div class="scoring-profile-header">
<div>
<h3 class="scoring-section-title">Scoring profiles</h3>
<p class="scoring-section-desc">
Weighted multi-questionnaire scores with Green / Yellow / Red bands for insights.
</p>
</div>
${canEdit() ? '<button type="button" class="btn btn-primary btn-sm" id="newScoringProfileBtn">+ New profile</button>' : ''}
</div>
<div id="scoringProfilesList" class="scoring-profiles-list"></div>
</div>
</div>`;
const list = panel.querySelector('#scoringProfilesList');
if (!profiles.length) {
list.innerHTML = '<p class="scoring-empty-hint">No scoring profiles yet. Create one to combine questionnaire scores for insights.</p>';
} else {
list.innerHTML = profiles.map(p => scoringProfileCardHTML(p, questionnaires)).join('');
}
if (canEdit()) {
panel.querySelector('#newScoringProfileBtn')?.addEventListener('click', () => {
openScoringProfileModal(null, questionnaires, profiles);
});
list.querySelectorAll('.edit-profile-btn').forEach(btn => {
btn.addEventListener('click', () => {
const profile = profiles.find(x => x.profileID === btn.dataset.id);
openScoringProfileModal(profile, questionnaires, profiles);
});
});
list.querySelectorAll('.delete-profile-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm(`Delete scoring profile "${btn.dataset.name}"?`)) return;
try {
await apiDelete('scoring_profiles.php', { profileID: btn.dataset.id });
showToast('Profile deleted', 'success');
await questionnairesPage();
} catch (err) {
showToast(err.message, 'error');
}
});
});
}
}
function scoringProfileCardHTML(profile, questionnaires) {
const qnById = Object.fromEntries((questionnaires || []).map(q => [q.questionnaireID, q]));
const members = profile.questionnaires || [];
const activeBadge = profile.isActive
? '<span class="badge badge-active">Active</span>'
: '<span class="badge badge-draft">Inactive</span>';
const memberRows = members.map(m => {
const q = qnById[m.questionnaireID];
const name = q?.name || m.questionnaireID;
return `<div class="scoring-profile-q-row"><span class="scoring-profile-q-name">${esc(name)}</span><span class="scoring-profile-q-weight">× ${m.weight}</span></div>`;
}).join('');
return `
<div class="scoring-profile-card">
<div class="scoring-profile-card-top">
<div class="scoring-profile-card-main">
<div class="scoring-profile-name-row">
<strong>${esc(profile.name)}</strong>
${activeBadge}
</div>
${profile.description ? `<p class="scoring-profile-desc">${esc(profile.description)}</p>` : ''}
<p class="scoring-profile-bands">${bandSummary(profile)}</p>
</div>
${canEdit() ? `
<div class="scoring-profile-card-actions">
<button type="button" class="btn btn-sm edit-profile-btn" data-id="${esc(profile.profileID)}">Edit</button>
<button type="button" class="btn btn-sm btn-danger delete-profile-btn"
data-id="${esc(profile.profileID)}" data-name="${esc(profile.name)}">Delete</button>
</div>` : ''}
</div>
<p class="scoring-profile-member-count">${members.length} questionnaire(s)</p>
<div class="scoring-profile-members">${memberRows || '<p class="scoring-empty-hint">No questionnaires linked</p>'}</div>
</div>`;
}
function openScoringProfileModal(profile, questionnaires, allProfiles) {
const isEdit = !!profile;
const members = profile?.questionnaires?.length
? [...profile.questionnaires]
: [];
const selectedIds = new Set(members.map(m => m.questionnaireID));
const initialBands = normalizeProfileBands(profile || {});
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<div class="modal-dialog modal-dialog-wide">
<h2>${isEdit ? 'Edit scoring profile' : 'New scoring profile'}</h2>
<p class="modal-subtitle">Combine questionnaire totals with weights, then map the sum to Green / Yellow / Red bands.</p>
<div class="form-group">
<label for="spName">Name</label>
<input type="text" id="spName" value="${esc(profile?.name || '')}" placeholder="e.g. RHS Index">
</div>
<div class="form-group">
<label for="spDesc">Description</label>
<textarea id="spDesc" rows="2" placeholder="Optional note for coaches and admins">${esc(profile?.description || '')}</textarea>
</div>
<div class="form-group scoring-profile-active-row">
<label class="checkbox-inline">
<input type="checkbox" id="spActive" ${profile?.isActive !== 0 ? 'checked' : ''}>
Active (shown in insights)
</label>
</div>
${bandRangesEditorHTML(initialBands)}
<h4 class="scoring-modal-section-title">Questionnaires</h4>
<p class="data-toolbar-hint" style="margin:0 0 10px">Add questionnaires and set weight. List order is the display order.</p>
<div id="spQnPicker" class="scoring-profile-picker"></div>
<div class="modal-actions">
<button type="button" class="btn btn-sm" id="spCancel">Cancel</button>
<button type="button" class="btn btn-primary btn-sm" id="spSave">${isEdit ? 'Save' : 'Create'}</button>
</div>
</div>`;
document.body.appendChild(overlay);
document.body.classList.add('modal-open');
const picker = overlay.querySelector('#spQnPicker');
const sortedQn = [...(questionnaires || [])].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0));
function renderPicker() {
const ordered = members.length
? members.map(m => ({ ...m, q: sortedQn.find(q => q.questionnaireID === m.questionnaireID) }))
: [];
const available = sortedQn.filter(q => !selectedIds.has(q.questionnaireID));
picker.innerHTML = `
${ordered.map((m, idx) => `
<div class="scoring-profile-q-row" data-qn="${esc(m.questionnaireID)}">
<span class="scoring-profile-q-name">${esc(m.q?.name || m.questionnaireID)}</span>
<span class="scoring-profile-q-actions">
<label class="sp-weight-wrap">Weight
<input type="number" class="sp-weight" step="0.1" min="0.1"
value="${m.weight ?? 1}">
</label>
<button type="button" class="btn btn-sm sp-move-up" ${idx === 0 ? 'disabled' : ''} title="Move up">↑</button>
<button type="button" class="btn btn-sm sp-move-down" ${idx === ordered.length - 1 ? 'disabled' : ''} title="Move down">↓</button>
<button type="button" class="btn btn-sm btn-danger sp-remove" title="Remove">Remove</button>
</span>
</div>
`).join('')}
${available.length ? `
<div class="scoring-profile-add-row">
<select id="spAddSelect">
<option value="">Add questionnaire…</option>
${available.map(q => `<option value="${esc(q.questionnaireID)}">${esc(q.name)}</option>`).join('')}
</select>
<button type="button" class="btn btn-sm" id="spAddBtn">Add</button>
</div>` : ''}`;
picker.querySelectorAll('.sp-weight').forEach((input, idx) => {
input.addEventListener('change', () => {
members[idx].weight = parseFloat(input.value) || 1;
});
});
picker.querySelectorAll('.sp-remove').forEach(btn => {
btn.addEventListener('click', () => {
const row = btn.closest('.scoring-profile-q-row');
const id = row?.dataset.qn;
if (!id) return;
selectedIds.delete(id);
const i = members.findIndex(m => m.questionnaireID === id);
if (i >= 0) members.splice(i, 1);
renderPicker();
});
});
picker.querySelectorAll('.sp-move-up').forEach(btn => {
btn.addEventListener('click', () => {
const row = btn.closest('.scoring-profile-q-row');
const id = row?.dataset.qn;
const i = members.findIndex(m => m.questionnaireID === id);
if (i > 0) {
[members[i - 1], members[i]] = [members[i], members[i - 1]];
renderPicker();
}
});
});
picker.querySelectorAll('.sp-move-down').forEach(btn => {
btn.addEventListener('click', () => {
const row = btn.closest('.scoring-profile-q-row');
const id = row?.dataset.qn;
const i = members.findIndex(m => m.questionnaireID === id);
if (i >= 0 && i < members.length - 1) {
[members[i], members[i + 1]] = [members[i + 1], members[i]];
renderPicker();
}
});
});
overlay.querySelector('#spAddBtn')?.addEventListener('click', () => {
const sel = overlay.querySelector('#spAddSelect');
const id = sel?.value;
if (!id || selectedIds.has(id)) return;
selectedIds.add(id);
members.push({ questionnaireID: id, weight: 1, orderIndex: members.length });
renderPicker();
});
}
function updateBandPreview() {
const el = overlay.querySelector('#spBandPreview');
if (!el) return;
const b = readBandsFromModal(overlay);
const err = validateBands(b);
if (err) {
el.innerHTML = `<p class="error-text" style="margin:0">${esc(err)}</p>`;
return;
}
el.innerHTML = bandPreviewHTML(b);
}
renderPicker();
updateBandPreview();
['#spGreenMin', '#spGreenMax', '#spYellowMin', '#spYellowMax', '#spRedMin'].forEach(sel => {
overlay.querySelector(sel)?.addEventListener('input', updateBandPreview);
});
const close = () => {
overlay.remove();
document.body.classList.remove('modal-open');
};
overlay.querySelector('#spCancel')?.addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
overlay.querySelector('#spSave')?.addEventListener('click', async () => {
const name = overlay.querySelector('#spName')?.value.trim();
const description = overlay.querySelector('#spDesc')?.value.trim() || '';
const isActive = overlay.querySelector('#spActive')?.checked;
const bands = readBandsFromModal(overlay);
const bandErr = validateBands(bands);
if (!name) {
showToast('Profile name is required', 'error');
return;
}
if (bandErr) {
showToast(bandErr, 'error');
return;
}
if (!members.length) {
showToast('Add at least one questionnaire', 'error');
return;
}
const payload = {
name,
description,
isActive,
...bands,
questionnaires: members.map((m, idx) => ({
questionnaireID: m.questionnaireID,
weight: parseFloat(picker.querySelector(`[data-qn="${m.questionnaireID}"] .sp-weight`)?.value) || m.weight || 1,
orderIndex: idx,
})),
};
if (isEdit) payload.profileID = profile.profileID;
const saveBtn = overlay.querySelector('#spSave');
saveBtn.disabled = true;
try {
if (isEdit) {
await apiPut('scoring_profiles.php', payload);
} else {
await apiPost('scoring_profiles.php', payload);
}
showToast(isEdit ? 'Profile saved' : 'Profile created', 'success');
close();
await questionnairesPage();
} catch (err) {
showToast(err.message, 'error');
saveBtn.disabled = false;
}
});
}
function bindImportBundle() { function bindImportBundle() {
const fileInput = document.getElementById('importBundleFile'); const fileInput = document.getElementById('importBundleFile');
const btn = document.getElementById('importBundleBtn'); const btn = document.getElementById('importBundleBtn');