diff --git a/api/index.php b/api/index.php index 31353c0..ffedb89 100644 --- a/api/index.php +++ b/api/index.php @@ -49,6 +49,7 @@ $routes = [ 'results' => __DIR__ . '/../handlers/results.php', 'export' => __DIR__ . '/../handlers/export.php', 'analytics' => __DIR__ . '/../handlers/analytics.php', + 'scoring_profiles' => __DIR__ . '/../handlers/scoring_profiles.php', 'coaches' => __DIR__ . '/../handlers/coaches.php', 'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php', 'logout' => __DIR__ . '/../handlers/logout.php', diff --git a/common.php b/common.php index 43b52e4..6bea687 100644 --- a/common.php +++ b/common.php @@ -1728,6 +1728,7 @@ function qdb_translation_row_label(array $e): string { /** Export one questionnaire with structure + all translations (portable JSON). */ 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->execute([':id' => $qnID]); $qnRow = $qn->fetch(PDO::FETCH_ASSOC); @@ -1779,6 +1780,7 @@ function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array { 'isRequired' => (int)$dbQ['isRequired'], 'config' => $config, 'answerOptions' => $optionsOut, + 'scoreRules' => qdb_get_score_rules_for_question($pdo, $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 [ - 'exportVersion' => 1, + 'exportVersion' => 2, 'exportedAt' => time(), 'sourceLanguage' => QDB_SOURCE_LANGUAGE, 'questionnaireCount' => count($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). */ function qdb_delete_questionnaire_cascade(PDO $pdo, string $qnID): void { $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_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) { $optKey = trim((string)($opt['optionKey'] ?? '')); $optGerman = trim($opt['defaultText'] ?? ''); @@ -2213,12 +2227,102 @@ function qdb_import_questionnaires_bundle(PDO $pdo, array $bundle, bool $replace foreach ($items as $item) { $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 [ 'imported' => count($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 --- function aes256_cbc_encrypt_bytes(string $plain, string $key): string { $key = str_pad(substr($key, 0, 32), 32, "\0"); diff --git a/data/app_ui_strings.json b/data/app_ui_strings.json index 5b28968..bb2de1d 100644 --- a/data/app_ui_strings.json +++ b/data/app_ui_strings.json @@ -1,6 +1,6 @@ { "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": [ "all_clients", "answer", @@ -10,6 +10,9 @@ "auth_subtitle_change_password", "auth_subtitle_login", "auth_title", + "category_green", + "category_red", + "category_yellow", "cancel", "choose_answer", "choose_more_elements", @@ -84,6 +87,16 @@ "question", "questions_filled", "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_password_btn", "select_one_answer", @@ -111,6 +124,9 @@ "auth_subtitle_login": "Melden Sie sich mit Ihrem Coach-Konto an.", "auth_title": "BW Schützt", "cancel": "Abbrechen", + "category_green": "Grün", + "category_red": "Rot", + "category_yellow": "Gelb", "choose_answer": "Antwort wählen", "choose_more_elements": "Es müssen mehr Elemenete ausgewählt werden.", "client": "Klient", @@ -184,6 +200,16 @@ "question": "Frage", "questions_filled": "Antworten", "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_password_btn": "Passwort speichern", "select_one_answer": "Bitte wählen Sie eine Antwort aus!", @@ -200,7 +226,7 @@ "upload_success_message": "Hochladen: {count} erledigt.", "username_hint": "Benutzername", "year": "Jahr", - "questionnaire_group_demographics": "Demografie", + "questionnaire_group_demographics": "Demografie test", "questionnaire_group_rhs": "Gesundheitsinterview" } } diff --git a/db_init.php b/db_init.php index 55ccb80..b10351d 100644 --- a/db_init.php +++ b/db_init.php @@ -13,7 +13,7 @@ if (defined('QDB_TEST_UPLOADS')) { define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock'); } define('QDB_SCHEMA', __DIR__ . '/schema.sql'); -define('QDB_VERSION', 6); +define('QDB_VERSION', 9); function qdb_table_exists(PDO $pdo, string $table): bool { $stmt = $pdo->prepare( @@ -126,6 +126,92 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool { $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')) { require_once __DIR__ . '/lib/submissions.php'; 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(); if ($currentVersion < 6 && qdb_table_exists($pdo, 'questionnaire_submission')) { $pdo->exec( diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index 6c3740a..ad35ae3 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -7,6 +7,9 @@ * 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 ?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). * 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). @@ -27,6 +30,58 @@ if ($method === 'POST') { require_role(['admin', 'supervisor', 'coach'], $tokenRec); $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']); $qnID = trim($body['questionnaireID']); @@ -126,7 +181,6 @@ if ($method === 'POST') { $pdo->beginTransaction(); - $sumPoints = 0; $glassByParent = []; $submittedQuestionIds = []; $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; if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) { $opt = $optionMap[$fullQID][(string)$optionKey]; $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 @@ -247,6 +293,9 @@ if ($method === 'POST') { } // Upsert completed_questionnaire record + require_once __DIR__ . '/../lib/scoring.php'; + $sumPoints = qdb_compute_questionnaire_score($pdo, $clientCode, $qnID); + $pdo->prepare(" INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints) VALUES (:cc, :qn, :abc, 'completed', :sa, :ca, :sp) @@ -277,6 +326,8 @@ if ($method === 'POST') { $assignedByCoach ); + qdb_recompute_profile_scores_for_client($pdo, $clientCode, $qnID); + $pdo->commit(); qdb_save($tmpDb, $lockFp); @@ -298,8 +349,50 @@ $translations = $_GET['translations'] ?? ''; $fetchClients = $_GET['clients'] ?? ''; $fetchAnswers = $_GET['answers'] ?? ''; $fetchAnswersBulk = $_GET['answersBulk'] ?? ''; +$fetchScoringProfiles = $_GET['scoringProfiles'] ?? ''; +$fetchScoringReview = $_GET['scoringReview'] ?? ''; $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) { require_role(['coach'], $tokenRec); require_once __DIR__ . '/../lib/app_answers.php'; @@ -441,6 +534,14 @@ if ($qnID) { $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(" SELECT defaultText, points, nextQuestionId FROM answer_option WHERE questionID = :qid ORDER BY orderIndex diff --git a/handlers/clients.php b/handlers/clients.php index 4bbf3a6..7d73629 100644 --- a/handlers/clients.php +++ b/handlers/clients.php @@ -36,8 +36,21 @@ case 'GET': $stmt->execute(); $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); - json_success(['clients' => $clients]); + json_success([ + 'clients' => $clients, + 'scoringProfiles' => $scoringSummary['profiles'], + ]); } catch (Throwable $e) { qdb_handler_fail($e, 'clients', null, $tmpDb ?? null, $lockFp ?? null); } diff --git a/handlers/questions.php b/handlers/questions.php index b2fa0cf..4f76a47 100644 --- a/handlers/questions.php +++ b/handlers/questions.php @@ -1,5 +1,7 @@ prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :qid"); $tr->execute([':qid' => $q['questionID']]); @@ -111,6 +115,10 @@ case 'POST': if ($type === 'glass_scale_question') { 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); json_success(['question' => [ 'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text, @@ -118,7 +126,9 @@ case 'POST': 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req, 'configJson' => $configJson, 'answerOptions' => [], 'translations' => [], '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) { qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null); @@ -172,6 +182,10 @@ case 'PUT': if ($type === 'glass_scale_question') { 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); json_success(['question' => [ 'questionID' => $id, 'questionnaireID' => $row['questionnaireID'], @@ -179,7 +193,9 @@ case 'PUT': 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req, 'configJson' => $configJson, '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) { 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 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 client_answer WHERE questionID = :id")->execute([':id' => $id]); $pdo->prepare("DELETE FROM question WHERE questionID = :id")->execute([':id' => $id]); diff --git a/handlers/scoring_profiles.php b/handlers/scoring_profiles.php new file mode 100644 index 0000000..f15c37d --- /dev/null +++ b/handlers/scoring_profiles.php @@ -0,0 +1,203 @@ + $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 + */ +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); + } +} diff --git a/lib/analytics.php b/lib/analytics.php index 4fd611b..91a8f1d 100644 --- a/lib/analytics.php +++ b/lib/analytics.php @@ -101,9 +101,77 @@ function qdb_analytics_overview(PDO $pdo, array $tokenRec): array { 'submissionsLast30d' => $submissions30d, 'questionnaires' => $perQuestionnaire, '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> + */ +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 { [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); $now = time(); diff --git a/lib/scoring.php b/lib/scoring.php new file mode 100644 index 0000000..99e429d --- /dev/null +++ b/lib/scoring.php @@ -0,0 +1,757 @@ + 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 + */ +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 $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> + */ +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}> + */ +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 + */ +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}> $glassSymptoms + * @return list + */ +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|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 + */ +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> + */ +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 (0–4) 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 0–12, yellow 13–36, 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 $clientCodes + * @return list>}> + */ +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 + */ +/** + * Store coach band; create a result row from app-local computed values when none exists yet. + * + * @return array + */ +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); +} diff --git a/lib/submissions.php b/lib/submissions.php index 8ad65e7..546b2c6 100644 --- a/lib/submissions.php +++ b/lib/submissions.php @@ -732,5 +732,116 @@ function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array 'supervisorUsername' => $client['supervisorUsername'] ?? '', ], '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, byClient: array>} + */ +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 + */ +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; +} diff --git a/schema.sql b/schema.sql index dd0f6d0..6e0bdce 100644 --- a/schema.sql +++ b/schema.sql @@ -184,3 +184,58 @@ CREATE TABLE IF NOT EXISTS client_followup_note ( 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); + diff --git a/tests/Unit/ScoringTest.php b/tests/Unit/ScoringTest.php new file mode 100644 index 0000000..731c489 --- /dev/null +++ b/tests/Unit/ScoringTest.php @@ -0,0 +1,211 @@ +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']); + } +} diff --git a/website/css/style.css b/website/css/style.css index 29af89d..b61d0c4 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -2635,3 +2635,423 @@ select:disabled { font-size: .85rem; 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); +} diff --git a/website/js/pages/clients.js b/website/js/pages/clients.js index f590e0c..46f70dc 100644 --- a/website/js/pages/clients.js +++ b/website/js/pages/clients.js @@ -6,6 +6,7 @@ const PAGE_SIZE = 50; let clientsList = []; let coachesList = []; +let scoringProfileCatalog = []; let filterSearch = ''; let filterTestData = ''; let page = 0; @@ -42,6 +43,7 @@ async function loadClients() { apiGet('assignments.php'), ]); clientsList = clientsData.clients || []; + scoringProfileCatalog = clientsData.scoringProfiles || []; coachesList = assignData.coaches || []; renderClients(); } catch (e) { @@ -65,6 +67,9 @@ function renderClients() { return; } + const showProfileColumn = scoringProfileCatalog.length > 0; + const profileLegend = showProfileColumn ? scoringLegendHTML() : ''; + container.innerHTML = `
@@ -76,6 +81,7 @@ function renderClients() {
+ ${profileLegend}

Expand a row to view questionnaire responses and upload version history.

@@ -85,6 +91,7 @@ function renderClients() { Client Code Current Coach + ${showProfileColumn ? 'Profile scores' : ''} Actions @@ -132,6 +139,7 @@ function renderClientTableBody() { const countEl = document.getElementById('clientListCount'); const filtered = getFilteredClientsList(); const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const colCount = scoringProfileCatalog.length > 0 ? 4 : 3; if (page >= totalPages) page = totalPages - 1; const slice = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); @@ -140,7 +148,7 @@ function renderClientTableBody() { } if (!slice.length) { - body.innerHTML = `No clients match`; + body.innerHTML = `No clients match`; } else { body.innerHTML = slice.map(c => clientRowHTML(c)).join(''); body.querySelectorAll('.client-expand-btn').forEach(btn => { @@ -243,9 +251,103 @@ function clientDetailPanelHTML(clientCode) { Coach: ${esc(cl.coachUsername || '—')} ${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''}

+ ${renderClientScoringProfiles(detail.scoringProfiles || [])} ${questionnaires.map(q => clientQnBlockHTML(clientCode, q)).join('')}`; } +function scoringLegendHTML() { + return ` +
+

How profile scores work

+

+ Each profile adds up questionnaire points (× weight) into a weighted total, + then maps that total to green + yellow + red using the thresholds on the Questionnaires page. +

+
+
+
Calculated
+
Automatic category from answers and profile rules (shown before coach review).
+
+
+
Coach
+
Category confirmed or changed by the coach in the app (“Review scores”).
+
+
+
Incomplete
+
Not all questionnaires in that profile are completed yet.
+
+
+
`; +} + +function bandBadgeHTML(band, { pending = false, incomplete = false, label = '' } = {}) { + const text = label || band || ''; + if (incomplete) { + return 'incomplete'; + } + if (pending) { + return 'awaiting review'; + } + if (!band) { + return ''; + } + return `${esc(text || band)}`; +} + +function profileScoreBlockHTML(p) { + const calcBand = p.calculatedBand || p.band; + if (!calcBand) { + return ` +
+
${esc(p.name)}
+

Profile incomplete — not all member questionnaires are done.

+
`; + } + const coachPending = p.pendingReview !== false && !p.coachBand; + const coachDiffers = p.coachBand && p.coachBand !== calcBand; + return ` +
+
${esc(p.name)}
+
+ Calculated + ${bandBadgeHTML(calcBand)} + ${esc(String(p.weightedTotal))} +
+
+ Coach + ${coachPending ? bandBadgeHTML(null, { pending: true }) : bandBadgeHTML(p.coachBand)} + ${coachDiffers ? 'override' : ''} +
+
`; +} + +function renderClientScoringProfiles(profiles) { + if (!profiles.length) return ''; + return ` +
+ Scoring profiles +

+ Weighted totals and calculated vs coach categories for this client. +

+
${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 ` +
+ ${profileScoreBlockHTML(p)} + ${meta ? `

${meta}

` : ''} + ${coachPending ? '

Coach has not reviewed this profile in the app yet.

' : ''} +
`; + }).join('')}
+
`; +} + function clientQnBlockHTML(clientCode, q) { const key = qnKey(clientCode, q.questionnaireID); const qnExpanded = expandedQnKey === key; @@ -311,6 +413,17 @@ function clientQnBlockHTML(clientCode, q) {
`; } +function profileDotsHTML(profiles) { + if (!profiles?.length) { + return ''; + } + const hasAny = profiles.some(p => p.calculatedBand || p.band); + if (!hasAny) { + return ``; + } + return `
${profiles.map(p => profileScoreBlockHTML(p)).join('')}
`; +} + function clientHasResponseData(c) { return Number(c.hasResponseData) === 1; } @@ -324,6 +437,10 @@ function clientRowHTML(c) { const expandControl = canExpand ? ` ` : ''; + const profileCol = scoringProfileCatalog.length > 0 + ? `${profileDotsHTML(c.scoringProfiles || [])}` + : ''; + const colCount = scoringProfileCatalog.length > 0 ? 4 : 3; return ` @@ -331,13 +448,14 @@ function clientRowHTML(c) { ${expandControl}${esc(c.clientCode)} ${coach} + ${profileCol} ${expanded ? ` - +
${clientDetailPanelHTML(c.clientCode)}
` : ''}`; diff --git a/website/js/pages/editor.js b/website/js/pages/editor.js index 038a116..b2a310b 100644 --- a/website/js/pages/editor.js +++ b/website/js/pages/editor.js @@ -33,6 +33,14 @@ const LAYOUT_TYPES = [ 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 questions = []; let isNew = false; @@ -194,10 +202,79 @@ function glassSymptomsRowsFromQuestion(q) { return q.glassSymptoms.map(r => ({ key: (r.key || '').trim(), labelDe: (r.labelDe || '').trim(), + scoreLevels: r.scoreLevels || defaultGlassScoreLevels(), })); } 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 => ` +
+ ${v} + +
`).join(''); + return ` +
+ +

Set points for each selectable value (used when the questionnaire is scored).

+
${rows}
+
`; +} + +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) { @@ -227,6 +304,13 @@ function glassSymptomsSectionHTML(rows, prefix) { } function glassSymptomRowHTML(prefix, row, idx) { + const levels = row.scoreLevels || defaultGlassScoreLevels(); + const ptsRow = GLASS_SCORE_LEVELS.map(l => ` +
+ + +
`).join(''); return `
  • @@ -242,6 +326,7 @@ function glassSymptomRowHTML(prefix, row, idx) {
    +
    ${ptsRow}
  • `; } @@ -252,7 +337,11 @@ function readGlassSymptomsFromForm(prefix) { container.querySelectorAll('.glass-symptom-row').forEach(rowEl => { const key = rowEl.querySelector('.glass-symptom-key')?.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; } @@ -319,7 +408,7 @@ function wireGlassSymptomsSection(prefix) { container.querySelector('[data-action=add-glass-symptom]')?.addEventListener('click', () => { const rows = collectRows(); - rows.push({ key: '', labelDe: '' }); + rows.push({ key: '', labelDe: '', scoreLevels: defaultGlassScoreLevels() }); renderList(rows); const lastKey = container.querySelector('.glass-symptom-row:last-child .glass-symptom-label'); lastKey?.focus(); @@ -477,7 +566,8 @@ function configFormHTML(layout, config, prefix, opts = {}) { break; } case 'value_spinner': - case 'slider_question': + case 'slider_question': { + const scoreRules = opts.scoreRules || []; html = `
    @@ -497,8 +587,10 @@ function configFormHTML(layout, config, prefix, opts = {}) {
    ` : ''} -
    `; + + ${valueScoreRulesSectionHTML(prefix, config, scoreRules)}`; break; + } case 'date_spinner': { const notBefore = config.constraints?.notBefore || ''; const notAfter = config.constraints?.notAfter || ''; @@ -694,11 +786,6 @@ function renderEditor() { -
    - -
    @@ -838,7 +925,7 @@ async function saveMeta() { const version = document.getElementById('metaVersion').value.trim(); const state = document.getElementById('metaState').value; 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(); let conditionJson = '{}'; let conditionForm = null; @@ -971,6 +1058,7 @@ function showAddQuestionForm() { document.getElementById('aq_config_section').innerHTML = configFormHTML(type, {}, 'aq_cfg'); wireStringSpinnerOtherToggle('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'); if (notesEl) notesEl.innerHTML = notesSectionHTML({}, 'aq_cfg', qKey); } @@ -1060,6 +1148,9 @@ function showAddQuestionForm() { if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return; postBody.glassSymptoms = glassSymptoms; } + if (type === 'slider_question' || type === 'value_spinner') { + postBody.scoreRules = readValueScoreRulesFromForm('aq_cfg'); + } if (!validateStableKey(questionKey, 'Question key')) return; if (!text) { showToast('German text is required', 'error'); return; } if (type === 'string_spinner' && !validateStringSpinnerConfig('aq_cfg')) return; @@ -1203,7 +1294,10 @@ function renderQuestionBody(q) { ${notesSectionHTML(config, `eq_cfg_${q.questionID}`, qKey)}
    - ${configFormHTML(layout, config, `eq_cfg_${q.questionID}`, { glassSymptoms: glassSymptomsRowsFromQuestion(q) })} + ${configFormHTML(layout, config, `eq_cfg_${q.questionID}`, { + glassSymptoms: glassSymptomsRowsFromQuestion(q), + scoreRules: q.scoreRules || [], + })}
    @@ -1271,6 +1365,9 @@ function renderQuestionBody(q) { if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return; payload.glassSymptoms = glassSymptoms; } + if (type === 'slider_question' || type === 'value_spinner') { + payload.scoreRules = readValueScoreRulesFromForm(cfgPrefix); + } if (!validateStableKey(questionKey, 'Question key')) return; if (!text) { showToast('German text is required', 'error'); 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 opts = newLayout === 'glass_scale_question' ? { glassSymptoms: glassSymptomsRowsFromQuestion(q) } - : {}; + : (newLayout === 'slider_question' || newLayout === 'value_spinner') + ? { scoreRules: q.scoreRules || [] } + : {}; document.getElementById(`eq_config_${q.questionID}`).innerHTML = configFormHTML(newLayout, {}, cfgPrefix, opts); wireStringSpinnerOtherToggle(cfgPrefix); if (newLayout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix); + if (newLayout === 'slider_question' || newLayout === 'value_spinner') wireValueScoreRules(cfgPrefix); }); wireStringSpinnerOtherToggle(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)); diff --git a/website/js/pages/home.js b/website/js/pages/home.js index 2da1994..1e69b84 100644 --- a/website/js/pages/home.js +++ b/website/js/pages/home.js @@ -48,6 +48,12 @@ function renderHomeDashboard(ov, isAdmin) { 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 = `
    diff --git a/website/js/pages/insights.js b/website/js/pages/insights.js index df8d226..5206cc6 100644 --- a/website/js/pages/insights.js +++ b/website/js/pages/insights.js @@ -81,6 +81,35 @@ function renderInsights() { { 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 ` +
    +

    ${esc(sp.name)}

    +

    + ${sp.clientCount ?? 0} client(s) with complete profile + ${sp.averageTotal != null ? ` · avg ${sp.averageTotal}` : ''} + ${sp.pendingReview ? ` · ${sp.pendingReview} pending coach review` : ''} +

    +

    Calculated bands

    + ${computedChart} +

    Coach decisions

    + ${coachChart} +
    `; + }).join(''); + const qRows = (ov.questionnaires || []).map(q => ` ${esc(q.name)} @@ -108,6 +137,12 @@ function renderInsights() { ${idleChart}
    + ${scoringProfiles.length ? ` +
    +

    Scoring profiles

    +

    Calculated vs coach band distribution among clients with a complete weighted score

    +
    ${scoringCharts}
    +
    ` : ''}

    Completion by questionnaire

    Share of clients who completed each active questionnaire

    diff --git a/website/js/pages/questionnaires.js b/website/js/pages/questionnaires.js index 4e85a5d..e6d14c9 100644 --- a/website/js/pages/questionnaires.js +++ b/website/js/pages/questionnaires.js @@ -7,6 +7,7 @@ export async function questionnairesPage() { app.innerHTML = ` ${pageHeaderHTML('Questionnaires', '')}
    +
    `; @@ -20,9 +21,13 @@ export async function questionnairesPage() { } 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 || []; renderGrid(data.questionnaires || [], categoryKeys); + renderScoringProfilesSection(profilesData.profiles || [], data.questionnaires || []); renderCategoryKeysPanel(categoryKeys); bindCategoryLabelEditors(); } catch (e) { @@ -232,7 +237,6 @@ function groupQuestionnairesByCategory(questionnaires, categoryKeys) { function renderQuestionnaireCard(q) { const stateClass = `badge-${(q.state || 'draft').toLowerCase().replace(/\s+/g, '_')}`; - const showPts = parseInt(q.showPoints || 0, 10); return `
    @@ -246,7 +250,6 @@ function renderQuestionnaireCard(q) { v${esc(q.version || '—')} ${q.questionCount || 0} question${q.questionCount == 1 ? '' : 's'} #${q.orderIndex ?? '—'} - ${showPts ? 'pts' : ''}
    Edit @@ -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 ` +
    + Green: ${b.greenMin} – ${b.greenMax} + Yellow: ${b.yellowMin} – ${b.yellowMax} + Red: ${b.redMin}+ +
    `; +} + +function bandRangesEditorHTML(bands) { + const b = normalizeProfileBands(bands); + return ` +

    Score bands

    +

    Set inclusive min/max for each band. Ranges must not overlap.

    +
    +
    + BandMinMax +
    +
    + Green + + +
    +
    + Yellow + + +
    +
    + Red + + +
    +
    +
    `; +} + +function renderScoringProfilesSection(profiles, questionnaires) { + const panel = document.getElementById('scoringProfilesPanel'); + if (!panel) return; + + panel.innerHTML = ` +
    +
    +
    +
    +

    Scoring profiles

    +

    + Weighted multi-questionnaire scores with Green / Yellow / Red bands for insights. +

    +
    + ${canEdit() ? '' : ''} +
    +
    +
    +
    `; + + const list = panel.querySelector('#scoringProfilesList'); + if (!profiles.length) { + list.innerHTML = '

    No scoring profiles yet. Create one to combine questionnaire scores for insights.

    '; + } 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 + ? 'Active' + : 'Inactive'; + const memberRows = members.map(m => { + const q = qnById[m.questionnaireID]; + const name = q?.name || m.questionnaireID; + return `
    ${esc(name)}× ${m.weight}
    `; + }).join(''); + + return ` +
    +
    +
    +
    + ${esc(profile.name)} + ${activeBadge} +
    + ${profile.description ? `

    ${esc(profile.description)}

    ` : ''} +

    ${bandSummary(profile)}

    +
    + ${canEdit() ? ` +
    + + +
    ` : ''} +
    +

    ${members.length} questionnaire(s)

    +
    ${memberRows || '

    No questionnaires linked

    '}
    +
    `; +} + +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 = ` + `; + 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) => ` +
    + ${esc(m.q?.name || m.questionnaireID)} + + + + + + +
    + `).join('')} + ${available.length ? ` +
    + + +
    ` : ''}`; + + 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 = `

    ${esc(err)}

    `; + 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() { const fileInput = document.getElementById('importBundleFile'); const btn = document.getElementById('importBundleBtn');