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

@ -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) {