| ${esc(c.clientCode)} |
${coach} |
@@ -104,7 +179,8 @@ async function deleteClient(clientCode) {
await apiDelete('clients.php', { clientCode });
clientsList = clientsList.filter(c => c.clientCode !== clientCode);
showToast(`Client "${clientCode}" deleted`, 'success');
- renderClients();
+ if (!clientsList.length) renderClients();
+ else renderClientTableBody();
} catch (e) {
showToast(e.message, 'error');
}
diff --git a/website/js/pages/results.js b/website/js/pages/results.js
index 8fefff9..b09e45d 100644
--- a/website/js/pages/results.js
+++ b/website/js/pages/results.js
@@ -1,5 +1,12 @@
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';
@@ -11,6 +18,10 @@ 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');
@@ -21,6 +32,10 @@ export async function resultsPage(params) {
filterStatus = '';
filterDateFrom = '';
filterDateTo = '';
+ filterSearch = '';
+ filterTestData = '';
+ filterQuestion = '';
+ page = 0;
app.innerHTML = `
+ Question columns show keys; hover headers for full text. Test rows (DEV-CL-*) have a yellow background.
-
`;
+ const qKeyList = document.getElementById('questionKeyList');
+ qKeyList.innerHTML = questionsDef
+ .map(q => ` `)
+ .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) => {
@@ -140,17 +201,24 @@ function renderTableHead() {
{ key: 'sumPoints', label: 'Score' },
{ key: 'completedAt', label: 'Completed' },
];
- const questionCols = questionsDef.map(q => ({
- key: `q_${q.questionID}`,
- label: q.defaultText,
- questionID: q.questionID,
- }));
+ 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 => {
+ head.innerHTML = allCols.map((col, idx) => {
let cls = 'sortable';
if (sortCol === col.key) cls += sortDir === 'asc' ? ' sort-asc' : ' sort-desc';
- return ` ${esc(col.label)} | `;
+ if (idx === 0) cls += ' sticky-col';
+ const title = col.title ? ` title="${esc(col.title)}"` : '';
+ return ` ${esc(col.label)} | `;
}).join('');
head.querySelectorAll('th.sortable').forEach(th => {
@@ -158,12 +226,31 @@ function renderTableHead() {
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 => {
@@ -203,9 +290,49 @@ function getFilteredClients() {
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
+ ? ` ${totalRows} row${totalRows === 1 ? '' : 's'}`
+ : '';
+ return;
+ }
+
+ const from = page * PAGE_SIZE + 1;
+ const to = Math.min((page + 1) * PAGE_SIZE, totalRows);
+ bar.innerHTML = `
+
+ Rows ${from}–${to} of ${totalRows} (page ${page + 1}/${totalPages})
+
+ `;
+ 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);
@@ -237,11 +364,17 @@ function renderTableRows() {
document.getElementById('resultCount').textContent = `${clients.length} client${clients.length === 1 ? '' : 's'}`;
+ renderPagination(clients.length);
+
if (!clients.length) {
body.innerHTML = ` | No results found | `;
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 => {
@@ -269,8 +402,8 @@ function renderTableRows() {
return ' — | ';
}).join('');
- return `
- | ${esc(c.clientCode)} |
+ return `
+ | ${esc(c.clientCode)} |
${esc(coachDisplayName(c))} |
${esc(supervisorDisplayName(c))} |
${statusBadge} |
@@ -313,7 +446,7 @@ function exportCSV() {
});
const headers = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'completedAt',
- ...questionsDef.map(q => q.defaultText)];
+ ...questionsDef.map(q => questionLocalKey(q.questionID, q.defaultText))];
const rows = clients.map(c => {
const base = [
diff --git a/website/js/pages/users.js b/website/js/pages/users.js
index 1a5e18a..196064b 100644
--- a/website/js/pages/users.js
+++ b/website/js/pages/users.js
@@ -1,5 +1,6 @@
import { apiGet, apiPost, apiDelete } from '../api.js';
import { getRole, getUser, showToast } from '../app.js';
+import { isDevTestUsername, testDataRowClassForUser } from '../test-data.js';
// Roles an admin can create; supervisors can only create coaches
const CREATEABLE_ROLES = {
@@ -10,6 +11,8 @@ const CREATEABLE_ROLES = {
let usersList = [];
let supervisorsList = [];
let callerRole = '';
+let filterSearch = '';
+let filterTestData = '';
export async function usersPage() {
callerRole = getRole();
@@ -86,8 +89,19 @@ function renderUsers() {
{ label: 'Coaches', key: 'coach' },
];
- container.innerHTML = groups.map(group => {
- const users = byRole[group.key] || [];
+ container.innerHTML = `
+
+
+
+
+
+
+ ` + groups.map(group => {
+ const users = filterUsers(byRole[group.key] || []);
if (!users.length) return '';
return `
@@ -111,11 +125,34 @@ function renderUsers() {
`;
}).join('');
+ document.getElementById('userListSearch')?.addEventListener('input', (e) => {
+ filterSearch = e.target.value.trim();
+ renderUsers();
+ });
+ document.getElementById('userTestFilter')?.addEventListener('change', (e) => {
+ filterTestData = e.target.value;
+ renderUsers();
+ });
+
container.querySelectorAll('.delete-user-btn').forEach(btn => {
btn.addEventListener('click', () => deleteUser(btn.dataset.id, btn.dataset.name));
});
}
+function filterUsers(users) {
+ let list = users;
+ if (filterSearch) {
+ const q = filterSearch.toLowerCase();
+ list = list.filter(u => (u.username || '').toLowerCase().includes(q));
+ }
+ if (filterTestData === 'test') {
+ list = list.filter(u => isDevTestUsername(u.username));
+ } else if (filterTestData === 'hide-test') {
+ list = list.filter(u => !isDevTestUsername(u.username));
+ }
+ return list;
+}
+
function userRowHTML(u, myUsername) {
const isSelf = u.username === myUsername;
const detail = u.role === 'coach'
@@ -134,7 +171,7 @@ function userRowHTML(u, myUsername) {
);
return `
-
+
|
${esc(u.username)}
${isSelf ? 'You' : ''}
diff --git a/website/js/test-data.js b/website/js/test-data.js
new file mode 100644
index 0000000..20bd554
--- /dev/null
+++ b/website/js/test-data.js
@@ -0,0 +1,34 @@
+/** Dev fixture prefixes (import script / dev_fixture.php). */
+export function isDevTestClientCode(code) {
+ const c = String(code ?? '').trim();
+ return c.toUpperCase().startsWith('DEV-CL-');
+}
+
+export function isDevTestUsername(username) {
+ return String(username ?? '').trim().toLowerCase().startsWith('dev_');
+}
+
+export function testDataRowClassForClient(clientCode) {
+ return isDevTestClientCode(clientCode) ? ' row-test-data' : '';
+}
+
+export function testDataRowClassForUser(username) {
+ return isDevTestUsername(username) ? ' row-test-data' : '';
+}
+
+/** Local question key from full questionID (hash__q5 → q5). */
+export function questionLocalKey(questionID, fallback = '') {
+ const id = String(questionID ?? '');
+ const sep = id.lastIndexOf('__');
+ if (sep >= 0) return id.slice(sep + 2);
+ return fallback || id;
+}
+
+/** header_order.json column id → question key (questionnaire_x-y → y). */
+export function headerColumnQuestionKey(headerId) {
+ const id = String(headerId ?? '');
+ if (id === 'client_code') return 'client_code';
+ const dash = id.indexOf('-');
+ if (dash < 0) return id;
+ return id.slice(dash + 1);
+}
| |