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'] ?? '';
$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) {

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,
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<string, string> */
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<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 = [];
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<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}
*/
@ -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'];

View File

@ -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;

View File

@ -48,7 +48,7 @@ function renderExport() {
${bundleBlock}
<div class="card">
<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>
<div class="filter-bar">
<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 { 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 = `
<div class="page-header">
@ -49,18 +56,29 @@ export async function resultsPage(params) {
`;
try {
const data = await apiGet(`results.php?questionnaireID=${encodeURIComponent(params.id)}`);
questionnaireMeta = data.questionnaire;
questionsDef = data.questions || [];
allClients = data.clients || [];
document.getElementById('resultsTitle').textContent = `Results: ${questionnaireMeta.name} v${questionnaireMeta.version}`;
renderResults();
await loadResultsData();
} catch (e) {
showToast(e.message, 'error');
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() {
const container = document.getElementById('resultsContent');
resultColumns = buildResultColumns(questionsDef);
@ -68,12 +86,20 @@ function renderResults() {
// Collect unique coaches and statuses for filters
const coachOptions = getCoachFilterOptions();
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 = `
<div class="card" style="margin-bottom:16px">
<div class="filter-bar">
<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">
<option value="">All supervisors</option>
${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="clearFiltersBtn">Clear filters</button>
<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>
<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 class="card">
<div class="table-wrapper" id="resultsTableWrap">
@ -172,6 +202,31 @@ function renderResults() {
page = 0;
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) => {
e.preventDefault();
exportCSV();
@ -181,16 +236,54 @@ function renderResults() {
renderTableRows();
}
function getSourceRows() {
return showAllVersions ? allSubmissions : allClients;
}
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: 'coachUsername', label: 'Counselor' },
{ key: 'supervisorUsername', label: 'Supervisor' },
{ key: 'status', label: 'Status' },
{ key: 'sumPoints', label: 'Score' },
{ key: 'programCycleNumber', label: 'Cycle' },
{ key: 'programSessionNumber', label: 'Session' },
{ key: 'startedAt', label: 'Started' },
{ 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 = [
{ key: 'clientCode', label: 'Client' },
{ key: 'coachUsername', label: 'Counselor' },
{ key: 'supervisorUsername', label: 'Supervisor' },
{ key: 'status', label: 'Status' },
{ key: 'sumPoints', label: 'Score' },
{ key: 'completedAt', label: 'Completed' },
];
const fixedCols = getTableFixedColumns();
const allCols = [...fixedCols, ...resultColumns.map(col => ({
key: col.key,
label: col.label,
@ -237,7 +330,7 @@ function scrollToQuestionColumn(query) {
function getCoachFilterOptions() {
const map = new Map();
allClients.forEach(c => {
getSourceRows().forEach(c => {
if (!c.coachID || map.has(c.coachID)) return;
map.set(c.coachID, c.coachUsername || c.coachID);
});
@ -245,7 +338,7 @@ function getCoachFilterOptions() {
}
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)
);
}
@ -258,15 +351,15 @@ function supervisorDisplayName(c) {
return c.supervisorUsername || '—';
}
function getFilteredClients() {
let clients = allClients;
if (filterSupervisor) clients = clients.filter(c => c.supervisorUsername === filterSupervisor);
if (filterCoach) clients = clients.filter(c => c.coachID === filterCoach);
if (filterStatus) clients = clients.filter(c => c.status === filterStatus);
function getFilteredRows() {
let rows = [...getSourceRows()];
if (filterSupervisor) rows = rows.filter(c => c.supervisorUsername === filterSupervisor);
if (filterCoach) rows = rows.filter(c => c.coachID === filterCoach);
if (filterStatus) rows = rows.filter(c => c.status === filterStatus);
if (filterDateFrom || filterDateTo) {
const fromTs = filterDateFrom ? dateInputToUnixStart(filterDateFrom) : null;
const toTs = filterDateTo ? dateInputToUnixEnd(filterDateTo) : null;
clients = clients.filter(c => {
rows = rows.filter(c => {
const ts = c.completedAt;
if (!ts) return false;
if (fromTs !== null && ts < fromTs) return false;
@ -276,17 +369,28 @@ function getFilteredClients() {
}
if (filterSearch) {
const q = filterSearch.toLowerCase();
clients = clients.filter(c => {
rows = rows.filter(c => {
const hay = [
c.clientCode,
c.coachUsername,
c.supervisorUsername,
c.status,
showAllVersions ? String(c.version ?? '') : '',
c.programCycleNumber != null ? String(c.programCycleNumber) : '',
c.programSessionNumber != null ? String(c.programSessionNumber) : '',
c.programSessionID,
].filter(Boolean).join(' ').toLowerCase();
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) {
@ -330,13 +434,50 @@ function dateInputToUnixEnd(yyyyMmDd) {
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() {
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) {
clients = [...clients].sort((a, b) => {
rows = [...rows].sort((a, b) => {
let va = getCellValue(a, sortCol);
let vb = getCellValue(b, sortCol);
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>`;
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;
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 = {};
questionsDef.forEach(q => {
(q.answerOptions || []).forEach(o => {
@ -370,9 +511,11 @@ function renderTableRows() {
});
});
body.innerHTML = clients.map(c => {
const completedDate = c.completedAt ? new Date(c.completedAt * 1000).toLocaleDateString() : '—';
const statusBadge = c.status ? `<span class="badge badge-${c.status.toLowerCase().replace(/\s+/g,'_')}">${esc(c.status)}</span>` : '—';
const fixedCols = getTableFixedColumns();
let prevClientCode = null;
body.innerHTML = pageRows.map(c => {
const groupStart = showAllVersions && c.clientCode !== prevClientCode;
prevClientCode = c.clientCode;
const questionCells = resultColumns.map(col => {
const ans = c.answers && c.answers[col.questionID];
@ -381,25 +524,24 @@ function renderTableRows() {
return `<td>${esc(String(val))}</td>`;
}).join('');
return `<tr>
<td class="sticky-col">${esc(c.clientCode)}</td>
<td>${esc(coachDisplayName(c))}</td>
<td>${esc(supervisorDisplayName(c))}</td>
<td>${statusBadge}</td>
<td>${c.sumPoints !== null ? c.sumPoints : '—'}</td>
<td>${completedDate}</td>
${questionCells}
</tr>`;
const fixedCells = fixedCols.map(col => fixedColumnCellHtml(c, col, { groupStart })).join('');
return `<tr class="${groupStart ? 'client-group-row' : ''}">${fixedCells}${questionCells}</tr>`;
}).join('');
}
function getCellValue(client, key) {
if (key === 'clientCode') return client.clientCode;
if (key === 'version') return client.version;
if (key === 'coachUsername') return coachDisplayName(client);
if (key === 'supervisorUsername') return supervisorDisplayName(client);
if (key === 'status') return client.status;
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 === 'submittedAt') return client.submittedAt;
const col = resultColumns.find(c => c.key === key);
if (col) {
const ans = client.answers && client.answers[col.questionID];
@ -414,51 +556,35 @@ function getCellValue(client, key) {
return null;
}
function exportCSV() {
const clients = getFilteredClients();
const optionMap = {};
questionsDef.forEach(q => {
(q.answerOptions || []).forEach(o => {
optionMap[o.answerOptionID] = o.defaultText;
});
});
const headers = ['clientCode', 'Counselor', 'supervisor', 'status', 'sumPoints', 'completedAt',
...resultColumns.map(col => col.label)];
const rows = clients.map(c => {
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 a = document.createElement('a');
a.href = url;
a.download = `${questionnaireMeta.name}_v${questionnaireMeta.version}_results.csv`;
a.click();
URL.revokeObjectURL(url);
showToast('CSV exported', 'success');
}
function csvEsc(val) {
const s = String(val ?? '');
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return '"' + s.replace(/"/g, '""') + '"';
async function exportCSV() {
const versionParam = showAllVersions ? '&allVersions=1' : '';
const btn = document.getElementById('exportCsvLink');
const prev = btn.textContent;
btn.disabled = true;
btn.textContent = 'Downloading…';
try {
const res = await apiDownloadFetch(apiUrl(`export?questionnaireID=${encodeURIComponent(questionnaireId)}${versionParam}`));
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error?.message || err.error || 'Export failed');
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
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();
URL.revokeObjectURL(url);
showToast('CSV exported', 'success');
} catch (e) {
showToast(e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = prev;
}
return s;
}
function esc(s) {