extending results page to show metadata and group by clientid to improve tracking progress
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-07-01 09:26:18 +02:00
parent 13119f300a
commit 223f38a11f
5 changed files with 415 additions and 141 deletions

View File

@ -8,6 +8,7 @@ if ($method !== 'GET') {
$qnID = $_GET['questionnaireID'] ?? ''; $qnID = $_GET['questionnaireID'] ?? '';
$clientCode = $_GET['clientCode'] ?? ''; $clientCode = $_GET['clientCode'] ?? '';
$allVersions = !empty($_GET['allVersions']);
if (!$qnID) { if (!$qnID) {
json_error('MISSING_PARAM', 'questionnaireID query param required', 400); json_error('MISSING_PARAM', 'questionnaireID query param required', 400);
@ -51,11 +52,40 @@ unset($q);
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); [$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 = " $sql = "
SELECT cl.clientCode, cl.coachID, SELECT cl.clientCode, cl.coachID,
co.username AS coachUsername, co.username AS coachUsername,
sv.username AS supervisorUsername, 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 FROM client cl
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
LEFT JOIN coach co ON co.coachID = cl.coachID 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['sumPoints'] = $c['sumPoints'] !== null ? (int)$c['sumPoints'] : null;
$c['startedAt'] = $c['startedAt'] !== null ? (int)$c['startedAt'] : null; $c['startedAt'] = $c['startedAt'] !== null ? (int)$c['startedAt'] : null;
$c['completedAt'] = $c['completedAt'] !== null ? (int)$c['completedAt'] : 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); $bindParams = array_merge([$c['clientCode']], $questionIDs);
$answerStmt->execute($bindParams); $answerStmt->execute($bindParams);
@ -107,6 +139,8 @@ if (!empty($questionIDs) && !empty($clients)) {
$c['sumPoints'] = $c['sumPoints'] !== null ? (int)$c['sumPoints'] : null; $c['sumPoints'] = $c['sumPoints'] !== null ? (int)$c['sumPoints'] : null;
$c['startedAt'] = $c['startedAt'] !== null ? (int)$c['startedAt'] : null; $c['startedAt'] = $c['startedAt'] !== null ? (int)$c['startedAt'] : null;
$c['completedAt'] = $c['completedAt'] !== null ? (int)$c['completedAt'] : 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)[]; $c['answers'] = (object)[];
} }
unset($c); unset($c);
@ -117,6 +151,7 @@ if (!empty($questionIDs) && !empty($clients)) {
json_success([ json_success([
'questionnaire' => $questionnaire, 'questionnaire' => $questionnaire,
'questions' => $questions, 'questions' => $questions,
'allVersions' => false,
'clients' => $clients, 'clients' => $clients,
]); ]);
} catch (Throwable $e) { } catch (Throwable $e) {

View File

@ -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<array<string, string>> * @return list<array<string, mixed>>
*/ */
function qdb_export_all_versions_rows( function qdb_fetch_questionnaire_submissions_scoped(
PDO $pdo, PDO $pdo,
string $questionnaireID, string $questionnaireID,
array $questions,
array $resultColumns,
array $optionTextMap,
array $tokenRec array $tokenRec
): array { ): array {
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); [$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.completedAt AS submissionCompletedAt, qs.sumPoints AS submissionSumPoints,
qs.startedAt AS submissionStartedAt, qs.status AS submissionStatus, qs.startedAt AS submissionStartedAt, qs.status AS submissionStatus,
qs.structureRevision, qs.structureSnapshotJson, qs.structureRevision, qs.structureSnapshotJson,
qs.programCycleNumber, qs.programSessionID, qs.programSessionNumber,
cl.clientCode, cl.coachID, cl.clientCode, cl.coachID,
co.username AS coachUsername, co.username AS coachUsername,
sv.username AS supervisorUsername sv.username AS supervisorUsername
@ -321,58 +319,99 @@ function qdb_export_all_versions_rows(
$params = array_merge([':qnid' => $questionnaireID], $rbacParams); $params = array_merge([':qnid' => $questionnaireID], $rbacParams);
$stmt = $pdo->prepare($sql); $stmt = $pdo->prepare($sql);
$stmt->execute($params); $stmt->execute($params);
$submissions = $stmt->fetchAll(PDO::FETCH_ASSOC);
$defaultQuestionIDs = array_column($questions, 'questionID'); return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/** @return array<string, string> */
function qdb_submission_submitter_username_map(PDO $pdo): array {
$userMap = []; $userMap = [];
$userStmt = $pdo->query('SELECT userID, username FROM users'); $userStmt = $pdo->query('SELECT userID, username FROM users');
foreach ($userStmt->fetchAll(PDO::FETCH_ASSOC) as $u) { foreach ($userStmt->fetchAll(PDO::FETCH_ASSOC) as $u) {
$userMap[$u['userID']] = $u['username']; $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<string>}
*/
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<array<string, string>>
*/
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 = []; $rows = [];
foreach ($submissions as $s) { foreach ($submissions as $s) {
$snap = json_decode($s['structureSnapshotJson'] ?? '{}', true) ?: []; $ctx = qdb_submission_display_context($pdo, $s, $questions, $resultColumns, $optionTextMap);
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;
}
$row = [ $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'], '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'], 'coach' => $s['coachUsername'] ?? $s['coachID'],
'supervisor' => $s['supervisorUsername'] ?? '', 'supervisor' => $s['supervisorUsername'] ?? '',
'status' => $s['submissionStatus'] ?? '', 'status' => $s['submissionStatus'] ?? '',
'sumPoints' => $s['submissionSumPoints'] ?? '', 'sumPoints' => $s['submissionSumPoints'] ?? '',
'startedAt' => $s['submissionStartedAt'] ? date('Y-m-d H:i', (int)$s['submissionStartedAt']) : '', 'submissionID' => $s['submissionID'],
'completedAt' => $s['submissionCompletedAt'] ? date('Y-m-d H:i', (int)$s['submissionCompletedAt']) : '', 'structureRevision' => (string)max(1, (int)($s['structureRevision'] ?? 1)),
'submittedByRole' => $s['submittedByRole'] ?? '',
'submittedBy' => $userMap[$s['submittedByUserID'] ?? ''] ?? ($s['submittedByUserID'] ?? ''),
]; ];
$answerMap = qdb_load_client_answer_map( $answerMap = qdb_load_client_answer_map(
$pdo, $pdo,
$s['clientCode'], $s['clientCode'],
$submissionQuestionIDs, $ctx['submissionQuestionIDs'],
'client_answer_submission', 'client_answer_submission',
$s['submissionID'] $s['submissionID']
); );
foreach ($submissionColumns as $col) { foreach ($ctx['submissionColumns'] as $col) {
$qid = $col['questionID']; $qid = $col['questionID'];
$a = $answerMap[$qid] ?? null; $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; $rows[] = $row;
} }
@ -380,6 +419,68 @@ function qdb_export_all_versions_rows(
return $rows; return $rows;
} }
/**
* Submission rows for the results API (all versions, grouped by client in sort order).
*
* @return list<array<string, mixed>>
*/
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} * @return array{questions: array, resultColumns: array, optionTextMap: array}
*/ */
@ -428,7 +529,8 @@ function qdb_export_current_rows(
SELECT cl.clientCode, cl.coachID, SELECT cl.clientCode, cl.coachID,
co.username AS coachUsername, co.username AS coachUsername,
sv.username AS supervisorUsername, 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 FROM client cl
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
LEFT JOIN coach co ON co.coachID = cl.coachID LEFT JOIN coach co ON co.coachID = cl.coachID
@ -452,12 +554,15 @@ function qdb_export_current_rows(
foreach ($clients as $c) { foreach ($clients as $c) {
$row = [ $row = [
'clientCode' => $c['clientCode'], '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'], 'coach' => $c['coachUsername'] ?? $c['coachID'],
'supervisor' => $c['supervisorUsername'] ?? '', 'supervisor' => $c['supervisorUsername'] ?? '',
'status' => $c['status'], 'status' => $c['status'],
'sumPoints' => $c['sumPoints'], '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']) : '',
]; ];
$bindParams = array_merge([$c['clientCode']], $questionIDs); $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 { 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) { foreach ($resultColumns as $col) {
$header[] = $col['header']; $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 { function qdb_export_all_versions_csv_fallback_header(array $resultColumns): array {
$header = [ $header = [
'submissionID', 'version', 'submittedAt', 'submittedByRole', 'submittedBy', 'clientCode', 'version', 'programCycleNumber', 'programSessionNumber', 'programSessionID',
'clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt', 'startedAt', 'completedAt', 'submittedAt',
'coach', 'supervisor', 'status', 'sumPoints',
'submissionID', 'structureRevision', 'submittedByRole', 'submittedBy',
]; ];
foreach ($resultColumns as $col) { foreach ($resultColumns as $col) {
$header[] = $col['header']; $header[] = $col['header'];

View File

@ -929,6 +929,9 @@ table.data-table .sticky-col {
background: var(--surface); background: var(--surface);
box-shadow: 2px 0 4px var(--sticky-shadow); 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 { table.data-table thead th.sticky-col {
z-index: 5; z-index: 5;
top: 0; top: 0;

View File

@ -48,7 +48,7 @@ function renderExport() {
${bundleBlock} ${bundleBlock}
<div class="card"> <div class="card">
<p style="margin:0 0 12px;color:var(--text-secondary)"> <p style="margin:0 0 12px;color:var(--text-secondary)">
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 <strong>All versions</strong> for every upload per client, including cycle, session, and start/finish times.
</p> </p>
<div class="filter-bar"> <div class="filter-bar">
<input type="search" id="exportListSearch" class="filter-search" placeholder="Search questionnaire name…" value="${esc(filterSearch)}"> <input type="search" id="exportListSearch" class="filter-search" placeholder="Search questionnaire name…" value="${esc(filterSearch)}">

View File

@ -1,4 +1,4 @@
import { apiGet } from '../api.js'; import { apiGet, apiUrl, apiDownloadFetch } from '../api.js';
import { homeNavButton, showToast } from '../app.js'; import { homeNavButton, showToast } from '../app.js';
import { buildResultColumns, resultColumnCellValue } from '../test-data.js'; import { buildResultColumns, resultColumnCellValue } from '../test-data.js';
@ -7,6 +7,10 @@ const PAGE_SIZE = 50;
let sortCol = null; let sortCol = null;
let sortDir = 'asc'; let sortDir = 'asc';
let allClients = []; let allClients = [];
let allSubmissions = [];
let showAllVersions = true;
let showMetadata = true;
let questionnaireId = '';
let questionsDef = []; let questionsDef = [];
let resultColumns = []; let resultColumns = [];
let questionnaireMeta = null; let questionnaireMeta = null;
@ -31,6 +35,9 @@ export async function resultsPage(params) {
filterSearch = ''; filterSearch = '';
filterQuestion = ''; filterQuestion = '';
page = 0; page = 0;
showAllVersions = true;
showMetadata = true;
questionnaireId = params.id;
app.innerHTML = ` app.innerHTML = `
<div class="page-header"> <div class="page-header">
@ -49,18 +56,29 @@ export async function resultsPage(params) {
`; `;
try { try {
const data = await apiGet(`results.php?questionnaireID=${encodeURIComponent(params.id)}`); await loadResultsData();
questionnaireMeta = data.questionnaire;
questionsDef = data.questions || [];
allClients = data.clients || [];
document.getElementById('resultsTitle').textContent = `Results: ${questionnaireMeta.name} v${questionnaireMeta.version}`;
renderResults();
} catch (e) { } catch (e) {
showToast(e.message, 'error'); showToast(e.message, 'error');
document.getElementById('resultsContent').innerHTML = `<p class="error-text">${esc(e.message)}</p>`; document.getElementById('resultsContent').innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
} }
} }
async function loadResultsData() {
const versionParam = showAllVersions ? '&allVersions=1' : '';
const data = await apiGet(`results.php?questionnaireID=${encodeURIComponent(questionnaireId)}${versionParam}`);
questionnaireMeta = data.questionnaire;
questionsDef = data.questions || [];
if (data.allVersions) {
allSubmissions = data.submissions || [];
allClients = [];
} else {
allClients = data.clients || [];
allSubmissions = [];
}
document.getElementById('resultsTitle').textContent = `Results: ${questionnaireMeta.name} v${questionnaireMeta.version}`;
renderResults();
}
function renderResults() { function renderResults() {
const container = document.getElementById('resultsContent'); const container = document.getElementById('resultsContent');
resultColumns = buildResultColumns(questionsDef); resultColumns = buildResultColumns(questionsDef);
@ -68,12 +86,20 @@ function renderResults() {
// Collect unique coaches and statuses for filters // Collect unique coaches and statuses for filters
const coachOptions = getCoachFilterOptions(); const coachOptions = getCoachFilterOptions();
const supervisorOptions = getSupervisorFilterOptions(); const supervisorOptions = getSupervisorFilterOptions();
const statuses = [...new Set(allClients.map(c => c.status))].filter(Boolean).sort(); const statuses = [...new Set(getSourceRows().map(c => c.status))].filter(Boolean).sort();
container.innerHTML = ` container.innerHTML = `
<div class="card" style="margin-bottom:16px"> <div class="card" style="margin-bottom:16px">
<div class="filter-bar"> <div class="filter-bar">
<label style="font-size:.85rem;font-weight:500">Filter:</label> <label style="font-size:.85rem;font-weight:500">Filter:</label>
<label style="font-size:.85rem;display:flex;align-items:center;gap:6px">
<input type="checkbox" id="toggleAllVersions" ${showAllVersions ? 'checked' : ''}>
All versions (grouped by client)
</label>
<label style="font-size:.85rem;display:flex;align-items:center;gap:6px">
<input type="checkbox" id="toggleShowMetadata" ${showMetadata ? 'checked' : ''}>
Show metadata
</label>
<select id="filterSupervisor"> <select id="filterSupervisor">
<option value="">All supervisors</option> <option value="">All supervisors</option>
${supervisorOptions.map(name => `<option value="${esc(name)}" ${filterSupervisor === name ? 'selected' : ''}>${esc(name)}</option>`).join('')} ${supervisorOptions.map(name => `<option value="${esc(name)}" ${filterSupervisor === name ? 'selected' : ''}>${esc(name)}</option>`).join('')}
@ -100,9 +126,13 @@ function renderResults() {
<button type="button" class="btn btn-sm" id="gotoQuestionBtn">Go to column</button> <button type="button" class="btn btn-sm" id="gotoQuestionBtn">Go to column</button>
<button type="button" class="btn btn-sm" id="clearFiltersBtn">Clear filters</button> <button type="button" class="btn btn-sm" id="clearFiltersBtn">Clear filters</button>
<span style="margin-left:auto;font-size:.85rem;color:var(--text-secondary)" id="resultCount"></span> <span style="margin-left:auto;font-size:.85rem;color:var(--text-secondary)" id="resultCount"></span>
<a id="exportCsvLink" class="btn btn-sm" href="#">Export CSV</a> <button type="button" class="btn btn-sm" id="exportCsvLink">Export CSV</button>
</div> </div>
<p class="data-toolbar-hint" style="margin-top:8px">Each question has its own column; glass-scale symptoms are separate columns. Hover headers for context.</p> <p class="data-toolbar-hint" style="margin-top:8px">
${showAllVersions
? `Each row is one uploaded version, sorted by client then version.${showMetadata ? ' Cycle, session, and timestamps are shown per submission.' : ' Turn on metadata to see version, cycle, session, and timestamps.'}`
: `Latest response per client.${showMetadata ? ' Cycle, session, and completion times are shown.' : ' Turn on metadata to see cycle, session, and timestamps.'} Each question has its own column.`}
</p>
</div> </div>
<div class="card"> <div class="card">
<div class="table-wrapper" id="resultsTableWrap"> <div class="table-wrapper" id="resultsTableWrap">
@ -172,6 +202,31 @@ function renderResults() {
page = 0; page = 0;
renderResults(); renderResults();
}); });
document.getElementById('toggleAllVersions').addEventListener('change', async (e) => {
showAllVersions = e.target.checked;
page = 0;
sortCol = null;
document.getElementById('resultsContent').innerHTML = '<div class="spinner"></div>';
try {
await loadResultsData();
} catch (err) {
showToast(err.message, 'error');
}
});
document.getElementById('toggleShowMetadata').addEventListener('change', (e) => {
showMetadata = e.target.checked;
if (!showMetadata && sortCol && META_COL_KEYS.has(sortCol)) {
sortCol = null;
}
renderTableHead();
renderTableRows();
const hint = document.querySelector('#resultsContent .data-toolbar-hint');
if (hint) {
hint.textContent = showAllVersions
? `Each row is one uploaded version, sorted by client then version.${showMetadata ? ' Cycle, session, and timestamps are shown per submission.' : ' Turn on metadata to see version, cycle, session, and timestamps.'}`
: `Latest response per client.${showMetadata ? ' Cycle, session, and completion times are shown.' : ' Turn on metadata to see cycle, session, and timestamps.'} Each question has its own column.`;
}
});
document.getElementById('exportCsvLink').addEventListener('click', (e) => { document.getElementById('exportCsvLink').addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
exportCSV(); exportCSV();
@ -181,16 +236,54 @@ function renderResults() {
renderTableRows(); renderTableRows();
} }
function renderTableHead() { function getSourceRows() {
const head = document.getElementById('resultsHead'); return showAllVersions ? allSubmissions : allClients;
const fixedCols = [ }
const META_COL_KEYS = new Set([
'version',
'programCycleNumber',
'programSessionNumber',
'startedAt',
'completedAt',
'submittedAt',
]);
const ALL_VERSIONS_FIXED_COLUMNS = [
{ key: 'clientCode', label: 'Client' },
{ key: 'version', label: 'Version' },
{ key: 'programCycleNumber', label: 'Cycle' },
{ key: 'programSessionNumber', label: 'Session' },
{ key: 'coachUsername', label: 'Counselor' },
{ key: 'supervisorUsername', label: 'Supervisor' },
{ key: 'status', label: 'Status' },
{ key: 'sumPoints', label: 'Score' },
{ key: 'startedAt', label: 'Started' },
{ key: 'completedAt', label: 'Completed' },
{ key: 'submittedAt', label: 'Uploaded' },
];
const CURRENT_FIXED_COLUMNS = [
{ key: 'clientCode', label: 'Client' }, { key: 'clientCode', label: 'Client' },
{ key: 'coachUsername', label: 'Counselor' }, { key: 'coachUsername', label: 'Counselor' },
{ key: 'supervisorUsername', label: 'Supervisor' }, { key: 'supervisorUsername', label: 'Supervisor' },
{ key: 'status', label: 'Status' }, { key: 'status', label: 'Status' },
{ key: 'sumPoints', label: 'Score' }, { key: 'sumPoints', label: 'Score' },
{ key: 'programCycleNumber', label: 'Cycle' },
{ key: 'programSessionNumber', label: 'Session' },
{ key: 'startedAt', label: 'Started' },
{ key: 'completedAt', label: 'Completed' }, { key: 'completedAt', label: 'Completed' },
]; ];
function getTableFixedColumns() {
const full = showAllVersions ? ALL_VERSIONS_FIXED_COLUMNS : CURRENT_FIXED_COLUMNS;
if (showMetadata) return full;
return full.filter(col => !META_COL_KEYS.has(col.key));
}
function renderTableHead() {
const head = document.getElementById('resultsHead');
const fixedCols = getTableFixedColumns();
const allCols = [...fixedCols, ...resultColumns.map(col => ({ const allCols = [...fixedCols, ...resultColumns.map(col => ({
key: col.key, key: col.key,
label: col.label, label: col.label,
@ -237,7 +330,7 @@ function scrollToQuestionColumn(query) {
function getCoachFilterOptions() { function getCoachFilterOptions() {
const map = new Map(); const map = new Map();
allClients.forEach(c => { getSourceRows().forEach(c => {
if (!c.coachID || map.has(c.coachID)) return; if (!c.coachID || map.has(c.coachID)) return;
map.set(c.coachID, c.coachUsername || c.coachID); map.set(c.coachID, c.coachUsername || c.coachID);
}); });
@ -245,7 +338,7 @@ function getCoachFilterOptions() {
} }
function getSupervisorFilterOptions() { function getSupervisorFilterOptions() {
return [...new Set(allClients.map(c => c.supervisorUsername).filter(Boolean))].sort((a, b) => return [...new Set(getSourceRows().map(c => c.supervisorUsername).filter(Boolean))].sort((a, b) =>
a.localeCompare(b) a.localeCompare(b)
); );
} }
@ -258,15 +351,15 @@ function supervisorDisplayName(c) {
return c.supervisorUsername || '—'; return c.supervisorUsername || '—';
} }
function getFilteredClients() { function getFilteredRows() {
let clients = allClients; let rows = [...getSourceRows()];
if (filterSupervisor) clients = clients.filter(c => c.supervisorUsername === filterSupervisor); if (filterSupervisor) rows = rows.filter(c => c.supervisorUsername === filterSupervisor);
if (filterCoach) clients = clients.filter(c => c.coachID === filterCoach); if (filterCoach) rows = rows.filter(c => c.coachID === filterCoach);
if (filterStatus) clients = clients.filter(c => c.status === filterStatus); if (filterStatus) rows = rows.filter(c => c.status === filterStatus);
if (filterDateFrom || filterDateTo) { if (filterDateFrom || filterDateTo) {
const fromTs = filterDateFrom ? dateInputToUnixStart(filterDateFrom) : null; const fromTs = filterDateFrom ? dateInputToUnixStart(filterDateFrom) : null;
const toTs = filterDateTo ? dateInputToUnixEnd(filterDateTo) : null; const toTs = filterDateTo ? dateInputToUnixEnd(filterDateTo) : null;
clients = clients.filter(c => { rows = rows.filter(c => {
const ts = c.completedAt; const ts = c.completedAt;
if (!ts) return false; if (!ts) return false;
if (fromTs !== null && ts < fromTs) return false; if (fromTs !== null && ts < fromTs) return false;
@ -276,17 +369,28 @@ function getFilteredClients() {
} }
if (filterSearch) { if (filterSearch) {
const q = filterSearch.toLowerCase(); const q = filterSearch.toLowerCase();
clients = clients.filter(c => { rows = rows.filter(c => {
const hay = [ const hay = [
c.clientCode, c.clientCode,
c.coachUsername, c.coachUsername,
c.supervisorUsername, c.supervisorUsername,
c.status, c.status,
showAllVersions ? String(c.version ?? '') : '',
c.programCycleNumber != null ? String(c.programCycleNumber) : '',
c.programSessionNumber != null ? String(c.programSessionNumber) : '',
c.programSessionID,
].filter(Boolean).join(' ').toLowerCase(); ].filter(Boolean).join(' ').toLowerCase();
return hay.includes(q); return hay.includes(q);
}); });
} }
return clients; if (showAllVersions) {
rows.sort((a, b) => {
const cc = a.clientCode.localeCompare(b.clientCode);
if (cc !== 0) return cc;
return (a.version ?? 0) - (b.version ?? 0);
});
}
return rows;
} }
function renderPagination(totalRows) { function renderPagination(totalRows) {
@ -330,13 +434,50 @@ function dateInputToUnixEnd(yyyyMmDd) {
return Math.floor(new Date(y, m - 1, d, 23, 59, 59, 999).getTime() / 1000); return Math.floor(new Date(y, m - 1, d, 23, 59, 59, 999).getTime() / 1000);
} }
function formatUnixDate(ts) {
return ts ? new Date(ts * 1000).toLocaleDateString() : '—';
}
function fixedColumnCellHtml(c, col, { groupStart = false } = {}) {
switch (col.key) {
case 'clientCode':
return `<td class="sticky-col${groupStart ? ' client-group-start' : ''}">${esc(c.clientCode)}</td>`;
case 'version':
return `<td>${c.version ?? '—'}</td>`;
case 'programCycleNumber':
return `<td>${c.programCycleNumber ?? '—'}</td>`;
case 'programSessionNumber':
return `<td>${c.programSessionNumber ?? '—'}</td>`;
case 'coachUsername':
return `<td>${esc(coachDisplayName(c))}</td>`;
case 'supervisorUsername':
return `<td>${esc(supervisorDisplayName(c))}</td>`;
case 'status': {
const statusBadge = c.status
? `<span class="badge badge-${c.status.toLowerCase().replace(/\s+/g, '_')}">${esc(c.status)}</span>`
: '—';
return `<td>${statusBadge}</td>`;
}
case 'sumPoints':
return `<td>${c.sumPoints !== null && c.sumPoints !== undefined ? c.sumPoints : '—'}</td>`;
case 'startedAt':
return `<td>${formatUnixDate(c.startedAt)}</td>`;
case 'completedAt':
return `<td>${formatUnixDate(c.completedAt)}</td>`;
case 'submittedAt':
return `<td>${formatUnixDate(c.submittedAt)}</td>`;
default:
return '<td>—</td>';
}
}
function renderTableRows() { function renderTableRows() {
const body = document.getElementById('resultsBody'); const body = document.getElementById('resultsBody');
let clients = getFilteredClients(); let rows = getFilteredRows();
// Sort // Sort (preserve client grouping when sorting by client in all-versions mode)
if (sortCol) { if (sortCol) {
clients = [...clients].sort((a, b) => { rows = [...rows].sort((a, b) => {
let va = getCellValue(a, sortCol); let va = getCellValue(a, sortCol);
let vb = getCellValue(b, sortCol); let vb = getCellValue(b, sortCol);
if (va == null) va = ''; if (va == null) va = '';
@ -349,20 +490,20 @@ function renderTableRows() {
}); });
} }
document.getElementById('resultCount').textContent = `${clients.length} client${clients.length === 1 ? '' : 's'}`; const rowLabel = showAllVersions ? 'submission' : 'client';
document.getElementById('resultCount').textContent = `${rows.length} ${rowLabel}${rows.length === 1 ? '' : 's'}`;
renderPagination(clients.length); renderPagination(rows.length);
if (!clients.length) { if (!rows.length) {
body.innerHTML = `<tr><td colspan="99" style="text-align:center;color:var(--text-secondary);padding:30px">No results found</td></tr>`; body.innerHTML = `<tr><td colspan="99" style="text-align:center;color:var(--text-secondary);padding:30px">No results found</td></tr>`;
return; return;
} }
const totalPages = Math.max(1, Math.ceil(clients.length / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
if (page >= totalPages) page = totalPages - 1; if (page >= totalPages) page = totalPages - 1;
clients = clients.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); const pageRows = rows.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
// Build option text lookup
const optionMap = {}; const optionMap = {};
questionsDef.forEach(q => { questionsDef.forEach(q => {
(q.answerOptions || []).forEach(o => { (q.answerOptions || []).forEach(o => {
@ -370,9 +511,11 @@ function renderTableRows() {
}); });
}); });
body.innerHTML = clients.map(c => { const fixedCols = getTableFixedColumns();
const completedDate = c.completedAt ? new Date(c.completedAt * 1000).toLocaleDateString() : '—'; let prevClientCode = null;
const statusBadge = c.status ? `<span class="badge badge-${c.status.toLowerCase().replace(/\s+/g,'_')}">${esc(c.status)}</span>` : '—'; body.innerHTML = pageRows.map(c => {
const groupStart = showAllVersions && c.clientCode !== prevClientCode;
prevClientCode = c.clientCode;
const questionCells = resultColumns.map(col => { const questionCells = resultColumns.map(col => {
const ans = c.answers && c.answers[col.questionID]; const ans = c.answers && c.answers[col.questionID];
@ -381,25 +524,24 @@ function renderTableRows() {
return `<td>${esc(String(val))}</td>`; return `<td>${esc(String(val))}</td>`;
}).join(''); }).join('');
return `<tr> const fixedCells = fixedCols.map(col => fixedColumnCellHtml(c, col, { groupStart })).join('');
<td class="sticky-col">${esc(c.clientCode)}</td>
<td>${esc(coachDisplayName(c))}</td> return `<tr class="${groupStart ? 'client-group-row' : ''}">${fixedCells}${questionCells}</tr>`;
<td>${esc(supervisorDisplayName(c))}</td>
<td>${statusBadge}</td>
<td>${c.sumPoints !== null ? c.sumPoints : '—'}</td>
<td>${completedDate}</td>
${questionCells}
</tr>`;
}).join(''); }).join('');
} }
function getCellValue(client, key) { function getCellValue(client, key) {
if (key === 'clientCode') return client.clientCode; if (key === 'clientCode') return client.clientCode;
if (key === 'version') return client.version;
if (key === 'coachUsername') return coachDisplayName(client); if (key === 'coachUsername') return coachDisplayName(client);
if (key === 'supervisorUsername') return supervisorDisplayName(client); if (key === 'supervisorUsername') return supervisorDisplayName(client);
if (key === 'status') return client.status; if (key === 'status') return client.status;
if (key === 'sumPoints') return client.sumPoints; if (key === 'sumPoints') return client.sumPoints;
if (key === 'programCycleNumber') return client.programCycleNumber;
if (key === 'programSessionNumber') return client.programSessionNumber;
if (key === 'startedAt') return client.startedAt;
if (key === 'completedAt') return client.completedAt; if (key === 'completedAt') return client.completedAt;
if (key === 'submittedAt') return client.submittedAt;
const col = resultColumns.find(c => c.key === key); const col = resultColumns.find(c => c.key === key);
if (col) { if (col) {
const ans = client.answers && client.answers[col.questionID]; const ans = client.answers && client.answers[col.questionID];
@ -414,51 +556,35 @@ function getCellValue(client, key) {
return null; return null;
} }
function exportCSV() { async function exportCSV() {
const clients = getFilteredClients(); const versionParam = showAllVersions ? '&allVersions=1' : '';
const optionMap = {}; const btn = document.getElementById('exportCsvLink');
questionsDef.forEach(q => { const prev = btn.textContent;
(q.answerOptions || []).forEach(o => { btn.disabled = true;
optionMap[o.answerOptionID] = o.defaultText; btn.textContent = 'Downloading…';
}); try {
}); const res = await apiDownloadFetch(apiUrl(`export?questionnaireID=${encodeURIComponent(questionnaireId)}${versionParam}`));
if (!res.ok) {
const headers = ['clientCode', 'Counselor', 'supervisor', 'status', 'sumPoints', 'completedAt', const err = await res.json().catch(() => ({}));
...resultColumns.map(col => col.label)]; throw new Error(err.error?.message || err.error || 'Export failed');
}
const rows = clients.map(c => { const blob = await res.blob();
const base = [
c.clientCode, coachDisplayName(c), c.supervisorUsername || '', c.status, c.sumPoints ?? '',
c.completedAt ? new Date(c.completedAt * 1000).toISOString() : ''
];
const answerCols = resultColumns.map(col => {
const ans = c.answers && c.answers[col.questionID];
const val = resultColumnCellValue(col, ans, optionMap);
return val != null ? val : '';
});
return [...base, ...answerCols];
});
let csv = '\uFEFF'; // BOM
csv += headers.map(csvEsc).join(',') + '\n';
rows.forEach(r => { csv += r.map(csvEsc).join(',') + '\n'; });
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `${questionnaireMeta.name}_v${questionnaireMeta.version}_results.csv`; const stamp = new Date().toISOString().slice(0, 10);
a.download = showAllVersions
? `${questionnaireMeta.name}_all_versions_${stamp}.csv`
: `${questionnaireMeta.name}_v${questionnaireMeta.version}_results.csv`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
showToast('CSV exported', 'success'); showToast('CSV exported', 'success');
} } catch (e) {
showToast(e.message, 'error');
function csvEsc(val) { } finally {
const s = String(val ?? ''); btn.disabled = false;
if (s.includes(',') || s.includes('"') || s.includes('\n')) { btn.textContent = prev;
return '"' + s.replace(/"/g, '""') + '"';
} }
return s;
} }
function esc(s) { function esc(s) {