extending results page to show metadata and group by clientid to improve tracking progress
Some checks failed
PHPUnit / test (push) Has been cancelled
Some checks failed
PHPUnit / test (push) Has been cancelled
This commit is contained in:
@ -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) {
|
||||
|
||||
@ -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'],
|
||||
'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'] ?? '',
|
||||
'startedAt' => $s['submissionStartedAt'] ? date('Y-m-d H:i', (int)$s['submissionStartedAt']) : '',
|
||||
'completedAt' => $s['submissionCompletedAt'] ? date('Y-m-d H:i', (int)$s['submissionCompletedAt']) : '',
|
||||
'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
|
||||
@ -452,12 +554,15 @@ function qdb_export_current_rows(
|
||||
foreach ($clients as $c) {
|
||||
$row = [
|
||||
'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'],
|
||||
'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);
|
||||
@ -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'];
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)}">
|
||||
|
||||
@ -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 renderTableHead() {
|
||||
const head = document.getElementById('resultsHead');
|
||||
const fixedCols = [
|
||||
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 = 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' });
|
||||
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;
|
||||
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();
|
||||
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, '""') + '"';
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = prev;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
|
||||
Reference in New Issue
Block a user