This commit is contained in:
@ -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',
|
||||
|
||||
106
common.php
106
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");
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
125
db_init.php
125
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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../lib/scoring.php';
|
||||
|
||||
$tokenRec = require_valid_token_web();
|
||||
|
||||
switch ($method) {
|
||||
@ -39,7 +41,9 @@ case 'GET':
|
||||
}
|
||||
unset($opt);
|
||||
if (($q['type'] ?? '') === 'glass_scale_question') {
|
||||
$q['glassSymptoms'] = qdb_glass_symptoms_with_labels($pdo, $cfg);
|
||||
$q['glassSymptoms'] = qdb_glass_symptoms_with_score_rules($pdo, $q['questionID'], $cfg);
|
||||
} elseif (in_array($q['type'] ?? '', ['slider_question', 'value_spinner'], true)) {
|
||||
$q['scoreRules'] = qdb_get_score_rules_for_question($pdo, $q['questionID']);
|
||||
}
|
||||
$tr = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :qid");
|
||||
$tr->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]);
|
||||
|
||||
203
handlers/scoring_profiles.php
Normal file
203
handlers/scoring_profiles.php
Normal file
@ -0,0 +1,203 @@
|
||||
<?php
|
||||
/**
|
||||
* Scoring profiles CRUD (weighted multi-questionnaire Green/Yellow/Red bands).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../lib/scoring.php';
|
||||
|
||||
$tokenRec = require_valid_token_web();
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
|
||||
$profileID = trim($_GET['id'] ?? '');
|
||||
if ($profileID !== '') {
|
||||
$profile = qdb_get_scoring_profile($pdo, $profileID);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
if (!$profile) {
|
||||
json_error('NOT_FOUND', 'Scoring profile not found', 404);
|
||||
}
|
||||
json_success(['profile' => $profile]);
|
||||
}
|
||||
json_success(['profiles' => qdb_list_scoring_profiles($pdo)]);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'List scoring profiles', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = read_json_body();
|
||||
$name = trim($body['name'] ?? '');
|
||||
if ($name === '') {
|
||||
json_error('BAD_REQUEST', 'name is required', 400);
|
||||
}
|
||||
$bands = qdb_parse_scoring_bands_body($body);
|
||||
$bandErr = qdb_validate_scoring_bands($bands);
|
||||
if ($bandErr !== null) {
|
||||
json_error('INVALID_FIELD', $bandErr, 400);
|
||||
}
|
||||
$members = qdb_parse_profile_questionnaires($body['questionnaires'] ?? []);
|
||||
if ($members === []) {
|
||||
json_error('INVALID_FIELD', 'At least one questionnaire is required', 400);
|
||||
}
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
$profileID = bin2hex(random_bytes(16));
|
||||
$now = time();
|
||||
$pdo->prepare(
|
||||
'INSERT INTO scoring_profile (profileID, name, description, isActive,
|
||||
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
|
||||
VALUES (:id, :name, :desc, :act, :gmin, :gmax, :ymin, :ymax, :rmin, :ca, :ua)'
|
||||
)->execute([
|
||||
':id' => $profileID,
|
||||
':name' => $name,
|
||||
':desc' => trim($body['description'] ?? ''),
|
||||
':act' => !empty($body['isActive']) ? 1 : 0,
|
||||
':gmin' => $bands['greenMin'],
|
||||
':gmax' => $bands['greenMax'],
|
||||
':ymin' => $bands['yellowMin'],
|
||||
':ymax' => $bands['yellowMax'],
|
||||
':rmin' => $bands['redMin'],
|
||||
':ca' => $now,
|
||||
':ua' => $now,
|
||||
]);
|
||||
qdb_sync_profile_questionnaires($pdo, $profileID, $members);
|
||||
qdb_recompute_all_profiles_after_change($pdo);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['profile' => qdb_get_scoring_profile($pdo, $profileID)]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Create scoring profile', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = read_json_body();
|
||||
$profileID = trim($body['profileID'] ?? '');
|
||||
if ($profileID === '') {
|
||||
json_error('BAD_REQUEST', 'profileID is required', 400);
|
||||
}
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
$existing = qdb_get_scoring_profile($pdo, $profileID);
|
||||
if (!$existing) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Scoring profile not found', 404);
|
||||
}
|
||||
$name = trim($body['name'] ?? $existing['name']);
|
||||
$bands = qdb_normalize_scoring_bands(array_merge($existing, $body));
|
||||
$bandErr = qdb_validate_scoring_bands($bands);
|
||||
if ($bandErr !== null) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('INVALID_FIELD', $bandErr, 400);
|
||||
}
|
||||
$members = isset($body['questionnaires'])
|
||||
? qdb_parse_profile_questionnaires($body['questionnaires'])
|
||||
: null;
|
||||
if ($members !== null && $members === []) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('INVALID_FIELD', 'At least one questionnaire is required', 400);
|
||||
}
|
||||
$pdo->prepare(
|
||||
'UPDATE scoring_profile SET name = :name, description = :desc, isActive = :act,
|
||||
greenMin = :gmin, greenMax = :gmax, yellowMin = :ymin, yellowMax = :ymax, redMin = :rmin,
|
||||
updatedAt = :ua WHERE profileID = :id'
|
||||
)->execute([
|
||||
':name' => $name,
|
||||
':desc' => trim($body['description'] ?? $existing['description']),
|
||||
':act' => isset($body['isActive']) ? (!empty($body['isActive']) ? 1 : 0) : $existing['isActive'],
|
||||
':gmin' => $bands['greenMin'],
|
||||
':gmax' => $bands['greenMax'],
|
||||
':ymin' => $bands['yellowMin'],
|
||||
':ymax' => $bands['yellowMax'],
|
||||
':rmin' => $bands['redMin'],
|
||||
':ua' => time(),
|
||||
':id' => $profileID,
|
||||
]);
|
||||
if ($members !== null) {
|
||||
qdb_sync_profile_questionnaires($pdo, $profileID, $members);
|
||||
}
|
||||
qdb_recompute_all_profiles_after_change($pdo, $profileID);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['profile' => qdb_get_scoring_profile($pdo, $profileID)]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Update scoring profile', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
require_role(['admin', 'supervisor'], $tokenRec);
|
||||
$body = read_json_body();
|
||||
$profileID = trim($body['profileID'] ?? '');
|
||||
if ($profileID === '') {
|
||||
json_error('BAD_REQUEST', 'profileID is required', 400);
|
||||
}
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
$pdo->prepare('DELETE FROM scoring_profile WHERE profileID = :id')->execute([':id' => $profileID]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['deleted' => true]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Delete scoring profile', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{questionnaireID: string, weight: float, orderIndex: int}>
|
||||
*/
|
||||
function qdb_parse_profile_questionnaires(array $rows): array {
|
||||
$out = [];
|
||||
$idx = 0;
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$qnID = trim((string)($row['questionnaireID'] ?? ''));
|
||||
if ($qnID === '') {
|
||||
continue;
|
||||
}
|
||||
$weight = (float)($row['weight'] ?? 1.0);
|
||||
if ($weight <= 0) {
|
||||
$weight = 1.0;
|
||||
}
|
||||
$out[] = [
|
||||
'questionnaireID' => $qnID,
|
||||
'weight' => $weight,
|
||||
'orderIndex' => (int)($row['orderIndex'] ?? $idx),
|
||||
];
|
||||
$idx++;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function qdb_sync_profile_questionnaires(PDO $pdo, string $profileID, array $members): void {
|
||||
$pdo->prepare('DELETE FROM scoring_profile_questionnaire WHERE profileID = :pid')
|
||||
->execute([':pid' => $profileID]);
|
||||
$ins = $pdo->prepare(
|
||||
'INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
|
||||
VALUES (:pid, :qn, :w, :o)'
|
||||
);
|
||||
foreach ($members as $m) {
|
||||
$ins->execute([
|
||||
':pid' => $profileID,
|
||||
':qn' => $m['questionnaireID'],
|
||||
':w' => $m['weight'],
|
||||
':o' => $m['orderIndex'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_recompute_all_profiles_after_change(PDO $pdo, ?string $profileID = null): void {
|
||||
$clients = $pdo->query('SELECT DISTINCT clientCode FROM completed_questionnaire WHERE status = \'completed\'')
|
||||
->fetchAll(PDO::FETCH_COLUMN);
|
||||
foreach ($clients as $clientCode) {
|
||||
qdb_recompute_profile_scores_for_client($pdo, (string)$clientCode);
|
||||
}
|
||||
}
|
||||
@ -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<array<string, mixed>>
|
||||
*/
|
||||
function qdb_analytics_scoring_profiles(PDO $pdo, array $tokenRec): array {
|
||||
require_once __DIR__ . '/scoring.php';
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
|
||||
$profiles = $pdo->query(
|
||||
"SELECT profileID, name, description,
|
||||
greenMin, greenMax, yellowMin, yellowMax, redMin, isActive
|
||||
FROM scoring_profile WHERE isActive = 1 ORDER BY name"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$out = [];
|
||||
foreach ($profiles as $p) {
|
||||
$profileID = $p['profileID'];
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT r.band, r.coachBand, r.weightedTotal
|
||||
FROM client_scoring_profile_result r
|
||||
INNER JOIN client cl ON cl.clientCode = r.clientCode
|
||||
WHERE r.profileID = :pid AND ($rbacClause)"
|
||||
);
|
||||
$stmt->execute(array_merge([':pid' => $profileID], $rbacParams));
|
||||
$computedBands = ['green' => 0, 'yellow' => 0, 'red' => 0];
|
||||
$coachBands = ['green' => 0, 'yellow' => 0, 'red' => 0];
|
||||
$pendingReview = 0;
|
||||
$totalSum = 0.0;
|
||||
$count = 0;
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$calcBand = $row['band'] ?? '';
|
||||
if (isset($computedBands[$calcBand])) {
|
||||
$computedBands[$calcBand]++;
|
||||
}
|
||||
$coachBand = trim((string)($row['coachBand'] ?? ''));
|
||||
if ($coachBand === '') {
|
||||
$pendingReview++;
|
||||
} elseif (isset($coachBands[$coachBand])) {
|
||||
$coachBands[$coachBand]++;
|
||||
}
|
||||
$totalSum += (float)$row['weightedTotal'];
|
||||
$count++;
|
||||
}
|
||||
$members = qdb_scoring_profile_members($pdo, $profileID);
|
||||
$bandRanges = qdb_normalize_scoring_bands($p);
|
||||
$out[] = [
|
||||
'profileID' => $profileID,
|
||||
'name' => $p['name'],
|
||||
'description' => $p['description'] ?? '',
|
||||
'greenMin' => $bandRanges['greenMin'],
|
||||
'greenMax' => $bandRanges['greenMax'],
|
||||
'yellowMin' => $bandRanges['yellowMin'],
|
||||
'yellowMax' => $bandRanges['yellowMax'],
|
||||
'redMin' => $bandRanges['redMin'],
|
||||
'questionnaires' => $members,
|
||||
'clientCount' => $count,
|
||||
'bands' => $computedBands,
|
||||
'computedBands' => $computedBands,
|
||||
'coachBands' => $coachBands,
|
||||
'pendingReview' => $pendingReview,
|
||||
'averageTotal' => $count > 0 ? round($totalSum / $count, 2) : null,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function qdb_analytics_stale_clients(PDO $pdo, array $tokenRec): array {
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
$now = time();
|
||||
|
||||
757
lib/scoring.php
Normal file
757
lib/scoring.php
Normal file
@ -0,0 +1,757 @@
|
||||
<?php
|
||||
/**
|
||||
* Questionnaire scoring engine and scoring-profile aggregation.
|
||||
*/
|
||||
|
||||
/** Default glass-scale level keys and implicit points (0–4). */
|
||||
function qdb_glass_scale_level_keys(): array {
|
||||
return ['never_glass', 'little_glass', 'moderate_glass', 'much_glass', 'extreme_glass'];
|
||||
}
|
||||
|
||||
function qdb_default_glass_level_points(): array {
|
||||
return [
|
||||
'never_glass' => 0,
|
||||
'little_glass' => 1,
|
||||
'moderate_glass' => 2,
|
||||
'much_glass' => 3,
|
||||
'extreme_glass' => 4,
|
||||
];
|
||||
}
|
||||
|
||||
function qdb_make_score_rule_id(string $questionID, string $scopeKey, string $levelKey): string {
|
||||
return substr(hash('sha256', $questionID . "\0" . $scopeKey . "\0" . $levelKey), 0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{scopeKey: string, levelKey: string, points: int}>
|
||||
*/
|
||||
function qdb_get_score_rules_for_question(PDO $pdo, string $questionID): array {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT scopeKey, levelKey, points FROM question_score_rule WHERE questionID = :qid ORDER BY scopeKey, levelKey'
|
||||
);
|
||||
$stmt->execute([':qid' => $questionID]);
|
||||
$rows = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$rows[] = [
|
||||
'scopeKey' => (string)($r['scopeKey'] ?? ''),
|
||||
'levelKey' => (string)($r['levelKey'] ?? ''),
|
||||
'points' => (int)($r['points'] ?? 0),
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{scopeKey?: string, levelKey: string, points: int}> $rules
|
||||
*/
|
||||
function qdb_sync_question_score_rules(PDO $pdo, string $questionID, array $rules): void {
|
||||
$pdo->prepare('DELETE FROM question_score_rule WHERE questionID = :qid')
|
||||
->execute([':qid' => $questionID]);
|
||||
if ($rules === []) {
|
||||
return;
|
||||
}
|
||||
$ins = $pdo->prepare(
|
||||
'INSERT INTO question_score_rule (ruleID, questionID, scopeKey, levelKey, points)
|
||||
VALUES (:rid, :qid, :sk, :lk, :pts)'
|
||||
);
|
||||
foreach ($rules as $rule) {
|
||||
$scopeKey = trim((string)($rule['scopeKey'] ?? ''));
|
||||
$levelKey = trim((string)($rule['levelKey'] ?? ''));
|
||||
if ($levelKey === '') {
|
||||
continue;
|
||||
}
|
||||
$ins->execute([
|
||||
':rid' => qdb_make_score_rule_id($questionID, $scopeKey, $levelKey),
|
||||
':qid' => $questionID,
|
||||
':sk' => $scopeKey,
|
||||
':lk' => $levelKey,
|
||||
':pts' => (int)($rule['points'] ?? 0),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build nested map: scopeKey -> levelKey -> points.
|
||||
*
|
||||
* @return array<string, array<string, int>>
|
||||
*/
|
||||
function qdb_score_rule_map_for_question(PDO $pdo, string $questionID): array {
|
||||
$map = [];
|
||||
foreach (qdb_get_score_rules_for_question($pdo, $questionID) as $rule) {
|
||||
$map[$rule['scopeKey']][$rule['levelKey']] = $rule['points'];
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach scoreRules to glass symptom rows for editor API.
|
||||
*
|
||||
* @return list<array{key: string, labelDe: string, scoreLevels: array<string, int>}>
|
||||
*/
|
||||
function qdb_glass_symptoms_with_score_rules(PDO $pdo, string $questionID, array $config): array {
|
||||
$ruleMap = $questionID !== '' ? qdb_score_rule_map_for_question($pdo, $questionID) : [];
|
||||
$defaults = qdb_default_glass_level_points();
|
||||
$rows = [];
|
||||
foreach (qdb_glass_symptoms_with_labels($pdo, $config) as $row) {
|
||||
$key = $row['key'];
|
||||
$levels = [];
|
||||
foreach (qdb_glass_scale_level_keys() as $levelKey) {
|
||||
$levels[$levelKey] = $ruleMap[$key][$levelKey]
|
||||
?? $ruleMap[''][$levelKey]
|
||||
?? $defaults[$levelKey]
|
||||
?? 0;
|
||||
}
|
||||
$rows[] = [
|
||||
'key' => $key,
|
||||
'labelDe' => $row['labelDe'],
|
||||
'scoreLevels' => $levels,
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse scoreRules from question save body.
|
||||
*
|
||||
* @return list<array{scopeKey: string, levelKey: string, points: int}>
|
||||
*/
|
||||
function qdb_parse_score_rules_body(array $body, string $questionType, array $config): array {
|
||||
if (!isset($body['scoreRules']) || !is_array($body['scoreRules'])) {
|
||||
if ($questionType === 'glass_scale_question' && isset($body['glassSymptoms']) && is_array($body['glassSymptoms'])) {
|
||||
return qdb_score_rules_from_glass_symptoms($body['glassSymptoms']);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
$rules = [];
|
||||
foreach ($body['scoreRules'] as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$levelKey = trim((string)($row['levelKey'] ?? ''));
|
||||
if ($levelKey === '') {
|
||||
continue;
|
||||
}
|
||||
$rules[] = [
|
||||
'scopeKey' => trim((string)($row['scopeKey'] ?? '')),
|
||||
'levelKey' => $levelKey,
|
||||
'points' => (int)($row['points'] ?? 0),
|
||||
];
|
||||
}
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{key?: string, scoreLevels?: array<string, int>}> $glassSymptoms
|
||||
* @return list<array{scopeKey: string, levelKey: string, points: int}>
|
||||
*/
|
||||
function qdb_score_rules_from_glass_symptoms(array $glassSymptoms): array {
|
||||
$rules = [];
|
||||
$defaults = qdb_default_glass_level_points();
|
||||
foreach ($glassSymptoms as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$scopeKey = trim((string)($row['key'] ?? ''));
|
||||
if ($scopeKey === '') {
|
||||
continue;
|
||||
}
|
||||
$levels = is_array($row['scoreLevels'] ?? null) ? $row['scoreLevels'] : $defaults;
|
||||
foreach (qdb_glass_scale_level_keys() as $levelKey) {
|
||||
$rules[] = [
|
||||
'scopeKey' => $scopeKey,
|
||||
'levelKey' => $levelKey,
|
||||
'points' => (int)($levels[$levelKey] ?? $defaults[$levelKey] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
return $rules;
|
||||
}
|
||||
|
||||
function qdb_normalize_scoring_bands(array $row): array {
|
||||
$greenMax = (int)($row['greenMax'] ?? 12);
|
||||
$yellowMax = (int)($row['yellowMax'] ?? 36);
|
||||
return [
|
||||
'greenMin' => (int)($row['greenMin'] ?? 0),
|
||||
'greenMax' => $greenMax,
|
||||
'yellowMin' => (int)($row['yellowMin'] ?? ($greenMax + 1)),
|
||||
'yellowMax' => $yellowMax,
|
||||
'redMin' => (int)($row['redMin'] ?? ($yellowMax + 1)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null Error message, or null if valid.
|
||||
*/
|
||||
function qdb_validate_scoring_bands(array $bands): ?string {
|
||||
if ($bands['greenMin'] > $bands['greenMax']) {
|
||||
return 'Green min must not exceed green max';
|
||||
}
|
||||
if ($bands['yellowMin'] > $bands['yellowMax']) {
|
||||
return 'Yellow min must not exceed yellow max';
|
||||
}
|
||||
if ($bands['greenMax'] >= $bands['yellowMin']) {
|
||||
return 'Green range must end before yellow range starts';
|
||||
}
|
||||
if ($bands['yellowMax'] >= $bands['redMin']) {
|
||||
return 'Yellow range must end before red range starts';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int>|int $bandsOrGreenMax Band map or legacy greenMax
|
||||
*/
|
||||
function qdb_band_for_total(float $total, array|int $bandsOrGreenMax, ?int $yellowMax = null): string {
|
||||
$bands = is_array($bandsOrGreenMax)
|
||||
? qdb_normalize_scoring_bands($bandsOrGreenMax)
|
||||
: qdb_normalize_scoring_bands(['greenMax' => $bandsOrGreenMax, 'yellowMax' => $yellowMax ?? 36]);
|
||||
|
||||
if ($total >= $bands['greenMin'] && $total <= $bands['greenMax']) {
|
||||
return 'green';
|
||||
}
|
||||
if ($total >= $bands['yellowMin'] && $total <= $bands['yellowMax']) {
|
||||
return 'yellow';
|
||||
}
|
||||
if ($total >= $bands['redMin']) {
|
||||
return 'red';
|
||||
}
|
||||
if ($total < $bands['greenMin']) {
|
||||
return 'green';
|
||||
}
|
||||
if ($total < $bands['yellowMin']) {
|
||||
return 'yellow';
|
||||
}
|
||||
return 'red';
|
||||
}
|
||||
|
||||
function qdb_parse_scoring_bands_body(array $body): array {
|
||||
$greenMax = (int)($body['greenMax'] ?? 12);
|
||||
$yellowMax = (int)($body['yellowMax'] ?? 36);
|
||||
return qdb_normalize_scoring_bands([
|
||||
'greenMin' => (int)($body['greenMin'] ?? 0),
|
||||
'greenMax' => $greenMax,
|
||||
'yellowMin' => (int)($body['yellowMin'] ?? ($greenMax + 1)),
|
||||
'yellowMax' => $yellowMax,
|
||||
'redMin' => (int)($body['redMin'] ?? ($yellowMax + 1)),
|
||||
]);
|
||||
}
|
||||
|
||||
function qdb_scoring_bands_to_api(array $row): array {
|
||||
$bands = qdb_normalize_scoring_bands($row);
|
||||
return $bands;
|
||||
}
|
||||
|
||||
function qdb_compute_questionnaire_score(PDO $pdo, string $clientCode, string $questionnaireID): int {
|
||||
$qStmt = $pdo->prepare(
|
||||
'SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn ORDER BY orderIndex'
|
||||
);
|
||||
$qStmt->execute([':qn' => $questionnaireID]);
|
||||
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$aoStmt = $pdo->prepare(
|
||||
'SELECT ao.questionID, ao.defaultText, ao.points
|
||||
FROM answer_option ao
|
||||
JOIN question q ON q.questionID = ao.questionID
|
||||
WHERE q.questionnaireID = :qn'
|
||||
);
|
||||
$aoStmt->execute([':qn' => $questionnaireID]);
|
||||
$optionPoints = [];
|
||||
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||
$optionPoints[$ao['questionID']][$ao['defaultText']] = (int)$ao['points'];
|
||||
}
|
||||
|
||||
$aStmt = $pdo->prepare(
|
||||
'SELECT ca.questionID, ca.answerOptionID, ca.freeTextValue, ca.numericValue,
|
||||
ao.defaultText AS optionKey
|
||||
FROM client_answer ca
|
||||
LEFT JOIN answer_option ao ON ao.answerOptionID = ca.answerOptionID
|
||||
JOIN question q ON q.questionID = ca.questionID
|
||||
WHERE ca.clientCode = :cc AND q.questionnaireID = :qn'
|
||||
);
|
||||
$aStmt->execute([':cc' => $clientCode, ':qn' => $questionnaireID]);
|
||||
$answersByQuestion = [];
|
||||
foreach ($aStmt->fetchAll(PDO::FETCH_ASSOC) as $a) {
|
||||
$answersByQuestion[$a['questionID']] = $a;
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
foreach ($questions as $qRow) {
|
||||
$total += qdb_score_points_for_question(
|
||||
$pdo,
|
||||
$qRow,
|
||||
$answersByQuestion[$qRow['questionID']] ?? null,
|
||||
$optionPoints[$qRow['questionID']] ?? []
|
||||
);
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
function qdb_score_points_for_question(
|
||||
PDO $pdo,
|
||||
array $questionRow,
|
||||
?array $answerRow,
|
||||
array $optionPointsMap,
|
||||
): int {
|
||||
if ($answerRow === null) {
|
||||
return 0;
|
||||
}
|
||||
$type = (string)($questionRow['type'] ?? '');
|
||||
$questionID = (string)($questionRow['questionID'] ?? '');
|
||||
$config = json_decode($questionRow['configJson'] ?? '{}', true) ?: [];
|
||||
$ruleMap = qdb_score_rule_map_for_question($pdo, $questionID);
|
||||
|
||||
switch ($type) {
|
||||
case 'radio_question':
|
||||
case 'string_spinner':
|
||||
$key = trim((string)($answerRow['optionKey'] ?? $answerRow['freeTextValue'] ?? ''));
|
||||
return $key !== '' ? (int)($optionPointsMap[$key] ?? 0) : 0;
|
||||
|
||||
case 'multi_check_box_question':
|
||||
$raw = trim((string)($answerRow['freeTextValue'] ?? ''));
|
||||
if ($raw === '') {
|
||||
return 0;
|
||||
}
|
||||
$sum = 0;
|
||||
foreach (qdb_decode_multi_check_values($raw) as $mk) {
|
||||
$sum += (int)($optionPointsMap[$mk] ?? 0);
|
||||
}
|
||||
return $sum;
|
||||
|
||||
case 'glass_scale_question':
|
||||
$json = trim((string)($answerRow['freeTextValue'] ?? ''));
|
||||
if ($json === '') {
|
||||
return 0;
|
||||
}
|
||||
$decoded = json_decode($json, true);
|
||||
if (!is_array($decoded)) {
|
||||
return 0;
|
||||
}
|
||||
$defaults = qdb_default_glass_level_points();
|
||||
$sum = 0;
|
||||
foreach ($decoded as $symptomKey => $levelKey) {
|
||||
$sk = trim((string)$symptomKey);
|
||||
$lk = trim((string)$levelKey);
|
||||
if ($sk === '' || $lk === '') {
|
||||
continue;
|
||||
}
|
||||
$sum += (int)($ruleMap[$sk][$lk]
|
||||
?? $ruleMap[''][$lk]
|
||||
?? $defaults[$lk]
|
||||
?? 0);
|
||||
}
|
||||
return $sum;
|
||||
|
||||
case 'slider_question':
|
||||
case 'value_spinner':
|
||||
$value = $answerRow['numericValue'];
|
||||
if ($value === null || $value === '') {
|
||||
$value = $answerRow['freeTextValue'];
|
||||
}
|
||||
if ($value === null || $value === '') {
|
||||
return 0;
|
||||
}
|
||||
$levelKey = is_numeric($value)
|
||||
? (string)(int)round((float)$value)
|
||||
: trim((string)$value);
|
||||
return (int)($ruleMap[''][$levelKey] ?? 0);
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_recompute_profile_scores_for_client(PDO $pdo, string $clientCode, ?string $triggerQuestionnaireID = null): void {
|
||||
$profileSql = 'SELECT profileID, name, greenMin, greenMax, yellowMin, yellowMax, redMin FROM scoring_profile WHERE isActive = 1';
|
||||
$params = [];
|
||||
if ($triggerQuestionnaireID !== null && $triggerQuestionnaireID !== '') {
|
||||
$profileSql = '
|
||||
SELECT sp.profileID, sp.name, sp.greenMin, sp.greenMax, sp.yellowMin, sp.yellowMax, sp.redMin
|
||||
FROM scoring_profile sp
|
||||
JOIN scoring_profile_questionnaire spq ON spq.profileID = sp.profileID
|
||||
WHERE sp.isActive = 1 AND spq.questionnaireID = :qn';
|
||||
$params[':qn'] = $triggerQuestionnaireID;
|
||||
}
|
||||
$pStmt = $pdo->prepare($profileSql);
|
||||
$pStmt->execute($params);
|
||||
$profiles = $pStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$deleteOrphan = $pdo->prepare(
|
||||
'DELETE FROM client_scoring_profile_result WHERE clientCode = :cc AND profileID = :pid'
|
||||
);
|
||||
$upsert = $pdo->prepare(
|
||||
'INSERT INTO client_scoring_profile_result
|
||||
(clientCode, profileID, weightedTotal, band, computedAt, questionnaireSnapshot)
|
||||
VALUES (:cc, :pid, :wt, :band, :at, :snap)
|
||||
ON CONFLICT(clientCode, profileID) DO UPDATE SET
|
||||
weightedTotal = excluded.weightedTotal,
|
||||
band = excluded.band,
|
||||
computedAt = excluded.computedAt,
|
||||
questionnaireSnapshot = excluded.questionnaireSnapshot'
|
||||
);
|
||||
|
||||
foreach ($profiles as $profile) {
|
||||
$profileID = $profile['profileID'];
|
||||
$members = qdb_scoring_profile_members($pdo, $profileID);
|
||||
if ($members === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot = [];
|
||||
$weightedTotal = 0.0;
|
||||
$allComplete = true;
|
||||
|
||||
foreach ($members as $member) {
|
||||
$qnID = $member['questionnaireID'];
|
||||
$weight = (float)$member['weight'];
|
||||
$cq = $pdo->prepare(
|
||||
"SELECT status, sumPoints FROM completed_questionnaire
|
||||
WHERE clientCode = :cc AND questionnaireID = :qn"
|
||||
);
|
||||
$cq->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
||||
$row = $cq->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row || ($row['status'] ?? '') !== 'completed') {
|
||||
$allComplete = false;
|
||||
break;
|
||||
}
|
||||
$sumPoints = (int)($row['sumPoints'] ?? 0);
|
||||
$contribution = $sumPoints * $weight;
|
||||
$weightedTotal += $contribution;
|
||||
$snapshot[$qnID] = [
|
||||
'sumPoints' => $sumPoints,
|
||||
'weight' => $weight,
|
||||
'contribution' => round($contribution, 4),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$allComplete) {
|
||||
$deleteOrphan->execute([':cc' => $clientCode, ':pid' => $profileID]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$band = qdb_band_for_total($weightedTotal, qdb_normalize_scoring_bands($profile));
|
||||
$upsert->execute([
|
||||
':cc' => $clientCode,
|
||||
':pid' => $profileID,
|
||||
':wt' => round($weightedTotal, 4),
|
||||
':band' => $band,
|
||||
':at' => time(),
|
||||
':snap' => json_encode($snapshot, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{questionnaireID: string, weight: float, orderIndex: int, name?: string}>
|
||||
*/
|
||||
function qdb_scoring_profile_members(PDO $pdo, string $profileID): array {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT spq.questionnaireID, spq.weight, spq.orderIndex, q.name
|
||||
FROM scoring_profile_questionnaire spq
|
||||
JOIN questionnaire q ON q.questionnaireID = spq.questionnaireID
|
||||
WHERE spq.profileID = :pid
|
||||
ORDER BY spq.orderIndex, spq.questionnaireID'
|
||||
);
|
||||
$stmt->execute([':pid' => $profileID]);
|
||||
$rows = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$rows[] = [
|
||||
'questionnaireID' => $r['questionnaireID'],
|
||||
'weight' => (float)$r['weight'],
|
||||
'orderIndex' => (int)$r['orderIndex'],
|
||||
'name' => $r['name'] ?? '',
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
function qdb_list_scoring_profiles(PDO $pdo): array {
|
||||
$stmt = $pdo->query(
|
||||
'SELECT profileID, name, description, isActive,
|
||||
greenMin, greenMax, yellowMin, yellowMax, redMin,
|
||||
createdAt, updatedAt
|
||||
FROM scoring_profile ORDER BY name'
|
||||
);
|
||||
$profiles = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$row['isActive'] = (int)$row['isActive'];
|
||||
$bands = qdb_normalize_scoring_bands($row);
|
||||
$row = array_merge($row, $bands);
|
||||
$row['questionnaires'] = qdb_scoring_profile_members($pdo, $row['profileID']);
|
||||
$profiles[] = $row;
|
||||
}
|
||||
return $profiles;
|
||||
}
|
||||
|
||||
function qdb_get_scoring_profile(PDO $pdo, string $profileID): ?array {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT profileID, name, description, isActive,
|
||||
greenMin, greenMax, yellowMin, yellowMax, redMin,
|
||||
createdAt, updatedAt
|
||||
FROM scoring_profile WHERE profileID = :pid'
|
||||
);
|
||||
$stmt->execute([':pid' => $profileID]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
$row['isActive'] = (int)$row['isActive'];
|
||||
$row = array_merge($row, qdb_normalize_scoring_bands($row));
|
||||
$row['questionnaires'] = qdb_scoring_profile_members($pdo, $profileID);
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill default glass score rules (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<string> $clientCodes
|
||||
* @return list<array{clientCode: string, profiles: list<array<string, mixed>>}>
|
||||
*/
|
||||
function qdb_app_scoring_review_for_clients(PDO $pdo, array $clientCodes): array {
|
||||
if ($clientCodes === []) {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($clientCodes), '?'));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT r.clientCode, r.profileID, r.coachBand, sp.name
|
||||
FROM client_scoring_profile_result r
|
||||
JOIN scoring_profile sp ON sp.profileID = r.profileID AND sp.isActive = 1
|
||||
WHERE r.clientCode IN ($placeholders)
|
||||
ORDER BY r.clientCode, sp.name"
|
||||
);
|
||||
$stmt->execute(array_values($clientCodes));
|
||||
$byClient = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$cc = (string)$row['clientCode'];
|
||||
$byClient[$cc][] = qdb_scoring_review_row_to_api($row, false);
|
||||
}
|
||||
$out = [];
|
||||
foreach ($clientCodes as $code) {
|
||||
$out[] = [
|
||||
'clientCode' => $code,
|
||||
'profiles' => $byClient[$code] ?? [],
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
/**
|
||||
* Store coach band; create a result row from app-local computed values when none exists yet.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function qdb_save_coach_scoring_review(
|
||||
PDO $pdo,
|
||||
string $clientCode,
|
||||
string $profileID,
|
||||
string $coachBand,
|
||||
string $userID,
|
||||
?float $weightedTotal = null,
|
||||
?string $calculatedBand = null,
|
||||
): array {
|
||||
if (!qdb_is_valid_scoring_band($coachBand)) {
|
||||
throw new InvalidArgumentException('coachBand must be green, yellow, or red');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT r.band, r.coachBand, r.weightedTotal, r.computedAt, sp.name
|
||||
FROM client_scoring_profile_result r
|
||||
JOIN scoring_profile sp ON sp.profileID = r.profileID
|
||||
WHERE r.clientCode = :cc AND r.profileID = :pid'
|
||||
);
|
||||
$stmt->execute([':cc' => $clientCode, ':pid' => $profileID]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
if ($weightedTotal === null || $calculatedBand === null || !qdb_is_valid_scoring_band($calculatedBand)) {
|
||||
throw new RuntimeException('Scoring profile result not found; calculatedBand and weightedTotal required');
|
||||
}
|
||||
$profile = qdb_get_scoring_profile($pdo, $profileID);
|
||||
if (!$profile || (int)($profile['isActive'] ?? 0) !== 1) {
|
||||
throw new RuntimeException('Scoring profile not found');
|
||||
}
|
||||
$now = time();
|
||||
$pdo->prepare(
|
||||
'INSERT INTO client_scoring_profile_result
|
||||
(clientCode, profileID, weightedTotal, band, computedAt, questionnaireSnapshot,
|
||||
coachBand, coachReviewedAt, coachReviewedByUserID)
|
||||
VALUES (:cc, :pid, :wt, :band, :at, :snap, :cb, :rat, :uid)'
|
||||
)->execute([
|
||||
':cc' => $clientCode,
|
||||
':pid' => $profileID,
|
||||
':wt' => round($weightedTotal, 4),
|
||||
':band' => $calculatedBand,
|
||||
':at' => $now,
|
||||
':snap' => '{}',
|
||||
':cb' => $coachBand,
|
||||
':rat' => $now,
|
||||
':uid' => $userID,
|
||||
]);
|
||||
return qdb_scoring_review_row_to_api([
|
||||
'profileID' => $profileID,
|
||||
'name' => $profile['name'],
|
||||
'weightedTotal' => $weightedTotal,
|
||||
'band' => $calculatedBand,
|
||||
'coachBand' => $coachBand,
|
||||
'computedAt' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return qdb_set_coach_scoring_band($pdo, $clientCode, $profileID, $coachBand, $userID);
|
||||
}
|
||||
|
||||
function qdb_set_coach_scoring_band(
|
||||
PDO $pdo,
|
||||
string $clientCode,
|
||||
string $profileID,
|
||||
string $coachBand,
|
||||
string $userID
|
||||
): array {
|
||||
if (!qdb_is_valid_scoring_band($coachBand)) {
|
||||
throw new InvalidArgumentException('coachBand must be green, yellow, or red');
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT r.band, r.coachBand, r.weightedTotal, r.computedAt, sp.name
|
||||
FROM client_scoring_profile_result r
|
||||
JOIN scoring_profile sp ON sp.profileID = r.profileID
|
||||
WHERE r.clientCode = :cc AND r.profileID = :pid'
|
||||
);
|
||||
$stmt->execute([':cc' => $clientCode, ':pid' => $profileID]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
throw new RuntimeException('Scoring profile result not found or profile incomplete');
|
||||
}
|
||||
$now = time();
|
||||
$pdo->prepare(
|
||||
'UPDATE client_scoring_profile_result
|
||||
SET coachBand = :cb, coachReviewedAt = :at, coachReviewedByUserID = :uid
|
||||
WHERE clientCode = :cc AND profileID = :pid'
|
||||
)->execute([
|
||||
':cb' => $coachBand,
|
||||
':at' => $now,
|
||||
':uid' => $userID,
|
||||
':cc' => $clientCode,
|
||||
':pid' => $profileID,
|
||||
]);
|
||||
$row['coachBand'] = $coachBand;
|
||||
$row['profileID'] = $profileID;
|
||||
return qdb_scoring_review_row_to_api($row);
|
||||
}
|
||||
@ -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<array{profileID: string, name: string}>, byClient: array<string, array<string, array{band: string, weightedTotal: float, name: string}>>}
|
||||
*/
|
||||
function qdb_clients_scoring_summary_for_list(PDO $pdo, array $tokenRec): array {
|
||||
require_once __DIR__ . '/scoring.php';
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
|
||||
$profiles = $pdo->query(
|
||||
"SELECT profileID, name FROM scoring_profile WHERE isActive = 1 ORDER BY name"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($profiles === []) {
|
||||
return ['profiles' => [], 'byClient' => []];
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT r.clientCode, r.profileID, r.band, r.coachBand, r.weightedTotal, sp.name
|
||||
FROM client_scoring_profile_result r
|
||||
INNER JOIN scoring_profile sp ON sp.profileID = r.profileID AND sp.isActive = 1
|
||||
INNER JOIN client cl ON cl.clientCode = r.clientCode
|
||||
WHERE ($rbacClause)"
|
||||
);
|
||||
$stmt->execute($rbacParams);
|
||||
|
||||
$byClient = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$cc = (string)$row['clientCode'];
|
||||
$coachBand = trim((string)($row['coachBand'] ?? ''));
|
||||
$byClient[$cc][$row['profileID']] = [
|
||||
'band' => (string)$row['band'],
|
||||
'calculatedBand' => (string)$row['band'],
|
||||
'coachBand' => $coachBand !== '' ? $coachBand : null,
|
||||
'effectiveBand' => qdb_effective_scoring_band($row),
|
||||
'pendingReview' => $coachBand === '',
|
||||
'weightedTotal' => (float)$row['weightedTotal'],
|
||||
'name' => (string)$row['name'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'profiles' => array_map(static fn(array $p) => [
|
||||
'profileID' => (string)$p['profileID'],
|
||||
'name' => (string)$p['name'],
|
||||
], $profiles),
|
||||
'byClient' => $byClient,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{profileID: string, name: string, band: ?string, weightedTotal: ?float}>
|
||||
*/
|
||||
function qdb_client_scoring_dots_for_client(string $clientCode, array $summary): array {
|
||||
$out = [];
|
||||
foreach ($summary['profiles'] as $profile) {
|
||||
$result = $summary['byClient'][$clientCode][$profile['profileID']] ?? null;
|
||||
$out[] = [
|
||||
'profileID' => $profile['profileID'],
|
||||
'name' => $profile['name'],
|
||||
'band' => $result['calculatedBand'] ?? null,
|
||||
'calculatedBand' => $result['calculatedBand'] ?? null,
|
||||
'coachBand' => $result['coachBand'] ?? null,
|
||||
'effectiveBand' => $result['effectiveBand'] ?? null,
|
||||
'pendingReview' => $result['pendingReview'] ?? false,
|
||||
'weightedTotal' => $result !== null ? $result['weightedTotal'] : null,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
55
schema.sql
55
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);
|
||||
|
||||
|
||||
211
tests/Unit/ScoringTest.php
Normal file
211
tests/Unit/ScoringTest.php
Normal file
@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PDO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ScoringTest extends TestCase
|
||||
{
|
||||
private PDO $pdo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/common.php';
|
||||
require_once dirname(__DIR__, 2) . '/lib/scoring.php';
|
||||
|
||||
$this->pdo = new PDO('sqlite::memory:');
|
||||
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$this->pdo->exec('PRAGMA foreign_keys = ON');
|
||||
$schema = file_get_contents(dirname(__DIR__, 2) . '/schema.sql');
|
||||
$this->pdo->exec($schema);
|
||||
}
|
||||
|
||||
public function testBandForTotal(): void
|
||||
{
|
||||
$legacy = ['greenMax' => 12, 'yellowMax' => 36];
|
||||
$this->assertSame('green', qdb_band_for_total(0, $legacy));
|
||||
$this->assertSame('green', qdb_band_for_total(12, $legacy));
|
||||
$this->assertSame('yellow', qdb_band_for_total(13, $legacy));
|
||||
$this->assertSame('yellow', qdb_band_for_total(36, $legacy));
|
||||
$this->assertSame('red', qdb_band_for_total(37, $legacy));
|
||||
|
||||
$custom = [
|
||||
'greenMin' => 5,
|
||||
'greenMax' => 15,
|
||||
'yellowMin' => 16,
|
||||
'yellowMax' => 30,
|
||||
'redMin' => 31,
|
||||
];
|
||||
$this->assertSame('green', qdb_band_for_total(5, $custom));
|
||||
$this->assertSame('green', qdb_band_for_total(15, $custom));
|
||||
$this->assertSame('yellow', qdb_band_for_total(16, $custom));
|
||||
$this->assertSame('red', qdb_band_for_total(31, $custom));
|
||||
}
|
||||
|
||||
public function testValidateScoringBands(): void
|
||||
{
|
||||
$this->assertNull(qdb_validate_scoring_bands(qdb_normalize_scoring_bands(['greenMax' => 12, 'yellowMax' => 36])));
|
||||
$this->assertNotNull(qdb_validate_scoring_bands([
|
||||
'greenMin' => 0, 'greenMax' => 20,
|
||||
'yellowMin' => 15, 'yellowMax' => 36,
|
||||
'redMin' => 37,
|
||||
]));
|
||||
}
|
||||
|
||||
public function testGlassScoreRulesFromSymptoms(): void
|
||||
{
|
||||
$rules = qdb_score_rules_from_glass_symptoms([
|
||||
['key' => 'pain', 'scoreLevels' => ['never_glass' => 0, 'extreme_glass' => 5]],
|
||||
]);
|
||||
$this->assertCount(5, $rules);
|
||||
$painExtreme = array_values(array_filter(
|
||||
$rules,
|
||||
fn ($r) => $r['scopeKey'] === 'pain' && $r['levelKey'] === 'extreme_glass'
|
||||
));
|
||||
$this->assertSame(5, $painExtreme[0]['points'] ?? null);
|
||||
}
|
||||
|
||||
private function seedCoachChain(): void
|
||||
{
|
||||
$this->pdo->exec("INSERT INTO supervisor (supervisorID, username) VALUES ('sup1', 'sup')");
|
||||
$this->pdo->exec("INSERT INTO coach (coachID, supervisorID, username) VALUES ('coach1', 'sup1', 'coach')");
|
||||
}
|
||||
|
||||
public function testRadioAndGlassScoring(): void
|
||||
{
|
||||
$this->seedCoachChain();
|
||||
$qnID = 'qn_test';
|
||||
$this->pdo->exec("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
|
||||
VALUES ('$qnID', 'Test', '1', 'active', 0, 0, '{}', '')");
|
||||
|
||||
$radioQ = "{$qnID}__radio";
|
||||
$this->pdo->exec("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
|
||||
VALUES ('$radioQ', '$qnID', 'Pick one', 'radio_question', 0, 1, '{}')");
|
||||
$this->pdo->exec("INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
|
||||
VALUES ('ao1', '$radioQ', 'yes', 3, 0, '')");
|
||||
|
||||
$glassQ = "{$qnID}__glass";
|
||||
$this->pdo->exec("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
|
||||
VALUES ('$glassQ', '$qnID', 'Symptoms', 'glass_scale_question', 1, 1, '{\"symptoms\":[\"pain\"]}')");
|
||||
qdb_sync_question_score_rules($this->pdo, $glassQ, [
|
||||
['scopeKey' => 'pain', 'levelKey' => 'moderate_glass', 'points' => 2],
|
||||
]);
|
||||
|
||||
$client = 'CLIENT-001';
|
||||
$this->pdo->exec("INSERT INTO client (clientCode, coachID) VALUES ('$client', 'coach1')");
|
||||
$this->pdo->exec("INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue)
|
||||
VALUES ('$client', '$radioQ', 'ao1', NULL, NULL)");
|
||||
$this->pdo->exec("INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue)
|
||||
VALUES ('$client', '$glassQ', NULL, '{\"pain\":\"moderate_glass\"}', NULL)");
|
||||
|
||||
$score = qdb_compute_questionnaire_score($this->pdo, $client, $qnID);
|
||||
$this->assertSame(5, $score);
|
||||
}
|
||||
|
||||
public function testIncompleteProfileProducesNoResult(): void
|
||||
{
|
||||
$this->seedCoachChain();
|
||||
$qnA = 'qn_a';
|
||||
$qnB = 'qn_b';
|
||||
foreach ([$qnA, $qnB] as $id) {
|
||||
$this->pdo->exec("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
|
||||
VALUES ('$id', '$id', '1', 'active', 0, 0, '{}', '')");
|
||||
}
|
||||
$profileID = 'profile1';
|
||||
$this->pdo->exec("INSERT INTO scoring_profile (profileID, name, description, isActive,
|
||||
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
|
||||
VALUES ('$profileID', 'Combo', '', 1, 0, 10, 11, 20, 21, 0, 0)");
|
||||
$this->pdo->exec("INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
|
||||
VALUES ('$profileID', '$qnA', 1.0, 0), ('$profileID', '$qnB', 1.0, 1)");
|
||||
|
||||
$client = 'CLIENT-002';
|
||||
$this->pdo->exec("INSERT INTO client (clientCode, coachID) VALUES ('$client', 'coach1')");
|
||||
$this->pdo->exec("INSERT INTO completed_questionnaire (clientCode, questionnaireID, status, sumPoints, completedAt)
|
||||
VALUES ('$client', '$qnA', 'completed', 5, 1)");
|
||||
|
||||
qdb_recompute_profile_scores_for_client($this->pdo, $client);
|
||||
|
||||
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM client_scoring_profile_result WHERE clientCode = :cc');
|
||||
$stmt->execute([':cc' => $client]);
|
||||
$this->assertSame(0, (int)$stmt->fetchColumn());
|
||||
}
|
||||
|
||||
public function testCoachScoringReviewPreservesComputedBand(): void
|
||||
{
|
||||
$this->seedCoachChain();
|
||||
$qnID = 'qn_review';
|
||||
$this->pdo->exec("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
|
||||
VALUES ('$qnID', 'Review', '1', 'active', 0, 0, '{}', '')");
|
||||
$profileID = 'profile_review';
|
||||
$this->pdo->exec("INSERT INTO scoring_profile (profileID, name, description, isActive,
|
||||
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
|
||||
VALUES ('$profileID', 'Review profile', '', 1, 0, 10, 11, 20, 21, 0, 0)");
|
||||
$this->pdo->exec("INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
|
||||
VALUES ('$profileID', '$qnID', 1.0, 0)");
|
||||
|
||||
$client = 'CLIENT-REVIEW';
|
||||
$this->pdo->exec("INSERT INTO client (clientCode, coachID) VALUES ('$client', 'coach1')");
|
||||
$this->pdo->exec("INSERT INTO completed_questionnaire (clientCode, questionnaireID, status, sumPoints, completedAt)
|
||||
VALUES ('$client', '$qnID', 'completed', 15, 1)");
|
||||
|
||||
qdb_recompute_profile_scores_for_client($this->pdo, $client);
|
||||
|
||||
$row = $this->pdo->query(
|
||||
"SELECT band, coachBand FROM client_scoring_profile_result WHERE clientCode = '$client'"
|
||||
)->fetch(PDO::FETCH_ASSOC);
|
||||
$this->assertSame('yellow', $row['band']);
|
||||
$this->assertSame('', $row['coachBand']);
|
||||
|
||||
qdb_set_coach_scoring_band($this->pdo, $client, $profileID, 'green', 'user1');
|
||||
|
||||
$row = $this->pdo->query(
|
||||
"SELECT band, coachBand FROM client_scoring_profile_result WHERE clientCode = '$client'"
|
||||
)->fetch(PDO::FETCH_ASSOC);
|
||||
$this->assertSame('yellow', $row['band']);
|
||||
$this->assertSame('green', $row['coachBand']);
|
||||
|
||||
qdb_recompute_profile_scores_for_client($this->pdo, $client);
|
||||
$row = $this->pdo->query(
|
||||
"SELECT band, coachBand FROM client_scoring_profile_result WHERE clientCode = '$client'"
|
||||
)->fetch(PDO::FETCH_ASSOC);
|
||||
$this->assertSame('yellow', $row['band']);
|
||||
$this->assertSame('green', $row['coachBand']);
|
||||
}
|
||||
|
||||
public function testSaveCoachReviewFromAppBeforeServerRecompute(): void
|
||||
{
|
||||
$this->seedCoachChain();
|
||||
$qnID = 'qn_pre';
|
||||
$this->pdo->exec("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
|
||||
VALUES ('$qnID', 'Pre', '1', 'active', 0, 0, '{}', '')");
|
||||
$profileID = 'profile_pre';
|
||||
$this->pdo->exec("INSERT INTO scoring_profile (profileID, name, description, isActive,
|
||||
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
|
||||
VALUES ('$profileID', 'Pre profile', '', 1, 0, 10, 11, 20, 21, 0, 0)");
|
||||
$this->pdo->exec("INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
|
||||
VALUES ('$profileID', '$qnID', 1.0, 0)");
|
||||
|
||||
$client = 'CLIENT-PRE';
|
||||
$this->pdo->exec("INSERT INTO client (clientCode, coachID) VALUES ('$client', 'coach1')");
|
||||
|
||||
qdb_save_coach_scoring_review(
|
||||
$this->pdo,
|
||||
$client,
|
||||
$profileID,
|
||||
'yellow',
|
||||
'user1',
|
||||
15.0,
|
||||
'yellow'
|
||||
);
|
||||
|
||||
$row = $this->pdo->query(
|
||||
"SELECT band, coachBand, weightedTotal FROM client_scoring_profile_result WHERE clientCode = '$client'"
|
||||
)->fetch(PDO::FETCH_ASSOC);
|
||||
$this->assertSame('yellow', $row['band']);
|
||||
$this->assertSame('yellow', $row['coachBand']);
|
||||
$this->assertSame(15.0, (float)$row['weightedTotal']);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 = `
|
||||
<div class="card">
|
||||
<div class="filter-bar">
|
||||
@ -76,6 +81,7 @@ function renderClients() {
|
||||
</select>
|
||||
<span class="data-toolbar-hint" id="clientListCount"></span>
|
||||
</div>
|
||||
${profileLegend}
|
||||
<p class="data-toolbar-hint" style="margin:0 0 12px">
|
||||
Expand a row to view questionnaire responses and upload version history.
|
||||
</p>
|
||||
@ -85,6 +91,7 @@ function renderClients() {
|
||||
<tr>
|
||||
<th>Client Code</th>
|
||||
<th>Current Coach</th>
|
||||
${showProfileColumn ? '<th>Profile scores</th>' : ''}
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -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 = `<tr><td colspan="3" style="text-align:center;color:var(--text-secondary);padding:24px">No clients match</td></tr>`;
|
||||
body.innerHTML = `<tr><td colspan="${colCount}" style="text-align:center;color:var(--text-secondary);padding:24px">No clients match</td></tr>`;
|
||||
} else {
|
||||
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)}` : ''}
|
||||
</p>
|
||||
${renderClientScoringProfiles(detail.scoringProfiles || [])}
|
||||
${questionnaires.map(q => clientQnBlockHTML(clientCode, q)).join('')}`;
|
||||
}
|
||||
|
||||
function scoringLegendHTML() {
|
||||
return `
|
||||
<div class="client-scoring-legend">
|
||||
<p class="client-scoring-legend-title"><strong>How profile scores work</strong></p>
|
||||
<p class="data-toolbar-hint client-scoring-legend-body">
|
||||
Each profile adds up questionnaire points (× weight) into a <strong>weighted total</strong>,
|
||||
then maps that total to <span class="band-badge band-green">green</span>
|
||||
<span class="band-badge band-yellow">yellow</span>
|
||||
<span class="band-badge band-red">red</span> using the thresholds on the Questionnaires page.
|
||||
</p>
|
||||
<dl class="client-scoring-legend-dl">
|
||||
<div>
|
||||
<dt>Calculated</dt>
|
||||
<dd>Automatic category from answers and profile rules (shown before coach review).</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Coach</dt>
|
||||
<dd>Category confirmed or changed by the coach in the app (“Review scores”).</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Incomplete</dt>
|
||||
<dd>Not all questionnaires in that profile are completed yet.</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function bandBadgeHTML(band, { pending = false, incomplete = false, label = '' } = {}) {
|
||||
const text = label || band || '';
|
||||
if (incomplete) {
|
||||
return '<span class="band-badge band-none">incomplete</span>';
|
||||
}
|
||||
if (pending) {
|
||||
return '<span class="band-badge band-none">awaiting review</span>';
|
||||
}
|
||||
if (!band) {
|
||||
return '<span class="band-badge band-none">—</span>';
|
||||
}
|
||||
return `<span class="band-badge band-${esc(band)}">${esc(text || band)}</span>`;
|
||||
}
|
||||
|
||||
function profileScoreBlockHTML(p) {
|
||||
const calcBand = p.calculatedBand || p.band;
|
||||
if (!calcBand) {
|
||||
return `
|
||||
<div class="client-profile-score-block is-incomplete">
|
||||
<div class="client-profile-score-block-title">${esc(p.name)}</div>
|
||||
<p class="client-profile-score-hint">Profile incomplete — not all member questionnaires are done.</p>
|
||||
</div>`;
|
||||
}
|
||||
const coachPending = p.pendingReview !== false && !p.coachBand;
|
||||
const coachDiffers = p.coachBand && p.coachBand !== calcBand;
|
||||
return `
|
||||
<div class="client-profile-score-block${coachDiffers ? ' has-coach-override' : ''}">
|
||||
<div class="client-profile-score-block-title">${esc(p.name)}</div>
|
||||
<div class="client-profile-score-row">
|
||||
<span class="client-profile-score-label">Calculated</span>
|
||||
${bandBadgeHTML(calcBand)}
|
||||
<span class="client-profile-score-total" title="Weighted total">${esc(String(p.weightedTotal))}</span>
|
||||
</div>
|
||||
<div class="client-profile-score-row">
|
||||
<span class="client-profile-score-label">Coach</span>
|
||||
${coachPending ? bandBadgeHTML(null, { pending: true }) : bandBadgeHTML(p.coachBand)}
|
||||
${coachDiffers ? '<span class="client-profile-override-hint">override</span>' : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderClientScoringProfiles(profiles) {
|
||||
if (!profiles.length) return '';
|
||||
return `
|
||||
<div class="client-scoring-profiles-detail">
|
||||
<strong>Scoring profiles</strong>
|
||||
<p class="data-toolbar-hint" style="margin:4px 0 10px">
|
||||
Weighted totals and calculated vs coach categories for this client.
|
||||
</p>
|
||||
<div class="client-profile-scores">${profiles.map(p => {
|
||||
const calc = p.calculatedBand || p.band;
|
||||
if (!calc) return profileScoreBlockHTML(p);
|
||||
const coachPending = p.pendingReview !== false && !p.coachBand;
|
||||
const computedAt = p.computedAt ? `Last computed ${esc(p.computedAt)}` : '';
|
||||
const reviewedAt = p.coachReviewedAt ? `Coach reviewed ${esc(p.coachReviewedAt)}` : '';
|
||||
const meta = [computedAt, reviewedAt].filter(Boolean).join(' · ');
|
||||
return `
|
||||
<div class="scoring-profile-detail-card">
|
||||
${profileScoreBlockHTML(p)}
|
||||
${meta ? `<p class="client-profile-score-meta">${meta}</p>` : ''}
|
||||
${coachPending ? '<p class="client-profile-score-hint">Coach has not reviewed this profile in the app yet.</p>' : ''}
|
||||
</div>`;
|
||||
}).join('')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function clientQnBlockHTML(clientCode, q) {
|
||||
const key = qnKey(clientCode, q.questionnaireID);
|
||||
const qnExpanded = expandedQnKey === key;
|
||||
@ -311,6 +413,17 @@ function clientQnBlockHTML(clientCode, q) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function profileDotsHTML(profiles) {
|
||||
if (!profiles?.length) {
|
||||
return '<span class="client-profile-dots-empty">—</span>';
|
||||
}
|
||||
const hasAny = profiles.some(p => p.calculatedBand || p.band);
|
||||
if (!hasAny) {
|
||||
return `<span class="client-profile-dots-empty" title="No complete scoring profiles yet">—</span>`;
|
||||
}
|
||||
return `<div class="client-profile-scores">${profiles.map(p => profileScoreBlockHTML(p)).join('')}</div>`;
|
||||
}
|
||||
|
||||
function clientHasResponseData(c) {
|
||||
return Number(c.hasResponseData) === 1;
|
||||
}
|
||||
@ -324,6 +437,10 @@ function clientRowHTML(c) {
|
||||
const expandControl = canExpand
|
||||
? `<button type="button" class="btn btn-sm btn-link client-expand-btn" data-code="${esc(c.clientCode)}">${expanded ? '▼' : '▶'}</button> `
|
||||
: '';
|
||||
const profileCol = scoringProfileCatalog.length > 0
|
||||
? `<td class="client-profile-dots-cell">${profileDotsHTML(c.scoringProfiles || [])}</td>`
|
||||
: '';
|
||||
const colCount = scoringProfileCatalog.length > 0 ? 4 : 3;
|
||||
|
||||
return `
|
||||
<tr class="${testDataRowClassForClient(c.clientCode).trim()}">
|
||||
@ -331,13 +448,14 @@ function clientRowHTML(c) {
|
||||
${expandControl}<strong>${esc(c.clientCode)}</strong>
|
||||
</td>
|
||||
<td>${coach}</td>
|
||||
${profileCol}
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger delete-client-btn" data-code="${esc(c.clientCode)}">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
${expanded ? `
|
||||
<tr class="client-detail-row">
|
||||
<td colspan="3">
|
||||
<td colspan="${colCount}">
|
||||
<div class="client-detail-panel">${clientDetailPanelHTML(c.clientCode)}</div>
|
||||
</td>
|
||||
</tr>` : ''}`;
|
||||
|
||||
@ -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 => `
|
||||
<div class="value-score-row">
|
||||
<span class="value-score-label">${v}</span>
|
||||
<input type="number" class="value-score-pts" data-value="${v}" value="${ruleMap[String(v)] ?? 0}" min="0" step="1">
|
||||
</div>`).join('');
|
||||
return `
|
||||
<div class="value-score-rules" id="${prefix}_value_scores">
|
||||
<label style="font-size:.85rem;font-weight:600;color:var(--text-secondary)">Points per value</label>
|
||||
<p class="field-hint">Set points for each selectable value (used when the questionnaire is scored).</p>
|
||||
<div class="value-score-grid">${rows}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function readValueScoreRulesFromForm(prefix) {
|
||||
const container = document.getElementById(`${prefix}_value_scores`);
|
||||
if (!container) return [];
|
||||
const rules = [];
|
||||
container.querySelectorAll('.value-score-pts').forEach(inp => {
|
||||
rules.push({
|
||||
scopeKey: '',
|
||||
levelKey: String(inp.dataset.value),
|
||||
points: parseInt(inp.value || '0', 10) || 0,
|
||||
});
|
||||
});
|
||||
return rules;
|
||||
}
|
||||
|
||||
function wireValueScoreRules(prefix) {
|
||||
const minInp = document.getElementById(`${prefix}_rangeMin`);
|
||||
const maxInp = document.getElementById(`${prefix}_rangeMax`);
|
||||
const stepInp = document.getElementById(`${prefix}_step`);
|
||||
const rerender = () => {
|
||||
const section = document.getElementById(`${prefix}_value_scores`);
|
||||
if (!section) return;
|
||||
const config = {
|
||||
range: {
|
||||
min: parseInt(minInp?.value || '0', 10),
|
||||
max: parseInt(maxInp?.value || '100', 10),
|
||||
},
|
||||
step: parseInt(stepInp?.value || '1', 10) || 1,
|
||||
};
|
||||
const existing = readValueScoreRulesFromForm(prefix);
|
||||
section.outerHTML = valueScoreRulesSectionHTML(prefix, config, existing);
|
||||
};
|
||||
[minInp, maxInp, stepInp].forEach(el => el?.addEventListener('change', rerender));
|
||||
}
|
||||
|
||||
function suggestSymptomKey(label) {
|
||||
@ -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 => `
|
||||
<div class="glass-score-cell">
|
||||
<label>${esc(l.label)}</label>
|
||||
<input type="number" class="glass-score-pts" data-level="${esc(l.key)}"
|
||||
value="${Number(levels[l.key] ?? 0)}" min="0" step="1">
|
||||
</div>`).join('');
|
||||
return `
|
||||
<li class="glass-symptom-row" data-idx="${idx}">
|
||||
<div class="form-row" style="align-items:flex-end;margin-bottom:0">
|
||||
@ -242,6 +326,7 @@ function glassSymptomRowHTML(prefix, row, idx) {
|
||||
</div>
|
||||
<button type="button" class="btn-icon btn-icon-danger glass-symptom-remove" title="Remove">✖</button>
|
||||
</div>
|
||||
<div class="glass-score-grid">${ptsRow}</div>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
@ -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 = `
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
@ -497,8 +587,10 @@ function configFormHTML(layout, config, prefix, opts = {}) {
|
||||
<label>Unit label (optional)</label>
|
||||
<input type="text" id="${prefix}_unitLabel" value="${esc(config.unitLabel || '')}" placeholder="e.g. years">
|
||||
</div>` : ''}
|
||||
</div>`;
|
||||
</div>
|
||||
${valueScoreRulesSectionHTML(prefix, config, scoreRules)}`;
|
||||
break;
|
||||
}
|
||||
case 'date_spinner': {
|
||||
const notBefore = config.constraints?.notBefore || '';
|
||||
const notAfter = config.constraints?.notAfter || '';
|
||||
@ -694,11 +786,6 @@ function renderEditor() {
|
||||
<label>Order index</label>
|
||||
<input type="number" id="metaOrder" value="${questionnaire.orderIndex ?? 0}" min="0" ${editable ? '' : 'disabled'}>
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:flex-end;padding-bottom:16px">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="metaShowPoints" ${questionnaire.showPoints ? 'checked' : ''} ${editable ? '' : 'disabled'}> Show points
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Category key (app grouping)</label>
|
||||
<input type="text" id="metaCategoryKey" value="${esc(questionnaire.categoryKey || '')}" placeholder="e.g. health_interview" ${editable ? '' : 'disabled'}>
|
||||
@ -838,7 +925,7 @@ async function saveMeta() {
|
||||
const version = document.getElementById('metaVersion').value.trim();
|
||||
const 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) {
|
||||
</label>
|
||||
${notesSectionHTML(config, `eq_cfg_${q.questionID}`, qKey)}
|
||||
<div id="eq_config_${q.questionID}">
|
||||
${configFormHTML(layout, config, `eq_cfg_${q.questionID}`, { glassSymptoms: glassSymptomsRowsFromQuestion(q) })}
|
||||
${configFormHTML(layout, config, `eq_cfg_${q.questionID}`, {
|
||||
glassSymptoms: glassSymptomsRowsFromQuestion(q),
|
||||
scoreRules: q.scoreRules || [],
|
||||
})}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px">
|
||||
<button class="btn btn-primary btn-sm save-q-btn">Save Question</button>
|
||||
@ -1271,6 +1365,9 @@ function renderQuestionBody(q) {
|
||||
if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return;
|
||||
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));
|
||||
|
||||
|
||||
@ -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 = `
|
||||
<div class="insights-kpi-grid">
|
||||
<a href="#/clients" class="insights-kpi card home-kpi-link">
|
||||
@ -66,6 +72,16 @@ function renderHomeDashboard(ov, isAdmin) {
|
||||
<div class="insights-kpi-value">${ov.submissionsLast30d ?? 0}</div>
|
||||
<div class="insights-kpi-label">Uploads (30 days)</div>
|
||||
</a>
|
||||
${primaryProfile ? `
|
||||
<a href="#/insights" class="insights-kpi card home-kpi-link">
|
||||
<div class="insights-kpi-value">${primaryProfile.clientCount ?? 0}</div>
|
||||
<div class="insights-kpi-label">${esc(primaryProfile.name)} scored</div>
|
||||
</a>` : ''}
|
||||
${greenPct != null ? `
|
||||
<a href="#/insights" class="insights-kpi card home-kpi-link">
|
||||
<div class="insights-kpi-value">${greenPct}%</div>
|
||||
<div class="insights-kpi-label">Green (${esc(primaryProfile.name)})</div>
|
||||
</a>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:20px">
|
||||
|
||||
@ -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 `
|
||||
<div class="card insights-chart-card">
|
||||
<h3>${esc(sp.name)}</h3>
|
||||
<p class="insights-chart-hint">
|
||||
${sp.clientCount ?? 0} client(s) with complete profile
|
||||
${sp.averageTotal != null ? ` · avg ${sp.averageTotal}` : ''}
|
||||
${sp.pendingReview ? ` · ${sp.pendingReview} pending coach review` : ''}
|
||||
</p>
|
||||
<p class="insights-chart-hint" style="margin:0 0 6px"><strong>Calculated bands</strong></p>
|
||||
${computedChart}
|
||||
<p class="insights-chart-hint" style="margin:12px 0 6px"><strong>Coach decisions</strong></p>
|
||||
${coachChart}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const qRows = (ov.questionnaires || []).map(q => `
|
||||
<tr>
|
||||
<td><strong>${esc(q.name)}</strong></td>
|
||||
@ -108,6 +137,12 @@ function renderInsights() {
|
||||
${idleChart}
|
||||
</div>
|
||||
</div>
|
||||
${scoringProfiles.length ? `
|
||||
<div class="card" style="margin-top:20px">
|
||||
<h3 style="margin:0 0 8px">Scoring profiles</h3>
|
||||
<p class="insights-chart-hint" style="margin:0 0 12px">Calculated vs coach band distribution among clients with a complete weighted score</p>
|
||||
<div class="insights-charts-grid">${scoringCharts}</div>
|
||||
</div>` : ''}
|
||||
<div class="card" style="margin-top:20px">
|
||||
<h3 style="margin:0 0 8px">Completion by questionnaire</h3>
|
||||
<p class="insights-chart-hint" style="margin:0 0 12px">Share of clients who completed each active questionnaire</p>
|
||||
|
||||
@ -7,6 +7,7 @@ export async function questionnairesPage() {
|
||||
app.innerHTML = `
|
||||
${pageHeaderHTML('Questionnaires', '<span id="headerActions"></span>')}
|
||||
<div id="qGrid" class="q-dashboard-root"><div class="spinner"></div></div>
|
||||
<div id="scoringProfilesPanel"></div>
|
||||
<div id="categoryKeysPanel"></div>
|
||||
`;
|
||||
|
||||
@ -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 `
|
||||
<div class="q-card" data-id="${q.questionnaireID}" draggable="${canEdit()}">
|
||||
<div class="q-card-header">
|
||||
@ -246,7 +250,6 @@ function renderQuestionnaireCard(q) {
|
||||
<span>v${esc(q.version || '—')}</span>
|
||||
<span>${q.questionCount || 0} question${q.questionCount == 1 ? '' : 's'}</span>
|
||||
<span>#${q.orderIndex ?? '—'}</span>
|
||||
${showPts ? '<span class="badge badge-active" style="font-size:.7rem;padding:1px 6px">pts</span>' : ''}
|
||||
</div>
|
||||
<div class="q-card-actions">
|
||||
<a href="#/questionnaire/${q.questionnaireID}" class="btn btn-sm">Edit</a>
|
||||
@ -377,6 +380,360 @@ function initGridDrag(questionnaires) {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeProfileBands(profile) {
|
||||
const greenMax = profile?.greenMax ?? 12;
|
||||
const yellowMax = profile?.yellowMax ?? 36;
|
||||
return {
|
||||
greenMin: profile?.greenMin ?? 0,
|
||||
greenMax,
|
||||
yellowMin: profile?.yellowMin ?? (greenMax + 1),
|
||||
yellowMax,
|
||||
redMin: profile?.redMin ?? (yellowMax + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function bandSummary(profile) {
|
||||
const b = normalizeProfileBands(profile || {});
|
||||
return `Green ${b.greenMin}–${b.greenMax} · Yellow ${b.yellowMin}–${b.yellowMax} · Red ${b.redMin}+`;
|
||||
}
|
||||
|
||||
function readBandsFromModal(overlay) {
|
||||
return {
|
||||
greenMin: parseInt(overlay.querySelector('#spGreenMin')?.value || '0', 10),
|
||||
greenMax: parseInt(overlay.querySelector('#spGreenMax')?.value || '0', 10),
|
||||
yellowMin: parseInt(overlay.querySelector('#spYellowMin')?.value || '0', 10),
|
||||
yellowMax: parseInt(overlay.querySelector('#spYellowMax')?.value || '0', 10),
|
||||
redMin: parseInt(overlay.querySelector('#spRedMin')?.value || '0', 10),
|
||||
};
|
||||
}
|
||||
|
||||
function validateBands(b) {
|
||||
if (b.greenMin > b.greenMax) return 'Green min must not exceed green max';
|
||||
if (b.yellowMin > b.yellowMax) return 'Yellow min must not exceed yellow max';
|
||||
if (b.greenMax >= b.yellowMin) return 'Green range must end before yellow range starts';
|
||||
if (b.yellowMax >= b.redMin) return 'Yellow range must end before red range starts';
|
||||
return null;
|
||||
}
|
||||
|
||||
function bandPreviewHTML(b) {
|
||||
return `
|
||||
<div class="scoring-band-strips">
|
||||
<span class="band-badge band-green">Green: ${b.greenMin} – ${b.greenMax}</span>
|
||||
<span class="band-badge band-yellow">Yellow: ${b.yellowMin} – ${b.yellowMax}</span>
|
||||
<span class="band-badge band-red">Red: ${b.redMin}+</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function bandRangesEditorHTML(bands) {
|
||||
const b = normalizeProfileBands(bands);
|
||||
return `
|
||||
<h4 class="scoring-modal-section-title">Score bands</h4>
|
||||
<p class="data-toolbar-hint" style="margin:0 0 10px">Set inclusive min/max for each band. Ranges must not overlap.</p>
|
||||
<div class="scoring-band-ranges-table">
|
||||
<div class="scoring-band-range-row scoring-band-range-header">
|
||||
<span>Band</span><span>Min</span><span>Max</span>
|
||||
</div>
|
||||
<div class="scoring-band-range-row">
|
||||
<span class="band-badge band-green">Green</span>
|
||||
<input type="number" id="spGreenMin" min="0" value="${b.greenMin}">
|
||||
<input type="number" id="spGreenMax" min="0" value="${b.greenMax}">
|
||||
</div>
|
||||
<div class="scoring-band-range-row">
|
||||
<span class="band-badge band-yellow">Yellow</span>
|
||||
<input type="number" id="spYellowMin" min="0" value="${b.yellowMin}">
|
||||
<input type="number" id="spYellowMax" min="0" value="${b.yellowMax}">
|
||||
</div>
|
||||
<div class="scoring-band-range-row">
|
||||
<span class="band-badge band-red">Red</span>
|
||||
<input type="number" id="spRedMin" min="0" value="${b.redMin}">
|
||||
<span class="scoring-band-max-placeholder">∞</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="spBandPreview" class="scoring-band-labels"></div>`;
|
||||
}
|
||||
|
||||
function renderScoringProfilesSection(profiles, questionnaires) {
|
||||
const panel = document.getElementById('scoringProfilesPanel');
|
||||
if (!panel) return;
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="scoring-profiles-section">
|
||||
<div class="card scoring-profiles-card">
|
||||
<div class="scoring-profile-header">
|
||||
<div>
|
||||
<h3 class="scoring-section-title">Scoring profiles</h3>
|
||||
<p class="scoring-section-desc">
|
||||
Weighted multi-questionnaire scores with Green / Yellow / Red bands for insights.
|
||||
</p>
|
||||
</div>
|
||||
${canEdit() ? '<button type="button" class="btn btn-primary btn-sm" id="newScoringProfileBtn">+ New profile</button>' : ''}
|
||||
</div>
|
||||
<div id="scoringProfilesList" class="scoring-profiles-list"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const list = panel.querySelector('#scoringProfilesList');
|
||||
if (!profiles.length) {
|
||||
list.innerHTML = '<p class="scoring-empty-hint">No scoring profiles yet. Create one to combine questionnaire scores for insights.</p>';
|
||||
} else {
|
||||
list.innerHTML = profiles.map(p => scoringProfileCardHTML(p, questionnaires)).join('');
|
||||
}
|
||||
|
||||
if (canEdit()) {
|
||||
panel.querySelector('#newScoringProfileBtn')?.addEventListener('click', () => {
|
||||
openScoringProfileModal(null, questionnaires, profiles);
|
||||
});
|
||||
list.querySelectorAll('.edit-profile-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const profile = profiles.find(x => x.profileID === btn.dataset.id);
|
||||
openScoringProfileModal(profile, questionnaires, profiles);
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.delete-profile-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm(`Delete scoring profile "${btn.dataset.name}"?`)) return;
|
||||
try {
|
||||
await apiDelete('scoring_profiles.php', { profileID: btn.dataset.id });
|
||||
showToast('Profile deleted', 'success');
|
||||
await questionnairesPage();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function scoringProfileCardHTML(profile, questionnaires) {
|
||||
const qnById = Object.fromEntries((questionnaires || []).map(q => [q.questionnaireID, q]));
|
||||
const members = profile.questionnaires || [];
|
||||
const activeBadge = profile.isActive
|
||||
? '<span class="badge badge-active">Active</span>'
|
||||
: '<span class="badge badge-draft">Inactive</span>';
|
||||
const memberRows = members.map(m => {
|
||||
const q = qnById[m.questionnaireID];
|
||||
const name = q?.name || m.questionnaireID;
|
||||
return `<div class="scoring-profile-q-row"><span class="scoring-profile-q-name">${esc(name)}</span><span class="scoring-profile-q-weight">× ${m.weight}</span></div>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="scoring-profile-card">
|
||||
<div class="scoring-profile-card-top">
|
||||
<div class="scoring-profile-card-main">
|
||||
<div class="scoring-profile-name-row">
|
||||
<strong>${esc(profile.name)}</strong>
|
||||
${activeBadge}
|
||||
</div>
|
||||
${profile.description ? `<p class="scoring-profile-desc">${esc(profile.description)}</p>` : ''}
|
||||
<p class="scoring-profile-bands">${bandSummary(profile)}</p>
|
||||
</div>
|
||||
${canEdit() ? `
|
||||
<div class="scoring-profile-card-actions">
|
||||
<button type="button" class="btn btn-sm edit-profile-btn" data-id="${esc(profile.profileID)}">Edit</button>
|
||||
<button type="button" class="btn btn-sm btn-danger delete-profile-btn"
|
||||
data-id="${esc(profile.profileID)}" data-name="${esc(profile.name)}">Delete</button>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
<p class="scoring-profile-member-count">${members.length} questionnaire(s)</p>
|
||||
<div class="scoring-profile-members">${memberRows || '<p class="scoring-empty-hint">No questionnaires linked</p>'}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function openScoringProfileModal(profile, questionnaires, allProfiles) {
|
||||
const isEdit = !!profile;
|
||||
const members = profile?.questionnaires?.length
|
||||
? [...profile.questionnaires]
|
||||
: [];
|
||||
const selectedIds = new Set(members.map(m => m.questionnaireID));
|
||||
const initialBands = normalizeProfileBands(profile || {});
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-dialog modal-dialog-wide">
|
||||
<h2>${isEdit ? 'Edit scoring profile' : 'New scoring profile'}</h2>
|
||||
<p class="modal-subtitle">Combine questionnaire totals with weights, then map the sum to Green / Yellow / Red bands.</p>
|
||||
<div class="form-group">
|
||||
<label for="spName">Name</label>
|
||||
<input type="text" id="spName" value="${esc(profile?.name || '')}" placeholder="e.g. RHS Index">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="spDesc">Description</label>
|
||||
<textarea id="spDesc" rows="2" placeholder="Optional note for coaches and admins">${esc(profile?.description || '')}</textarea>
|
||||
</div>
|
||||
<div class="form-group scoring-profile-active-row">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" id="spActive" ${profile?.isActive !== 0 ? 'checked' : ''}>
|
||||
Active (shown in insights)
|
||||
</label>
|
||||
</div>
|
||||
${bandRangesEditorHTML(initialBands)}
|
||||
<h4 class="scoring-modal-section-title">Questionnaires</h4>
|
||||
<p class="data-toolbar-hint" style="margin:0 0 10px">Add questionnaires and set weight. List order is the display order.</p>
|
||||
<div id="spQnPicker" class="scoring-profile-picker"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-sm" id="spCancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="spSave">${isEdit ? 'Save' : 'Create'}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
document.body.classList.add('modal-open');
|
||||
|
||||
const picker = overlay.querySelector('#spQnPicker');
|
||||
const sortedQn = [...(questionnaires || [])].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0));
|
||||
|
||||
function renderPicker() {
|
||||
const ordered = members.length
|
||||
? members.map(m => ({ ...m, q: sortedQn.find(q => q.questionnaireID === m.questionnaireID) }))
|
||||
: [];
|
||||
const available = sortedQn.filter(q => !selectedIds.has(q.questionnaireID));
|
||||
|
||||
picker.innerHTML = `
|
||||
${ordered.map((m, idx) => `
|
||||
<div class="scoring-profile-q-row" data-qn="${esc(m.questionnaireID)}">
|
||||
<span class="scoring-profile-q-name">${esc(m.q?.name || m.questionnaireID)}</span>
|
||||
<span class="scoring-profile-q-actions">
|
||||
<label class="sp-weight-wrap">Weight
|
||||
<input type="number" class="sp-weight" step="0.1" min="0.1"
|
||||
value="${m.weight ?? 1}">
|
||||
</label>
|
||||
<button type="button" class="btn btn-sm sp-move-up" ${idx === 0 ? 'disabled' : ''} title="Move up">↑</button>
|
||||
<button type="button" class="btn btn-sm sp-move-down" ${idx === ordered.length - 1 ? 'disabled' : ''} title="Move down">↓</button>
|
||||
<button type="button" class="btn btn-sm btn-danger sp-remove" title="Remove">Remove</button>
|
||||
</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
${available.length ? `
|
||||
<div class="scoring-profile-add-row">
|
||||
<select id="spAddSelect">
|
||||
<option value="">Add questionnaire…</option>
|
||||
${available.map(q => `<option value="${esc(q.questionnaireID)}">${esc(q.name)}</option>`).join('')}
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm" id="spAddBtn">Add</button>
|
||||
</div>` : ''}`;
|
||||
|
||||
picker.querySelectorAll('.sp-weight').forEach((input, idx) => {
|
||||
input.addEventListener('change', () => {
|
||||
members[idx].weight = parseFloat(input.value) || 1;
|
||||
});
|
||||
});
|
||||
picker.querySelectorAll('.sp-remove').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest('.scoring-profile-q-row');
|
||||
const id = row?.dataset.qn;
|
||||
if (!id) return;
|
||||
selectedIds.delete(id);
|
||||
const i = members.findIndex(m => m.questionnaireID === id);
|
||||
if (i >= 0) members.splice(i, 1);
|
||||
renderPicker();
|
||||
});
|
||||
});
|
||||
picker.querySelectorAll('.sp-move-up').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest('.scoring-profile-q-row');
|
||||
const id = row?.dataset.qn;
|
||||
const i = members.findIndex(m => m.questionnaireID === id);
|
||||
if (i > 0) {
|
||||
[members[i - 1], members[i]] = [members[i], members[i - 1]];
|
||||
renderPicker();
|
||||
}
|
||||
});
|
||||
});
|
||||
picker.querySelectorAll('.sp-move-down').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest('.scoring-profile-q-row');
|
||||
const id = row?.dataset.qn;
|
||||
const i = members.findIndex(m => m.questionnaireID === id);
|
||||
if (i >= 0 && i < members.length - 1) {
|
||||
[members[i], members[i + 1]] = [members[i + 1], members[i]];
|
||||
renderPicker();
|
||||
}
|
||||
});
|
||||
});
|
||||
overlay.querySelector('#spAddBtn')?.addEventListener('click', () => {
|
||||
const sel = overlay.querySelector('#spAddSelect');
|
||||
const id = sel?.value;
|
||||
if (!id || selectedIds.has(id)) return;
|
||||
selectedIds.add(id);
|
||||
members.push({ questionnaireID: id, weight: 1, orderIndex: members.length });
|
||||
renderPicker();
|
||||
});
|
||||
}
|
||||
|
||||
function updateBandPreview() {
|
||||
const el = overlay.querySelector('#spBandPreview');
|
||||
if (!el) return;
|
||||
const b = readBandsFromModal(overlay);
|
||||
const err = validateBands(b);
|
||||
if (err) {
|
||||
el.innerHTML = `<p class="error-text" style="margin:0">${esc(err)}</p>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = bandPreviewHTML(b);
|
||||
}
|
||||
|
||||
renderPicker();
|
||||
updateBandPreview();
|
||||
['#spGreenMin', '#spGreenMax', '#spYellowMin', '#spYellowMax', '#spRedMin'].forEach(sel => {
|
||||
overlay.querySelector(sel)?.addEventListener('input', updateBandPreview);
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
overlay.remove();
|
||||
document.body.classList.remove('modal-open');
|
||||
};
|
||||
overlay.querySelector('#spCancel')?.addEventListener('click', close);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
|
||||
overlay.querySelector('#spSave')?.addEventListener('click', async () => {
|
||||
const name = overlay.querySelector('#spName')?.value.trim();
|
||||
const description = overlay.querySelector('#spDesc')?.value.trim() || '';
|
||||
const isActive = overlay.querySelector('#spActive')?.checked;
|
||||
const bands = readBandsFromModal(overlay);
|
||||
const bandErr = validateBands(bands);
|
||||
if (!name) {
|
||||
showToast('Profile name is required', 'error');
|
||||
return;
|
||||
}
|
||||
if (bandErr) {
|
||||
showToast(bandErr, 'error');
|
||||
return;
|
||||
}
|
||||
if (!members.length) {
|
||||
showToast('Add at least one questionnaire', 'error');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name,
|
||||
description,
|
||||
isActive,
|
||||
...bands,
|
||||
questionnaires: members.map((m, idx) => ({
|
||||
questionnaireID: m.questionnaireID,
|
||||
weight: parseFloat(picker.querySelector(`[data-qn="${m.questionnaireID}"] .sp-weight`)?.value) || m.weight || 1,
|
||||
orderIndex: idx,
|
||||
})),
|
||||
};
|
||||
if (isEdit) payload.profileID = profile.profileID;
|
||||
|
||||
const saveBtn = overlay.querySelector('#spSave');
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
if (isEdit) {
|
||||
await apiPut('scoring_profiles.php', payload);
|
||||
} else {
|
||||
await apiPost('scoring_profiles.php', payload);
|
||||
}
|
||||
showToast(isEdit ? 'Profile saved' : 'Profile created', 'success');
|
||||
close();
|
||||
await questionnairesPage();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindImportBundle() {
|
||||
const fileInput = document.getElementById('importBundleFile');
|
||||
const btn = document.getElementById('importBundleBtn');
|
||||
|
||||
Reference in New Issue
Block a user