513 lines
20 KiB
JavaScript
513 lines
20 KiB
JavaScript
import { apiGet } from '../api.js';
|
||
import { showToast } from '../app.js';
|
||
import {
|
||
isDevTestClientCode,
|
||
questionLocalKey,
|
||
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">← 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 keys; hover headers for full 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(questionLocalKey(q.questionID, q.defaultText))}"></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 = questionLocalKey(q.questionID, q.defaultText);
|
||
return {
|
||
key: `q_${q.questionID}`,
|
||
label: qKey,
|
||
title: q.defaultText || 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 => questionLocalKey(q.questionID, q.defaultText))];
|
||
|
||
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;
|
||
}
|