628 lines
25 KiB
JavaScript
628 lines
25 KiB
JavaScript
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 ? ' Metadata includes version, cycle, session, start/end/upload times, and submission details.' : ' Turn on metadata to see version, cycle, session, and timestamps.'}`
|
||
: `Latest response per client.${showMetadata ? ' Metadata includes cycle, session, and start/end times.' : ' 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 ? ' Metadata includes version, cycle, session, start/end/upload times, and submission details.' : ' Turn on metadata to see version, cycle, session, and timestamps.'}`
|
||
: `Latest response per client.${showMetadata ? ' Metadata includes cycle, session, and start/end times.' : ' 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',
|
||
'programSessionID',
|
||
'startedAt',
|
||
'completedAt',
|
||
'submittedAt',
|
||
'submissionID',
|
||
'structureRevision',
|
||
'submittedByRole',
|
||
'submittedBy',
|
||
]);
|
||
|
||
const IDENTITY_FIXED_COLUMNS = [
|
||
{ key: 'coachUsername', label: 'Counselor' },
|
||
{ key: 'supervisorUsername', label: 'Supervisor' },
|
||
{ key: 'status', label: 'Status' },
|
||
{ key: 'sumPoints', label: 'Score' },
|
||
];
|
||
|
||
const META_COLUMNS_ALL_VERSIONS = [
|
||
{ key: 'version', label: 'Version' },
|
||
{ key: 'programCycleNumber', label: 'Cycle' },
|
||
{ key: 'programSessionNumber', label: 'Session' },
|
||
{ key: 'programSessionID', label: 'Session ID' },
|
||
{ key: 'startedAt', label: 'Started' },
|
||
{ key: 'completedAt', label: 'Completed' },
|
||
{ key: 'submittedAt', label: 'Uploaded' },
|
||
{ key: 'submissionID', label: 'Submission ID' },
|
||
{ key: 'structureRevision', label: 'Structure rev.' },
|
||
{ key: 'submittedByRole', label: 'Submitted by role' },
|
||
{ key: 'submittedBy', label: 'Submitted by' },
|
||
];
|
||
|
||
const META_COLUMNS_CURRENT = [
|
||
{ key: 'programCycleNumber', label: 'Cycle' },
|
||
{ key: 'programSessionNumber', label: 'Session' },
|
||
{ key: 'programSessionID', label: 'Session ID' },
|
||
{ key: 'startedAt', label: 'Started' },
|
||
{ key: 'completedAt', label: 'Completed' },
|
||
];
|
||
|
||
function getTableFixedColumns() {
|
||
const clientCol = [{ key: 'clientCode', label: 'Client' }];
|
||
if (!showMetadata) {
|
||
return [...clientCol, ...IDENTITY_FIXED_COLUMNS];
|
||
}
|
||
const meta = showAllVersions ? META_COLUMNS_ALL_VERSIONS : META_COLUMNS_CURRENT;
|
||
return [...clientCol, ...meta, ...IDENTITY_FIXED_COLUMNS];
|
||
}
|
||
|
||
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 formatUnixDateTime(ts) {
|
||
if (!ts) return '—';
|
||
return new Date(ts * 1000).toLocaleString(undefined, {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
}
|
||
|
||
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 'programSessionID':
|
||
return `<td>${c.programSessionID ? esc(c.programSessionID) : '—'}</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>${formatUnixDateTime(c.startedAt)}</td>`;
|
||
case 'completedAt':
|
||
return `<td>${formatUnixDateTime(c.completedAt)}</td>`;
|
||
case 'submittedAt':
|
||
return `<td>${formatUnixDateTime(c.submittedAt)}</td>`;
|
||
case 'submissionID':
|
||
return `<td>${c.submissionID ? esc(c.submissionID) : '—'}</td>`;
|
||
case 'structureRevision':
|
||
return `<td>${c.structureRevision ?? '—'}</td>`;
|
||
case 'submittedByRole':
|
||
return `<td>${c.submittedByRole ? esc(c.submittedByRole) : '—'}</td>`;
|
||
case 'submittedBy':
|
||
return `<td>${c.submittedBy ? esc(c.submittedBy) : '—'}</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 === 'programSessionID') return client.programSessionID;
|
||
if (key === 'startedAt') return client.startedAt;
|
||
if (key === 'completedAt') return client.completedAt;
|
||
if (key === 'submittedAt') return client.submittedAt;
|
||
if (key === 'submissionID') return client.submissionID;
|
||
if (key === 'structureRevision') return client.structureRevision;
|
||
if (key === 'submittedByRole') return client.submittedByRole;
|
||
if (key === 'submittedBy') return client.submittedBy;
|
||
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;
|
||
}
|