Files
barometer-server/website/js/pages/results.js
2026-07-01 09:26:18 +02:00

595 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { apiGet, apiUrl, apiDownloadFetch } from '../api.js';
import { homeNavButton, showToast } from '../app.js';
import { buildResultColumns, resultColumnCellValue } from '../test-data.js';
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;
let filterCoach = '';
let filterSupervisor = '';
let filterStatus = '';
let filterDateFrom = '';
let filterDateTo = '';
let filterSearch = '';
let filterQuestion = '';
let page = 0;
export async function resultsPage(params) {
const app = document.getElementById('app');
sortCol = null;
sortDir = 'asc';
filterCoach = '';
filterSupervisor = '';
filterStatus = '';
filterDateFrom = '';
filterDateTo = '';
filterSearch = '';
filterQuestion = '';
page = 0;
showAllVersions = true;
showMetadata = true;
questionnaireId = params.id;
app.innerHTML = `
<div class="page-header">
<div class="page-header-start">
${homeNavButton()}
<div class="page-header-titles">
<h1 id="resultsTitle">Results</h1>
</div>
</div>
<div class="actions">
<a href="#/questionnaire/${params.id}" class="btn">Editor</a>
<a href="#/questionnaires" class="btn">Session measures</a>
</div>
</div>
<div id="resultsContent"><div class="spinner"></div></div>
`;
try {
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);
// Collect unique coaches and statuses for filters
const coachOptions = getCoachFilterOptions();
const supervisorOptions = getSupervisorFilterOptions();
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('')}
</select>
<select id="filterCoach">
<option value="">All counselors</option>
${coachOptions.map(([id, label]) => `<option value="${esc(id)}" ${filterCoach === id ? 'selected' : ''}>${esc(label)}</option>`).join('')}
</select>
<select id="filterStatus">
<option value="">All statuses</option>
${statuses.map(s => `<option value="${esc(s)}" ${filterStatus === s ? 'selected' : ''}>${esc(s)}</option>`).join('')}
</select>
<label style="font-size:.85rem;display:flex;align-items:center;gap:4px">
Completed from
<input type="date" id="filterDateFrom" value="${esc(filterDateFrom)}">
</label>
<label style="font-size:.85rem;display:flex;align-items:center;gap:4px">
to
<input type="date" id="filterDateTo" value="${esc(filterDateTo)}">
</label>
<input type="search" id="filterSearch" class="filter-search" placeholder="Search client, counselors, status…" value="${esc(filterSearch)}">
<input type="search" id="filterQuestion" placeholder="Go to question key…" value="${esc(filterQuestion)}" style="min-width:160px" list="questionKeyList">
<datalist id="questionKeyList"></datalist>
<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>
<button type="button" class="btn btn-sm" id="exportCsvLink">Export CSV</button>
</div>
<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">
<table class="data-table" id="resultsTable">
<thead><tr id="resultsHead"></tr></thead>
<tbody id="resultsBody"></tbody>
</table>
</div>
<div class="pagination-bar" id="paginationBar"></div>
</div>
`;
const qKeyList = document.getElementById('questionKeyList');
qKeyList.innerHTML = resultColumns
.map(col => `<option value="${esc(col.label)}"></option>`)
.join('');
document.getElementById('filterSupervisor').addEventListener('change', (e) => {
filterSupervisor = e.target.value;
page = 0;
renderTableRows();
});
document.getElementById('filterCoach').addEventListener('change', (e) => {
filterCoach = e.target.value;
page = 0;
renderTableRows();
});
document.getElementById('filterStatus').addEventListener('change', (e) => {
filterStatus = e.target.value;
page = 0;
renderTableRows();
});
document.getElementById('filterDateFrom').addEventListener('change', (e) => {
filterDateFrom = e.target.value;
page = 0;
renderTableRows();
});
document.getElementById('filterDateTo').addEventListener('change', (e) => {
filterDateTo = e.target.value;
page = 0;
renderTableRows();
});
document.getElementById('filterSearch').addEventListener('input', (e) => {
filterSearch = e.target.value.trim();
page = 0;
renderTableRows();
});
document.getElementById('gotoQuestionBtn').addEventListener('click', () => {
filterQuestion = document.getElementById('filterQuestion').value.trim();
scrollToQuestionColumn(filterQuestion);
});
document.getElementById('filterQuestion').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
filterQuestion = e.target.value.trim();
scrollToQuestionColumn(filterQuestion);
}
});
document.getElementById('clearFiltersBtn').addEventListener('click', () => {
filterCoach = '';
filterSupervisor = '';
filterStatus = '';
filterDateFrom = '';
filterDateTo = '';
filterSearch = '';
filterQuestion = '';
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();
});
renderTableHead();
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 = getTableFixedColumns();
const allCols = [...fixedCols, ...resultColumns.map(col => ({
key: col.key,
label: col.label,
title: col.title,
}))];
head.innerHTML = allCols.map((col, idx) => {
let cls = 'sortable';
if (sortCol === col.key) cls += sortDir === 'asc' ? ' sort-asc' : ' sort-desc';
if (idx === 0) cls += ' sticky-col';
const title = col.title ? ` title="${esc(col.title)}"` : '';
return `<th class="${cls}" data-key="${col.key}"${title}>${esc(col.label)}</th>`;
}).join('');
head.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const key = th.dataset.key;
if (sortCol === key) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
else { sortCol = key; sortDir = 'asc'; }
page = 0;
renderTableHead();
renderTableRows();
});
});
}
function scrollToQuestionColumn(query) {
if (!query) return;
const q = query.toLowerCase();
const th = [...document.querySelectorAll('#resultsHead th')].find(el => {
const key = (el.dataset.key || '').toLowerCase();
const text = (el.textContent || '').trim().toLowerCase();
const title = (el.getAttribute('title') || '').toLowerCase();
return text === q || title === q || key.endsWith(q) || text.includes(q);
});
if (!th) {
showToast(`No column matching "${query}"`, 'error');
return;
}
th.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
th.style.outline = '2px solid var(--primary)';
setTimeout(() => { th.style.outline = ''; }, 2000);
}
function getCoachFilterOptions() {
const map = new Map();
getSourceRows().forEach(c => {
if (!c.coachID || map.has(c.coachID)) return;
map.set(c.coachID, c.coachUsername || c.coachID);
});
return [...map.entries()].sort((a, b) => a[1].localeCompare(b[1]));
}
function getSupervisorFilterOptions() {
return [...new Set(getSourceRows().map(c => c.supervisorUsername).filter(Boolean))].sort((a, b) =>
a.localeCompare(b)
);
}
function coachDisplayName(c) {
return c.coachUsername || c.coachID || '—';
}
function supervisorDisplayName(c) {
return c.supervisorUsername || '—';
}
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;
rows = rows.filter(c => {
const ts = c.completedAt;
if (!ts) return false;
if (fromTs !== null && ts < fromTs) return false;
if (toTs !== null && ts > toTs) return false;
return true;
});
}
if (filterSearch) {
const q = filterSearch.toLowerCase();
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);
});
}
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) {
const bar = document.getElementById('paginationBar');
if (!bar) return;
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
if (page >= totalPages) page = totalPages - 1;
if (page < 0) page = 0;
if (totalRows <= PAGE_SIZE) {
bar.innerHTML = totalRows
? `<span>${totalRows} row${totalRows === 1 ? '' : 's'}</span>`
: '';
return;
}
const from = page * PAGE_SIZE + 1;
const to = Math.min((page + 1) * PAGE_SIZE, totalRows);
bar.innerHTML = `
<button type="button" class="btn btn-sm" id="pagePrev" ${page <= 0 ? 'disabled' : ''}>Prev</button>
<span>Rows ${from}${to} of ${totalRows} (page ${page + 1}/${totalPages})</span>
<button type="button" class="btn btn-sm" id="pageNext" ${page >= totalPages - 1 ? 'disabled' : ''}>Next</button>
`;
document.getElementById('pagePrev')?.addEventListener('click', () => {
page--;
renderTableRows();
});
document.getElementById('pageNext')?.addEventListener('click', () => {
page++;
renderTableRows();
});
}
function dateInputToUnixStart(yyyyMmDd) {
const [y, m, d] = yyyyMmDd.split('-').map(Number);
return Math.floor(new Date(y, m - 1, d).getTime() / 1000);
}
function dateInputToUnixEnd(yyyyMmDd) {
const [y, m, d] = yyyyMmDd.split('-').map(Number);
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 rows = getFilteredRows();
// Sort (preserve client grouping when sorting by client in all-versions mode)
if (sortCol) {
rows = [...rows].sort((a, b) => {
let va = getCellValue(a, sortCol);
let vb = getCellValue(b, sortCol);
if (va == null) va = '';
if (vb == null) vb = '';
if (typeof va === 'number' && typeof vb === 'number') {
return sortDir === 'asc' ? va - vb : vb - va;
}
const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true });
return sortDir === 'asc' ? cmp : -cmp;
});
}
const rowLabel = showAllVersions ? 'submission' : 'client';
document.getElementById('resultCount').textContent = `${rows.length} ${rowLabel}${rows.length === 1 ? '' : 's'}`;
renderPagination(rows.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(rows.length / PAGE_SIZE));
if (page >= totalPages) page = totalPages - 1;
const pageRows = rows.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const optionMap = {};
questionsDef.forEach(q => {
(q.answerOptions || []).forEach(o => {
optionMap[o.answerOptionID] = o.defaultText;
});
});
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];
const val = resultColumnCellValue(col, ans, optionMap);
if (val == null || val === '') return '<td>—</td>';
return `<td>${esc(String(val))}</td>`;
}).join('');
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];
const optionMap = {};
questionsDef.forEach(q => {
(q.answerOptions || []).forEach(o => {
optionMap[o.answerOptionID] = o.defaultText;
});
});
return resultColumnCellValue(col, ans, optionMap);
}
return null;
}
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;
}
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}