Files
nat-as-server/website/js/pages/results.js

515 lines
20 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 } from '../api.js';
import { showToast } from '../app.js';
import {
isDevTestClientCode,
questionDisplayKey,
questionGermanLabel,
testDataRowClassForClient,
} from '../test-data.js';
const PAGE_SIZE = 50;
let sortCol = null;
let sortDir = 'asc';
let allClients = [];
let questionsDef = [];
let questionnaireMeta = null;
let filterCoach = '';
let filterSupervisor = '';
let filterStatus = '';
let filterDateFrom = '';
let filterDateTo = '';
let filterSearch = '';
let filterTestData = '';
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 = '';
filterTestData = '';
filterQuestion = '';
page = 0;
app.innerHTML = `
<div class="page-header">
<h1 id="resultsTitle">Results</h1>
<div class="actions">
<a href="#/questionnaire/${params.id}" class="btn">&larr; Back to Editor</a>
<a href="#/" class="btn">Dashboard</a>
</div>
</div>
<div id="resultsContent"><div class="spinner"></div></div>
`;
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();
} catch (e) {
showToast(e.message, 'error');
document.getElementById('resultsContent').innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
}
}
function renderResults() {
const container = document.getElementById('resultsContent');
// 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();
container.innerHTML = `
<div class="card" style="margin-bottom:16px">
<div class="filter-bar">
<label style="font-size:.85rem;font-weight:500">Filter:</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 coaches</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 code…" value="${esc(filterSearch)}">
<select id="filterTestData">
<option value="">All clients</option>
<option value="test" ${filterTestData === 'test' ? 'selected' : ''}>Test data only</option>
<option value="hide-test" ${filterTestData === 'hide-test' ? 'selected' : ''}>Hide test data</option>
</select>
<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>
<a id="exportCsvLink" class="btn btn-sm" href="#">Export CSV</a>
</div>
<p class="data-toolbar-hint" style="margin-top:8px">Question columns show stable keys (e.g. country_of_origin); hover for German text. Test rows (DEV-CL-*) have a yellow background.</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 = questionsDef
.map(q => `<option value="${esc(questionDisplayKey(q))}"></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('filterTestData').addEventListener('change', (e) => {
filterTestData = e.target.value;
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 = '';
filterTestData = '';
filterQuestion = '';
page = 0;
renderResults();
});
document.getElementById('exportCsvLink').addEventListener('click', (e) => {
e.preventDefault();
exportCSV();
});
renderTableHead();
renderTableRows();
}
function renderTableHead() {
const head = document.getElementById('resultsHead');
const fixedCols = [
{ key: 'clientCode', label: 'Client' },
{ key: 'coachUsername', label: 'Coach' },
{ key: 'supervisorUsername', label: 'Supervisor' },
{ key: 'status', label: 'Status' },
{ key: 'sumPoints', label: 'Score' },
{ key: 'completedAt', label: 'Completed' },
];
const questionCols = questionsDef.map(q => {
const qKey = questionDisplayKey(q);
const german = questionGermanLabel(q);
return {
key: `q_${q.questionID}`,
label: qKey,
title: german || qKey,
questionID: q.questionID,
questionKey: qKey,
};
});
const allCols = [...fixedCols, ...questionCols];
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();
allClients.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(allClients.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 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);
if (filterDateFrom || filterDateTo) {
const fromTs = filterDateFrom ? dateInputToUnixStart(filterDateFrom) : null;
const toTs = filterDateTo ? dateInputToUnixEnd(filterDateTo) : null;
clients = clients.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();
clients = clients.filter(c => (c.clientCode || '').toLowerCase().includes(q));
}
if (filterTestData === 'test') {
clients = clients.filter(c => isDevTestClientCode(c.clientCode));
} else if (filterTestData === 'hide-test') {
clients = clients.filter(c => !isDevTestClientCode(c.clientCode));
}
return clients;
}
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 renderTableRows() {
const body = document.getElementById('resultsBody');
let clients = getFilteredClients();
// Sort
if (sortCol) {
clients = [...clients].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;
});
}
document.getElementById('resultCount').textContent = `${clients.length} client${clients.length === 1 ? '' : 's'}`;
renderPagination(clients.length);
if (!clients.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));
if (page >= totalPages) page = totalPages - 1;
clients = clients.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
// Build option text lookup
const optionMap = {};
questionsDef.forEach(q => {
(q.answerOptions || []).forEach(o => {
optionMap[o.answerOptionID] = o.defaultText;
});
});
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 questionCells = questionsDef.map(q => {
const ans = c.answers && c.answers[q.questionID];
if (!ans) return '<td>—</td>';
if (q.type === 'glass_scale_question') {
const text = formatGlassAnswer(ans.freeTextValue);
return `<td>${esc(text || '—')}</td>`;
}
if (ans.answerOptionID && optionMap[ans.answerOptionID]) {
return `<td>${esc(optionMap[ans.answerOptionID])}</td>`;
}
if (ans.freeTextValue) return `<td>${esc(ans.freeTextValue)}</td>`;
if (ans.numericValue !== null && ans.numericValue !== undefined) return `<td>${ans.numericValue}</td>`;
return '<td>—</td>';
}).join('');
return `<tr class="${testDataRowClassForClient(c.clientCode).trim()}">
<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>`;
}).join('');
}
function getCellValue(client, key) {
if (key === 'clientCode') return client.clientCode;
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 === 'completedAt') return client.completedAt;
if (key.startsWith('q_')) {
const qid = key.substring(2);
const q = questionsDef.find(qq => qq.questionID === qid);
const ans = client.answers && client.answers[qid];
if (!ans) return null;
if (q?.type === 'glass_scale_question') {
return formatGlassAnswer(ans.freeTextValue) || null;
}
if (ans.numericValue !== null && ans.numericValue !== undefined) return ans.numericValue;
if (ans.freeTextValue) return ans.freeTextValue;
return ans.answerOptionID || null;
}
return null;
}
function exportCSV() {
const clients = getFilteredClients();
const optionMap = {};
questionsDef.forEach(q => {
(q.answerOptions || []).forEach(o => {
optionMap[o.answerOptionID] = o.defaultText;
});
});
const headers = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'completedAt',
...questionsDef.map(q => questionDisplayKey(q))];
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 = questionsDef.map(q => {
const ans = c.answers && c.answers[q.questionID];
if (!ans) return '';
if (q.type === 'glass_scale_question') {
return formatGlassAnswer(ans.freeTextValue);
}
if (ans.answerOptionID && optionMap[ans.answerOptionID]) return optionMap[ans.answerOptionID];
if (ans.freeTextValue) return ans.freeTextValue;
if (ans.numericValue !== null && ans.numericValue !== undefined) return ans.numericValue;
return '';
});
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, '""') + '"';
}
return s;
}
function formatGlassAnswer(raw) {
if (!raw) return '';
try {
const o = JSON.parse(raw);
if (o && typeof o === 'object' && !Array.isArray(o)) {
return Object.entries(o)
.filter(([, v]) => v != null && String(v).trim() !== '')
.map(([k, v]) => `${k}: ${v}`)
.join('; ');
}
} catch {
/* plain text fallback */
}
return String(raw);
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}