This commit is contained in:
@ -49,6 +49,7 @@ $routes = [
|
|||||||
'results' => __DIR__ . '/../handlers/results.php',
|
'results' => __DIR__ . '/../handlers/results.php',
|
||||||
'export' => __DIR__ . '/../handlers/export.php',
|
'export' => __DIR__ . '/../handlers/export.php',
|
||||||
'analytics' => __DIR__ . '/../handlers/analytics.php',
|
'analytics' => __DIR__ . '/../handlers/analytics.php',
|
||||||
|
'scoring_profiles' => __DIR__ . '/../handlers/scoring_profiles.php',
|
||||||
'coaches' => __DIR__ . '/../handlers/coaches.php',
|
'coaches' => __DIR__ . '/../handlers/coaches.php',
|
||||||
'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php',
|
'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php',
|
||||||
'logout' => __DIR__ . '/../handlers/logout.php',
|
'logout' => __DIR__ . '/../handlers/logout.php',
|
||||||
|
|||||||
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). */
|
/** Export one questionnaire with structure + all translations (portable JSON). */
|
||||||
function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array {
|
function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array {
|
||||||
|
require_once __DIR__ . '/lib/scoring.php';
|
||||||
$qn = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id');
|
$qn = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id');
|
||||||
$qn->execute([':id' => $qnID]);
|
$qn->execute([':id' => $qnID]);
|
||||||
$qnRow = $qn->fetch(PDO::FETCH_ASSOC);
|
$qnRow = $qn->fetch(PDO::FETCH_ASSOC);
|
||||||
@ -1779,6 +1780,7 @@ function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array {
|
|||||||
'isRequired' => (int)$dbQ['isRequired'],
|
'isRequired' => (int)$dbQ['isRequired'],
|
||||||
'config' => $config,
|
'config' => $config,
|
||||||
'answerOptions' => $optionsOut,
|
'answerOptions' => $optionsOut,
|
||||||
|
'scoreRules' => qdb_get_score_rules_for_question($pdo, $dbQ['questionID']),
|
||||||
'translations' => qdb_fetch_translation_map($pdo, 'question', $dbQ['questionID']),
|
'translations' => qdb_fetch_translation_map($pdo, 'question', $dbQ['questionID']),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -1823,14 +1825,21 @@ function qdb_export_all_questionnaires_bundle(PDO $pdo): array {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
'exportVersion' => 1,
|
'exportVersion' => 2,
|
||||||
'exportedAt' => time(),
|
'exportedAt' => time(),
|
||||||
'sourceLanguage' => QDB_SOURCE_LANGUAGE,
|
'sourceLanguage' => QDB_SOURCE_LANGUAGE,
|
||||||
'questionnaireCount' => count($items),
|
'questionnaireCount' => count($items),
|
||||||
'questionnaires' => $items,
|
'questionnaires' => $items,
|
||||||
|
'scoringProfiles' => qdb_export_scoring_profiles_bundle($pdo),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Export all scoring profiles for bundle portability. */
|
||||||
|
function qdb_export_scoring_profiles_bundle(PDO $pdo): array {
|
||||||
|
require_once __DIR__ . '/lib/scoring.php';
|
||||||
|
return qdb_list_scoring_profiles($pdo);
|
||||||
|
}
|
||||||
|
|
||||||
/** Remove questionnaire and related rows (no client_answer cleanup for other qns). */
|
/** Remove questionnaire and related rows (no client_answer cleanup for other qns). */
|
||||||
function qdb_delete_questionnaire_cascade(PDO $pdo, string $qnID): void {
|
function qdb_delete_questionnaire_cascade(PDO $pdo, string $qnID): void {
|
||||||
$qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id');
|
$qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id');
|
||||||
@ -1957,6 +1966,11 @@ function qdb_import_questionnaire_bundle(PDO $pdo, array $item, bool $replaceIfE
|
|||||||
qdb_apply_translation_map($pdo, 'question', $questionID, is_array($qTr) ? $qTr : []);
|
qdb_apply_translation_map($pdo, 'question', $questionID, is_array($qTr) ? $qTr : []);
|
||||||
qdb_sync_question_note_strings($pdo, $questionKey, $config);
|
qdb_sync_question_note_strings($pdo, $questionKey, $config);
|
||||||
|
|
||||||
|
if (!empty($q['scoreRules']) && is_array($q['scoreRules'])) {
|
||||||
|
require_once __DIR__ . '/lib/scoring.php';
|
||||||
|
qdb_sync_question_score_rules($pdo, $questionID, $q['scoreRules']);
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($q['answerOptions'] ?? [] as $opt) {
|
foreach ($q['answerOptions'] ?? [] as $opt) {
|
||||||
$optKey = trim((string)($opt['optionKey'] ?? ''));
|
$optKey = trim((string)($opt['optionKey'] ?? ''));
|
||||||
$optGerman = trim($opt['defaultText'] ?? '');
|
$optGerman = trim($opt['defaultText'] ?? '');
|
||||||
@ -2213,12 +2227,102 @@ function qdb_import_questionnaires_bundle(PDO $pdo, array $bundle, bool $replace
|
|||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$results[] = qdb_import_questionnaire_bundle($pdo, $item, $replaceIfExists);
|
$results[] = qdb_import_questionnaire_bundle($pdo, $item, $replaceIfExists);
|
||||||
}
|
}
|
||||||
|
if (!empty($bundle['scoringProfiles']) && is_array($bundle['scoringProfiles'])) {
|
||||||
|
qdb_import_scoring_profiles_bundle($pdo, $bundle['scoringProfiles'], $replaceIfExists);
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
'imported' => count($results),
|
'imported' => count($results),
|
||||||
'results' => $results,
|
'results' => $results,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Import scoring profiles from a questionnaire bundle. */
|
||||||
|
function qdb_import_scoring_profiles_bundle(PDO $pdo, array $profiles, bool $replaceIfExists = false): void {
|
||||||
|
require_once __DIR__ . '/lib/scoring.php';
|
||||||
|
foreach ($profiles as $profile) {
|
||||||
|
if (!is_array($profile)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$wantedId = trim($profile['profileID'] ?? '');
|
||||||
|
$name = trim($profile['name'] ?? '');
|
||||||
|
if ($name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$exists = false;
|
||||||
|
if ($wantedId !== '') {
|
||||||
|
$chk = $pdo->prepare('SELECT 1 FROM scoring_profile WHERE profileID = :id');
|
||||||
|
$chk->execute([':id' => $wantedId]);
|
||||||
|
$exists = (bool)$chk->fetch();
|
||||||
|
}
|
||||||
|
if ($exists && $replaceIfExists) {
|
||||||
|
$pdo->prepare('DELETE FROM scoring_profile WHERE profileID = :id')->execute([':id' => $wantedId]);
|
||||||
|
$profileID = $wantedId;
|
||||||
|
} elseif ($exists) {
|
||||||
|
$profileID = bin2hex(random_bytes(16));
|
||||||
|
} else {
|
||||||
|
$profileID = $wantedId !== '' ? $wantedId : bin2hex(random_bytes(16));
|
||||||
|
}
|
||||||
|
$now = time();
|
||||||
|
$pdo->prepare(
|
||||||
|
'INSERT INTO scoring_profile (profileID, name, description, isActive,
|
||||||
|
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
|
||||||
|
VALUES (:id, :name, :desc, :act, :gmin, :gmax, :ymin, :ymax, :rmin, :ca, :ua)'
|
||||||
|
)->execute([
|
||||||
|
':id' => $profileID,
|
||||||
|
':name' => $name,
|
||||||
|
':desc' => trim($profile['description'] ?? ''),
|
||||||
|
':act' => !empty($profile['isActive']) ? 1 : 0,
|
||||||
|
':gmin' => (int)($profile['greenMin'] ?? 0),
|
||||||
|
':gmax' => (int)($profile['greenMax'] ?? 12),
|
||||||
|
':ymin' => (int)($profile['yellowMin'] ?? ((int)($profile['greenMax'] ?? 12) + 1)),
|
||||||
|
':ymax' => (int)($profile['yellowMax'] ?? 36),
|
||||||
|
':rmin' => (int)($profile['redMin'] ?? ((int)($profile['yellowMax'] ?? 36) + 1)),
|
||||||
|
':ca' => (int)($profile['createdAt'] ?? $now),
|
||||||
|
':ua' => (int)($profile['updatedAt'] ?? $now),
|
||||||
|
]);
|
||||||
|
$members = [];
|
||||||
|
foreach ($profile['questionnaires'] ?? [] as $idx => $row) {
|
||||||
|
if (!is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$qnID = trim((string)($row['questionnaireID'] ?? ''));
|
||||||
|
if ($qnID === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$chk = $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id');
|
||||||
|
$chk->execute([':id' => $qnID]);
|
||||||
|
if (!$chk->fetch()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$members[] = [
|
||||||
|
'questionnaireID' => $qnID,
|
||||||
|
'weight' => (float)($row['weight'] ?? 1.0),
|
||||||
|
'orderIndex' => (int)($row['orderIndex'] ?? $idx),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($members !== []) {
|
||||||
|
qdb_sync_profile_questionnaire_members($pdo, $profileID, $members);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_sync_profile_questionnaire_members(PDO $pdo, string $profileID, array $members): void {
|
||||||
|
$pdo->prepare('DELETE FROM scoring_profile_questionnaire WHERE profileID = :pid')
|
||||||
|
->execute([':pid' => $profileID]);
|
||||||
|
$ins = $pdo->prepare(
|
||||||
|
'INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
|
||||||
|
VALUES (:pid, :qn, :w, :o)'
|
||||||
|
);
|
||||||
|
foreach ($members as $m) {
|
||||||
|
$ins->execute([
|
||||||
|
':pid' => $profileID,
|
||||||
|
':qn' => $m['questionnaireID'],
|
||||||
|
':w' => $m['weight'],
|
||||||
|
':o' => $m['orderIndex'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- AES-256-CBC: IV(16) + CIPHERTEXT ---
|
// --- AES-256-CBC: IV(16) + CIPHERTEXT ---
|
||||||
function aes256_cbc_encrypt_bytes(string $plain, string $key): string {
|
function aes256_cbc_encrypt_bytes(string $plain, string $key): string {
|
||||||
$key = str_pad(substr($key, 0, 32), 32, "\0");
|
$key = str_pad(substr($key, 0, 32), 32, "\0");
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"description": "Coach-visible app UI strings only (excludes dev settings / database tools).",
|
"description": "Coach-visible app UI strings only (excludes dev settings \/ database tools).",
|
||||||
"keys": [
|
"keys": [
|
||||||
"all_clients",
|
"all_clients",
|
||||||
"answer",
|
"answer",
|
||||||
@ -10,6 +10,9 @@
|
|||||||
"auth_subtitle_change_password",
|
"auth_subtitle_change_password",
|
||||||
"auth_subtitle_login",
|
"auth_subtitle_login",
|
||||||
"auth_title",
|
"auth_title",
|
||||||
|
"category_green",
|
||||||
|
"category_red",
|
||||||
|
"category_yellow",
|
||||||
"cancel",
|
"cancel",
|
||||||
"choose_answer",
|
"choose_answer",
|
||||||
"choose_more_elements",
|
"choose_more_elements",
|
||||||
@ -84,6 +87,16 @@
|
|||||||
"question",
|
"question",
|
||||||
"questions_filled",
|
"questions_filled",
|
||||||
"refresh",
|
"refresh",
|
||||||
|
"review_scores",
|
||||||
|
"review_scores_agree",
|
||||||
|
"review_scores_calculated_category",
|
||||||
|
"review_scores_coach_category",
|
||||||
|
"review_scores_empty",
|
||||||
|
"review_scores_save_failed",
|
||||||
|
"review_scores_saved",
|
||||||
|
"review_scores_set_category",
|
||||||
|
"review_scores_subtitle",
|
||||||
|
"review_scores_total",
|
||||||
"save",
|
"save",
|
||||||
"save_password_btn",
|
"save_password_btn",
|
||||||
"select_one_answer",
|
"select_one_answer",
|
||||||
@ -111,6 +124,9 @@
|
|||||||
"auth_subtitle_login": "Melden Sie sich mit Ihrem Coach-Konto an.",
|
"auth_subtitle_login": "Melden Sie sich mit Ihrem Coach-Konto an.",
|
||||||
"auth_title": "BW Schützt",
|
"auth_title": "BW Schützt",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
|
"category_green": "Grün",
|
||||||
|
"category_red": "Rot",
|
||||||
|
"category_yellow": "Gelb",
|
||||||
"choose_answer": "Antwort wählen",
|
"choose_answer": "Antwort wählen",
|
||||||
"choose_more_elements": "Es müssen mehr Elemenete ausgewählt werden.",
|
"choose_more_elements": "Es müssen mehr Elemenete ausgewählt werden.",
|
||||||
"client": "Klient",
|
"client": "Klient",
|
||||||
@ -184,6 +200,16 @@
|
|||||||
"question": "Frage",
|
"question": "Frage",
|
||||||
"questions_filled": "Antworten",
|
"questions_filled": "Antworten",
|
||||||
"refresh": "Aktualisieren",
|
"refresh": "Aktualisieren",
|
||||||
|
"review_scores": "Punkte prüfen",
|
||||||
|
"review_scores_agree": "Berechnete Kategorie bestätigen",
|
||||||
|
"review_scores_calculated_category": "Berechnete Kategorie",
|
||||||
|
"review_scores_coach_category": "Coach-Kategorie",
|
||||||
|
"review_scores_empty": "Noch keine vollständigen Profilwerte zum Prüfen.",
|
||||||
|
"review_scores_save_failed": "Kategorie konnte nicht gespeichert werden.",
|
||||||
|
"review_scores_saved": "Kategorie gespeichert.",
|
||||||
|
"review_scores_set_category": "Kategorie setzen",
|
||||||
|
"review_scores_subtitle": "Prüfen Sie die berechneten Werte und bestätigen oder setzen Sie die Kategorie.",
|
||||||
|
"review_scores_total": "Gewichtete Summe",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"save_password_btn": "Passwort speichern",
|
"save_password_btn": "Passwort speichern",
|
||||||
"select_one_answer": "Bitte wählen Sie eine Antwort aus!",
|
"select_one_answer": "Bitte wählen Sie eine Antwort aus!",
|
||||||
@ -200,7 +226,7 @@
|
|||||||
"upload_success_message": "Hochladen: {count} erledigt.",
|
"upload_success_message": "Hochladen: {count} erledigt.",
|
||||||
"username_hint": "Benutzername",
|
"username_hint": "Benutzername",
|
||||||
"year": "Jahr",
|
"year": "Jahr",
|
||||||
"questionnaire_group_demographics": "Demografie",
|
"questionnaire_group_demographics": "Demografie test",
|
||||||
"questionnaire_group_rhs": "Gesundheitsinterview"
|
"questionnaire_group_rhs": "Gesundheitsinterview"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock');
|
||||||
}
|
}
|
||||||
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
|
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
|
||||||
define('QDB_VERSION', 6);
|
define('QDB_VERSION', 9);
|
||||||
|
|
||||||
function qdb_table_exists(PDO $pdo, string $table): bool {
|
function qdb_table_exists(PDO $pdo, string $table): bool {
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
@ -126,6 +126,92 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
|
|||||||
$changed = true;
|
$changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!qdb_table_exists($pdo, 'question_score_rule')) {
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE question_score_rule (
|
||||||
|
ruleID TEXT NOT NULL PRIMARY KEY,
|
||||||
|
questionID TEXT NOT NULL,
|
||||||
|
scopeKey TEXT NOT NULL DEFAULT '',
|
||||||
|
levelKey TEXT NOT NULL,
|
||||||
|
points INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY(questionID) REFERENCES question(questionID) ON DELETE CASCADE,
|
||||||
|
UNIQUE(questionID, scopeKey, levelKey)
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_score_rule_question ON question_score_rule(questionID)');
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!qdb_table_exists($pdo, 'scoring_profile')) {
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE scoring_profile (
|
||||||
|
profileID TEXT NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
isActive INTEGER NOT NULL DEFAULT 1,
|
||||||
|
greenMin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
greenMax INTEGER NOT NULL DEFAULT 12,
|
||||||
|
yellowMin INTEGER NOT NULL DEFAULT 13,
|
||||||
|
yellowMax INTEGER NOT NULL DEFAULT 36,
|
||||||
|
redMin INTEGER NOT NULL DEFAULT 37,
|
||||||
|
createdAt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updatedAt INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!qdb_table_exists($pdo, 'scoring_profile_questionnaire')) {
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE scoring_profile_questionnaire (
|
||||||
|
profileID TEXT NOT NULL,
|
||||||
|
questionnaireID TEXT NOT NULL,
|
||||||
|
weight REAL NOT NULL DEFAULT 1.0,
|
||||||
|
orderIndex INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (profileID, questionnaireID),
|
||||||
|
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!qdb_table_exists($pdo, 'client_scoring_profile_result')) {
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE client_scoring_profile_result (
|
||||||
|
clientCode TEXT NOT NULL,
|
||||||
|
profileID TEXT NOT NULL,
|
||||||
|
weightedTotal REAL NOT NULL DEFAULT 0,
|
||||||
|
band TEXT NOT NULL DEFAULT '',
|
||||||
|
computedAt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
questionnaireSnapshot TEXT NOT NULL DEFAULT '{}',
|
||||||
|
coachBand TEXT NOT NULL DEFAULT '',
|
||||||
|
coachReviewedAt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
coachReviewedByUserID TEXT NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (clientCode, profileID),
|
||||||
|
FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_client_profile_result_profile ON client_scoring_profile_result(profileID)');
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qdb_table_exists($pdo, 'client_scoring_profile_result')) {
|
||||||
|
if (!qdb_column_exists($pdo, 'client_scoring_profile_result', 'coachBand')) {
|
||||||
|
$pdo->exec("ALTER TABLE client_scoring_profile_result ADD COLUMN coachBand TEXT NOT NULL DEFAULT ''");
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
if (!qdb_column_exists($pdo, 'client_scoring_profile_result', 'coachReviewedAt')) {
|
||||||
|
$pdo->exec('ALTER TABLE client_scoring_profile_result ADD COLUMN coachReviewedAt INTEGER NOT NULL DEFAULT 0');
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
if (!qdb_column_exists($pdo, 'client_scoring_profile_result', 'coachReviewedByUserID')) {
|
||||||
|
$pdo->exec("ALTER TABLE client_scoring_profile_result ADD COLUMN coachReviewedByUserID TEXT NOT NULL DEFAULT ''");
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
|
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||||
require_once __DIR__ . '/lib/submissions.php';
|
require_once __DIR__ . '/lib/submissions.php';
|
||||||
if (qdb_backfill_submissions_from_live($pdo)) {
|
if (qdb_backfill_submissions_from_live($pdo)) {
|
||||||
@ -133,6 +219,43 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
|
||||||
|
if ($currentVersion < 7 && qdb_table_exists($pdo, 'question_score_rule')) {
|
||||||
|
require_once __DIR__ . '/lib/scoring.php';
|
||||||
|
if (qdb_backfill_glass_score_rules($pdo) > 0) {
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
if (qdb_recompute_all_questionnaire_scores($pdo) > 0) {
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
if (qdb_seed_default_rhs_scoring_profile($pdo) !== null) {
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
|
||||||
|
if ($currentVersion < 8 && qdb_table_exists($pdo, 'scoring_profile')) {
|
||||||
|
if (!qdb_column_exists($pdo, 'scoring_profile', 'greenMin')) {
|
||||||
|
$pdo->exec('ALTER TABLE scoring_profile ADD COLUMN greenMin INTEGER NOT NULL DEFAULT 0');
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
if (!qdb_column_exists($pdo, 'scoring_profile', 'yellowMin')) {
|
||||||
|
$pdo->exec('ALTER TABLE scoring_profile ADD COLUMN yellowMin INTEGER NOT NULL DEFAULT 13');
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
if (!qdb_column_exists($pdo, 'scoring_profile', 'redMin')) {
|
||||||
|
$pdo->exec('ALTER TABLE scoring_profile ADD COLUMN redMin INTEGER NOT NULL DEFAULT 37');
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
$pdo->exec(
|
||||||
|
'UPDATE scoring_profile SET
|
||||||
|
greenMin = COALESCE(greenMin, 0),
|
||||||
|
yellowMin = greenMax + 1,
|
||||||
|
redMin = yellowMax + 1'
|
||||||
|
);
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
|
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
|
||||||
if ($currentVersion < 6 && qdb_table_exists($pdo, 'questionnaire_submission')) {
|
if ($currentVersion < 6 && qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||||
$pdo->exec(
|
$pdo->exec(
|
||||||
|
|||||||
@ -7,6 +7,9 @@
|
|||||||
* GET ?clients=1 -> list of clients assigned to the authenticated coach
|
* GET ?clients=1 -> list of clients assigned to the authenticated coach
|
||||||
* GET ?clientCode=X&answers=1 -> all completed questionnaire answers for one client (encrypted)
|
* GET ?clientCode=X&answers=1 -> all completed questionnaire answers for one client (encrypted)
|
||||||
* GET ?answersBulk=1 -> all assigned clients' answers in one payload (coach, encrypted)
|
* GET ?answersBulk=1 -> all assigned clients' answers in one payload (coach, encrypted)
|
||||||
|
* GET ?scoringProfiles=1 -> active scoring profile definitions (encrypted)
|
||||||
|
* GET ?scoringReview=1 -> coach review state (coachBand only; computed on device)
|
||||||
|
* POST action=coachScoringReview -> coach agrees with or overrides calculated band (encrypted)
|
||||||
* POST -> submit interview answers for a client (coach / supervisor / admin).
|
* POST -> submit interview answers for a client (coach / supervisor / admin).
|
||||||
* Re-posting the same clientCode + questionnaireID updates answers in place (re-edit).
|
* Re-posting the same clientCode + questionnaireID updates answers in place (re-edit).
|
||||||
* Sensitive POST/GET ?clients=1 bodies use encrypted payloads (HKDF from Bearer token).
|
* Sensitive POST/GET ?clients=1 bodies use encrypted payloads (HKDF from Bearer token).
|
||||||
@ -27,6 +30,58 @@ if ($method === 'POST') {
|
|||||||
require_role(['admin', 'supervisor', 'coach'], $tokenRec);
|
require_role(['admin', 'supervisor', 'coach'], $tokenRec);
|
||||||
|
|
||||||
$body = read_encrypted_json_body($tokenRec['_token']);
|
$body = read_encrypted_json_body($tokenRec['_token']);
|
||||||
|
|
||||||
|
if (($body['action'] ?? '') === 'coachScoringReview') {
|
||||||
|
require_fields($body, ['clientCode', 'profileID', 'coachBand']);
|
||||||
|
$clientCode = trim((string)$body['clientCode']);
|
||||||
|
$profileID = trim((string)$body['profileID']);
|
||||||
|
$coachBand = trim((string)($body['coachBand'] ?? ''));
|
||||||
|
$calculatedBand = trim((string)($body['calculatedBand'] ?? ''));
|
||||||
|
$weightedTotal = isset($body['weightedTotal']) ? (float)$body['weightedTotal'] : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$opened = qdb_open_write_or_fail();
|
||||||
|
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||||
|
|
||||||
|
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||||
|
$clStmt = $pdo->prepare(
|
||||||
|
"SELECT cl.clientCode FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)"
|
||||||
|
);
|
||||||
|
$clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
|
||||||
|
if (!$clStmt->fetch()) {
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../lib/scoring.php';
|
||||||
|
if (!qdb_is_valid_scoring_band($coachBand)) {
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_error('INVALID_FIELD', 'coachBand must be green, yellow, or red', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile = qdb_save_coach_scoring_review(
|
||||||
|
$pdo,
|
||||||
|
$clientCode,
|
||||||
|
$profileID,
|
||||||
|
$coachBand,
|
||||||
|
(string)($tokenRec['userID'] ?? ''),
|
||||||
|
$weightedTotal,
|
||||||
|
$calculatedBand !== '' ? $calculatedBand : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
qdb_save($tmpDb, $lockFp);
|
||||||
|
json_success(['profile' => $profile]);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
qdb_discard($tmpDb ?? null, $lockFp ?? null);
|
||||||
|
json_error('INVALID_FIELD', $e->getMessage(), 400);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
qdb_discard($tmpDb ?? null, $lockFp ?? null);
|
||||||
|
json_error('NOT_FOUND', $e->getMessage(), 404);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
qdb_handler_fail($e, 'Coach scoring review', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
require_fields($body, ['questionnaireID', 'clientCode', 'answers']);
|
require_fields($body, ['questionnaireID', 'clientCode', 'answers']);
|
||||||
|
|
||||||
$qnID = trim($body['questionnaireID']);
|
$qnID = trim($body['questionnaireID']);
|
||||||
@ -126,7 +181,6 @@ if ($method === 'POST') {
|
|||||||
|
|
||||||
$pdo->beginTransaction();
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
$sumPoints = 0;
|
|
||||||
$glassByParent = [];
|
$glassByParent = [];
|
||||||
$submittedQuestionIds = [];
|
$submittedQuestionIds = [];
|
||||||
$answerInsert = $pdo->prepare("
|
$answerInsert = $pdo->prepare("
|
||||||
@ -183,19 +237,11 @@ if ($method === 'POST') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve option key to answerOptionID and accumulate points
|
// Resolve option key to answerOptionID (points computed by scoring engine after save)
|
||||||
$optionKey = $a['answerOptionKey'] ?? null;
|
$optionKey = $a['answerOptionKey'] ?? null;
|
||||||
if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) {
|
if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) {
|
||||||
$opt = $optionMap[$fullQID][(string)$optionKey];
|
$opt = $optionMap[$fullQID][(string)$optionKey];
|
||||||
$answerOptionID = $opt['answerOptionID'];
|
$answerOptionID = $opt['answerOptionID'];
|
||||||
$sumPoints += $opt['points'];
|
|
||||||
} elseif (($shortIdToType[$shortId] ?? '') === 'multi_check_box_question'
|
|
||||||
&& $freeTextValue !== null && $freeTextValue !== '') {
|
|
||||||
foreach (qdb_decode_multi_check_values($freeTextValue) as $mk) {
|
|
||||||
if (isset($optionMap[$fullQID][$mk])) {
|
|
||||||
$sumPoints += $optionMap[$fullQID][$mk]['points'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$hasValue = $answerOptionID !== null
|
$hasValue = $answerOptionID !== null
|
||||||
@ -247,6 +293,9 @@ if ($method === 'POST') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Upsert completed_questionnaire record
|
// Upsert completed_questionnaire record
|
||||||
|
require_once __DIR__ . '/../lib/scoring.php';
|
||||||
|
$sumPoints = qdb_compute_questionnaire_score($pdo, $clientCode, $qnID);
|
||||||
|
|
||||||
$pdo->prepare("
|
$pdo->prepare("
|
||||||
INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints)
|
INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints)
|
||||||
VALUES (:cc, :qn, :abc, 'completed', :sa, :ca, :sp)
|
VALUES (:cc, :qn, :abc, 'completed', :sa, :ca, :sp)
|
||||||
@ -277,6 +326,8 @@ if ($method === 'POST') {
|
|||||||
$assignedByCoach
|
$assignedByCoach
|
||||||
);
|
);
|
||||||
|
|
||||||
|
qdb_recompute_profile_scores_for_client($pdo, $clientCode, $qnID);
|
||||||
|
|
||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
|
|
||||||
@ -298,8 +349,50 @@ $translations = $_GET['translations'] ?? '';
|
|||||||
$fetchClients = $_GET['clients'] ?? '';
|
$fetchClients = $_GET['clients'] ?? '';
|
||||||
$fetchAnswers = $_GET['answers'] ?? '';
|
$fetchAnswers = $_GET['answers'] ?? '';
|
||||||
$fetchAnswersBulk = $_GET['answersBulk'] ?? '';
|
$fetchAnswersBulk = $_GET['answersBulk'] ?? '';
|
||||||
|
$fetchScoringProfiles = $_GET['scoringProfiles'] ?? '';
|
||||||
|
$fetchScoringReview = $_GET['scoringReview'] ?? '';
|
||||||
$clientCode = trim($_GET['clientCode'] ?? '');
|
$clientCode = trim($_GET['clientCode'] ?? '');
|
||||||
|
|
||||||
|
if ($fetchScoringProfiles) {
|
||||||
|
require_role(['coach'], $tokenRec);
|
||||||
|
require_once __DIR__ . '/../lib/scoring.php';
|
||||||
|
$profiles = array_values(array_filter(
|
||||||
|
qdb_list_scoring_profiles($pdo),
|
||||||
|
static fn(array $p): bool => (int)($p['isActive'] ?? 0) === 1
|
||||||
|
));
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_success_sensitive(['profiles' => $profiles], $tokenRec['_token']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($fetchScoringReview) {
|
||||||
|
require_role(['coach'], $tokenRec);
|
||||||
|
require_once __DIR__ . '/../lib/scoring.php';
|
||||||
|
|
||||||
|
$coachID = $tokenRec['entityID'] ?? '';
|
||||||
|
if ($clientCode !== '') {
|
||||||
|
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||||
|
$clStmt = $pdo->prepare(
|
||||||
|
"SELECT cl.clientCode FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)"
|
||||||
|
);
|
||||||
|
$clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
|
||||||
|
if (!$clStmt->fetch()) {
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
|
||||||
|
}
|
||||||
|
$clientCodes = [$clientCode];
|
||||||
|
} else {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT clientCode FROM client WHERE coachID = :cid ORDER BY clientCode'
|
||||||
|
);
|
||||||
|
$stmt->execute([':cid' => $coachID]);
|
||||||
|
$clientCodes = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
}
|
||||||
|
|
||||||
|
$clients = qdb_app_scoring_review_for_clients($pdo, $clientCodes);
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_success_sensitive(['clients' => $clients], $tokenRec['_token']);
|
||||||
|
}
|
||||||
|
|
||||||
if ($fetchAnswersBulk) {
|
if ($fetchAnswersBulk) {
|
||||||
require_role(['coach'], $tokenRec);
|
require_role(['coach'], $tokenRec);
|
||||||
require_once __DIR__ . '/../lib/app_answers.php';
|
require_once __DIR__ . '/../lib/app_answers.php';
|
||||||
@ -441,6 +534,14 @@ if ($qnID) {
|
|||||||
$q['options'] = $config['valueOptions'];
|
$q['options'] = $config['valueOptions'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (in_array($layout, ['glass_scale_question', 'slider_question', 'value_spinner'], true)) {
|
||||||
|
require_once __DIR__ . '/../lib/scoring.php';
|
||||||
|
$ruleMap = qdb_score_rule_map_for_question($pdo, $dbQ['questionID']);
|
||||||
|
if ($ruleMap !== []) {
|
||||||
|
$q['scoreRuleMap'] = $ruleMap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$ao = $pdo->prepare("
|
$ao = $pdo->prepare("
|
||||||
SELECT defaultText, points, nextQuestionId
|
SELECT defaultText, points, nextQuestionId
|
||||||
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
|
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
|
||||||
|
|||||||
@ -36,8 +36,21 @@ case 'GET':
|
|||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$clients = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
$clients = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../lib/submissions.php';
|
||||||
|
$scoringSummary = qdb_clients_scoring_summary_for_list($pdo, $tokenRec);
|
||||||
|
foreach ($clients as &$client) {
|
||||||
|
$client['scoringProfiles'] = qdb_client_scoring_dots_for_client(
|
||||||
|
$client['clientCode'],
|
||||||
|
$scoringSummary
|
||||||
|
);
|
||||||
|
}
|
||||||
|
unset($client);
|
||||||
|
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
json_success(['clients' => $clients]);
|
json_success([
|
||||||
|
'clients' => $clients,
|
||||||
|
'scoringProfiles' => $scoringSummary['profiles'],
|
||||||
|
]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_handler_fail($e, 'clients', null, $tmpDb ?? null, $lockFp ?? null);
|
qdb_handler_fail($e, 'clients', null, $tmpDb ?? null, $lockFp ?? null);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../lib/scoring.php';
|
||||||
|
|
||||||
$tokenRec = require_valid_token_web();
|
$tokenRec = require_valid_token_web();
|
||||||
|
|
||||||
switch ($method) {
|
switch ($method) {
|
||||||
@ -39,7 +41,9 @@ case 'GET':
|
|||||||
}
|
}
|
||||||
unset($opt);
|
unset($opt);
|
||||||
if (($q['type'] ?? '') === 'glass_scale_question') {
|
if (($q['type'] ?? '') === 'glass_scale_question') {
|
||||||
$q['glassSymptoms'] = qdb_glass_symptoms_with_labels($pdo, $cfg);
|
$q['glassSymptoms'] = qdb_glass_symptoms_with_score_rules($pdo, $q['questionID'], $cfg);
|
||||||
|
} elseif (in_array($q['type'] ?? '', ['slider_question', 'value_spinner'], true)) {
|
||||||
|
$q['scoreRules'] = qdb_get_score_rules_for_question($pdo, $q['questionID']);
|
||||||
}
|
}
|
||||||
$tr = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :qid");
|
$tr = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :qid");
|
||||||
$tr->execute([':qid' => $q['questionID']]);
|
$tr->execute([':qid' => $q['questionID']]);
|
||||||
@ -111,6 +115,10 @@ case 'POST':
|
|||||||
if ($type === 'glass_scale_question') {
|
if ($type === 'glass_scale_question') {
|
||||||
qdb_sync_glass_symptom_strings($pdo, $glassSymptoms);
|
qdb_sync_glass_symptom_strings($pdo, $glassSymptoms);
|
||||||
}
|
}
|
||||||
|
$scoreRules = qdb_parse_score_rules_body($body, $type, $config);
|
||||||
|
if ($scoreRules !== [] || in_array($type, ['slider_question', 'value_spinner', 'glass_scale_question'], true)) {
|
||||||
|
qdb_sync_question_score_rules($pdo, $id, $scoreRules);
|
||||||
|
}
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success(['question' => [
|
json_success(['question' => [
|
||||||
'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text,
|
'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text,
|
||||||
@ -118,7 +126,9 @@ case 'POST':
|
|||||||
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
|
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
|
||||||
'configJson' => $configJson, 'answerOptions' => [], 'translations' => [],
|
'configJson' => $configJson, 'answerOptions' => [], 'translations' => [],
|
||||||
'glassSymptoms' => $type === 'glass_scale_question'
|
'glassSymptoms' => $type === 'glass_scale_question'
|
||||||
? qdb_glass_symptoms_with_labels($pdo, $config) : [],
|
? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [],
|
||||||
|
'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true)
|
||||||
|
? qdb_get_score_rules_for_question($pdo, $id) : [],
|
||||||
]]);
|
]]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
|
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
|
||||||
@ -172,6 +182,10 @@ case 'PUT':
|
|||||||
if ($type === 'glass_scale_question') {
|
if ($type === 'glass_scale_question') {
|
||||||
qdb_sync_glass_symptom_strings($pdo, $glassSymptoms);
|
qdb_sync_glass_symptom_strings($pdo, $glassSymptoms);
|
||||||
}
|
}
|
||||||
|
$scoreRules = qdb_parse_score_rules_body($body, $type, $config);
|
||||||
|
if ($scoreRules !== [] || in_array($type, ['slider_question', 'value_spinner', 'glass_scale_question'], true)) {
|
||||||
|
qdb_sync_question_score_rules($pdo, $id, $scoreRules);
|
||||||
|
}
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success(['question' => [
|
json_success(['question' => [
|
||||||
'questionID' => $id, 'questionnaireID' => $row['questionnaireID'],
|
'questionID' => $id, 'questionnaireID' => $row['questionnaireID'],
|
||||||
@ -179,7 +193,9 @@ case 'PUT':
|
|||||||
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
|
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
|
||||||
'configJson' => $configJson,
|
'configJson' => $configJson,
|
||||||
'glassSymptoms' => $type === 'glass_scale_question'
|
'glassSymptoms' => $type === 'glass_scale_question'
|
||||||
? qdb_glass_symptoms_with_labels($pdo, $config) : [],
|
? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [],
|
||||||
|
'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true)
|
||||||
|
? qdb_get_score_rules_for_question($pdo, $id) : [],
|
||||||
]]);
|
]]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
|
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
|
||||||
@ -203,6 +219,7 @@ case 'DELETE':
|
|||||||
$pdo->prepare("DELETE FROM answer_option_translation WHERE answerOptionID = :id")->execute([':id' => $aoid]);
|
$pdo->prepare("DELETE FROM answer_option_translation WHERE answerOptionID = :id")->execute([':id' => $aoid]);
|
||||||
}
|
}
|
||||||
$pdo->prepare("DELETE FROM answer_option WHERE questionID = :id")->execute([':id' => $id]);
|
$pdo->prepare("DELETE FROM answer_option WHERE questionID = :id")->execute([':id' => $id]);
|
||||||
|
$pdo->prepare("DELETE FROM question_score_rule WHERE questionID = :id")->execute([':id' => $id]);
|
||||||
$pdo->prepare("DELETE FROM question_translation WHERE questionID = :id")->execute([':id' => $id]);
|
$pdo->prepare("DELETE FROM question_translation WHERE questionID = :id")->execute([':id' => $id]);
|
||||||
$pdo->prepare("DELETE FROM client_answer WHERE questionID = :id")->execute([':id' => $id]);
|
$pdo->prepare("DELETE FROM client_answer WHERE questionID = :id")->execute([':id' => $id]);
|
||||||
$pdo->prepare("DELETE FROM question WHERE questionID = :id")->execute([':id' => $id]);
|
$pdo->prepare("DELETE FROM question WHERE questionID = :id")->execute([':id' => $id]);
|
||||||
|
|||||||
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,
|
'submissionsLast30d' => $submissions30d,
|
||||||
'questionnaires' => $perQuestionnaire,
|
'questionnaires' => $perQuestionnaire,
|
||||||
'submissionsByDay' => qdb_analytics_submissions_by_day($pdo, $tokenRec, 14),
|
'submissionsByDay' => qdb_analytics_submissions_by_day($pdo, $tokenRec, 14),
|
||||||
|
'scoringProfiles' => qdb_analytics_scoring_profiles($pdo, $tokenRec),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Band distribution and averages per active scoring profile (RBAC-scoped).
|
||||||
|
*
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
function qdb_analytics_scoring_profiles(PDO $pdo, array $tokenRec): array {
|
||||||
|
require_once __DIR__ . '/scoring.php';
|
||||||
|
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||||
|
|
||||||
|
$profiles = $pdo->query(
|
||||||
|
"SELECT profileID, name, description,
|
||||||
|
greenMin, greenMax, yellowMin, yellowMax, redMin, isActive
|
||||||
|
FROM scoring_profile WHERE isActive = 1 ORDER BY name"
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($profiles as $p) {
|
||||||
|
$profileID = $p['profileID'];
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT r.band, r.coachBand, r.weightedTotal
|
||||||
|
FROM client_scoring_profile_result r
|
||||||
|
INNER JOIN client cl ON cl.clientCode = r.clientCode
|
||||||
|
WHERE r.profileID = :pid AND ($rbacClause)"
|
||||||
|
);
|
||||||
|
$stmt->execute(array_merge([':pid' => $profileID], $rbacParams));
|
||||||
|
$computedBands = ['green' => 0, 'yellow' => 0, 'red' => 0];
|
||||||
|
$coachBands = ['green' => 0, 'yellow' => 0, 'red' => 0];
|
||||||
|
$pendingReview = 0;
|
||||||
|
$totalSum = 0.0;
|
||||||
|
$count = 0;
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$calcBand = $row['band'] ?? '';
|
||||||
|
if (isset($computedBands[$calcBand])) {
|
||||||
|
$computedBands[$calcBand]++;
|
||||||
|
}
|
||||||
|
$coachBand = trim((string)($row['coachBand'] ?? ''));
|
||||||
|
if ($coachBand === '') {
|
||||||
|
$pendingReview++;
|
||||||
|
} elseif (isset($coachBands[$coachBand])) {
|
||||||
|
$coachBands[$coachBand]++;
|
||||||
|
}
|
||||||
|
$totalSum += (float)$row['weightedTotal'];
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
$members = qdb_scoring_profile_members($pdo, $profileID);
|
||||||
|
$bandRanges = qdb_normalize_scoring_bands($p);
|
||||||
|
$out[] = [
|
||||||
|
'profileID' => $profileID,
|
||||||
|
'name' => $p['name'],
|
||||||
|
'description' => $p['description'] ?? '',
|
||||||
|
'greenMin' => $bandRanges['greenMin'],
|
||||||
|
'greenMax' => $bandRanges['greenMax'],
|
||||||
|
'yellowMin' => $bandRanges['yellowMin'],
|
||||||
|
'yellowMax' => $bandRanges['yellowMax'],
|
||||||
|
'redMin' => $bandRanges['redMin'],
|
||||||
|
'questionnaires' => $members,
|
||||||
|
'clientCount' => $count,
|
||||||
|
'bands' => $computedBands,
|
||||||
|
'computedBands' => $computedBands,
|
||||||
|
'coachBands' => $coachBands,
|
||||||
|
'pendingReview' => $pendingReview,
|
||||||
|
'averageTotal' => $count > 0 ? round($totalSum / $count, 2) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
function qdb_analytics_stale_clients(PDO $pdo, array $tokenRec): array {
|
function qdb_analytics_stale_clients(PDO $pdo, array $tokenRec): array {
|
||||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||||
$now = time();
|
$now = time();
|
||||||
|
|||||||
757
lib/scoring.php
Normal file
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'] ?? '',
|
'supervisorUsername' => $client['supervisorUsername'] ?? '',
|
||||||
],
|
],
|
||||||
'questionnaires' => $questionnaires,
|
'questionnaires' => $questionnaires,
|
||||||
|
'scoringProfiles' => qdb_client_scoring_profile_results($pdo, $clientCode),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function qdb_client_scoring_profile_results(PDO $pdo, string $clientCode): array {
|
||||||
|
require_once __DIR__ . '/scoring.php';
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT r.profileID, r.weightedTotal, r.band, r.computedAt, r.questionnaireSnapshot,
|
||||||
|
r.coachBand, r.coachReviewedAt, r.coachReviewedByUserID,
|
||||||
|
sp.name, sp.greenMin, sp.greenMax, sp.yellowMin, sp.yellowMax, sp.redMin
|
||||||
|
FROM client_scoring_profile_result r
|
||||||
|
JOIN scoring_profile sp ON sp.profileID = r.profileID
|
||||||
|
WHERE r.clientCode = :cc
|
||||||
|
ORDER BY sp.name'
|
||||||
|
);
|
||||||
|
$stmt->execute([':cc' => $clientCode]);
|
||||||
|
$out = [];
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$bands = qdb_normalize_scoring_bands($row);
|
||||||
|
$coachBand = trim((string)($row['coachBand'] ?? ''));
|
||||||
|
$out[] = [
|
||||||
|
'profileID' => $row['profileID'],
|
||||||
|
'name' => $row['name'],
|
||||||
|
'weightedTotal' => (float)$row['weightedTotal'],
|
||||||
|
'band' => $row['band'],
|
||||||
|
'calculatedBand' => $row['band'],
|
||||||
|
'coachBand' => $coachBand !== '' ? $coachBand : null,
|
||||||
|
'effectiveBand' => qdb_effective_scoring_band($row),
|
||||||
|
'pendingReview' => $coachBand === '',
|
||||||
|
'greenMin' => $bands['greenMin'],
|
||||||
|
'greenMax' => $bands['greenMax'],
|
||||||
|
'yellowMin' => $bands['yellowMin'],
|
||||||
|
'yellowMax' => $bands['yellowMax'],
|
||||||
|
'redMin' => $bands['redMin'],
|
||||||
|
'computedAt' => $row['computedAt'] ? date('Y-m-d H:i', (int)$row['computedAt']) : '',
|
||||||
|
'coachReviewedAt' => !empty($row['coachReviewedAt'])
|
||||||
|
? date('Y-m-d H:i', (int)$row['coachReviewedAt']) : '',
|
||||||
|
'snapshot' => json_decode($row['questionnaireSnapshot'] ?? '{}', true) ?: [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active scoring profiles + per-client results for the clients list (RBAC-scoped).
|
||||||
|
*
|
||||||
|
* @return array{profiles: list<array{profileID: string, name: string}>, byClient: array<string, array<string, array{band: string, weightedTotal: float, name: string}>>}
|
||||||
|
*/
|
||||||
|
function qdb_clients_scoring_summary_for_list(PDO $pdo, array $tokenRec): array {
|
||||||
|
require_once __DIR__ . '/scoring.php';
|
||||||
|
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||||
|
|
||||||
|
$profiles = $pdo->query(
|
||||||
|
"SELECT profileID, name FROM scoring_profile WHERE isActive = 1 ORDER BY name"
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($profiles === []) {
|
||||||
|
return ['profiles' => [], 'byClient' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT r.clientCode, r.profileID, r.band, r.coachBand, r.weightedTotal, sp.name
|
||||||
|
FROM client_scoring_profile_result r
|
||||||
|
INNER JOIN scoring_profile sp ON sp.profileID = r.profileID AND sp.isActive = 1
|
||||||
|
INNER JOIN client cl ON cl.clientCode = r.clientCode
|
||||||
|
WHERE ($rbacClause)"
|
||||||
|
);
|
||||||
|
$stmt->execute($rbacParams);
|
||||||
|
|
||||||
|
$byClient = [];
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$cc = (string)$row['clientCode'];
|
||||||
|
$coachBand = trim((string)($row['coachBand'] ?? ''));
|
||||||
|
$byClient[$cc][$row['profileID']] = [
|
||||||
|
'band' => (string)$row['band'],
|
||||||
|
'calculatedBand' => (string)$row['band'],
|
||||||
|
'coachBand' => $coachBand !== '' ? $coachBand : null,
|
||||||
|
'effectiveBand' => qdb_effective_scoring_band($row),
|
||||||
|
'pendingReview' => $coachBand === '',
|
||||||
|
'weightedTotal' => (float)$row['weightedTotal'],
|
||||||
|
'name' => (string)$row['name'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'profiles' => array_map(static fn(array $p) => [
|
||||||
|
'profileID' => (string)$p['profileID'],
|
||||||
|
'name' => (string)$p['name'],
|
||||||
|
], $profiles),
|
||||||
|
'byClient' => $byClient,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{profileID: string, name: string, band: ?string, weightedTotal: ?float}>
|
||||||
|
*/
|
||||||
|
function qdb_client_scoring_dots_for_client(string $clientCode, array $summary): array {
|
||||||
|
$out = [];
|
||||||
|
foreach ($summary['profiles'] as $profile) {
|
||||||
|
$result = $summary['byClient'][$clientCode][$profile['profileID']] ?? null;
|
||||||
|
$out[] = [
|
||||||
|
'profileID' => $profile['profileID'],
|
||||||
|
'name' => $profile['name'],
|
||||||
|
'band' => $result['calculatedBand'] ?? null,
|
||||||
|
'calculatedBand' => $result['calculatedBand'] ?? null,
|
||||||
|
'coachBand' => $result['coachBand'] ?? null,
|
||||||
|
'effectiveBand' => $result['effectiveBand'] ?? null,
|
||||||
|
'pendingReview' => $result['pendingReview'] ?? false,
|
||||||
|
'weightedTotal' => $result !== null ? $result['weightedTotal'] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|||||||
55
schema.sql
55
schema.sql
@ -184,3 +184,58 @@ CREATE TABLE IF NOT EXISTS client_followup_note (
|
|||||||
FOREIGN KEY(clientCode) REFERENCES client(clientCode)
|
FOREIGN KEY(clientCode) REFERENCES client(clientCode)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS question_score_rule (
|
||||||
|
ruleID TEXT NOT NULL PRIMARY KEY,
|
||||||
|
questionID TEXT NOT NULL,
|
||||||
|
scopeKey TEXT NOT NULL DEFAULT '',
|
||||||
|
levelKey TEXT NOT NULL,
|
||||||
|
points INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY(questionID) REFERENCES question(questionID) ON DELETE CASCADE,
|
||||||
|
UNIQUE(questionID, scopeKey, levelKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_score_rule_question
|
||||||
|
ON question_score_rule(questionID);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scoring_profile (
|
||||||
|
profileID TEXT NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
isActive INTEGER NOT NULL DEFAULT 1,
|
||||||
|
greenMin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
greenMax INTEGER NOT NULL DEFAULT 12,
|
||||||
|
yellowMin INTEGER NOT NULL DEFAULT 13,
|
||||||
|
yellowMax INTEGER NOT NULL DEFAULT 36,
|
||||||
|
redMin INTEGER NOT NULL DEFAULT 37,
|
||||||
|
createdAt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updatedAt INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scoring_profile_questionnaire (
|
||||||
|
profileID TEXT NOT NULL,
|
||||||
|
questionnaireID TEXT NOT NULL,
|
||||||
|
weight REAL NOT NULL DEFAULT 1.0,
|
||||||
|
orderIndex INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (profileID, questionnaireID),
|
||||||
|
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS client_scoring_profile_result (
|
||||||
|
clientCode TEXT NOT NULL,
|
||||||
|
profileID TEXT NOT NULL,
|
||||||
|
weightedTotal REAL NOT NULL DEFAULT 0,
|
||||||
|
band TEXT NOT NULL DEFAULT '',
|
||||||
|
computedAt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
questionnaireSnapshot TEXT NOT NULL DEFAULT '{}',
|
||||||
|
coachBand TEXT NOT NULL DEFAULT '',
|
||||||
|
coachReviewedAt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
coachReviewedByUserID TEXT NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (clientCode, profileID),
|
||||||
|
FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_client_profile_result_profile
|
||||||
|
ON client_scoring_profile_result(profileID);
|
||||||
|
|
||||||
|
|||||||
211
tests/Unit/ScoringTest.php
Normal file
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-size: .85rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
.glass-score-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.glass-score-cell label {
|
||||||
|
display: block;
|
||||||
|
font-size: .7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.glass-score-cell input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.value-score-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.value-score-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
.value-score-label {
|
||||||
|
min-width: 2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.value-score-pts {
|
||||||
|
width: 4rem;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog-wide {
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scoring profiles on questionnaire dashboard */
|
||||||
|
#scoringProfilesPanel {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
.scoring-profiles-section {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.scoring-profiles-card {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.scoring-section-title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.scoring-section-desc,
|
||||||
|
.scoring-empty-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: .875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.scoring-empty-hint {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.scoring-profiles-list {
|
||||||
|
margin-top: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.scoring-profile-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px 18px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.scoring-profile-card-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.scoring-profile-card-main {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.scoring-profile-name-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.scoring-profile-name-row strong {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.scoring-profile-desc {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: .875rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.scoring-profile-bands {
|
||||||
|
margin: 0;
|
||||||
|
font-size: .85rem;
|
||||||
|
color: var(--primary-text);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
}
|
||||||
|
.scoring-profile-member-count {
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.scoring-profile-members {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
.scoring-profile-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.scoring-profile-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.scoring-modal-section-title {
|
||||||
|
margin: 18px 0 0;
|
||||||
|
font-size: .95rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.scoring-profile-active-row {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.checkbox-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: .9rem;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.scoring-profile-picker {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
.scoring-profile-picker input,
|
||||||
|
.scoring-profile-picker select {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: .875rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.scoring-profile-picker input:focus,
|
||||||
|
.scoring-profile-picker select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||||
|
}
|
||||||
|
.scoring-profile-add-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.scoring-profile-add-row select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.scoring-band-labels {
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
min-height: 1.5rem;
|
||||||
|
}
|
||||||
|
.scoring-band-ranges-table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.scoring-band-range-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100px 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.scoring-band-range-row.scoring-band-range-header {
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.scoring-band-range-row input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: .875rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.scoring-band-range-row input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||||
|
}
|
||||||
|
.scoring-band-max-placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.scoring-band-strips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.scoring-profile-q-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.scoring-profile-q-row:last-child { border-bottom: none; }
|
||||||
|
.scoring-profile-q-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.scoring-profile-q-weight {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.scoring-profile-q-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.sp-weight-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.sp-weight-wrap input {
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
.band-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
.band-badge.band-green { background: #14532d; color: #bbf7d0; border: 1px solid #166534; }
|
||||||
|
.band-badge.band-yellow { background: #713f12; color: #fef08a; border: 1px solid #854d0e; }
|
||||||
|
.band-badge.band-red { background: #7f1d1d; color: #fecaca; border: 1px solid #991b1b; }
|
||||||
|
.band-badge.band-none { background: #334155; color: #cbd5e1; border: 1px solid #475569; }
|
||||||
|
|
||||||
|
/* Client list — scoring profiles */
|
||||||
|
.client-scoring-legend {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-elevated, rgba(0, 0, 0, 0.02));
|
||||||
|
}
|
||||||
|
.client-scoring-legend-title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
.client-scoring-legend-body {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.client-scoring-legend-body .band-badge {
|
||||||
|
margin: 0 2px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.client-scoring-legend-dl {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px 16px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: .8125rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.client-scoring-legend-dl {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.client-scoring-legend-dl dt {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.client-scoring-legend-dl dd {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.client-profile-scores {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.client-profile-score-block {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-elevated, rgba(0, 0, 0, 0.02));
|
||||||
|
font-size: .8125rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.client-profile-score-block.is-incomplete {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.client-profile-score-block.has-coach-override {
|
||||||
|
border-color: rgba(251, 191, 36, 0.45);
|
||||||
|
}
|
||||||
|
.client-profile-score-block-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.client-profile-score-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.client-profile-score-row:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.client-profile-score-label {
|
||||||
|
flex: 0 0 4.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .03em;
|
||||||
|
}
|
||||||
|
.client-profile-score-total {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: .75rem;
|
||||||
|
}
|
||||||
|
.client-profile-score-total::before {
|
||||||
|
content: "total ";
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.client-profile-override-hint {
|
||||||
|
font-size: .7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: #ca8a04;
|
||||||
|
}
|
||||||
|
.client-profile-score-hint,
|
||||||
|
.client-profile-score-meta {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: .75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.client-scoring-profiles-detail {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.scoring-profile-detail-card {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.client-profile-dots-cell {
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 320px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.client-profile-dots-empty {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ const PAGE_SIZE = 50;
|
|||||||
|
|
||||||
let clientsList = [];
|
let clientsList = [];
|
||||||
let coachesList = [];
|
let coachesList = [];
|
||||||
|
let scoringProfileCatalog = [];
|
||||||
let filterSearch = '';
|
let filterSearch = '';
|
||||||
let filterTestData = '';
|
let filterTestData = '';
|
||||||
let page = 0;
|
let page = 0;
|
||||||
@ -42,6 +43,7 @@ async function loadClients() {
|
|||||||
apiGet('assignments.php'),
|
apiGet('assignments.php'),
|
||||||
]);
|
]);
|
||||||
clientsList = clientsData.clients || [];
|
clientsList = clientsData.clients || [];
|
||||||
|
scoringProfileCatalog = clientsData.scoringProfiles || [];
|
||||||
coachesList = assignData.coaches || [];
|
coachesList = assignData.coaches || [];
|
||||||
renderClients();
|
renderClients();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -65,6 +67,9 @@ function renderClients() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showProfileColumn = scoringProfileCatalog.length > 0;
|
||||||
|
const profileLegend = showProfileColumn ? scoringLegendHTML() : '';
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
@ -76,6 +81,7 @@ function renderClients() {
|
|||||||
</select>
|
</select>
|
||||||
<span class="data-toolbar-hint" id="clientListCount"></span>
|
<span class="data-toolbar-hint" id="clientListCount"></span>
|
||||||
</div>
|
</div>
|
||||||
|
${profileLegend}
|
||||||
<p class="data-toolbar-hint" style="margin:0 0 12px">
|
<p class="data-toolbar-hint" style="margin:0 0 12px">
|
||||||
Expand a row to view questionnaire responses and upload version history.
|
Expand a row to view questionnaire responses and upload version history.
|
||||||
</p>
|
</p>
|
||||||
@ -85,6 +91,7 @@ function renderClients() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Client Code</th>
|
<th>Client Code</th>
|
||||||
<th>Current Coach</th>
|
<th>Current Coach</th>
|
||||||
|
${showProfileColumn ? '<th>Profile scores</th>' : ''}
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -132,6 +139,7 @@ function renderClientTableBody() {
|
|||||||
const countEl = document.getElementById('clientListCount');
|
const countEl = document.getElementById('clientListCount');
|
||||||
const filtered = getFilteredClientsList();
|
const filtered = getFilteredClientsList();
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
|
const colCount = scoringProfileCatalog.length > 0 ? 4 : 3;
|
||||||
if (page >= totalPages) page = totalPages - 1;
|
if (page >= totalPages) page = totalPages - 1;
|
||||||
const slice = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
const slice = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||||
|
|
||||||
@ -140,7 +148,7 @@ function renderClientTableBody() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!slice.length) {
|
if (!slice.length) {
|
||||||
body.innerHTML = `<tr><td colspan="3" style="text-align:center;color:var(--text-secondary);padding:24px">No clients match</td></tr>`;
|
body.innerHTML = `<tr><td colspan="${colCount}" style="text-align:center;color:var(--text-secondary);padding:24px">No clients match</td></tr>`;
|
||||||
} else {
|
} else {
|
||||||
body.innerHTML = slice.map(c => clientRowHTML(c)).join('');
|
body.innerHTML = slice.map(c => clientRowHTML(c)).join('');
|
||||||
body.querySelectorAll('.client-expand-btn').forEach(btn => {
|
body.querySelectorAll('.client-expand-btn').forEach(btn => {
|
||||||
@ -243,9 +251,103 @@ function clientDetailPanelHTML(clientCode) {
|
|||||||
Coach: ${esc(cl.coachUsername || '—')}
|
Coach: ${esc(cl.coachUsername || '—')}
|
||||||
${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''}
|
${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''}
|
||||||
</p>
|
</p>
|
||||||
|
${renderClientScoringProfiles(detail.scoringProfiles || [])}
|
||||||
${questionnaires.map(q => clientQnBlockHTML(clientCode, q)).join('')}`;
|
${questionnaires.map(q => clientQnBlockHTML(clientCode, q)).join('')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scoringLegendHTML() {
|
||||||
|
return `
|
||||||
|
<div class="client-scoring-legend">
|
||||||
|
<p class="client-scoring-legend-title"><strong>How profile scores work</strong></p>
|
||||||
|
<p class="data-toolbar-hint client-scoring-legend-body">
|
||||||
|
Each profile adds up questionnaire points (× weight) into a <strong>weighted total</strong>,
|
||||||
|
then maps that total to <span class="band-badge band-green">green</span>
|
||||||
|
<span class="band-badge band-yellow">yellow</span>
|
||||||
|
<span class="band-badge band-red">red</span> using the thresholds on the Questionnaires page.
|
||||||
|
</p>
|
||||||
|
<dl class="client-scoring-legend-dl">
|
||||||
|
<div>
|
||||||
|
<dt>Calculated</dt>
|
||||||
|
<dd>Automatic category from answers and profile rules (shown before coach review).</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Coach</dt>
|
||||||
|
<dd>Category confirmed or changed by the coach in the app (“Review scores”).</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Incomplete</dt>
|
||||||
|
<dd>Not all questionnaires in that profile are completed yet.</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandBadgeHTML(band, { pending = false, incomplete = false, label = '' } = {}) {
|
||||||
|
const text = label || band || '';
|
||||||
|
if (incomplete) {
|
||||||
|
return '<span class="band-badge band-none">incomplete</span>';
|
||||||
|
}
|
||||||
|
if (pending) {
|
||||||
|
return '<span class="band-badge band-none">awaiting review</span>';
|
||||||
|
}
|
||||||
|
if (!band) {
|
||||||
|
return '<span class="band-badge band-none">—</span>';
|
||||||
|
}
|
||||||
|
return `<span class="band-badge band-${esc(band)}">${esc(text || band)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileScoreBlockHTML(p) {
|
||||||
|
const calcBand = p.calculatedBand || p.band;
|
||||||
|
if (!calcBand) {
|
||||||
|
return `
|
||||||
|
<div class="client-profile-score-block is-incomplete">
|
||||||
|
<div class="client-profile-score-block-title">${esc(p.name)}</div>
|
||||||
|
<p class="client-profile-score-hint">Profile incomplete — not all member questionnaires are done.</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
const coachPending = p.pendingReview !== false && !p.coachBand;
|
||||||
|
const coachDiffers = p.coachBand && p.coachBand !== calcBand;
|
||||||
|
return `
|
||||||
|
<div class="client-profile-score-block${coachDiffers ? ' has-coach-override' : ''}">
|
||||||
|
<div class="client-profile-score-block-title">${esc(p.name)}</div>
|
||||||
|
<div class="client-profile-score-row">
|
||||||
|
<span class="client-profile-score-label">Calculated</span>
|
||||||
|
${bandBadgeHTML(calcBand)}
|
||||||
|
<span class="client-profile-score-total" title="Weighted total">${esc(String(p.weightedTotal))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="client-profile-score-row">
|
||||||
|
<span class="client-profile-score-label">Coach</span>
|
||||||
|
${coachPending ? bandBadgeHTML(null, { pending: true }) : bandBadgeHTML(p.coachBand)}
|
||||||
|
${coachDiffers ? '<span class="client-profile-override-hint">override</span>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderClientScoringProfiles(profiles) {
|
||||||
|
if (!profiles.length) return '';
|
||||||
|
return `
|
||||||
|
<div class="client-scoring-profiles-detail">
|
||||||
|
<strong>Scoring profiles</strong>
|
||||||
|
<p class="data-toolbar-hint" style="margin:4px 0 10px">
|
||||||
|
Weighted totals and calculated vs coach categories for this client.
|
||||||
|
</p>
|
||||||
|
<div class="client-profile-scores">${profiles.map(p => {
|
||||||
|
const calc = p.calculatedBand || p.band;
|
||||||
|
if (!calc) return profileScoreBlockHTML(p);
|
||||||
|
const coachPending = p.pendingReview !== false && !p.coachBand;
|
||||||
|
const computedAt = p.computedAt ? `Last computed ${esc(p.computedAt)}` : '';
|
||||||
|
const reviewedAt = p.coachReviewedAt ? `Coach reviewed ${esc(p.coachReviewedAt)}` : '';
|
||||||
|
const meta = [computedAt, reviewedAt].filter(Boolean).join(' · ');
|
||||||
|
return `
|
||||||
|
<div class="scoring-profile-detail-card">
|
||||||
|
${profileScoreBlockHTML(p)}
|
||||||
|
${meta ? `<p class="client-profile-score-meta">${meta}</p>` : ''}
|
||||||
|
${coachPending ? '<p class="client-profile-score-hint">Coach has not reviewed this profile in the app yet.</p>' : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('')}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function clientQnBlockHTML(clientCode, q) {
|
function clientQnBlockHTML(clientCode, q) {
|
||||||
const key = qnKey(clientCode, q.questionnaireID);
|
const key = qnKey(clientCode, q.questionnaireID);
|
||||||
const qnExpanded = expandedQnKey === key;
|
const qnExpanded = expandedQnKey === key;
|
||||||
@ -311,6 +413,17 @@ function clientQnBlockHTML(clientCode, q) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function profileDotsHTML(profiles) {
|
||||||
|
if (!profiles?.length) {
|
||||||
|
return '<span class="client-profile-dots-empty">—</span>';
|
||||||
|
}
|
||||||
|
const hasAny = profiles.some(p => p.calculatedBand || p.band);
|
||||||
|
if (!hasAny) {
|
||||||
|
return `<span class="client-profile-dots-empty" title="No complete scoring profiles yet">—</span>`;
|
||||||
|
}
|
||||||
|
return `<div class="client-profile-scores">${profiles.map(p => profileScoreBlockHTML(p)).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function clientHasResponseData(c) {
|
function clientHasResponseData(c) {
|
||||||
return Number(c.hasResponseData) === 1;
|
return Number(c.hasResponseData) === 1;
|
||||||
}
|
}
|
||||||
@ -324,6 +437,10 @@ function clientRowHTML(c) {
|
|||||||
const expandControl = canExpand
|
const expandControl = canExpand
|
||||||
? `<button type="button" class="btn btn-sm btn-link client-expand-btn" data-code="${esc(c.clientCode)}">${expanded ? '▼' : '▶'}</button> `
|
? `<button type="button" class="btn btn-sm btn-link client-expand-btn" data-code="${esc(c.clientCode)}">${expanded ? '▼' : '▶'}</button> `
|
||||||
: '';
|
: '';
|
||||||
|
const profileCol = scoringProfileCatalog.length > 0
|
||||||
|
? `<td class="client-profile-dots-cell">${profileDotsHTML(c.scoringProfiles || [])}</td>`
|
||||||
|
: '';
|
||||||
|
const colCount = scoringProfileCatalog.length > 0 ? 4 : 3;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="${testDataRowClassForClient(c.clientCode).trim()}">
|
<tr class="${testDataRowClassForClient(c.clientCode).trim()}">
|
||||||
@ -331,13 +448,14 @@ function clientRowHTML(c) {
|
|||||||
${expandControl}<strong>${esc(c.clientCode)}</strong>
|
${expandControl}<strong>${esc(c.clientCode)}</strong>
|
||||||
</td>
|
</td>
|
||||||
<td>${coach}</td>
|
<td>${coach}</td>
|
||||||
|
${profileCol}
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-sm btn-danger delete-client-btn" data-code="${esc(c.clientCode)}">Delete</button>
|
<button class="btn btn-sm btn-danger delete-client-btn" data-code="${esc(c.clientCode)}">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
${expanded ? `
|
${expanded ? `
|
||||||
<tr class="client-detail-row">
|
<tr class="client-detail-row">
|
||||||
<td colspan="3">
|
<td colspan="${colCount}">
|
||||||
<div class="client-detail-panel">${clientDetailPanelHTML(c.clientCode)}</div>
|
<div class="client-detail-panel">${clientDetailPanelHTML(c.clientCode)}</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>` : ''}`;
|
</tr>` : ''}`;
|
||||||
|
|||||||
@ -33,6 +33,14 @@ const LAYOUT_TYPES = [
|
|||||||
|
|
||||||
const OPTION_TYPES = new Set(['radio_question', 'multi_check_box_question']);
|
const OPTION_TYPES = new Set(['radio_question', 'multi_check_box_question']);
|
||||||
|
|
||||||
|
const GLASS_SCORE_LEVELS = [
|
||||||
|
{ key: 'never_glass', label: 'Never' },
|
||||||
|
{ key: 'little_glass', label: 'Little' },
|
||||||
|
{ key: 'moderate_glass', label: 'Moderate' },
|
||||||
|
{ key: 'much_glass', label: 'Much' },
|
||||||
|
{ key: 'extreme_glass', label: 'Extreme' },
|
||||||
|
];
|
||||||
|
|
||||||
let questionnaire = null;
|
let questionnaire = null;
|
||||||
let questions = [];
|
let questions = [];
|
||||||
let isNew = false;
|
let isNew = false;
|
||||||
@ -194,10 +202,79 @@ function glassSymptomsRowsFromQuestion(q) {
|
|||||||
return q.glassSymptoms.map(r => ({
|
return q.glassSymptoms.map(r => ({
|
||||||
key: (r.key || '').trim(),
|
key: (r.key || '').trim(),
|
||||||
labelDe: (r.labelDe || '').trim(),
|
labelDe: (r.labelDe || '').trim(),
|
||||||
|
scoreLevels: r.scoreLevels || defaultGlassScoreLevels(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
const config = parseConfig(q);
|
const config = parseConfig(q);
|
||||||
return (config.symptoms || []).map(key => ({ key: String(key).trim(), labelDe: '' }));
|
return (config.symptoms || []).map(key => ({
|
||||||
|
key: String(key).trim(),
|
||||||
|
labelDe: '',
|
||||||
|
scoreLevels: defaultGlassScoreLevels(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultGlassScoreLevels() {
|
||||||
|
return { never_glass: 0, little_glass: 1, moderate_glass: 2, much_glass: 3, extreme_glass: 4 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueScoreRulesSectionHTML(prefix, config, scoreRules = []) {
|
||||||
|
const min = Number(config.range?.min ?? 0);
|
||||||
|
const max = Number(config.range?.max ?? 100);
|
||||||
|
const step = Math.max(1, Number(config.step ?? 1));
|
||||||
|
const lo = Math.min(min, max);
|
||||||
|
const hi = Math.max(min, max);
|
||||||
|
const ruleMap = {};
|
||||||
|
for (const r of scoreRules || []) {
|
||||||
|
ruleMap[r.levelKey] = r.points;
|
||||||
|
}
|
||||||
|
const values = [];
|
||||||
|
for (let v = lo; v <= hi; v += step) values.push(v);
|
||||||
|
if (!values.length) values.push(lo);
|
||||||
|
const rows = values.map(v => `
|
||||||
|
<div class="value-score-row">
|
||||||
|
<span class="value-score-label">${v}</span>
|
||||||
|
<input type="number" class="value-score-pts" data-value="${v}" value="${ruleMap[String(v)] ?? 0}" min="0" step="1">
|
||||||
|
</div>`).join('');
|
||||||
|
return `
|
||||||
|
<div class="value-score-rules" id="${prefix}_value_scores">
|
||||||
|
<label style="font-size:.85rem;font-weight:600;color:var(--text-secondary)">Points per value</label>
|
||||||
|
<p class="field-hint">Set points for each selectable value (used when the questionnaire is scored).</p>
|
||||||
|
<div class="value-score-grid">${rows}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readValueScoreRulesFromForm(prefix) {
|
||||||
|
const container = document.getElementById(`${prefix}_value_scores`);
|
||||||
|
if (!container) return [];
|
||||||
|
const rules = [];
|
||||||
|
container.querySelectorAll('.value-score-pts').forEach(inp => {
|
||||||
|
rules.push({
|
||||||
|
scopeKey: '',
|
||||||
|
levelKey: String(inp.dataset.value),
|
||||||
|
points: parseInt(inp.value || '0', 10) || 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireValueScoreRules(prefix) {
|
||||||
|
const minInp = document.getElementById(`${prefix}_rangeMin`);
|
||||||
|
const maxInp = document.getElementById(`${prefix}_rangeMax`);
|
||||||
|
const stepInp = document.getElementById(`${prefix}_step`);
|
||||||
|
const rerender = () => {
|
||||||
|
const section = document.getElementById(`${prefix}_value_scores`);
|
||||||
|
if (!section) return;
|
||||||
|
const config = {
|
||||||
|
range: {
|
||||||
|
min: parseInt(minInp?.value || '0', 10),
|
||||||
|
max: parseInt(maxInp?.value || '100', 10),
|
||||||
|
},
|
||||||
|
step: parseInt(stepInp?.value || '1', 10) || 1,
|
||||||
|
};
|
||||||
|
const existing = readValueScoreRulesFromForm(prefix);
|
||||||
|
section.outerHTML = valueScoreRulesSectionHTML(prefix, config, existing);
|
||||||
|
};
|
||||||
|
[minInp, maxInp, stepInp].forEach(el => el?.addEventListener('change', rerender));
|
||||||
}
|
}
|
||||||
|
|
||||||
function suggestSymptomKey(label) {
|
function suggestSymptomKey(label) {
|
||||||
@ -227,6 +304,13 @@ function glassSymptomsSectionHTML(rows, prefix) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function glassSymptomRowHTML(prefix, row, idx) {
|
function glassSymptomRowHTML(prefix, row, idx) {
|
||||||
|
const levels = row.scoreLevels || defaultGlassScoreLevels();
|
||||||
|
const ptsRow = GLASS_SCORE_LEVELS.map(l => `
|
||||||
|
<div class="glass-score-cell">
|
||||||
|
<label>${esc(l.label)}</label>
|
||||||
|
<input type="number" class="glass-score-pts" data-level="${esc(l.key)}"
|
||||||
|
value="${Number(levels[l.key] ?? 0)}" min="0" step="1">
|
||||||
|
</div>`).join('');
|
||||||
return `
|
return `
|
||||||
<li class="glass-symptom-row" data-idx="${idx}">
|
<li class="glass-symptom-row" data-idx="${idx}">
|
||||||
<div class="form-row" style="align-items:flex-end;margin-bottom:0">
|
<div class="form-row" style="align-items:flex-end;margin-bottom:0">
|
||||||
@ -242,6 +326,7 @@ function glassSymptomRowHTML(prefix, row, idx) {
|
|||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn-icon btn-icon-danger glass-symptom-remove" title="Remove">✖</button>
|
<button type="button" class="btn-icon btn-icon-danger glass-symptom-remove" title="Remove">✖</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="glass-score-grid">${ptsRow}</div>
|
||||||
</li>`;
|
</li>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -252,7 +337,11 @@ function readGlassSymptomsFromForm(prefix) {
|
|||||||
container.querySelectorAll('.glass-symptom-row').forEach(rowEl => {
|
container.querySelectorAll('.glass-symptom-row').forEach(rowEl => {
|
||||||
const key = rowEl.querySelector('.glass-symptom-key')?.value?.trim() || '';
|
const key = rowEl.querySelector('.glass-symptom-key')?.value?.trim() || '';
|
||||||
const labelDe = rowEl.querySelector('.glass-symptom-label')?.value?.trim() || '';
|
const labelDe = rowEl.querySelector('.glass-symptom-label')?.value?.trim() || '';
|
||||||
if (key || labelDe) rows.push({ key, labelDe });
|
const scoreLevels = {};
|
||||||
|
rowEl.querySelectorAll('.glass-score-pts').forEach(inp => {
|
||||||
|
scoreLevels[inp.dataset.level] = parseInt(inp.value || '0', 10) || 0;
|
||||||
|
});
|
||||||
|
if (key || labelDe) rows.push({ key, labelDe, scoreLevels });
|
||||||
});
|
});
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
@ -319,7 +408,7 @@ function wireGlassSymptomsSection(prefix) {
|
|||||||
|
|
||||||
container.querySelector('[data-action=add-glass-symptom]')?.addEventListener('click', () => {
|
container.querySelector('[data-action=add-glass-symptom]')?.addEventListener('click', () => {
|
||||||
const rows = collectRows();
|
const rows = collectRows();
|
||||||
rows.push({ key: '', labelDe: '' });
|
rows.push({ key: '', labelDe: '', scoreLevels: defaultGlassScoreLevels() });
|
||||||
renderList(rows);
|
renderList(rows);
|
||||||
const lastKey = container.querySelector('.glass-symptom-row:last-child .glass-symptom-label');
|
const lastKey = container.querySelector('.glass-symptom-row:last-child .glass-symptom-label');
|
||||||
lastKey?.focus();
|
lastKey?.focus();
|
||||||
@ -477,7 +566,8 @@ function configFormHTML(layout, config, prefix, opts = {}) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'value_spinner':
|
case 'value_spinner':
|
||||||
case 'slider_question':
|
case 'slider_question': {
|
||||||
|
const scoreRules = opts.scoreRules || [];
|
||||||
html = `
|
html = `
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@ -497,8 +587,10 @@ function configFormHTML(layout, config, prefix, opts = {}) {
|
|||||||
<label>Unit label (optional)</label>
|
<label>Unit label (optional)</label>
|
||||||
<input type="text" id="${prefix}_unitLabel" value="${esc(config.unitLabel || '')}" placeholder="e.g. years">
|
<input type="text" id="${prefix}_unitLabel" value="${esc(config.unitLabel || '')}" placeholder="e.g. years">
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>`;
|
</div>
|
||||||
|
${valueScoreRulesSectionHTML(prefix, config, scoreRules)}`;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'date_spinner': {
|
case 'date_spinner': {
|
||||||
const notBefore = config.constraints?.notBefore || '';
|
const notBefore = config.constraints?.notBefore || '';
|
||||||
const notAfter = config.constraints?.notAfter || '';
|
const notAfter = config.constraints?.notAfter || '';
|
||||||
@ -694,11 +786,6 @@ function renderEditor() {
|
|||||||
<label>Order index</label>
|
<label>Order index</label>
|
||||||
<input type="number" id="metaOrder" value="${questionnaire.orderIndex ?? 0}" min="0" ${editable ? '' : 'disabled'}>
|
<input type="number" id="metaOrder" value="${questionnaire.orderIndex ?? 0}" min="0" ${editable ? '' : 'disabled'}>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="display:flex;align-items:flex-end;padding-bottom:16px">
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" id="metaShowPoints" ${questionnaire.showPoints ? 'checked' : ''} ${editable ? '' : 'disabled'}> Show points
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Category key (app grouping)</label>
|
<label>Category key (app grouping)</label>
|
||||||
<input type="text" id="metaCategoryKey" value="${esc(questionnaire.categoryKey || '')}" placeholder="e.g. health_interview" ${editable ? '' : 'disabled'}>
|
<input type="text" id="metaCategoryKey" value="${esc(questionnaire.categoryKey || '')}" placeholder="e.g. health_interview" ${editable ? '' : 'disabled'}>
|
||||||
@ -838,7 +925,7 @@ async function saveMeta() {
|
|||||||
const version = document.getElementById('metaVersion').value.trim();
|
const version = document.getElementById('metaVersion').value.trim();
|
||||||
const state = document.getElementById('metaState').value;
|
const state = document.getElementById('metaState').value;
|
||||||
const orderIndex = parseInt(document.getElementById('metaOrder').value || '0', 10);
|
const orderIndex = parseInt(document.getElementById('metaOrder').value || '0', 10);
|
||||||
const showPoints = document.getElementById('metaShowPoints').checked ? 1 : 0;
|
const showPoints = 0;
|
||||||
const categoryKey = (document.getElementById('metaCategoryKey')?.value || '').trim();
|
const categoryKey = (document.getElementById('metaCategoryKey')?.value || '').trim();
|
||||||
let conditionJson = '{}';
|
let conditionJson = '{}';
|
||||||
let conditionForm = null;
|
let conditionForm = null;
|
||||||
@ -971,6 +1058,7 @@ function showAddQuestionForm() {
|
|||||||
document.getElementById('aq_config_section').innerHTML = configFormHTML(type, {}, 'aq_cfg');
|
document.getElementById('aq_config_section').innerHTML = configFormHTML(type, {}, 'aq_cfg');
|
||||||
wireStringSpinnerOtherToggle('aq_cfg');
|
wireStringSpinnerOtherToggle('aq_cfg');
|
||||||
if (type === 'glass_scale_question') wireGlassSymptomsSection('aq_cfg');
|
if (type === 'glass_scale_question') wireGlassSymptomsSection('aq_cfg');
|
||||||
|
if (type === 'slider_question' || type === 'value_spinner') wireValueScoreRules('aq_cfg');
|
||||||
const notesEl = document.getElementById('aq_notes_section');
|
const notesEl = document.getElementById('aq_notes_section');
|
||||||
if (notesEl) notesEl.innerHTML = notesSectionHTML({}, 'aq_cfg', qKey);
|
if (notesEl) notesEl.innerHTML = notesSectionHTML({}, 'aq_cfg', qKey);
|
||||||
}
|
}
|
||||||
@ -1060,6 +1148,9 @@ function showAddQuestionForm() {
|
|||||||
if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return;
|
if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return;
|
||||||
postBody.glassSymptoms = glassSymptoms;
|
postBody.glassSymptoms = glassSymptoms;
|
||||||
}
|
}
|
||||||
|
if (type === 'slider_question' || type === 'value_spinner') {
|
||||||
|
postBody.scoreRules = readValueScoreRulesFromForm('aq_cfg');
|
||||||
|
}
|
||||||
if (!validateStableKey(questionKey, 'Question key')) return;
|
if (!validateStableKey(questionKey, 'Question key')) return;
|
||||||
if (!text) { showToast('German text is required', 'error'); return; }
|
if (!text) { showToast('German text is required', 'error'); return; }
|
||||||
if (type === 'string_spinner' && !validateStringSpinnerConfig('aq_cfg')) return;
|
if (type === 'string_spinner' && !validateStringSpinnerConfig('aq_cfg')) return;
|
||||||
@ -1203,7 +1294,10 @@ function renderQuestionBody(q) {
|
|||||||
</label>
|
</label>
|
||||||
${notesSectionHTML(config, `eq_cfg_${q.questionID}`, qKey)}
|
${notesSectionHTML(config, `eq_cfg_${q.questionID}`, qKey)}
|
||||||
<div id="eq_config_${q.questionID}">
|
<div id="eq_config_${q.questionID}">
|
||||||
${configFormHTML(layout, config, `eq_cfg_${q.questionID}`, { glassSymptoms: glassSymptomsRowsFromQuestion(q) })}
|
${configFormHTML(layout, config, `eq_cfg_${q.questionID}`, {
|
||||||
|
glassSymptoms: glassSymptomsRowsFromQuestion(q),
|
||||||
|
scoreRules: q.scoreRules || [],
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;margin-top:8px">
|
<div style="display:flex;gap:8px;margin-top:8px">
|
||||||
<button class="btn btn-primary btn-sm save-q-btn">Save Question</button>
|
<button class="btn btn-primary btn-sm save-q-btn">Save Question</button>
|
||||||
@ -1271,6 +1365,9 @@ function renderQuestionBody(q) {
|
|||||||
if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return;
|
if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return;
|
||||||
payload.glassSymptoms = glassSymptoms;
|
payload.glassSymptoms = glassSymptoms;
|
||||||
}
|
}
|
||||||
|
if (type === 'slider_question' || type === 'value_spinner') {
|
||||||
|
payload.scoreRules = readValueScoreRulesFromForm(cfgPrefix);
|
||||||
|
}
|
||||||
if (!validateStableKey(questionKey, 'Question key')) return;
|
if (!validateStableKey(questionKey, 'Question key')) return;
|
||||||
if (!text) { showToast('German text is required', 'error'); return; }
|
if (!text) { showToast('German text is required', 'error'); return; }
|
||||||
if (type === 'string_spinner' && !validateStringSpinnerConfig(cfgPrefix)) return;
|
if (type === 'string_spinner' && !validateStringSpinnerConfig(cfgPrefix)) return;
|
||||||
@ -1291,15 +1388,19 @@ function renderQuestionBody(q) {
|
|||||||
const newLayout = document.getElementById(`eq_type_${q.questionID}`).value;
|
const newLayout = document.getElementById(`eq_type_${q.questionID}`).value;
|
||||||
const opts = newLayout === 'glass_scale_question'
|
const opts = newLayout === 'glass_scale_question'
|
||||||
? { glassSymptoms: glassSymptomsRowsFromQuestion(q) }
|
? { glassSymptoms: glassSymptomsRowsFromQuestion(q) }
|
||||||
: {};
|
: (newLayout === 'slider_question' || newLayout === 'value_spinner')
|
||||||
|
? { scoreRules: q.scoreRules || [] }
|
||||||
|
: {};
|
||||||
document.getElementById(`eq_config_${q.questionID}`).innerHTML =
|
document.getElementById(`eq_config_${q.questionID}`).innerHTML =
|
||||||
configFormHTML(newLayout, {}, cfgPrefix, opts);
|
configFormHTML(newLayout, {}, cfgPrefix, opts);
|
||||||
wireStringSpinnerOtherToggle(cfgPrefix);
|
wireStringSpinnerOtherToggle(cfgPrefix);
|
||||||
if (newLayout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
|
if (newLayout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
|
||||||
|
if (newLayout === 'slider_question' || newLayout === 'value_spinner') wireValueScoreRules(cfgPrefix);
|
||||||
});
|
});
|
||||||
|
|
||||||
wireStringSpinnerOtherToggle(cfgPrefix);
|
wireStringSpinnerOtherToggle(cfgPrefix);
|
||||||
if (layout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
|
if (layout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
|
||||||
|
if (layout === 'slider_question' || layout === 'value_spinner') wireValueScoreRules(cfgPrefix);
|
||||||
|
|
||||||
body.querySelector('.del-q-btn').addEventListener('click', () => deleteQuestion(q));
|
body.querySelector('.del-q-btn').addEventListener('click', () => deleteQuestion(q));
|
||||||
|
|
||||||
|
|||||||
@ -48,6 +48,12 @@ function renderHomeDashboard(ov, isAdmin) {
|
|||||||
links.push({ href: '#/dev', label: 'Admin settings', desc: 'Dev import, exports, database tools' });
|
links.push({ href: '#/dev', label: 'Admin settings', desc: 'Dev import, exports, database tools' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scoringProfiles = ov.scoringProfiles || [];
|
||||||
|
const primaryProfile = scoringProfiles[0];
|
||||||
|
const greenPct = primaryProfile && primaryProfile.clientCount
|
||||||
|
? Math.round(((primaryProfile.bands?.green ?? 0) / primaryProfile.clientCount) * 100)
|
||||||
|
: null;
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="insights-kpi-grid">
|
<div class="insights-kpi-grid">
|
||||||
<a href="#/clients" class="insights-kpi card home-kpi-link">
|
<a href="#/clients" class="insights-kpi card home-kpi-link">
|
||||||
@ -66,6 +72,16 @@ function renderHomeDashboard(ov, isAdmin) {
|
|||||||
<div class="insights-kpi-value">${ov.submissionsLast30d ?? 0}</div>
|
<div class="insights-kpi-value">${ov.submissionsLast30d ?? 0}</div>
|
||||||
<div class="insights-kpi-label">Uploads (30 days)</div>
|
<div class="insights-kpi-label">Uploads (30 days)</div>
|
||||||
</a>
|
</a>
|
||||||
|
${primaryProfile ? `
|
||||||
|
<a href="#/insights" class="insights-kpi card home-kpi-link">
|
||||||
|
<div class="insights-kpi-value">${primaryProfile.clientCount ?? 0}</div>
|
||||||
|
<div class="insights-kpi-label">${esc(primaryProfile.name)} scored</div>
|
||||||
|
</a>` : ''}
|
||||||
|
${greenPct != null ? `
|
||||||
|
<a href="#/insights" class="insights-kpi card home-kpi-link">
|
||||||
|
<div class="insights-kpi-value">${greenPct}%</div>
|
||||||
|
<div class="insights-kpi-label">Green (${esc(primaryProfile.name)})</div>
|
||||||
|
</a>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-top:20px">
|
<div class="card" style="margin-top:20px">
|
||||||
|
|||||||
@ -81,6 +81,35 @@ function renderInsights() {
|
|||||||
{ suffix: '%', max: 100 }
|
{ suffix: '%', max: 100 }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const scoringProfiles = ov.scoringProfiles || [];
|
||||||
|
const scoringCharts = scoringProfiles.map(sp => {
|
||||||
|
const computed = sp.computedBands || sp.bands || {};
|
||||||
|
const coach = sp.coachBands || {};
|
||||||
|
const computedChart = horizontalBarChart([
|
||||||
|
{ label: 'Green', value: computed.green ?? 0 },
|
||||||
|
{ label: 'Yellow', value: computed.yellow ?? 0 },
|
||||||
|
{ label: 'Red', value: computed.red ?? 0 },
|
||||||
|
], { suffix: '' });
|
||||||
|
const coachChart = horizontalBarChart([
|
||||||
|
{ label: 'Green', value: coach.green ?? 0 },
|
||||||
|
{ label: 'Yellow', value: coach.yellow ?? 0 },
|
||||||
|
{ label: 'Red', value: coach.red ?? 0 },
|
||||||
|
], { suffix: '' });
|
||||||
|
return `
|
||||||
|
<div class="card insights-chart-card">
|
||||||
|
<h3>${esc(sp.name)}</h3>
|
||||||
|
<p class="insights-chart-hint">
|
||||||
|
${sp.clientCount ?? 0} client(s) with complete profile
|
||||||
|
${sp.averageTotal != null ? ` · avg ${sp.averageTotal}` : ''}
|
||||||
|
${sp.pendingReview ? ` · ${sp.pendingReview} pending coach review` : ''}
|
||||||
|
</p>
|
||||||
|
<p class="insights-chart-hint" style="margin:0 0 6px"><strong>Calculated bands</strong></p>
|
||||||
|
${computedChart}
|
||||||
|
<p class="insights-chart-hint" style="margin:12px 0 6px"><strong>Coach decisions</strong></p>
|
||||||
|
${coachChart}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
const qRows = (ov.questionnaires || []).map(q => `
|
const qRows = (ov.questionnaires || []).map(q => `
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>${esc(q.name)}</strong></td>
|
<td><strong>${esc(q.name)}</strong></td>
|
||||||
@ -108,6 +137,12 @@ function renderInsights() {
|
|||||||
${idleChart}
|
${idleChart}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
${scoringProfiles.length ? `
|
||||||
|
<div class="card" style="margin-top:20px">
|
||||||
|
<h3 style="margin:0 0 8px">Scoring profiles</h3>
|
||||||
|
<p class="insights-chart-hint" style="margin:0 0 12px">Calculated vs coach band distribution among clients with a complete weighted score</p>
|
||||||
|
<div class="insights-charts-grid">${scoringCharts}</div>
|
||||||
|
</div>` : ''}
|
||||||
<div class="card" style="margin-top:20px">
|
<div class="card" style="margin-top:20px">
|
||||||
<h3 style="margin:0 0 8px">Completion by questionnaire</h3>
|
<h3 style="margin:0 0 8px">Completion by questionnaire</h3>
|
||||||
<p class="insights-chart-hint" style="margin:0 0 12px">Share of clients who completed each active questionnaire</p>
|
<p class="insights-chart-hint" style="margin:0 0 12px">Share of clients who completed each active questionnaire</p>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export async function questionnairesPage() {
|
|||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
${pageHeaderHTML('Questionnaires', '<span id="headerActions"></span>')}
|
${pageHeaderHTML('Questionnaires', '<span id="headerActions"></span>')}
|
||||||
<div id="qGrid" class="q-dashboard-root"><div class="spinner"></div></div>
|
<div id="qGrid" class="q-dashboard-root"><div class="spinner"></div></div>
|
||||||
|
<div id="scoringProfilesPanel"></div>
|
||||||
<div id="categoryKeysPanel"></div>
|
<div id="categoryKeysPanel"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -20,9 +21,13 @@ export async function questionnairesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiGet('questionnaires.php');
|
const [data, profilesData] = await Promise.all([
|
||||||
|
apiGet('questionnaires.php'),
|
||||||
|
apiGet('scoring_profiles.php').catch(() => ({ profiles: [] })),
|
||||||
|
]);
|
||||||
const categoryKeys = data.categoryKeys || [];
|
const categoryKeys = data.categoryKeys || [];
|
||||||
renderGrid(data.questionnaires || [], categoryKeys);
|
renderGrid(data.questionnaires || [], categoryKeys);
|
||||||
|
renderScoringProfilesSection(profilesData.profiles || [], data.questionnaires || []);
|
||||||
renderCategoryKeysPanel(categoryKeys);
|
renderCategoryKeysPanel(categoryKeys);
|
||||||
bindCategoryLabelEditors();
|
bindCategoryLabelEditors();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -232,7 +237,6 @@ function groupQuestionnairesByCategory(questionnaires, categoryKeys) {
|
|||||||
|
|
||||||
function renderQuestionnaireCard(q) {
|
function renderQuestionnaireCard(q) {
|
||||||
const stateClass = `badge-${(q.state || 'draft').toLowerCase().replace(/\s+/g, '_')}`;
|
const stateClass = `badge-${(q.state || 'draft').toLowerCase().replace(/\s+/g, '_')}`;
|
||||||
const showPts = parseInt(q.showPoints || 0, 10);
|
|
||||||
return `
|
return `
|
||||||
<div class="q-card" data-id="${q.questionnaireID}" draggable="${canEdit()}">
|
<div class="q-card" data-id="${q.questionnaireID}" draggable="${canEdit()}">
|
||||||
<div class="q-card-header">
|
<div class="q-card-header">
|
||||||
@ -246,7 +250,6 @@ function renderQuestionnaireCard(q) {
|
|||||||
<span>v${esc(q.version || '—')}</span>
|
<span>v${esc(q.version || '—')}</span>
|
||||||
<span>${q.questionCount || 0} question${q.questionCount == 1 ? '' : 's'}</span>
|
<span>${q.questionCount || 0} question${q.questionCount == 1 ? '' : 's'}</span>
|
||||||
<span>#${q.orderIndex ?? '—'}</span>
|
<span>#${q.orderIndex ?? '—'}</span>
|
||||||
${showPts ? '<span class="badge badge-active" style="font-size:.7rem;padding:1px 6px">pts</span>' : ''}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="q-card-actions">
|
<div class="q-card-actions">
|
||||||
<a href="#/questionnaire/${q.questionnaireID}" class="btn btn-sm">Edit</a>
|
<a href="#/questionnaire/${q.questionnaireID}" class="btn btn-sm">Edit</a>
|
||||||
@ -377,6 +380,360 @@ function initGridDrag(questionnaires) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeProfileBands(profile) {
|
||||||
|
const greenMax = profile?.greenMax ?? 12;
|
||||||
|
const yellowMax = profile?.yellowMax ?? 36;
|
||||||
|
return {
|
||||||
|
greenMin: profile?.greenMin ?? 0,
|
||||||
|
greenMax,
|
||||||
|
yellowMin: profile?.yellowMin ?? (greenMax + 1),
|
||||||
|
yellowMax,
|
||||||
|
redMin: profile?.redMin ?? (yellowMax + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandSummary(profile) {
|
||||||
|
const b = normalizeProfileBands(profile || {});
|
||||||
|
return `Green ${b.greenMin}–${b.greenMax} · Yellow ${b.yellowMin}–${b.yellowMax} · Red ${b.redMin}+`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBandsFromModal(overlay) {
|
||||||
|
return {
|
||||||
|
greenMin: parseInt(overlay.querySelector('#spGreenMin')?.value || '0', 10),
|
||||||
|
greenMax: parseInt(overlay.querySelector('#spGreenMax')?.value || '0', 10),
|
||||||
|
yellowMin: parseInt(overlay.querySelector('#spYellowMin')?.value || '0', 10),
|
||||||
|
yellowMax: parseInt(overlay.querySelector('#spYellowMax')?.value || '0', 10),
|
||||||
|
redMin: parseInt(overlay.querySelector('#spRedMin')?.value || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBands(b) {
|
||||||
|
if (b.greenMin > b.greenMax) return 'Green min must not exceed green max';
|
||||||
|
if (b.yellowMin > b.yellowMax) return 'Yellow min must not exceed yellow max';
|
||||||
|
if (b.greenMax >= b.yellowMin) return 'Green range must end before yellow range starts';
|
||||||
|
if (b.yellowMax >= b.redMin) return 'Yellow range must end before red range starts';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandPreviewHTML(b) {
|
||||||
|
return `
|
||||||
|
<div class="scoring-band-strips">
|
||||||
|
<span class="band-badge band-green">Green: ${b.greenMin} – ${b.greenMax}</span>
|
||||||
|
<span class="band-badge band-yellow">Yellow: ${b.yellowMin} – ${b.yellowMax}</span>
|
||||||
|
<span class="band-badge band-red">Red: ${b.redMin}+</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandRangesEditorHTML(bands) {
|
||||||
|
const b = normalizeProfileBands(bands);
|
||||||
|
return `
|
||||||
|
<h4 class="scoring-modal-section-title">Score bands</h4>
|
||||||
|
<p class="data-toolbar-hint" style="margin:0 0 10px">Set inclusive min/max for each band. Ranges must not overlap.</p>
|
||||||
|
<div class="scoring-band-ranges-table">
|
||||||
|
<div class="scoring-band-range-row scoring-band-range-header">
|
||||||
|
<span>Band</span><span>Min</span><span>Max</span>
|
||||||
|
</div>
|
||||||
|
<div class="scoring-band-range-row">
|
||||||
|
<span class="band-badge band-green">Green</span>
|
||||||
|
<input type="number" id="spGreenMin" min="0" value="${b.greenMin}">
|
||||||
|
<input type="number" id="spGreenMax" min="0" value="${b.greenMax}">
|
||||||
|
</div>
|
||||||
|
<div class="scoring-band-range-row">
|
||||||
|
<span class="band-badge band-yellow">Yellow</span>
|
||||||
|
<input type="number" id="spYellowMin" min="0" value="${b.yellowMin}">
|
||||||
|
<input type="number" id="spYellowMax" min="0" value="${b.yellowMax}">
|
||||||
|
</div>
|
||||||
|
<div class="scoring-band-range-row">
|
||||||
|
<span class="band-badge band-red">Red</span>
|
||||||
|
<input type="number" id="spRedMin" min="0" value="${b.redMin}">
|
||||||
|
<span class="scoring-band-max-placeholder">∞</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="spBandPreview" class="scoring-band-labels"></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScoringProfilesSection(profiles, questionnaires) {
|
||||||
|
const panel = document.getElementById('scoringProfilesPanel');
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="scoring-profiles-section">
|
||||||
|
<div class="card scoring-profiles-card">
|
||||||
|
<div class="scoring-profile-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="scoring-section-title">Scoring profiles</h3>
|
||||||
|
<p class="scoring-section-desc">
|
||||||
|
Weighted multi-questionnaire scores with Green / Yellow / Red bands for insights.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
${canEdit() ? '<button type="button" class="btn btn-primary btn-sm" id="newScoringProfileBtn">+ New profile</button>' : ''}
|
||||||
|
</div>
|
||||||
|
<div id="scoringProfilesList" class="scoring-profiles-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const list = panel.querySelector('#scoringProfilesList');
|
||||||
|
if (!profiles.length) {
|
||||||
|
list.innerHTML = '<p class="scoring-empty-hint">No scoring profiles yet. Create one to combine questionnaire scores for insights.</p>';
|
||||||
|
} else {
|
||||||
|
list.innerHTML = profiles.map(p => scoringProfileCardHTML(p, questionnaires)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canEdit()) {
|
||||||
|
panel.querySelector('#newScoringProfileBtn')?.addEventListener('click', () => {
|
||||||
|
openScoringProfileModal(null, questionnaires, profiles);
|
||||||
|
});
|
||||||
|
list.querySelectorAll('.edit-profile-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const profile = profiles.find(x => x.profileID === btn.dataset.id);
|
||||||
|
openScoringProfileModal(profile, questionnaires, profiles);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
list.querySelectorAll('.delete-profile-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
if (!confirm(`Delete scoring profile "${btn.dataset.name}"?`)) return;
|
||||||
|
try {
|
||||||
|
await apiDelete('scoring_profiles.php', { profileID: btn.dataset.id });
|
||||||
|
showToast('Profile deleted', 'success');
|
||||||
|
await questionnairesPage();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoringProfileCardHTML(profile, questionnaires) {
|
||||||
|
const qnById = Object.fromEntries((questionnaires || []).map(q => [q.questionnaireID, q]));
|
||||||
|
const members = profile.questionnaires || [];
|
||||||
|
const activeBadge = profile.isActive
|
||||||
|
? '<span class="badge badge-active">Active</span>'
|
||||||
|
: '<span class="badge badge-draft">Inactive</span>';
|
||||||
|
const memberRows = members.map(m => {
|
||||||
|
const q = qnById[m.questionnaireID];
|
||||||
|
const name = q?.name || m.questionnaireID;
|
||||||
|
return `<div class="scoring-profile-q-row"><span class="scoring-profile-q-name">${esc(name)}</span><span class="scoring-profile-q-weight">× ${m.weight}</span></div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="scoring-profile-card">
|
||||||
|
<div class="scoring-profile-card-top">
|
||||||
|
<div class="scoring-profile-card-main">
|
||||||
|
<div class="scoring-profile-name-row">
|
||||||
|
<strong>${esc(profile.name)}</strong>
|
||||||
|
${activeBadge}
|
||||||
|
</div>
|
||||||
|
${profile.description ? `<p class="scoring-profile-desc">${esc(profile.description)}</p>` : ''}
|
||||||
|
<p class="scoring-profile-bands">${bandSummary(profile)}</p>
|
||||||
|
</div>
|
||||||
|
${canEdit() ? `
|
||||||
|
<div class="scoring-profile-card-actions">
|
||||||
|
<button type="button" class="btn btn-sm edit-profile-btn" data-id="${esc(profile.profileID)}">Edit</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-danger delete-profile-btn"
|
||||||
|
data-id="${esc(profile.profileID)}" data-name="${esc(profile.name)}">Delete</button>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<p class="scoring-profile-member-count">${members.length} questionnaire(s)</p>
|
||||||
|
<div class="scoring-profile-members">${memberRows || '<p class="scoring-empty-hint">No questionnaires linked</p>'}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openScoringProfileModal(profile, questionnaires, allProfiles) {
|
||||||
|
const isEdit = !!profile;
|
||||||
|
const members = profile?.questionnaires?.length
|
||||||
|
? [...profile.questionnaires]
|
||||||
|
: [];
|
||||||
|
const selectedIds = new Set(members.map(m => m.questionnaireID));
|
||||||
|
const initialBands = normalizeProfileBands(profile || {});
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal-dialog modal-dialog-wide">
|
||||||
|
<h2>${isEdit ? 'Edit scoring profile' : 'New scoring profile'}</h2>
|
||||||
|
<p class="modal-subtitle">Combine questionnaire totals with weights, then map the sum to Green / Yellow / Red bands.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="spName">Name</label>
|
||||||
|
<input type="text" id="spName" value="${esc(profile?.name || '')}" placeholder="e.g. RHS Index">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="spDesc">Description</label>
|
||||||
|
<textarea id="spDesc" rows="2" placeholder="Optional note for coaches and admins">${esc(profile?.description || '')}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group scoring-profile-active-row">
|
||||||
|
<label class="checkbox-inline">
|
||||||
|
<input type="checkbox" id="spActive" ${profile?.isActive !== 0 ? 'checked' : ''}>
|
||||||
|
Active (shown in insights)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
${bandRangesEditorHTML(initialBands)}
|
||||||
|
<h4 class="scoring-modal-section-title">Questionnaires</h4>
|
||||||
|
<p class="data-toolbar-hint" style="margin:0 0 10px">Add questionnaires and set weight. List order is the display order.</p>
|
||||||
|
<div id="spQnPicker" class="scoring-profile-picker"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-sm" id="spCancel">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" id="spSave">${isEdit ? 'Save' : 'Create'}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
|
||||||
|
const picker = overlay.querySelector('#spQnPicker');
|
||||||
|
const sortedQn = [...(questionnaires || [])].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0));
|
||||||
|
|
||||||
|
function renderPicker() {
|
||||||
|
const ordered = members.length
|
||||||
|
? members.map(m => ({ ...m, q: sortedQn.find(q => q.questionnaireID === m.questionnaireID) }))
|
||||||
|
: [];
|
||||||
|
const available = sortedQn.filter(q => !selectedIds.has(q.questionnaireID));
|
||||||
|
|
||||||
|
picker.innerHTML = `
|
||||||
|
${ordered.map((m, idx) => `
|
||||||
|
<div class="scoring-profile-q-row" data-qn="${esc(m.questionnaireID)}">
|
||||||
|
<span class="scoring-profile-q-name">${esc(m.q?.name || m.questionnaireID)}</span>
|
||||||
|
<span class="scoring-profile-q-actions">
|
||||||
|
<label class="sp-weight-wrap">Weight
|
||||||
|
<input type="number" class="sp-weight" step="0.1" min="0.1"
|
||||||
|
value="${m.weight ?? 1}">
|
||||||
|
</label>
|
||||||
|
<button type="button" class="btn btn-sm sp-move-up" ${idx === 0 ? 'disabled' : ''} title="Move up">↑</button>
|
||||||
|
<button type="button" class="btn btn-sm sp-move-down" ${idx === ordered.length - 1 ? 'disabled' : ''} title="Move down">↓</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-danger sp-remove" title="Remove">Remove</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
${available.length ? `
|
||||||
|
<div class="scoring-profile-add-row">
|
||||||
|
<select id="spAddSelect">
|
||||||
|
<option value="">Add questionnaire…</option>
|
||||||
|
${available.map(q => `<option value="${esc(q.questionnaireID)}">${esc(q.name)}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-sm" id="spAddBtn">Add</button>
|
||||||
|
</div>` : ''}`;
|
||||||
|
|
||||||
|
picker.querySelectorAll('.sp-weight').forEach((input, idx) => {
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
members[idx].weight = parseFloat(input.value) || 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
picker.querySelectorAll('.sp-remove').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const row = btn.closest('.scoring-profile-q-row');
|
||||||
|
const id = row?.dataset.qn;
|
||||||
|
if (!id) return;
|
||||||
|
selectedIds.delete(id);
|
||||||
|
const i = members.findIndex(m => m.questionnaireID === id);
|
||||||
|
if (i >= 0) members.splice(i, 1);
|
||||||
|
renderPicker();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
picker.querySelectorAll('.sp-move-up').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const row = btn.closest('.scoring-profile-q-row');
|
||||||
|
const id = row?.dataset.qn;
|
||||||
|
const i = members.findIndex(m => m.questionnaireID === id);
|
||||||
|
if (i > 0) {
|
||||||
|
[members[i - 1], members[i]] = [members[i], members[i - 1]];
|
||||||
|
renderPicker();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
picker.querySelectorAll('.sp-move-down').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const row = btn.closest('.scoring-profile-q-row');
|
||||||
|
const id = row?.dataset.qn;
|
||||||
|
const i = members.findIndex(m => m.questionnaireID === id);
|
||||||
|
if (i >= 0 && i < members.length - 1) {
|
||||||
|
[members[i], members[i + 1]] = [members[i + 1], members[i]];
|
||||||
|
renderPicker();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
overlay.querySelector('#spAddBtn')?.addEventListener('click', () => {
|
||||||
|
const sel = overlay.querySelector('#spAddSelect');
|
||||||
|
const id = sel?.value;
|
||||||
|
if (!id || selectedIds.has(id)) return;
|
||||||
|
selectedIds.add(id);
|
||||||
|
members.push({ questionnaireID: id, weight: 1, orderIndex: members.length });
|
||||||
|
renderPicker();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBandPreview() {
|
||||||
|
const el = overlay.querySelector('#spBandPreview');
|
||||||
|
if (!el) return;
|
||||||
|
const b = readBandsFromModal(overlay);
|
||||||
|
const err = validateBands(b);
|
||||||
|
if (err) {
|
||||||
|
el.innerHTML = `<p class="error-text" style="margin:0">${esc(err)}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = bandPreviewHTML(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPicker();
|
||||||
|
updateBandPreview();
|
||||||
|
['#spGreenMin', '#spGreenMax', '#spYellowMin', '#spYellowMax', '#spRedMin'].forEach(sel => {
|
||||||
|
overlay.querySelector(sel)?.addEventListener('input', updateBandPreview);
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
overlay.remove();
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
};
|
||||||
|
overlay.querySelector('#spCancel')?.addEventListener('click', close);
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||||
|
|
||||||
|
overlay.querySelector('#spSave')?.addEventListener('click', async () => {
|
||||||
|
const name = overlay.querySelector('#spName')?.value.trim();
|
||||||
|
const description = overlay.querySelector('#spDesc')?.value.trim() || '';
|
||||||
|
const isActive = overlay.querySelector('#spActive')?.checked;
|
||||||
|
const bands = readBandsFromModal(overlay);
|
||||||
|
const bandErr = validateBands(bands);
|
||||||
|
if (!name) {
|
||||||
|
showToast('Profile name is required', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (bandErr) {
|
||||||
|
showToast(bandErr, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!members.length) {
|
||||||
|
showToast('Add at least one questionnaire', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
isActive,
|
||||||
|
...bands,
|
||||||
|
questionnaires: members.map((m, idx) => ({
|
||||||
|
questionnaireID: m.questionnaireID,
|
||||||
|
weight: parseFloat(picker.querySelector(`[data-qn="${m.questionnaireID}"] .sp-weight`)?.value) || m.weight || 1,
|
||||||
|
orderIndex: idx,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
if (isEdit) payload.profileID = profile.profileID;
|
||||||
|
|
||||||
|
const saveBtn = overlay.querySelector('#spSave');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
await apiPut('scoring_profiles.php', payload);
|
||||||
|
} else {
|
||||||
|
await apiPost('scoring_profiles.php', payload);
|
||||||
|
}
|
||||||
|
showToast(isEdit ? 'Profile saved' : 'Profile created', 'success');
|
||||||
|
close();
|
||||||
|
await questionnairesPage();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function bindImportBundle() {
|
function bindImportBundle() {
|
||||||
const fileInput = document.getElementById('importBundleFile');
|
const fileInput = document.getElementById('importBundleFile');
|
||||||
const btn = document.getElementById('importBundleBtn');
|
const btn = document.getElementById('importBundleBtn');
|
||||||
|
|||||||
Reference in New Issue
Block a user