diff --git a/handlers/results.php b/handlers/results.php index 234c97d..6103501 100644 --- a/handlers/results.php +++ b/handlers/results.php @@ -8,6 +8,7 @@ if ($method !== 'GET') { $qnID = $_GET['questionnaireID'] ?? ''; $clientCode = $_GET['clientCode'] ?? ''; +$allVersions = !empty($_GET['allVersions']); if (!$qnID) { json_error('MISSING_PARAM', 'questionnaireID query param required', 400); @@ -51,11 +52,40 @@ unset($q); [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); +if ($allVersions) { + require_once __DIR__ . '/../lib/submissions.php'; + $ctx = qdb_export_questionnaire_context($pdo, $qnID); + $submissions = qdb_results_submission_entries( + $pdo, + $qnID, + $ctx['questions'], + $ctx['resultColumns'], + $ctx['optionTextMap'], + $tokenRec + ); + if ($clientCode) { + $submissions = array_values(array_filter( + $submissions, + static fn(array $row): bool => ($row['clientCode'] ?? '') === $clientCode + )); + } + + qdb_discard($tmpDb, $lockFp); + + json_success([ + 'questionnaire' => $questionnaire, + 'questions' => $questions, + 'allVersions' => true, + 'submissions' => $submissions, + ]); +} + $sql = " SELECT cl.clientCode, cl.coachID, co.username AS coachUsername, sv.username AS supervisorUsername, - cq.status, cq.startedAt, cq.completedAt, cq.sumPoints, cq.assignedByCoach + cq.status, cq.startedAt, cq.completedAt, cq.sumPoints, cq.assignedByCoach, + cq.programCycleNumber, cq.programSessionID, cq.programSessionNumber FROM client cl INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid LEFT JOIN coach co ON co.coachID = cl.coachID @@ -86,6 +116,8 @@ if (!empty($questionIDs) && !empty($clients)) { $c['sumPoints'] = $c['sumPoints'] !== null ? (int)$c['sumPoints'] : null; $c['startedAt'] = $c['startedAt'] !== null ? (int)$c['startedAt'] : null; $c['completedAt'] = $c['completedAt'] !== null ? (int)$c['completedAt'] : null; + $c['programCycleNumber'] = $c['programCycleNumber'] !== null ? (int)$c['programCycleNumber'] : null; + $c['programSessionNumber'] = $c['programSessionNumber'] !== null ? (int)$c['programSessionNumber'] : null; $bindParams = array_merge([$c['clientCode']], $questionIDs); $answerStmt->execute($bindParams); @@ -107,6 +139,8 @@ if (!empty($questionIDs) && !empty($clients)) { $c['sumPoints'] = $c['sumPoints'] !== null ? (int)$c['sumPoints'] : null; $c['startedAt'] = $c['startedAt'] !== null ? (int)$c['startedAt'] : null; $c['completedAt'] = $c['completedAt'] !== null ? (int)$c['completedAt'] : null; + $c['programCycleNumber'] = $c['programCycleNumber'] !== null ? (int)$c['programCycleNumber'] : null; + $c['programSessionNumber'] = $c['programSessionNumber'] !== null ? (int)$c['programSessionNumber'] : null; $c['answers'] = (object)[]; } unset($c); @@ -117,6 +151,7 @@ if (!empty($questionIDs) && !empty($clients)) { json_success([ 'questionnaire' => $questionnaire, 'questions' => $questions, + 'allVersions' => false, 'clients' => $clients, ]); } catch (Throwable $e) { diff --git a/lib/submissions.php b/lib/submissions.php index 16d2432..4737833 100644 --- a/lib/submissions.php +++ b/lib/submissions.php @@ -289,16 +289,13 @@ function qdb_backfill_submissions_from_live(PDO $pdo): bool { } /** - * Build CSV rows for all submission versions of one questionnaire (RBAC-scoped). + * RBAC-scoped submission rows for one questionnaire (client, then version). * - * @return list> + * @return list> */ -function qdb_export_all_versions_rows( +function qdb_fetch_questionnaire_submissions_scoped( PDO $pdo, string $questionnaireID, - array $questions, - array $resultColumns, - array $optionTextMap, array $tokenRec ): array { [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); @@ -308,6 +305,7 @@ function qdb_export_all_versions_rows( qs.completedAt AS submissionCompletedAt, qs.sumPoints AS submissionSumPoints, qs.startedAt AS submissionStartedAt, qs.status AS submissionStatus, qs.structureRevision, qs.structureSnapshotJson, + qs.programCycleNumber, qs.programSessionID, qs.programSessionNumber, cl.clientCode, cl.coachID, co.username AS coachUsername, sv.username AS supervisorUsername @@ -321,58 +319,99 @@ function qdb_export_all_versions_rows( $params = array_merge([':qnid' => $questionnaireID], $rbacParams); $stmt = $pdo->prepare($sql); $stmt->execute($params); - $submissions = $stmt->fetchAll(PDO::FETCH_ASSOC); - $defaultQuestionIDs = array_column($questions, 'questionID'); + return $stmt->fetchAll(PDO::FETCH_ASSOC); +} +/** @return array */ +function qdb_submission_submitter_username_map(PDO $pdo): array { $userMap = []; $userStmt = $pdo->query('SELECT userID, username FROM users'); foreach ($userStmt->fetchAll(PDO::FETCH_ASSOC) as $u) { $userMap[$u['userID']] = $u['username']; } + return $userMap; +} + +/** + * Resolve per-submission question columns from structure snapshot or questionnaire defaults. + * + * @return array{submissionColumns: array, submissionOptionMap: array, submissionQuestionIDs: list} + */ +function qdb_submission_display_context( + PDO $pdo, + array $submissionRow, + array $defaultQuestions, + array $defaultResultColumns, + array $defaultOptionTextMap +): array { + $snap = json_decode($submissionRow['structureSnapshotJson'] ?? '{}', true) ?: []; + if (!empty($snap['questions'])) { + $snapCtx = qdb_display_context_from_manifest($pdo, $snap); + return [ + 'submissionColumns' => $snapCtx['resultColumns'], + 'submissionOptionMap' => $snapCtx['optionTextMap'], + 'submissionQuestionIDs' => array_column($snapCtx['questions'], 'questionID'), + ]; + } + return [ + 'submissionColumns' => $defaultResultColumns, + 'submissionOptionMap' => $defaultOptionTextMap, + 'submissionQuestionIDs' => array_column($defaultQuestions, 'questionID'), + ]; +} + +/** + * Build CSV rows for all submission versions of one questionnaire (RBAC-scoped). + * + * @return list> + */ +function qdb_export_all_versions_rows( + PDO $pdo, + string $questionnaireID, + array $questions, + array $resultColumns, + array $optionTextMap, + array $tokenRec +): array { + $submissions = qdb_fetch_questionnaire_submissions_scoped($pdo, $questionnaireID, $tokenRec); + $userMap = qdb_submission_submitter_username_map($pdo); $rows = []; foreach ($submissions as $s) { - $snap = json_decode($s['structureSnapshotJson'] ?? '{}', true) ?: []; - if (!empty($snap['questions'])) { - $snapCtx = qdb_display_context_from_manifest($pdo, $snap); - $submissionColumns = $snapCtx['resultColumns']; - $submissionOptionMap = $snapCtx['optionTextMap']; - $submissionQuestionIDs = array_column($snapCtx['questions'], 'questionID'); - } else { - $submissionColumns = $resultColumns; - $submissionOptionMap = $optionTextMap; - $submissionQuestionIDs = $defaultQuestionIDs; - } + $ctx = qdb_submission_display_context($pdo, $s, $questions, $resultColumns, $optionTextMap); $row = [ - 'submissionID' => $s['submissionID'], - 'version' => (string)(int)$s['version'], - 'structureRevision' => (string)max(1, (int)($s['structureRevision'] ?? 1)), - 'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '', - 'submittedByRole' => $s['submittedByRole'] ?? '', - 'submittedBy' => $userMap[$s['submittedByUserID'] ?? ''] ?? ($s['submittedByUserID'] ?? ''), - 'clientCode' => $s['clientCode'], - 'coach' => $s['coachUsername'] ?? $s['coachID'], - 'supervisor' => $s['supervisorUsername'] ?? '', - 'status' => $s['submissionStatus'] ?? '', - 'sumPoints' => $s['submissionSumPoints'] ?? '', - 'startedAt' => $s['submissionStartedAt'] ? date('Y-m-d H:i', (int)$s['submissionStartedAt']) : '', - 'completedAt' => $s['submissionCompletedAt'] ? date('Y-m-d H:i', (int)$s['submissionCompletedAt']) : '', + 'clientCode' => $s['clientCode'], + 'version' => (string)(int)$s['version'], + 'programCycleNumber' => $s['programCycleNumber'] !== null ? (string)(int)$s['programCycleNumber'] : '', + 'programSessionNumber' => $s['programSessionNumber'] !== null ? (string)(int)$s['programSessionNumber'] : '', + 'programSessionID' => $s['programSessionID'] ?? '', + 'startedAt' => $s['submissionStartedAt'] ? date('Y-m-d H:i', (int)$s['submissionStartedAt']) : '', + 'completedAt' => $s['submissionCompletedAt'] ? date('Y-m-d H:i', (int)$s['submissionCompletedAt']) : '', + 'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '', + 'coach' => $s['coachUsername'] ?? $s['coachID'], + 'supervisor' => $s['supervisorUsername'] ?? '', + 'status' => $s['submissionStatus'] ?? '', + 'sumPoints' => $s['submissionSumPoints'] ?? '', + 'submissionID' => $s['submissionID'], + 'structureRevision' => (string)max(1, (int)($s['structureRevision'] ?? 1)), + 'submittedByRole' => $s['submittedByRole'] ?? '', + 'submittedBy' => $userMap[$s['submittedByUserID'] ?? ''] ?? ($s['submittedByUserID'] ?? ''), ]; $answerMap = qdb_load_client_answer_map( $pdo, $s['clientCode'], - $submissionQuestionIDs, + $ctx['submissionQuestionIDs'], 'client_answer_submission', $s['submissionID'] ); - foreach ($submissionColumns as $col) { + foreach ($ctx['submissionColumns'] as $col) { $qid = $col['questionID']; $a = $answerMap[$qid] ?? null; - $row[$col['header']] = qdb_results_column_cell_value($col, $a, $submissionOptionMap); + $row[$col['header']] = qdb_results_column_cell_value($col, $a, $ctx['submissionOptionMap']); } $rows[] = $row; } @@ -380,6 +419,68 @@ function qdb_export_all_versions_rows( return $rows; } +/** + * Submission rows for the results API (all versions, grouped by client in sort order). + * + * @return list> + */ +function qdb_results_submission_entries( + PDO $pdo, + string $questionnaireID, + array $questions, + array $resultColumns, + array $optionTextMap, + array $tokenRec +): array { + $submissions = qdb_fetch_questionnaire_submissions_scoped($pdo, $questionnaireID, $tokenRec); + $userMap = qdb_submission_submitter_username_map($pdo); + $entries = []; + + foreach ($submissions as $s) { + $ctx = qdb_submission_display_context($pdo, $s, $questions, $resultColumns, $optionTextMap); + $answerMap = qdb_load_client_answer_map( + $pdo, + $s['clientCode'], + $ctx['submissionQuestionIDs'], + 'client_answer_submission', + $s['submissionID'] + ); + + $answers = []; + foreach ($answerMap as $qid => $a) { + $answers[$qid] = [ + 'answerOptionID' => $a['answerOptionID'], + 'freeTextValue' => $a['freeTextValue'], + 'numericValue' => $a['numericValue'] !== null ? (float)$a['numericValue'] : null, + 'answeredAt' => null, + ]; + } + + $entries[] = [ + 'submissionID' => $s['submissionID'], + 'version' => (int)$s['version'], + 'clientCode' => $s['clientCode'], + 'coachID' => $s['coachID'], + 'coachUsername' => $s['coachUsername'], + 'supervisorUsername' => $s['supervisorUsername'], + 'status' => $s['submissionStatus'] ?? '', + 'sumPoints' => $s['submissionSumPoints'] !== null ? (int)$s['submissionSumPoints'] : null, + 'startedAt' => $s['submissionStartedAt'] !== null ? (int)$s['submissionStartedAt'] : null, + 'completedAt' => $s['submissionCompletedAt'] !== null ? (int)$s['submissionCompletedAt'] : null, + 'submittedAt' => $s['submittedAt'] !== null ? (int)$s['submittedAt'] : null, + 'programCycleNumber' => $s['programCycleNumber'] !== null ? (int)$s['programCycleNumber'] : null, + 'programSessionID' => $s['programSessionID'], + 'programSessionNumber' => $s['programSessionNumber'] !== null ? (int)$s['programSessionNumber'] : null, + 'structureRevision' => max(1, (int)($s['structureRevision'] ?? 1)), + 'submittedByRole' => $s['submittedByRole'] ?? '', + 'submittedBy' => $userMap[$s['submittedByUserID'] ?? ''] ?? ($s['submittedByUserID'] ?? ''), + 'answers' => $answers, + ]; + } + + return $entries; +} + /** * @return array{questions: array, resultColumns: array, optionTextMap: array} */ @@ -428,7 +529,8 @@ function qdb_export_current_rows( SELECT cl.clientCode, cl.coachID, co.username AS coachUsername, sv.username AS supervisorUsername, - cq.status, cq.sumPoints, cq.startedAt, cq.completedAt + cq.status, cq.sumPoints, cq.startedAt, cq.completedAt, + cq.programCycleNumber, cq.programSessionID, cq.programSessionNumber FROM client cl INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid LEFT JOIN coach co ON co.coachID = cl.coachID @@ -451,13 +553,16 @@ function qdb_export_current_rows( $rows = []; foreach ($clients as $c) { $row = [ - 'clientCode' => $c['clientCode'], - 'coach' => $c['coachUsername'] ?? $c['coachID'], - 'supervisor' => $c['supervisorUsername'] ?? '', - 'status' => $c['status'], - 'sumPoints' => $c['sumPoints'], - 'startedAt' => $c['startedAt'] ? date('Y-m-d H:i', (int)$c['startedAt']) : '', - 'completedAt' => $c['completedAt'] ? date('Y-m-d H:i', (int)$c['completedAt']) : '', + 'clientCode' => $c['clientCode'], + 'programCycleNumber' => $c['programCycleNumber'] !== null ? (string)(int)$c['programCycleNumber'] : '', + 'programSessionNumber' => $c['programSessionNumber'] !== null ? (string)(int)$c['programSessionNumber'] : '', + 'programSessionID' => $c['programSessionID'] ?? '', + 'startedAt' => $c['startedAt'] ? date('Y-m-d H:i', (int)$c['startedAt']) : '', + 'completedAt' => $c['completedAt'] ? date('Y-m-d H:i', (int)$c['completedAt']) : '', + 'coach' => $c['coachUsername'] ?? $c['coachID'], + 'supervisor' => $c['supervisorUsername'] ?? '', + 'status' => $c['status'], + 'sumPoints' => $c['sumPoints'], ]; $bindParams = array_merge([$c['clientCode']], $questionIDs); @@ -523,7 +628,10 @@ function qdb_export_rows_to_csv_string(array $rows, array $fallbackHeader = []): } function qdb_export_current_csv_fallback_header(array $resultColumns): array { - $header = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt']; + $header = [ + 'clientCode', 'programCycleNumber', 'programSessionNumber', 'programSessionID', + 'startedAt', 'completedAt', 'coach', 'supervisor', 'status', 'sumPoints', + ]; foreach ($resultColumns as $col) { $header[] = $col['header']; } @@ -532,8 +640,10 @@ function qdb_export_current_csv_fallback_header(array $resultColumns): array { function qdb_export_all_versions_csv_fallback_header(array $resultColumns): array { $header = [ - 'submissionID', 'version', 'submittedAt', 'submittedByRole', 'submittedBy', - 'clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt', + 'clientCode', 'version', 'programCycleNumber', 'programSessionNumber', 'programSessionID', + 'startedAt', 'completedAt', 'submittedAt', + 'coach', 'supervisor', 'status', 'sumPoints', + 'submissionID', 'structureRevision', 'submittedByRole', 'submittedBy', ]; foreach ($resultColumns as $col) { $header[] = $col['header']; diff --git a/website/css/style.css b/website/css/style.css index ec4549b..c737b2a 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -929,6 +929,9 @@ table.data-table .sticky-col { background: var(--surface); box-shadow: 2px 0 4px var(--sticky-shadow); } +table.data-table tr.client-group-row td { + border-top: 2px solid color-mix(in srgb, var(--primary) 35%, var(--border)); +} table.data-table thead th.sticky-col { z-index: 5; top: 0; diff --git a/website/js/pages/export.js b/website/js/pages/export.js index 314f8d9..aef0ee1 100644 --- a/website/js/pages/export.js +++ b/website/js/pages/export.js @@ -48,7 +48,7 @@ function renderExport() { ${bundleBlock}

- Select a questionnaire and click Export to download a CSV file with all client responses (filtered by your role's visibility). + Download CSV files with client responses (filtered by your role). Use All versions for every upload per client, including cycle, session, and start/finish times.

diff --git a/website/js/pages/results.js b/website/js/pages/results.js index 5ad7216..37b945a 100644 --- a/website/js/pages/results.js +++ b/website/js/pages/results.js @@ -1,4 +1,4 @@ -import { apiGet } from '../api.js'; +import { apiGet, apiUrl, apiDownloadFetch } from '../api.js'; import { homeNavButton, showToast } from '../app.js'; import { buildResultColumns, resultColumnCellValue } from '../test-data.js'; @@ -7,6 +7,10 @@ const PAGE_SIZE = 50; let sortCol = null; let sortDir = 'asc'; let allClients = []; +let allSubmissions = []; +let showAllVersions = true; +let showMetadata = true; +let questionnaireId = ''; let questionsDef = []; let resultColumns = []; let questionnaireMeta = null; @@ -31,6 +35,9 @@ export async function resultsPage(params) { filterSearch = ''; filterQuestion = ''; page = 0; + showAllVersions = true; + showMetadata = true; + questionnaireId = params.id; app.innerHTML = `