From 3d56abd12434ac617d6ce9e58462e03634f73759 Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Mon, 1 Jun 2026 13:29:10 +0200 Subject: [PATCH] made client data browsable with dropdown and version diffs --- common.php | 143 ++++++++++++++++ db_init.php | 11 +- handlers/clients.php | 26 ++- lib/analytics.php | 17 +- lib/dev_fixture.php | 128 +++++++++++--- lib/submissions.php | 289 +++++++++++++++++++++++++++++++- scripts/generate_dev_fixture.py | 162 ++++++++++++++---- website/css/style.css | 39 +++++ website/js/pages/clients.js | 229 ++++++++++++++++++++++++- website/js/pages/dev.js | 10 +- 10 files changed, 971 insertions(+), 83 deletions(-) diff --git a/common.php b/common.php index 9895e11..0d82899 100644 --- a/common.php +++ b/common.php @@ -1105,6 +1105,149 @@ function qdb_option_german_label(PDO $pdo, string $answerOptionID, string $store return ''; } +/** German label for a string catalog key (symptoms, glass levels, etc.). */ +function qdb_string_german_label(PDO $pdo, string $stringKey, array &$cache = []): string { + $stringKey = trim($stringKey); + if ($stringKey === '') { + return ''; + } + if (isset($cache[$stringKey])) { + return $cache[$stringKey]; + } + $tr = qdb_fetch_translation_map($pdo, 'string', $stringKey); + $de = trim($tr[QDB_SOURCE_LANGUAGE] ?? ''); + $label = $de !== '' ? $de : $stringKey; + $cache[$stringKey] = $label; + return $label; +} + +/** German question text for UI display. */ +function qdb_question_german_label(PDO $pdo, array $questionRow): string { + $tr = qdb_fetch_translation_map($pdo, 'question', $questionRow['questionID']); + $de = trim($tr[QDB_SOURCE_LANGUAGE] ?? ''); + if ($de !== '') { + return $de; + } + $defaultText = trim($questionRow['defaultText'] ?? ''); + if ($defaultText !== '' && !qdb_is_stable_key($defaultText)) { + return $defaultText; + } + return qdb_question_local_id($questionRow['questionID']); +} + +/** + * Result columns + option map using German labels (client detail, not CSV export). + * + * @return array{questions: array, resultColumns: array, optionTextMap: array, stringLabelCache: array} + */ +function qdb_display_questionnaire_context(PDO $pdo, string $questionnaireID): array { + $qStmt = $pdo->prepare( + 'SELECT questionID, defaultText, type, orderIndex, configJson FROM question WHERE questionnaireID = :id ORDER BY orderIndex' + ); + $qStmt->execute([':id' => $questionnaireID]); + $questions = $qStmt->fetchAll(PDO::FETCH_ASSOC); + $stringLabelCache = []; + $resultColumns = []; + + foreach ($questions as $q) { + $cfg = json_decode($q['configJson'] ?? '{}', true) ?: []; + $type = $q['type'] ?? ''; + if ($type === 'glass_scale_question') { + $symptoms = $cfg['symptoms'] ?? []; + if (is_array($symptoms) && $symptoms !== []) { + foreach ($symptoms as $symptomKey) { + $sk = trim((string)$symptomKey); + if ($sk === '') { + continue; + } + $resultColumns[] = [ + 'header' => qdb_string_german_label($pdo, $sk, $stringLabelCache), + 'questionID' => $q['questionID'], + 'symptomKey' => $sk, + 'kind' => 'glass_symptom', + 'question' => $q, + ]; + } + continue; + } + } + $resultColumns[] = [ + 'header' => qdb_question_german_label($pdo, $q), + 'questionID' => $q['questionID'], + 'symptomKey' => null, + 'kind' => 'question', + 'question' => $q, + ]; + } + + $optionTextMap = []; + foreach ($questions as $q) { + $aoStmt = $pdo->prepare('SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid'); + $aoStmt->execute([':qid' => $q['questionID']]); + foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { + $optionTextMap[$ao['answerOptionID']] = qdb_option_german_label( + $pdo, + $ao['answerOptionID'], + $ao['defaultText'] + ); + } + } + + return [ + 'questions' => $questions, + 'resultColumns' => $resultColumns, + 'optionTextMap' => $optionTextMap, + 'stringLabelCache' => $stringLabelCache, + ]; +} + +/** Format one answer cell for UI (German labels). */ +function qdb_display_column_cell_value( + PDO $pdo, + array $column, + ?array $answerRow, + array $optionTextMap, + array &$stringLabelCache +): string { + if (($column['kind'] ?? '') === 'glass_symptom') { + $raw = qdb_glass_symptom_answer_value( + $answerRow['freeTextValue'] ?? null, + (string)($column['symptomKey'] ?? '') + ); + if ($raw === '') { + return ''; + } + return qdb_string_german_label($pdo, $raw, $stringLabelCache); + } + if (($column['question']['type'] ?? '') === 'glass_scale_question') { + return qdb_format_glass_answer_json_display($pdo, $answerRow['freeTextValue'] ?? null, $stringLabelCache); + } + return qdb_format_client_answer_display($column['question'], $answerRow, $optionTextMap); +} + +/** Glass-scale JSON with German symptom keys and level labels. */ +function qdb_format_glass_answer_json_display(PDO $pdo, ?string $raw, array &$stringLabelCache): string +{ + if ($raw === null || trim($raw) === '') { + return ''; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return $raw; + } + $parts = []; + foreach ($decoded as $key => $val) { + $val = trim((string)$val); + if ($val === '') { + continue; + } + $symLabel = qdb_string_german_label($pdo, (string)$key, $stringLabelCache); + $levelLabel = qdb_string_german_label($pdo, $val, $stringLabelCache); + $parts[] = $symLabel . ': ' . $levelLabel; + } + return implode('; ', $parts); +} + /** * Build translation entry lists for one questionnaire. * Returns ['stringEntries' => [...], 'contentEntries' => [...]] (questions + options interleaved). diff --git a/db_init.php b/db_init.php index 3b8696c..ecf22be 100644 --- a/db_init.php +++ b/db_init.php @@ -6,7 +6,7 @@ require_once __DIR__ . '/common.php'; define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database'); define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock'); define('QDB_SCHEMA', __DIR__ . '/schema.sql'); -define('QDB_VERSION', 5); +define('QDB_VERSION', 6); function qdb_table_exists(PDO $pdo, string $table): bool { $stmt = $pdo->prepare( @@ -101,6 +101,15 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool { } } + $currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn(); + if ($currentVersion < 6 && qdb_table_exists($pdo, 'questionnaire_submission')) { + $pdo->exec( + 'UPDATE questionnaire_submission SET submittedAt = completedAt + WHERE completedAt IS NOT NULL AND submittedAt != completedAt' + ); + $changed = true; + } + $currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn(); if ($currentVersion < QDB_VERSION) { $pdo->exec('PRAGMA foreign_keys = OFF;'); diff --git a/handlers/clients.php b/handlers/clients.php index 3b6f779..5c3f492 100644 --- a/handlers/clients.php +++ b/handlers/clients.php @@ -11,13 +11,26 @@ case 'GET': try { [$pdo, $tmpDb, $lockFp] = qdb_open(false); + $clientCode = trim((string)($_GET['clientCode'] ?? '')); + if ($clientCode !== '' && !empty($_GET['detail'])) { + require_once __DIR__ . '/../lib/submissions.php'; + $detail = qdb_client_detail($pdo, $tokenRec, $clientCode); + qdb_discard($tmpDb, $lockFp); + json_success($detail); + } + [$clause, $params] = rbac_client_filter($tokenRec, 'cl'); $stmt = $pdo->prepare( - "SELECT cl.clientCode, cl.coachID, co.username AS coachUsername + "SELECT cl.clientCode, cl.coachID, co.username AS coachUsername, + CASE WHEN EXISTS ( + SELECT 1 FROM completed_questionnaire cq WHERE cq.clientCode = cl.clientCode + ) OR EXISTS ( + SELECT 1 FROM questionnaire_submission qs WHERE qs.clientCode = cl.clientCode + ) THEN 1 ELSE 0 END AS hasResponseData FROM client cl LEFT JOIN coach co ON co.coachID = cl.coachID WHERE $clause - ORDER BY cl.clientCode" + ORDER BY hasResponseData DESC, cl.clientCode ASC" ); foreach ($params as $k => $v) $stmt->bindValue($k, $v); $stmt->execute(); @@ -98,12 +111,11 @@ case 'DELETE': json_error('NOT_FOUND', 'Client not found or not authorized', 404); } + require_once __DIR__ . '/../lib/submissions.php'; + $pdo->beginTransaction(); - $pdo->prepare("DELETE FROM client_answer WHERE clientCode = :cc") - ->execute([':cc' => $clientCode]); - $pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode = :cc") - ->execute([':cc' => $clientCode]); - $pdo->prepare("DELETE FROM client WHERE clientCode = :cc") + qdb_delete_client_response_data($pdo, [$clientCode]); + $pdo->prepare('DELETE FROM client WHERE clientCode = :cc') ->execute([':cc' => $clientCode]); $pdo->commit(); diff --git a/lib/analytics.php b/lib/analytics.php index 68556c4..4da067c 100644 --- a/lib/analytics.php +++ b/lib/analytics.php @@ -12,27 +12,24 @@ function qdb_analytics_submissions_by_day(PDO $pdo, array $tokenRec, int $days = $days = max(1, min(90, $days)); [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); - $today = strtotime('today'); - $startTs = $today - ($days - 1) * 86400; + $startTs = strtotime('today', time()) - ($days - 1) * 86400; $stmt = $pdo->prepare( - "SELECT strftime('%Y-%m-%d', qs.submittedAt, 'unixepoch') AS d, COUNT(*) AS cnt + "SELECT qs.submittedAt FROM questionnaire_submission qs INNER JOIN client cl ON cl.clientCode = qs.clientCode - WHERE $rbacClause AND qs.submittedAt >= :start - GROUP BY d - ORDER BY d" + WHERE $rbacClause AND qs.submittedAt >= :start" ); $stmt->execute(array_merge($rbacParams, [':start' => $startTs])); $byDate = []; - foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { - $byDate[$row['d']] = (int)$row['cnt']; + foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $submittedAt) { + $key = date('Y-m-d', (int)$submittedAt); + $byDate[$key] = ($byDate[$key] ?? 0) + 1; } $out = []; for ($i = 0; $i < $days; $i++) { - $ts = $startTs + $i * 86400; - $key = date('Y-m-d', $ts); + $key = date('Y-m-d', $startTs + $i * 86400); $out[] = ['date' => $key, 'count' => $byDate[$key] ?? 0]; } return $out; diff --git a/lib/dev_fixture.php b/lib/dev_fixture.php index 89a9dc5..0bd56a3 100644 --- a/lib/dev_fixture.php +++ b/lib/dev_fixture.php @@ -15,6 +15,7 @@ const QDB_DEV_GLASS_POINTS = [ ]; require_once __DIR__ . '/app_answers.php'; +require_once __DIR__ . '/submissions.php'; /** * @return array{imported: array, skipped: array, errors: string[]} @@ -33,6 +34,7 @@ function qdb_import_dev_fixture(PDO $pdo, array $fixture): array 'coaches' => 0, 'clients' => 0, 'completions' => 0, + 'submissions' => 0, 'answers' => 0, ]; $skipped = [ @@ -165,21 +167,57 @@ function qdb_import_dev_fixture(PDO $pdo, array $fixture): array continue; } - $completedAt = isset($row['completedAt']) ? (int)$row['completedAt'] : ($now - ($variant % 90) * 86400); - $startedAt = isset($row['startedAt']) ? (int)$row['startedAt'] : ($completedAt - 600); - - $answers = qdb_dev_build_answers_for_questionnaire($pdo, $qnID, $variant); - $answerCount = qdb_dev_persist_submission( - $pdo, - $qnID, - $clientCode, - (string)$coachID, - $answers, - $startedAt, - $completedAt + $coachUserStmt = $pdo->prepare( + "SELECT u.userID FROM users u + WHERE u.role = 'coach' AND u.entityID = :cid LIMIT 1" ); + $coachUserStmt->execute([':cid' => $coachID]); + $coachUserID = (string)($coachUserStmt->fetchColumn() ?: ''); + $tokenRec = ['userID' => $coachUserID, 'role' => 'coach']; + + $uploads = qdb_dev_normalize_uploads($row, $variant, $now); + $archiveSubmissions = qdb_table_exists($pdo, 'questionnaire_submission'); + + foreach ($uploads as $uploadIndex => $upload) { + $variantSeed = (int)($upload['variant'] ?? ($variant + $uploadIndex)); + $completedAt = (int)($upload['submittedAt'] ?? $upload['completedAt'] ?? $now); + $startedAt = (int)($upload['startedAt'] ?? ($completedAt - 600)); + + $answers = qdb_dev_build_answers_for_questionnaire($pdo, $qnID, $variantSeed); + $answerCount = qdb_dev_persist_submission( + $pdo, + $qnID, + $clientCode, + (string)$coachID, + $answers, + $startedAt, + $completedAt + ); + $imported['answers'] += $answerCount; + + if ($archiveSubmissions) { + $spStmt = $pdo->prepare( + 'SELECT sumPoints FROM completed_questionnaire + WHERE clientCode = :cc AND questionnaireID = :qn' + ); + $spStmt->execute([':cc' => $clientCode, ':qn' => $qnID]); + $sumPoints = (int)($spStmt->fetchColumn() ?: 0); + qdb_record_submission_after_submit( + $pdo, + $clientCode, + $qnID, + $tokenRec, + $startedAt, + $completedAt, + $sumPoints, + (string)$coachID, + $completedAt + ); + $imported['submissions']++; + } + } + $imported['completions']++; - $imported['answers'] += $answerCount; $variant++; } @@ -200,12 +238,56 @@ function qdb_dev_validate_fixture(array $fixture): string if ($prefix === '' || !preg_match('/^dev[a-z0-9_]*$/i', $prefix)) { json_error('INVALID_FIELD', 'fixture.prefix must start with "dev" (e.g. dev_)', 400); } - if ((int)($fixture['fixtureVersion'] ?? 0) !== 1) { - json_error('INVALID_FIELD', 'fixtureVersion must be 1', 400); + $fixtureVersion = (int)($fixture['fixtureVersion'] ?? 0); + if ($fixtureVersion !== 1 && $fixtureVersion !== 2) { + json_error('INVALID_FIELD', 'fixtureVersion must be 1 or 2', 400); } return $prefix; } +/** + * @return list + */ +function qdb_dev_normalize_uploads(array $row, int $fallbackVariant, int $now): array +{ + if (!empty($row['uploads']) && is_array($row['uploads'])) { + $out = []; + foreach ($row['uploads'] as $i => $upload) { + if (!is_array($upload)) { + continue; + } + $submittedAt = (int)($upload['submittedAt'] ?? $upload['completedAt'] ?? $now); + $startedAt = (int)($upload['startedAt'] ?? ($submittedAt - 600)); + $out[] = [ + 'submittedAt' => $submittedAt, + 'startedAt' => $startedAt, + 'variant' => (int)($upload['variant'] ?? ($fallbackVariant + $i)), + ]; + } + if ($out !== []) { + usort($out, static fn($a, $b) => $a['submittedAt'] <=> $b['submittedAt']); + return $out; + } + } + + $versionCount = max(1, (int)($row['versions'] ?? $row['uploadCount'] ?? 1)); + $finalCompleted = isset($row['completedAt']) ? (int)$row['completedAt'] : ($now - ($fallbackVariant % 90) * 86400); + $uploads = []; + for ($v = 0; $v < $versionCount; $v++) { + $gapDays = ($versionCount - 1 - $v) * 14 + ($fallbackVariant % 5); + $completedAt = $finalCompleted - ($gapDays * 86400); + $uploads[] = [ + 'submittedAt' => $completedAt, + 'startedAt' => isset($row['startedAt']) && $v === $versionCount - 1 + ? (int)$row['startedAt'] + : ($completedAt - 600 - ($v * 120)), + 'variant' => $fallbackVariant + $v, + ]; + } + usort($uploads, static fn($a, $b) => $a['submittedAt'] <=> $b['submittedAt']); + return $uploads; +} + function qdb_dev_has_prefix(string $value, string $prefix): bool { return str_starts_with(strtolower($value), strtolower($prefix)) @@ -603,6 +685,8 @@ function qdb_remove_dev_test_data(PDO $pdo, array $options = []): array $clientLike = $clientPrefix . '%'; $deleted = [ + 'submissions' => 0, + 'followup_notes' => 0, 'client_answers' => 0, 'completed_questionnaires' => 0, 'clients' => 0, @@ -673,17 +757,15 @@ function qdb_remove_dev_test_data(PDO $pdo, array $options = []): array $pdo->beginTransaction(); try { - $deleted['client_answers'] = qdb_dev_delete_by_ids($pdo, 'client_answer', 'clientCode', $clientCodeList); - if ($clientCodeList !== []) { - foreach (array_chunk($clientCodeList, 200) as $chunk) { - $ph = implode(',', array_fill(0, count($chunk), '?')); - $delCq = $pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode IN ($ph)"); - $delCq->execute($chunk); - $deleted['completed_questionnaires'] += $delCq->rowCount(); - } + $responseDeleted = qdb_delete_client_response_data($pdo, $clientCodeList); + $deleted['submissions'] += $responseDeleted['submissions']; + $deleted['followup_notes'] += $responseDeleted['followup_notes']; + $deleted['client_answers'] += $responseDeleted['client_answers']; + $deleted['completed_questionnaires'] += $responseDeleted['completed_questionnaires']; } if ($coachIdList !== []) { + $deleted['submissions'] += qdb_delete_submissions_for_coaches($pdo, $coachIdList); $deleted['completed_questionnaires'] += qdb_dev_delete_by_ids( $pdo, 'completed_questionnaire', diff --git a/lib/submissions.php b/lib/submissions.php index 0cc8c9c..6d4b46f 100644 --- a/lib/submissions.php +++ b/lib/submissions.php @@ -23,11 +23,12 @@ function qdb_record_submission_after_submit( ?int $startedAt, ?int $completedAt, int $sumPoints, - string $assignedByCoach + string $assignedByCoach, + ?int $submittedAt = null ): string { $version = qdb_next_submission_version($pdo, $clientCode, $questionnaireID); $submissionID = bin2hex(random_bytes(16)); - $now = time(); + $submittedAtValue = $submittedAt ?? $completedAt ?? time(); $cq = $pdo->prepare( 'SELECT status FROM completed_questionnaire WHERE clientCode = :cc AND questionnaireID = :qn' @@ -50,7 +51,7 @@ function qdb_record_submission_after_submit( ':cc' => $clientCode, ':qn' => $questionnaireID, ':ver' => $version, - ':sat' => $now, + ':sat' => $submittedAtValue, ':uid' => $tokenRec['userID'] ?? '', ':role' => $tokenRec['role'] ?? '', ':abc' => $assignedByCoach !== '' ? $assignedByCoach : null, @@ -451,3 +452,285 @@ function qdb_build_server_export_zip(PDO $pdo, array $tokenRec, bool $allVersion return $tmpZip; } + +/** + * Delete uploads, answers, and completions for client codes (does not delete client rows). + * + * @param list $clientCodes + * @return array{submissions: int, followup_notes: int, client_answers: int, completed_questionnaires: int} + */ +function qdb_delete_client_response_data(PDO $pdo, array $clientCodes): array { + $clientCodes = array_values(array_unique(array_filter( + $clientCodes, + static fn($c) => is_string($c) && $c !== '' + ))); + $deleted = [ + 'submissions' => 0, + 'followup_notes' => 0, + 'client_answers' => 0, + 'completed_questionnaires' => 0, + ]; + if ($clientCodes === []) { + return $deleted; + } + + foreach (array_chunk($clientCodes, 200) as $chunk) { + $ph = implode(',', array_fill(0, count($chunk), '?')); + if (qdb_table_exists($pdo, 'questionnaire_submission')) { + $stmt = $pdo->prepare("DELETE FROM questionnaire_submission WHERE clientCode IN ($ph)"); + $stmt->execute($chunk); + $deleted['submissions'] += $stmt->rowCount(); + } + if (qdb_table_exists($pdo, 'client_followup_note')) { + $stmt = $pdo->prepare("DELETE FROM client_followup_note WHERE clientCode IN ($ph)"); + $stmt->execute($chunk); + $deleted['followup_notes'] += $stmt->rowCount(); + } + $stmt = $pdo->prepare("DELETE FROM client_answer WHERE clientCode IN ($ph)"); + $stmt->execute($chunk); + $deleted['client_answers'] += $stmt->rowCount(); + $stmt = $pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode IN ($ph)"); + $stmt->execute($chunk); + $deleted['completed_questionnaires'] += $stmt->rowCount(); + } + + return $deleted; +} + +/** + * Remove submission rows assigned to coaches (FK before coach delete). + * + * @param list $coachIDs + */ +function qdb_delete_submissions_for_coaches(PDO $pdo, array $coachIDs): int { + if (!qdb_table_exists($pdo, 'questionnaire_submission')) { + return 0; + } + $coachIDs = array_values(array_unique(array_filter( + $coachIDs, + static fn($id) => is_string($id) && $id !== '' + ))); + if ($coachIDs === []) { + return 0; + } + $total = 0; + foreach (array_chunk($coachIDs, 200) as $chunk) { + $ph = implode(',', array_fill(0, count($chunk), '?')); + $stmt = $pdo->prepare("DELETE FROM questionnaire_submission WHERE assignedByCoach IN ($ph)"); + $stmt->execute($chunk); + $total += $stmt->rowCount(); + } + return $total; +} + +/** + * @param array> $answerMap questionID => answer row + * @param array>|null $compareMap live answers to diff against + * @return list + */ +function qdb_answers_display_rows( + PDO $pdo, + array $resultColumns, + array $answerMap, + array $optionTextMap, + array &$stringLabelCache, + ?array $compareMap = null +): array { + $rows = []; + foreach ($resultColumns as $col) { + $qid = $col['questionID']; + $a = $answerMap[$qid] ?? null; + $value = qdb_display_column_cell_value($pdo, $col, $a, $optionTextMap, $stringLabelCache); + $changedFromLive = false; + if ($compareMap !== null) { + $liveValue = qdb_display_column_cell_value( + $pdo, + $col, + $compareMap[$qid] ?? null, + $optionTextMap, + $stringLabelCache + ); + $changedFromLive = $value !== $liveValue; + } + $rows[] = [ + 'label' => $col['header'], + 'value' => $value, + 'changedFromLive' => $changedFromLive, + ]; + } + return $rows; +} + +/** + * @param list $questionIDs + * @return array> + */ +function qdb_load_client_answer_map( + PDO $pdo, + string $clientCode, + array $questionIDs, + string $table = 'client_answer', + ?string $submissionID = null +): array { + if ($questionIDs === []) { + return []; + } + $allowed = ['client_answer', 'client_answer_submission']; + if (!in_array($table, $allowed, true)) { + return []; + } + + $ph = implode(',', array_fill(0, count($questionIDs), '?')); + if ($table === 'client_answer_submission') { + if ($submissionID === null || $submissionID === '') { + return []; + } + $sql = "SELECT questionID, answerOptionID, freeTextValue, numericValue + FROM client_answer_submission + WHERE submissionID = ? AND questionID IN ($ph)"; + $params = array_merge([$submissionID], $questionIDs); + } else { + $sql = "SELECT questionID, answerOptionID, freeTextValue, numericValue + FROM client_answer + WHERE clientCode = ? AND questionID IN ($ph)"; + $params = array_merge([$clientCode], $questionIDs); + } + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $map = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $map[$row['questionID']] = $row; + } + return $map; +} + +/** + * Full client response detail for the web clients list (RBAC-scoped). + */ +function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array { + $clientCode = trim($clientCode); + if ($clientCode === '') { + json_error('INVALID_FIELD', 'clientCode is required', 400); + } + + [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); + $stmt = $pdo->prepare( + "SELECT cl.clientCode, cl.coachID, + co.username AS coachUsername, + sv.username AS supervisorUsername + FROM client cl + LEFT JOIN coach co ON co.coachID = cl.coachID + LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID + WHERE cl.clientCode = :cc AND $rbacClause" + ); + $stmt->execute(array_merge([':cc' => $clientCode], $rbacParams)); + $client = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$client) { + json_error('NOT_FOUND', 'Client not found', 404); + } + + $qnStmt = $pdo->prepare( + "SELECT q.questionnaireID, q.name, q.version AS questionnaireVersion, q.orderIndex + FROM questionnaire q + WHERE q.questionnaireID IN ( + SELECT questionnaireID FROM completed_questionnaire WHERE clientCode = :cc + UNION + SELECT questionnaireID FROM questionnaire_submission WHERE clientCode = :cc2 + ) + ORDER BY q.orderIndex, q.name" + ); + $qnStmt->execute([':cc' => $clientCode, ':cc2' => $clientCode]); + $qnRows = $qnStmt->fetchAll(PDO::FETCH_ASSOC); + + $questionnaires = []; + foreach ($qnRows as $qn) { + $qnID = $qn['questionnaireID']; + $ctx = qdb_display_questionnaire_context($pdo, $qnID); + $questionIDs = array_column($ctx['questions'], 'questionID'); + $resultColumns = $ctx['resultColumns']; + $optionTextMap = $ctx['optionTextMap']; + $stringLabelCache = $ctx['stringLabelCache']; + + $cqStmt = $pdo->prepare( + 'SELECT status, sumPoints, startedAt, completedAt + FROM completed_questionnaire + WHERE clientCode = :cc AND questionnaireID = :qn' + ); + $cqStmt->execute([':cc' => $clientCode, ':qn' => $qnID]); + $cq = $cqStmt->fetch(PDO::FETCH_ASSOC) ?: []; + + $liveMap = qdb_load_client_answer_map($pdo, $clientCode, $questionIDs); + $currentAnswers = qdb_answers_display_rows( + $pdo, + $resultColumns, + $liveMap, + $optionTextMap, + $stringLabelCache, + null + ); + + $subStmt = $pdo->prepare( + 'SELECT submissionID, version, submittedAt, status, sumPoints, completedAt + FROM questionnaire_submission + WHERE clientCode = :cc AND questionnaireID = :qn + ORDER BY version DESC' + ); + $subStmt->execute([':cc' => $clientCode, ':qn' => $qnID]); + $submissions = []; + foreach ($subStmt->fetchAll(PDO::FETCH_ASSOC) as $s) { + $subMap = qdb_load_client_answer_map( + $pdo, + $clientCode, + $questionIDs, + 'client_answer_submission', + $s['submissionID'] + ); + $versionAnswers = qdb_answers_display_rows( + $pdo, + $resultColumns, + $subMap, + $optionTextMap, + $stringLabelCache, + $liveMap + ); + $changedCount = count(array_filter( + $versionAnswers, + static fn($r) => !empty($r['changedFromLive']) + )); + $submissions[] = [ + 'submissionID' => $s['submissionID'], + 'version' => (int)$s['version'], + 'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '', + 'status' => $s['status'] ?? '', + 'sumPoints' => $s['sumPoints'] !== null ? (int)$s['sumPoints'] : null, + 'completedAt' => $s['completedAt'] ? date('Y-m-d H:i', (int)$s['completedAt']) : '', + 'changedCount' => $changedCount, + 'answers' => $versionAnswers, + ]; + } + + $questionnaires[] = [ + 'questionnaireID' => $qnID, + 'name' => $qn['name'], + 'questionnaireVersion' => $qn['questionnaireVersion'] ?? '', + 'status' => $cq['status'] ?? '', + 'sumPoints' => isset($cq['sumPoints']) ? (int)$cq['sumPoints'] : null, + 'startedAt' => !empty($cq['startedAt']) ? date('Y-m-d H:i', (int)$cq['startedAt']) : '', + 'completedAt' => !empty($cq['completedAt']) ? date('Y-m-d H:i', (int)$cq['completedAt']) : '', + 'submissionCount' => count($submissions), + 'currentAnswers' => $currentAnswers, + 'submissions' => $submissions, + ]; + } + + return [ + 'client' => [ + 'clientCode' => $client['clientCode'], + 'coachID' => $client['coachID'] ?? '', + 'coachUsername' => $client['coachUsername'] ?? '', + 'supervisorUsername' => $client['supervisorUsername'] ?? '', + ], + 'questionnaires' => $questionnaires, + ]; +} diff --git a/scripts/generate_dev_fixture.py b/scripts/generate_dev_fixture.py index 367b0ad..458d191 100644 --- a/scripts/generate_dev_fixture.py +++ b/scripts/generate_dev_fixture.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """Generate dev-test-fixture.json for admin Dev tab import.""" import json +import random +from datetime import datetime, timedelta from pathlib import Path PREFIX = "dev_" @@ -8,7 +10,10 @@ PASSWORD = "socialvrlab" CLIENT_PREFIX = "DEV-CL-" SCALE = 5 -# Newest questionnaire bundle (repo root); IDs are read from this file when present. +# Fixed RNG + anchor time so the file is reproducible but not grid-like. +RNG = random.Random(42) +ANCHOR = datetime(2026, 5, 27, 15, 30, 0) + BUNDLE_PATH = Path(__file__).resolve().parents[2] / "questionnaires_bundle_2026-05-28.json" FALLBACK_QUESTIONNAIRE_IDS = [ @@ -20,13 +25,15 @@ FALLBACK_QUESTIONNAIRE_IDS = [ "questionnaire_6_follow_up_survey", ] -PER_QUESTIONNAIRE = 40 * SCALE NUM_ADMINS = 2 * SCALE NUM_SUPERVISORS = 3 * SCALE NUM_COACHES = 20 * SCALE NUM_CLIENTS = 100 * SCALE CLIENTS_WITHOUT_COMPLETIONS = 15 * SCALE +# Roughly how many clients (with a coach) get at least one questionnaire. +COMPLETION_PARTICIPATION = 0.82 + def load_questionnaire_ids() -> list[str]: if not BUNDLE_PATH.is_file(): @@ -40,6 +47,111 @@ def load_questionnaire_ids() -> list[str]: return ids or FALLBACK_QUESTIONNAIRE_IDS +def ts_days_ago(min_days: float, max_days: float) -> int: + days = RNG.uniform(min_days, max_days) + return int((ANCHOR - timedelta(days=days)).timestamp()) + + +def variant_seed(client_code: str, qn_id: str, version_index: int) -> int: + raw = f"{client_code}:{qn_id}:v{version_index}" + return int.from_bytes(raw.encode(), "big") % 100_000 + + +def assign_coaches(clients: list[dict], coaches: list[dict]) -> None: + """Uneven coach load: a few busy coaches, several light ones.""" + coach_names = [c["username"] for c in coaches] + weights = [] + for i, _ in enumerate(coach_names): + # Coaches 1–4 and 7, 12 get more clients; tail is lighter. + base = RNG.uniform(0.4, 1.0) + if (i % 7) in (0, 1, 2): + base *= RNG.uniform(2.0, 4.5) + if i % 11 == 0: + base *= RNG.uniform(0.15, 0.45) + weights.append(base) + + for row in clients: + row["coach"] = RNG.choices(coach_names, weights=weights, k=1)[0] + + +def build_completions( + questionnaire_ids: list[str], + clients_with_data: list[str], +) -> list[dict]: + """ + Natural-ish completion patterns: + - Sequential questionnaire funnel (not random qn per slot) + - Irregular timestamps and gaps between uploads + - ~20% of client/qn pairs have 2–4 archived versions + """ + completions: list[dict] = [] + qn_count = len(questionnaire_ids) + + # How many questionnaires a client completes (skewed toward mid funnel). + qn_count_weights = [8, 14, 22, 24, 18, 10, 4][:qn_count] + if len(qn_count_weights) < qn_count: + qn_count_weights += [2] * (qn_count - len(qn_count_weights)) + + for client_code in clients_with_data: + if RNG.random() > COMPLETION_PARTICIPATION: + continue + + num_qn = RNG.choices( + list(range(1, qn_count + 1)), + weights=qn_count_weights[:qn_count], + k=1, + )[0] + selected = questionnaire_ids[:num_qn] + + # First activity somewhere in the last ~6 months; some clients are very recent. + cursor = ts_days_ago(3, 185) + if RNG.random() < 0.12: + cursor = ts_days_ago(0.5, 14) + + for qn_id in selected: + version_count = 1 + roll = RNG.random() + if roll < 0.08 and num_qn >= 2: + version_count = 4 + elif roll < 0.20: + version_count = 3 + elif roll < 0.38: + version_count = 2 + + uploads = [] + for v in range(version_count): + if v > 0: + # Re-upload after days or weeks (sometimes same day correction). + if RNG.random() < 0.18: + gap_sec = RNG.randint(2 * 3600, 36 * 3600) + else: + gap_sec = RNG.randint(2 * 86400, 55 * 86400) + cursor += gap_sec + else: + # First upload for this qn: often a few days after previous qn. + cursor += RNG.randint(0, 12 * 86400) + + duration = RNG.randint(4 * 60, 55 * 60) + started = cursor - duration + completed = cursor + RNG.randint(0, 120) + + uploads.append({ + "submittedAt": completed, + "startedAt": started, + "variant": variant_seed(client_code, qn_id, v), + }) + + completions.append({ + "clientCode": client_code, + "questionnaireID": qn_id, + "versions": version_count, + "uploads": uploads, + }) + + RNG.shuffle(completions) + return completions + + def main(): questionnaire_ids = load_questionnaire_ids() @@ -59,40 +171,27 @@ def main(): "supervisor": f"{PREFIX}supervisor_{sv}", }) - clients = [] - for i in range(1, NUM_CLIENTS + 1): - coach_idx = (i - 1) // 5 + 1 - if coach_idx > NUM_COACHES: - coach_idx = NUM_COACHES - clients.append({ - "clientCode": f"{CLIENT_PREFIX}{i:04d}", - "coach": f"{PREFIX}coach_{coach_idx:02d}", - }) - - clients_with_data = [ - c["clientCode"] - for c in clients - if int(c["clientCode"].split("-")[-1]) <= NUM_CLIENTS - CLIENTS_WITHOUT_COMPLETIONS + clients = [ + {"clientCode": f"{CLIENT_PREFIX}{i:04d}"} + for i in range(1, NUM_CLIENTS + 1) ] + assign_coaches(clients, coaches) - completions = [] - slot = 0 - for qn_id in questionnaire_ids: - for _ in range(PER_QUESTIONNAIRE): - client_code = clients_with_data[slot % len(clients_with_data)] - completions.append({ - "clientCode": client_code, - "questionnaireID": qn_id, - }) - slot += 1 + all_codes = [c["clientCode"] for c in clients] + no_data_codes = set(RNG.sample(all_codes, min(CLIENTS_WITHOUT_COMPLETIONS, len(all_codes)))) + clients_with_data = [c for c in all_codes if c not in no_data_codes] + + completions = build_completions(questionnaire_ids, clients_with_data) + total_uploads = sum(c["versions"] for c in completions) + multi_version_pairs = sum(1 for c in completions if c["versions"] > 1) bundle_note = BUNDLE_PATH.name if BUNDLE_PATH.is_file() else "fallback questionnaire list" fixture = { - "fixtureVersion": 1, + "fixtureVersion": 2, "description": ( - f"Dev/test users, clients, and completed questionnaires ({SCALE}x scale). " - f"Questionnaires aligned with {bundle_note}. Skip-on-conflict import; password socialvrlab." + f"Dev/test users, clients, completions with uneven coach load and upload history " + f"({SCALE}x scale). Questionnaires from {bundle_note}. Password: socialvrlab." ), "prefix": PREFIX, "defaultPassword": PASSWORD, @@ -110,8 +209,9 @@ def main(): "coaches": len(coaches), "clients": len(clients), "clientsWithoutCompletions": CLIENTS_WITHOUT_COMPLETIONS, - "completions": len(completions), - "perQuestionnaire": PER_QUESTIONNAIRE, + "completionRecords": len(completions), + "totalUploads": total_uploads, + "multiVersionPairs": multi_version_pairs, "questionnaires": len(questionnaire_ids), }, } diff --git a/website/css/style.css b/website/css/style.css index 3d9d891..de9e1b1 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -1889,6 +1889,45 @@ table.data-table tbody tr.row-orphan-category td { border-top: none; padding-top: 0; } +.client-detail-panel { + padding: 12px 8px 8px 32px; + background: var(--surface-muted); + border-radius: 8px; + margin: 4px 0 8px; +} +.client-detail-meta { + margin: 0 0 12px; + font-size: 0.85rem; + color: var(--text-secondary); +} +.client-qn-panel { + padding: 10px 8px 10px 28px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + margin-top: 8px; +} +.client-version-panel { + padding: 8px 8px 8px 28px; + margin-top: 8px; + border-left: 3px solid var(--primary-border); +} +.client-detail-row td { + border-top: none; + padding-top: 0; +} +tr.answer-changed td { + background: rgba(251, 191, 36, 0.1); +} +tr.answer-changed .answer-value-cell { + font-weight: 600; + color: var(--badge-warn-fg); +} +.client-qn-summary { + font-size: 0.82rem; + color: var(--text-secondary); + margin-left: 8px; +} input:disabled, select:disabled { diff --git a/website/js/pages/clients.js b/website/js/pages/clients.js index 8a9f679..06d3171 100644 --- a/website/js/pages/clients.js +++ b/website/js/pages/clients.js @@ -9,6 +9,10 @@ let coachesList = []; let filterSearch = ''; let filterTestData = ''; let page = 0; +let expandedClientCode = null; +let expandedQnKey = null; +let expandedVersionKey = null; +let detailByClient = {}; export async function clientsPage() { const app = document.getElementById('app'); @@ -77,6 +81,9 @@ function renderClients() { +

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

@@ -95,11 +102,13 @@ function renderClients() { document.getElementById('clientListSearch').addEventListener('input', (e) => { filterSearch = e.target.value.trim(); page = 0; + clearExpandIfHidden(); renderClientTableBody(); }); document.getElementById('clientTestFilter').addEventListener('change', (e) => { filterTestData = e.target.value; page = 0; + clearExpandIfHidden(); renderClientTableBody(); }); @@ -139,6 +148,19 @@ function renderClientTableBody() { body.innerHTML = ``; } else { body.innerHTML = slice.map(c => clientRowHTML(c)).join(''); + body.querySelectorAll('.client-expand-btn').forEach(btn => { + btn.addEventListener('click', () => toggleClient(btn.dataset.code)); + }); + body.querySelectorAll('.client-qn-expand-btn').forEach(btn => { + btn.addEventListener('click', () => toggleQn(btn.dataset.client, btn.dataset.qn)); + }); + body.querySelectorAll('.client-version-expand-btn').forEach(btn => { + btn.addEventListener('click', () => toggleVersion( + btn.dataset.client, + btn.dataset.qn, + btn.dataset.submission + )); + }); body.querySelectorAll('.delete-client-btn').forEach(btn => { btn.addEventListener('click', () => deleteClient(btn.dataset.code)); }); @@ -157,23 +179,216 @@ function renderClientTableBody() { Rows ${from}–${to} of ${filtered.length} `; - document.getElementById('clientsPagePrev')?.addEventListener('click', () => { page--; renderClientTableBody(); }); - document.getElementById('clientsPageNext')?.addEventListener('click', () => { page++; renderClientTableBody(); }); + document.getElementById('clientsPagePrev')?.addEventListener('click', () => { + page--; + clearExpandIfHidden(); + renderClientTableBody(); + }); + document.getElementById('clientsPageNext')?.addEventListener('click', () => { + page++; + clearExpandIfHidden(); + renderClientTableBody(); + }); +} + +function qnKey(clientCode, questionnaireID) { + return `${clientCode}|${questionnaireID}`; +} + +function versionKey(clientCode, questionnaireID, submissionID) { + return `${clientCode}|${questionnaireID}|${submissionID}`; +} + +function clearExpandIfHidden() { + const filtered = getFilteredClientsList(); + const slice = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); + if (expandedClientCode && !slice.some(c => c.clientCode === expandedClientCode)) { + expandedClientCode = null; + expandedQnKey = null; + expandedVersionKey = null; + } +} + +function answerTableHTML(rows, { highlightLiveDiff = false } = {}) { + if (!rows?.length) { + return '

No answers recorded

'; + } + return ` +
No clients match
+ + + ${rows.map(r => ` + + + + + `).join('')} + +
QuestionAnswer
${esc(r.label)}${esc(r.value || '—')}
`; +} + +function clientDetailPanelHTML(clientCode) { + const detail = detailByClient[clientCode]; + if (!detail) { + return '

Loading…

'; + } + const cl = detail.client || {}; + const questionnaires = detail.questionnaires || []; + if (!questionnaires.length) { + return ` +

+ Coach: ${esc(cl.coachUsername || '—')} + ${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''} +

+

No questionnaire data for this client yet.

`; + } + + return ` +

+ Coach: ${esc(cl.coachUsername || '—')} + ${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''} +

+ ${questionnaires.map(q => clientQnBlockHTML(clientCode, q)).join('')}`; +} + +function clientQnBlockHTML(clientCode, q) { + const key = qnKey(clientCode, q.questionnaireID); + const qnExpanded = expandedQnKey === key; + const meta = [ + q.status ? `Status: ${q.status}` : '', + q.sumPoints != null ? `Points: ${q.sumPoints}` : '', + q.completedAt ? `Completed: ${q.completedAt}` : '', + q.submissionCount ? `${q.submissionCount} upload(s)` : '', + ].filter(Boolean).join(' · '); + + const versionsHTML = (q.submissions || []).map(s => { + const vKey = versionKey(clientCode, q.questionnaireID, s.submissionID); + const vExpanded = expandedVersionKey === vKey; + const vMeta = [ + s.submittedAt ? `Uploaded: ${s.submittedAt}` : '', + s.status ? `Status: ${s.status}` : '', + s.sumPoints != null ? `Points: ${s.sumPoints}` : '', + ].filter(Boolean).join(' · '); + return ` +
+
+ + Version ${s.version} + ${s.changedCount > 0 + ? `${s.changedCount} diff vs live` + : 'Same as live'} + ${vMeta ? `${esc(vMeta)}` : ''} +
+ ${vExpanded ? ` +
+ ${s.changedCount > 0 + ? '

Highlighted rows differ from current live data.

' + : ''} + ${answerTableHTML(s.answers, { highlightLiveDiff: true })} +
+ ` : ''} +
`; + }).join(''); + + return ` +
+
+ + ${esc(q.name)} + ${q.questionnaireVersion ? `v${esc(q.questionnaireVersion)}` : ''} + ${meta ? `${esc(meta)}` : ''} +
+ ${qnExpanded ? ` +
+

Current data (live)

+ ${answerTableHTML(q.currentAnswers)} + ${(q.submissions || []).length ? ` +

Upload versions

+ ${versionsHTML} + ` : '

No archived upload versions.

'} +
+ ` : ''} +
`; +} + +function clientHasResponseData(c) { + return Number(c.hasResponseData) === 1; } function clientRowHTML(c) { const coach = c.coachUsername ? `${esc(c.coachUsername)}` : `Unassigned`; + const canExpand = clientHasResponseData(c); + const expanded = canExpand && expandedClientCode === c.clientCode; + const expandControl = canExpand + ? ` ` + : ''; return ` - ${esc(c.clientCode)} + + ${expandControl}${esc(c.clientCode)} + ${coach} - `; + + ${expanded ? ` + + +
${clientDetailPanelHTML(c.clientCode)}
+ + ` : ''}`; +} + +async function toggleClient(clientCode) { + const row = clientsList.find(c => c.clientCode === clientCode); + if (!row || !clientHasResponseData(row)) { + return; + } + if (expandedClientCode === clientCode) { + expandedClientCode = null; + expandedQnKey = null; + expandedVersionKey = null; + renderClientTableBody(); + return; + } + expandedClientCode = clientCode; + expandedQnKey = null; + expandedVersionKey = null; + renderClientTableBody(); + if (!detailByClient[clientCode]) { + try { + const data = await apiGet( + `clients.php?clientCode=${encodeURIComponent(clientCode)}&detail=1` + ); + detailByClient[clientCode] = data; + } catch (e) { + showToast(e.message, 'error'); + detailByClient[clientCode] = { client: {}, questionnaires: [] }; + } + renderClientTableBody(); + } +} + +function toggleQn(clientCode, questionnaireID) { + const key = qnKey(clientCode, questionnaireID); + expandedQnKey = expandedQnKey === key ? null : key; + expandedVersionKey = null; + renderClientTableBody(); +} + +function toggleVersion(clientCode, questionnaireID, submissionID) { + const key = versionKey(clientCode, questionnaireID, submissionID); + expandedVersionKey = expandedVersionKey === key ? null : key; + renderClientTableBody(); } async function deleteClient(clientCode) { @@ -181,6 +396,12 @@ async function deleteClient(clientCode) { try { await apiDelete('clients.php', { clientCode }); clientsList = clientsList.filter(c => c.clientCode !== clientCode); + delete detailByClient[clientCode]; + if (expandedClientCode === clientCode) { + expandedClientCode = null; + expandedQnKey = null; + expandedVersionKey = null; + } showToast(`Client "${clientCode}" deleted`, 'success'); if (!clientsList.length) renderClients(); else renderClientTableBody(); diff --git a/website/js/pages/dev.js b/website/js/pages/dev.js index a68ba06..f018c31 100644 --- a/website/js/pages/dev.js +++ b/website/js/pages/dev.js @@ -36,7 +36,7 @@ export function devPage() {
- Use dev-test-fixture.json from the repo root (generate via nat-as-server/scripts/generate_dev_fixture.py; reads questionnaires_bundle_2026-05-28.json, 5× scale). + Use dev-test-fixture.json from the repo root (generate via nat-as-server/scripts/generate_dev_fixture.py; uneven coach load, multi-version uploads, natural timestamps).
@@ -77,7 +77,8 @@ export function devPage() { ${parsedFixture.supervisors?.length ?? st.supervisors ?? 0} supervisors, ${parsedFixture.coaches?.length ?? st.coaches ?? 0} coaches, ${parsedFixture.clients?.length ?? st.clients ?? 0} clients, - ${parsedFixture.completions?.length ?? st.completions ?? 0} completions + ${parsedFixture.completions?.length ?? st.completionRecords ?? st.completions ?? 0} completions, + ${st.totalUploads ?? '—'} uploads (${st.multiVersionPairs ?? '—'} re-uploaded) `; summary.style.display = ''; btn.disabled = false; @@ -184,8 +185,9 @@ export function devPage() { const imp = res.imported || {}; const sk = res.skipped || {}; showToast( - `Imported: ${imp.completions ?? 0} completions, ${imp.clients ?? 0} clients, ` - + `${imp.coaches ?? 0} coaches (skipped ${sk.completions ?? 0} existing)`, + `Imported: ${imp.completions ?? 0} completions, ${imp.submissions ?? 0} uploads, ` + + `${imp.clients ?? 0} clients, ${imp.coaches ?? 0} coaches ` + + `(skipped ${sk.completions ?? 0} existing)`, 'success' ); if (res.errors?.length) {