better ux with search functionality
This commit is contained in:
@ -2,12 +2,23 @@ import { apiGet, apiPost } from '../api.js';
|
||||
import { showToast } from '../app.js';
|
||||
import { isDevTestClientCode, isDevTestUsername, testDataRowClassForClient } from '../test-data.js';
|
||||
|
||||
const CLIENT_PAGE_SIZE = 50;
|
||||
|
||||
let coaches = [];
|
||||
let clients = [];
|
||||
let selectedCoachID = null;
|
||||
let filterCoachSearch = '';
|
||||
let clientsByCoach = {};
|
||||
let clientPage = 0;
|
||||
let selectedClientCodes = new Set();
|
||||
let assignResizeObserver = null;
|
||||
let assignClientRowHeight = 0;
|
||||
|
||||
export async function assignmentsPage() {
|
||||
selectedCoachID = null;
|
||||
filterCoachSearch = '';
|
||||
clientPage = 0;
|
||||
selectedClientCodes = new Set();
|
||||
const app = document.getElementById('app');
|
||||
|
||||
app.innerHTML = `
|
||||
@ -22,6 +33,7 @@ export async function assignmentsPage() {
|
||||
const data = await apiGet('assignments.php');
|
||||
coaches = data.coaches || [];
|
||||
clients = data.clients || [];
|
||||
rebuildClientsByCoach();
|
||||
renderAssignments();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
@ -30,6 +42,15 @@ export async function assignmentsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function rebuildClientsByCoach() {
|
||||
clientsByCoach = {};
|
||||
clients.forEach(c => {
|
||||
if (c.coachID) {
|
||||
(clientsByCoach[c.coachID] = clientsByCoach[c.coachID] || []).push(c);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderAssignments() {
|
||||
const container = document.getElementById('assignContent');
|
||||
|
||||
@ -42,139 +63,283 @@ function renderAssignments() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Group clients by coachID for the filter badge counts
|
||||
const clientsByCoach = {};
|
||||
const unassigned = [];
|
||||
clients.forEach(c => {
|
||||
if (c.coachID) {
|
||||
(clientsByCoach[c.coachID] = clientsByCoach[c.coachID] || []).push(c);
|
||||
} else {
|
||||
unassigned.push(c);
|
||||
}
|
||||
});
|
||||
const unassignedCount = clients.filter(c => !c.coachID).length;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="assign-layout">
|
||||
<div class="assign-page">
|
||||
<div class="assign-layout">
|
||||
|
||||
<!-- Left: Coach list -->
|
||||
<div class="assign-coaches card">
|
||||
<h3 style="margin-bottom:12px">Coaches</h3>
|
||||
<ul class="coach-list" id="coachList">
|
||||
${coaches.map(c => {
|
||||
const count = (clientsByCoach[c.coachID] || []).length;
|
||||
const coachTest = isDevTestUsername(c.username) ? ' row-test-data' : '';
|
||||
return `
|
||||
<li class="coach-list-item${coachTest}" data-id="${c.coachID}">
|
||||
<div class="coach-name">${esc(c.username)}</div>
|
||||
${c.supervisorUsername
|
||||
? `<div class="coach-supervisor">${esc(c.supervisorUsername)}</div>`
|
||||
: ''}
|
||||
<span class="coach-client-count">${count} client${count === 1 ? '' : 's'}</span>
|
||||
</li>`;
|
||||
}).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
<aside class="card assign-coaches" aria-label="Coaches">
|
||||
<div class="assign-panel-header">
|
||||
<h3>Coaches <span class="data-toolbar-hint">(${coaches.length})</span></h3>
|
||||
<div class="filter-bar">
|
||||
<input type="search" id="coachSearch" class="filter-search"
|
||||
placeholder="Search coach or supervisor…" value="${esc(filterCoachSearch)}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="assign-panel-body">
|
||||
<ul class="coach-list" id="coachList" role="listbox" aria-label="Filter by coach"></ul>
|
||||
</div>
|
||||
<div class="assign-panel-footer assign-panel-footer--align" aria-hidden="true"></div>
|
||||
</aside>
|
||||
|
||||
<!-- Right: Client list + assign panel -->
|
||||
<div class="assign-right">
|
||||
<div class="card assign-clients-card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;flex-wrap:wrap;gap:8px">
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<h3>Clients</h3>
|
||||
<div class="filter-bar" style="margin:0">
|
||||
<select id="filterByCoach">
|
||||
<option value="">All coaches</option>
|
||||
<option value="__unassigned__">Unassigned</option>
|
||||
${coaches.map(c =>
|
||||
`<option value="${c.coachID}">${esc(c.username)}</option>`
|
||||
).join('')}
|
||||
<section class="card assign-clients-card" aria-label="Clients">
|
||||
<div class="assign-clients-toolbar">
|
||||
<div class="assign-clients-toolbar-top">
|
||||
<h3>Clients <span class="data-toolbar-hint" id="clientListSummary"></span></h3>
|
||||
<div class="assign-clients-actions">
|
||||
<button type="button" class="btn btn-sm" id="selectAllBtn">Select visible</button>
|
||||
<button type="button" class="btn btn-sm" id="clearSelBtn">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="assign-clients-filters filter-bar">
|
||||
<select id="filterByCoach" aria-label="Filter by coach">
|
||||
<option value="">All coaches (${clients.length})</option>
|
||||
<option value="__unassigned__">Unassigned (${unassignedCount})</option>
|
||||
${coaches.map(c => {
|
||||
const n = (clientsByCoach[c.coachID] || []).length;
|
||||
return `<option value="${c.coachID}">${esc(c.username)} (${n})</option>`;
|
||||
}).join('')}
|
||||
</select>
|
||||
<input type="text" id="clientSearch" placeholder="Search client code..." style="width:160px">
|
||||
<select id="clientTestFilter">
|
||||
<input type="search" id="clientSearch" class="filter-search"
|
||||
placeholder="Search client or coach…">
|
||||
<select id="clientTestFilter" aria-label="Test data filter">
|
||||
<option value="">All clients</option>
|
||||
<option value="test">Test only</option>
|
||||
<option value="hide-test">Hide test</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="btn btn-sm" id="selectAllBtn">Select all</button>
|
||||
<button class="btn btn-sm" id="clearSelBtn">Clear</button>
|
||||
<div class="assign-table-panel">
|
||||
<div class="table-wrapper assign-table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px">
|
||||
<input type="checkbox" id="selectAllCb" title="Toggle all visible rows" aria-label="Select all visible">
|
||||
</th>
|
||||
<th>Client Code</th>
|
||||
<th>Current Coach</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="clientTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper" style="max-height:420px">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:36px"><input type="checkbox" id="selectAllCb" title="Toggle all visible"></th>
|
||||
<th>Client Code</th>
|
||||
<th>Current Coach</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="clientTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="selectionInfo" class="selection-info" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<!-- Assign action card -->
|
||||
<div class="card assign-action-card" id="assignActionCard" style="display:none">
|
||||
<h4>Assign selected clients to:</h4>
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:10px">
|
||||
<select id="targetCoachSelect" style="flex:1;min-width:180px;padding:8px 12px;border:1px solid var(--border);border-radius:6px">
|
||||
<option value="">— select coach —</option>
|
||||
${coaches.map(c =>
|
||||
`<option value="${c.coachID}">${esc(c.username)}${c.supervisorUsername ? ` (${esc(c.supervisorUsername)})` : ''}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
<button class="btn btn-primary" id="assignBtn">Assign</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="assign-clients-footer">
|
||||
<div class="pagination-bar" id="clientPagination"></div>
|
||||
<div id="selectionInfo" class="selection-info" style="display:none"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card assign-action-card" id="assignActionCard" style="display:none" aria-label="Assign selection">
|
||||
<h4>Assign selected clients to</h4>
|
||||
<div class="assign-action-row">
|
||||
<select id="targetCoachSelect" aria-label="Target coach">
|
||||
<option value="">— select coach —</option>
|
||||
${coaches.map(c =>
|
||||
`<option value="${c.coachID}">${esc(c.username)}${c.supervisorUsername ? ` (${esc(c.supervisorUsername)})` : ''}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
<button type="button" class="btn btn-primary" id="assignBtn">Assign</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Wire coach list click → filter clients
|
||||
document.querySelectorAll('.coach-list-item').forEach(li => {
|
||||
li.addEventListener('click', () => {
|
||||
document.querySelectorAll('.coach-list-item').forEach(x => x.classList.remove('active'));
|
||||
li.classList.add('active');
|
||||
selectedCoachID = li.dataset.id;
|
||||
document.getElementById('filterByCoach').value = selectedCoachID;
|
||||
renderClientRows();
|
||||
});
|
||||
document.getElementById('coachSearch').addEventListener('input', (e) => {
|
||||
filterCoachSearch = e.target.value;
|
||||
renderCoachList();
|
||||
});
|
||||
|
||||
// Wire filters
|
||||
document.getElementById('filterByCoach').addEventListener('change', renderClientRows);
|
||||
document.getElementById('clientSearch').addEventListener('input', renderClientRows);
|
||||
document.getElementById('clientTestFilter').addEventListener('change', renderClientRows);
|
||||
document.getElementById('coachList').addEventListener('click', (e) => {
|
||||
const item = e.target.closest('.coach-list-item');
|
||||
if (!item || item.dataset.action === 'clear-filter') return;
|
||||
document.querySelectorAll('.coach-list-item').forEach(x => x.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
selectedCoachID = item.dataset.id;
|
||||
document.getElementById('filterByCoach').value = selectedCoachID;
|
||||
clientPage = 0;
|
||||
renderClientRows();
|
||||
});
|
||||
|
||||
document.getElementById('filterByCoach').addEventListener('change', (e) => {
|
||||
const value = e.target.value;
|
||||
clientPage = 0;
|
||||
if (value && value !== '__unassigned__') {
|
||||
selectedCoachID = value;
|
||||
highlightCoachInList(value);
|
||||
} else {
|
||||
selectedCoachID = null;
|
||||
document.querySelectorAll('.coach-list-item').forEach(x => x.classList.remove('active'));
|
||||
}
|
||||
renderClientRows();
|
||||
});
|
||||
|
||||
document.getElementById('clientSearch').addEventListener('input', () => {
|
||||
clientPage = 0;
|
||||
renderClientRows();
|
||||
});
|
||||
document.getElementById('clientTestFilter').addEventListener('change', () => {
|
||||
clientPage = 0;
|
||||
renderClientRows();
|
||||
});
|
||||
|
||||
// Wire select all / clear
|
||||
document.getElementById('selectAllBtn').addEventListener('click', () => {
|
||||
document.querySelectorAll('#clientTableBody .client-cb').forEach(cb => cb.checked = true);
|
||||
document.querySelectorAll('#clientTableBody .client-cb').forEach(cb => {
|
||||
cb.checked = true;
|
||||
selectedClientCodes.add(cb.value);
|
||||
});
|
||||
updateSelectionInfo();
|
||||
});
|
||||
document.getElementById('clearSelBtn').addEventListener('click', () => {
|
||||
document.querySelectorAll('#clientTableBody .client-cb').forEach(cb => cb.checked = false);
|
||||
selectedClientCodes.clear();
|
||||
document.querySelectorAll('#clientTableBody .client-cb').forEach(cb => { cb.checked = false; });
|
||||
syncSelectAllCb();
|
||||
updateSelectionInfo();
|
||||
});
|
||||
document.getElementById('selectAllCb').addEventListener('change', (e) => {
|
||||
document.querySelectorAll('#clientTableBody .client-cb').forEach(cb => cb.checked = e.target.checked);
|
||||
document.querySelectorAll('#clientTableBody .client-cb').forEach(cb => {
|
||||
cb.checked = e.target.checked;
|
||||
if (e.target.checked) selectedClientCodes.add(cb.value);
|
||||
else selectedClientCodes.delete(cb.value);
|
||||
});
|
||||
updateSelectionInfo();
|
||||
});
|
||||
|
||||
// Wire assign button
|
||||
document.getElementById('assignBtn').addEventListener('click', doAssign);
|
||||
|
||||
renderCoachList();
|
||||
renderClientRows();
|
||||
setupAssignPanelHeightSync();
|
||||
}
|
||||
|
||||
function renderClientRows() {
|
||||
const body = document.getElementById('clientTableBody');
|
||||
const coachFilter = document.getElementById('filterByCoach').value;
|
||||
const search = document.getElementById('clientSearch').value.trim().toLowerCase();
|
||||
const testMode = document.getElementById('clientTestFilter')?.value || '';
|
||||
function setupAssignPanelHeightSync() {
|
||||
assignResizeObserver?.disconnect();
|
||||
assignResizeObserver = null;
|
||||
|
||||
const clientCard = document.querySelector('.assign-clients-card');
|
||||
if (!clientCard || typeof ResizeObserver === 'undefined') {
|
||||
syncAssignLayoutHeights();
|
||||
return;
|
||||
}
|
||||
|
||||
assignResizeObserver = new ResizeObserver(() => syncAssignLayoutHeights());
|
||||
assignResizeObserver.observe(clientCard);
|
||||
syncAssignLayoutHeights();
|
||||
}
|
||||
|
||||
function syncAssignClientTableHeight() {
|
||||
const wrapper = document.querySelector('.assign-table-panel .table-wrapper');
|
||||
const table = wrapper?.querySelector('table.data-table');
|
||||
if (!wrapper || !table) return;
|
||||
|
||||
const headerH = table.querySelector('thead')?.offsetHeight ?? 40;
|
||||
const bodyRows = [...table.querySelectorAll('tbody tr')];
|
||||
const dataRows = bodyRows.filter(tr => !tr.querySelector('td[colspan]'));
|
||||
|
||||
let rowH = assignClientRowHeight;
|
||||
if (dataRows.length) {
|
||||
rowH = dataRows[0].offsetHeight;
|
||||
assignClientRowHeight = rowH;
|
||||
} else if (bodyRows.length) {
|
||||
rowH = bodyRows[0].offsetHeight;
|
||||
} else if (!rowH) {
|
||||
rowH = 44;
|
||||
}
|
||||
|
||||
const rowCount = dataRows.length || 1;
|
||||
const bodyH = rowH * Math.min(rowCount, CLIENT_PAGE_SIZE);
|
||||
const maxH = rowH * CLIENT_PAGE_SIZE;
|
||||
|
||||
wrapper.style.height = `${headerH + bodyH}px`;
|
||||
wrapper.style.maxHeight = `${headerH + maxH}px`;
|
||||
wrapper.style.overflowY = 'auto';
|
||||
}
|
||||
|
||||
function syncAssignCoachPanelHeight() {
|
||||
const coachCard = document.querySelector('.assign-coaches');
|
||||
const clientCard = document.querySelector('.assign-clients-card');
|
||||
const coachBody = document.querySelector('.assign-coaches .assign-panel-body');
|
||||
const clientTablePanel = document.querySelector('.assign-table-panel');
|
||||
const coachFooter = document.querySelector('.assign-coaches .assign-panel-footer--align');
|
||||
const clientFooter = document.querySelector('.assign-clients-footer');
|
||||
|
||||
if (clientFooter && coachFooter) {
|
||||
coachFooter.style.height = `${clientFooter.offsetHeight}px`;
|
||||
}
|
||||
if (coachBody && clientTablePanel) {
|
||||
const h = clientTablePanel.offsetHeight;
|
||||
coachBody.style.height = `${h}px`;
|
||||
coachBody.style.maxHeight = `${h}px`;
|
||||
}
|
||||
if (coachCard && clientCard) {
|
||||
coachCard.style.minHeight = `${clientCard.offsetHeight}px`;
|
||||
}
|
||||
}
|
||||
|
||||
function syncAssignLayoutHeights() {
|
||||
requestAnimationFrame(() => {
|
||||
syncAssignClientTableHeight();
|
||||
requestAnimationFrame(() => syncAssignCoachPanelHeight());
|
||||
});
|
||||
}
|
||||
|
||||
function getFilteredCoaches() {
|
||||
const q = filterCoachSearch.trim().toLowerCase();
|
||||
if (!q) return coaches;
|
||||
return coaches.filter(c => {
|
||||
const hay = `${c.username} ${c.supervisorUsername || ''}`.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
function renderCoachList() {
|
||||
const list = document.getElementById('coachList');
|
||||
if (!list) return;
|
||||
|
||||
const filtered = getFilteredCoaches();
|
||||
const q = filterCoachSearch.trim();
|
||||
|
||||
if (!filtered.length) {
|
||||
list.innerHTML = `<li class="coach-list-empty">${q ? 'No coaches match' : 'No coaches'}</li>`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = filtered.map(c => coachListItemHTML(c)).join('');
|
||||
|
||||
if (selectedCoachID) {
|
||||
highlightCoachInList(selectedCoachID);
|
||||
}
|
||||
}
|
||||
|
||||
function coachListItemHTML(c) {
|
||||
const count = (clientsByCoach[c.coachID] || []).length;
|
||||
const coachTest = isDevTestUsername(c.username) ? ' row-test-data' : '';
|
||||
const active = selectedCoachID === c.coachID ? ' active' : '';
|
||||
return `
|
||||
<li class="coach-list-item${coachTest}${active}" data-id="${c.coachID}" role="option" tabindex="0">
|
||||
<div class="coach-name">${esc(c.username)}</div>
|
||||
${c.supervisorUsername
|
||||
? `<div class="coach-supervisor">${esc(c.supervisorUsername)}</div>`
|
||||
: ''}
|
||||
<span class="coach-client-count">${count} client${count === 1 ? '' : 's'}</span>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function highlightCoachInList(coachID) {
|
||||
document.querySelectorAll('.coach-list-item').forEach(li => {
|
||||
li.classList.toggle('active', li.dataset.id === coachID);
|
||||
});
|
||||
const active = document.querySelector(`.coach-list-item[data-id="${CSS.escape(coachID)}"]`);
|
||||
active?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function getVisibleClients() {
|
||||
const coachFilter = document.getElementById('filterByCoach')?.value ?? '';
|
||||
const search = document.getElementById('clientSearch')?.value.trim().toLowerCase() ?? '';
|
||||
const testMode = document.getElementById('clientTestFilter')?.value || '';
|
||||
|
||||
let visible = clients;
|
||||
if (coachFilter === '__unassigned__') {
|
||||
@ -183,44 +348,152 @@ function renderClientRows() {
|
||||
visible = clients.filter(c => c.coachID === coachFilter);
|
||||
}
|
||||
if (search) {
|
||||
visible = visible.filter(c => c.clientCode.toLowerCase().includes(search));
|
||||
visible = visible.filter(c => {
|
||||
const hay = `${c.clientCode} ${c.coachUsername || ''}`.toLowerCase();
|
||||
return hay.includes(search);
|
||||
});
|
||||
}
|
||||
if (testMode === 'test') {
|
||||
visible = visible.filter(c => isDevTestClientCode(c.clientCode));
|
||||
} else if (testMode === 'hide-test') {
|
||||
visible = visible.filter(c => !isDevTestClientCode(c.clientCode));
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
if (!visible.length) {
|
||||
body.innerHTML = `<tr><td colspan="3" style="text-align:center;color:var(--text-secondary);padding:24px">No clients match the current filter</td></tr>`;
|
||||
updateSelectionInfo();
|
||||
function updateClientSummary(filteredCount, pageSlice) {
|
||||
const el = document.getElementById('clientListSummary');
|
||||
if (!el) return;
|
||||
const search = document.getElementById('clientSearch')?.value.trim() ?? '';
|
||||
const coachFilter = document.getElementById('filterByCoach')?.value ?? '';
|
||||
const testFilter = document.getElementById('clientTestFilter')?.value;
|
||||
const hasFilter = search || coachFilter || testFilter;
|
||||
const parts = [];
|
||||
if (hasFilter && filteredCount !== clients.length) {
|
||||
parts.push(`${filteredCount} of ${clients.length} match`);
|
||||
} else {
|
||||
parts.push(String(clients.length));
|
||||
}
|
||||
if (pageSlice && filteredCount > CLIENT_PAGE_SIZE) {
|
||||
const from = clientPage * CLIENT_PAGE_SIZE + 1;
|
||||
const to = Math.min((clientPage + 1) * CLIENT_PAGE_SIZE, filteredCount);
|
||||
parts.push(`rows ${from}–${to}`);
|
||||
}
|
||||
el.textContent = `(${parts.join(', ')})`;
|
||||
}
|
||||
|
||||
function renderPaginationBar(barId, totalRows, currentPage, pageSize, onPageChange) {
|
||||
const pag = document.getElementById(barId);
|
||||
if (!pag) return;
|
||||
|
||||
if (!totalRows) {
|
||||
pag.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = visible.map(c => `
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize));
|
||||
if (totalRows <= pageSize) {
|
||||
pag.innerHTML = `<span>${totalRows} row${totalRows === 1 ? '' : 's'}</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const from = currentPage * pageSize + 1;
|
||||
const to = Math.min((currentPage + 1) * pageSize, totalRows);
|
||||
const prevId = `${barId}Prev`;
|
||||
const nextId = `${barId}Next`;
|
||||
pag.innerHTML = `
|
||||
<button type="button" class="btn btn-sm" id="${prevId}" ${currentPage <= 0 ? 'disabled' : ''}>Prev</button>
|
||||
<span>Rows ${from}–${to} of ${totalRows} (page ${currentPage + 1}/${totalPages})</span>
|
||||
<button type="button" class="btn btn-sm" id="${nextId}" ${currentPage >= totalPages - 1 ? 'disabled' : ''}>Next</button>
|
||||
`;
|
||||
document.getElementById(prevId)?.addEventListener('click', () => onPageChange(currentPage - 1));
|
||||
document.getElementById(nextId)?.addEventListener('click', () => onPageChange(currentPage + 1));
|
||||
}
|
||||
|
||||
function syncSelectAllCb(codesOnPage = []) {
|
||||
const allCb = document.getElementById('selectAllCb');
|
||||
if (!allCb) return;
|
||||
if (!codesOnPage.length) {
|
||||
allCb.checked = false;
|
||||
allCb.indeterminate = false;
|
||||
return;
|
||||
}
|
||||
const allSelected = codesOnPage.every(code => selectedClientCodes.has(code));
|
||||
const someSelected = codesOnPage.some(code => selectedClientCodes.has(code));
|
||||
allCb.checked = allSelected;
|
||||
allCb.indeterminate = !allSelected && someSelected;
|
||||
}
|
||||
|
||||
function renderClientRows() {
|
||||
const body = document.getElementById('clientTableBody');
|
||||
if (!body) return;
|
||||
|
||||
const visible = getVisibleClients();
|
||||
const totalPages = Math.max(1, Math.ceil(visible.length / CLIENT_PAGE_SIZE));
|
||||
if (clientPage >= totalPages) clientPage = totalPages - 1;
|
||||
|
||||
if (!visible.length) {
|
||||
updateClientSummary(0, false);
|
||||
body.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="3" style="text-align:center;color:var(--text-secondary);padding:28px 16px">
|
||||
No clients match the current filters
|
||||
</td>
|
||||
</tr>`;
|
||||
renderPaginationBar('clientPagination', 0, clientPage, CLIENT_PAGE_SIZE, (p) => {
|
||||
clientPage = p;
|
||||
renderClientRows();
|
||||
});
|
||||
syncSelectAllCb();
|
||||
updateSelectionInfo();
|
||||
syncAssignCoachPanelHeight();
|
||||
return;
|
||||
}
|
||||
|
||||
const slice = visible.slice(clientPage * CLIENT_PAGE_SIZE, (clientPage + 1) * CLIENT_PAGE_SIZE);
|
||||
updateClientSummary(visible.length, visible.length > CLIENT_PAGE_SIZE);
|
||||
|
||||
body.innerHTML = slice.map(c => {
|
||||
const checked = selectedClientCodes.has(c.clientCode) ? ' checked' : '';
|
||||
return `
|
||||
<tr class="${testDataRowClassForClient(c.clientCode).trim()}">
|
||||
<td><input type="checkbox" class="client-cb" data-code="${esc(c.clientCode)}" value="${esc(c.clientCode)}"></td>
|
||||
<td>
|
||||
<input type="checkbox" class="client-cb" data-code="${esc(c.clientCode)}" value="${esc(c.clientCode)}"${checked} aria-label="Select ${esc(c.clientCode)}">
|
||||
</td>
|
||||
<td><strong>${esc(c.clientCode)}</strong></td>
|
||||
<td>${c.coachUsername ? esc(c.coachUsername) : '<span style="color:var(--text-secondary);font-style:italic">Unassigned</span>'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
<td>${c.coachUsername
|
||||
? esc(c.coachUsername)
|
||||
: '<span style="color:var(--text-secondary);font-style:italic">Unassigned</span>'}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
body.querySelectorAll('.client-cb').forEach(cb => {
|
||||
cb.addEventListener('change', updateSelectionInfo);
|
||||
cb.addEventListener('change', () => {
|
||||
if (cb.checked) selectedClientCodes.add(cb.value);
|
||||
else selectedClientCodes.delete(cb.value);
|
||||
syncSelectAllCb(slice.map(c => c.clientCode));
|
||||
updateSelectionInfo();
|
||||
});
|
||||
});
|
||||
|
||||
renderPaginationBar('clientPagination', visible.length, clientPage, CLIENT_PAGE_SIZE, (p) => {
|
||||
clientPage = p;
|
||||
renderClientRows();
|
||||
});
|
||||
syncSelectAllCb(slice.map(c => c.clientCode));
|
||||
updateSelectionInfo();
|
||||
syncAssignLayoutHeights();
|
||||
}
|
||||
|
||||
function getSelectedCodes() {
|
||||
return [...document.querySelectorAll('#clientTableBody .client-cb:checked')]
|
||||
.map(cb => cb.value);
|
||||
return [...selectedClientCodes];
|
||||
}
|
||||
|
||||
function updateSelectionInfo() {
|
||||
const selected = getSelectedCodes();
|
||||
const info = document.getElementById('selectionInfo');
|
||||
const card = document.getElementById('assignActionCard');
|
||||
const info = document.getElementById('selectionInfo');
|
||||
const card = document.getElementById('assignActionCard');
|
||||
const onPage = document.querySelectorAll('#clientTableBody .client-cb:checked').length;
|
||||
|
||||
if (!selected.length) {
|
||||
info.style.display = 'none';
|
||||
@ -228,16 +501,24 @@ function updateSelectionInfo() {
|
||||
return;
|
||||
}
|
||||
info.style.display = '';
|
||||
info.innerHTML = `<strong>${selected.length}</strong> client${selected.length === 1 ? '' : 's'} selected`;
|
||||
const extra = selected.length > onPage
|
||||
? ` <span class="data-toolbar-hint">(${onPage} on this page)</span>`
|
||||
: '';
|
||||
info.innerHTML = `<strong>${selected.length}</strong> client${selected.length === 1 ? '' : 's'} selected${extra}`;
|
||||
card.style.display = '';
|
||||
if (selectedCoachID) {
|
||||
const sel = document.getElementById('targetCoachSelect');
|
||||
if (sel) sel.value = selectedCoachID;
|
||||
}
|
||||
syncAssignLayoutHeights();
|
||||
}
|
||||
|
||||
async function doAssign() {
|
||||
const selected = getSelectedCodes();
|
||||
const coachID = document.getElementById('targetCoachSelect').value;
|
||||
const coachID = document.getElementById('targetCoachSelect').value;
|
||||
|
||||
if (!selected.length) { showToast('Select at least one client', 'error'); return; }
|
||||
if (!coachID) { showToast('Select a target coach', 'error'); return; }
|
||||
if (!coachID) { showToast('Select a target coach', 'error'); return; }
|
||||
|
||||
const coachName = coaches.find(c => c.coachID === coachID)?.username || coachID;
|
||||
if (!confirm(`Assign ${selected.length} client(s) to ${coachName}?`)) return;
|
||||
@ -249,7 +530,6 @@ async function doAssign() {
|
||||
try {
|
||||
const data = await apiPost('assignments.php', { clientCodes: selected, coachID });
|
||||
if (data.success) {
|
||||
// Update local data
|
||||
selected.forEach(code => {
|
||||
const c = clients.find(x => x.clientCode === code);
|
||||
if (c) {
|
||||
@ -257,6 +537,8 @@ async function doAssign() {
|
||||
c.coachUsername = coachName;
|
||||
}
|
||||
});
|
||||
selectedClientCodes.clear();
|
||||
rebuildClientsByCoach();
|
||||
showToast(`${data.assigned} client(s) assigned to ${coachName}`, 'success');
|
||||
renderAssignments();
|
||||
}
|
||||
|
||||
@ -69,7 +69,7 @@ function renderClients() {
|
||||
container.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="filter-bar">
|
||||
<input type="search" id="clientListSearch" class="filter-search" placeholder="Search client code…" value="${esc(filterSearch)}">
|
||||
<input type="search" id="clientListSearch" class="filter-search" placeholder="Search client or coach…" value="${esc(filterSearch)}">
|
||||
<select id="clientTestFilter">
|
||||
<option value="">All clients</option>
|
||||
<option value="test" ${filterTestData === 'test' ? 'selected' : ''}>Test data only</option>
|
||||
@ -110,7 +110,10 @@ function getFilteredClientsList() {
|
||||
let list = clientsList;
|
||||
if (filterSearch) {
|
||||
const q = filterSearch.toLowerCase();
|
||||
list = list.filter(c => (c.clientCode || '').toLowerCase().includes(q));
|
||||
list = list.filter(c => {
|
||||
const hay = `${c.clientCode || ''} ${c.coachUsername || ''}`.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}
|
||||
if (filterTestData === 'test') {
|
||||
list = list.filter(c => isDevTestClientCode(c.clientCode));
|
||||
|
||||
@ -23,7 +23,7 @@ export function devPage() {
|
||||
<div class="form-group">
|
||||
<label for="devFixtureFile">Fixture JSON</label>
|
||||
<input type="file" id="devFixtureFile" accept=".json,application/json">
|
||||
<span class="field-hint">Use <code>dev-test-fixture.json</code> from the project root (generate via <code>scripts/generate_dev_fixture.py</code>).</span>
|
||||
<span class="field-hint">Use <code>dev-test-fixture.json</code> from the repo root (generate via <code>nat-as-server/scripts/generate_dev_fixture.py</code>; reads <code>questionnaires_bundle_2026-05-28.json</code>, 5× scale).</span>
|
||||
</div>
|
||||
<div id="devFixtureSummary" class="field-hint" style="margin:12px 0;display:none"></div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import { canEdit, showToast } from '../app.js';
|
||||
let questionnairesList = [];
|
||||
let filterSearch = '';
|
||||
|
||||
export async function exportPage() {
|
||||
const app = document.getElementById('app');
|
||||
@ -13,17 +15,18 @@ export async function exportPage() {
|
||||
|
||||
try {
|
||||
const data = await apiGet('questionnaires.php');
|
||||
renderExport(data.questionnaires || []);
|
||||
questionnairesList = data.questionnaires || [];
|
||||
renderExport();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
document.getElementById('exportContent').innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderExport(questionnaires) {
|
||||
function renderExport() {
|
||||
const container = document.getElementById('exportContent');
|
||||
|
||||
if (!questionnaires.length) {
|
||||
if (!questionnairesList.length) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>No questionnaires</h3>
|
||||
@ -46,40 +49,101 @@ function renderExport(questionnaires) {
|
||||
|
||||
container.innerHTML = `
|
||||
${bundleBlock}
|
||||
<p style="margin-bottom:16px;color:var(--text-secondary)">
|
||||
Select a questionnaire and click Export to download a CSV file with all client responses (filtered by your role's visibility).
|
||||
</p>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Questionnaire</th>
|
||||
<th>Version</th>
|
||||
<th>State</th>
|
||||
<th>Questions</th>
|
||||
<th>Completed</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${questionnaires.map(q => `
|
||||
<tr>
|
||||
<td><strong>${esc(q.name)}</strong></td>
|
||||
<td>${esc(q.version || '—')}</td>
|
||||
<td><span class="badge badge-${(q.state || 'draft').toLowerCase()}">${esc(q.state || 'draft')}</span></td>
|
||||
<td>${q.questionCount || 0}</td>
|
||||
<td>${q.completedCount ?? 0}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary export-btn" data-id="${q.questionnaireID}" data-name="${esc(q.name)}" data-version="${esc(q.version)}">
|
||||
Export CSV
|
||||
</button>
|
||||
<a href="#/questionnaire/${q.questionnaireID}/results" class="btn btn-sm">View Results</a>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card">
|
||||
<p style="margin:0 0 12px;color:var(--text-secondary)">
|
||||
Select a questionnaire and click Export to download a CSV file with all client responses (filtered by your role's visibility).
|
||||
</p>
|
||||
<div class="filter-bar">
|
||||
<input type="search" id="exportListSearch" class="filter-search" placeholder="Search questionnaire name…" value="${esc(filterSearch)}">
|
||||
<span class="data-toolbar-hint" id="exportListCount"></span>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Questionnaire</th>
|
||||
<th>Version</th>
|
||||
<th>State</th>
|
||||
<th>Questions</th>
|
||||
<th>Completed</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="exportTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('exportListSearch').addEventListener('input', (e) => {
|
||||
filterSearch = e.target.value;
|
||||
renderExportTableBody();
|
||||
});
|
||||
|
||||
renderExportTableBody();
|
||||
wireExportActions();
|
||||
}
|
||||
|
||||
function exportRowSearchText(q) {
|
||||
return [q.name, q.questionnaireID, q.version, q.state].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function renderExportTableBody() {
|
||||
const body = document.getElementById('exportTableBody');
|
||||
const countEl = document.getElementById('exportListCount');
|
||||
if (!body) return;
|
||||
|
||||
const q = filterSearch.trim().toLowerCase();
|
||||
const filtered = q
|
||||
? questionnairesList.filter(item => exportRowSearchText(item).toLowerCase().includes(q))
|
||||
: questionnairesList;
|
||||
|
||||
if (!filtered.length) {
|
||||
body.innerHTML = `
|
||||
<tr data-filter-placeholder="1">
|
||||
<td colspan="6" style="text-align:center;color:var(--text-secondary);padding:24px">
|
||||
No questionnaires match
|
||||
</td>
|
||||
</tr>`;
|
||||
if (countEl) {
|
||||
countEl.textContent = q
|
||||
? `0 of ${questionnairesList.length} questionnaires`
|
||||
: '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = filtered.map(item => exportRowHTML(item)).join('');
|
||||
|
||||
if (countEl) {
|
||||
countEl.textContent = q
|
||||
? `${filtered.length} of ${questionnairesList.length} questionnaires`
|
||||
: `${questionnairesList.length} questionnaire${questionnairesList.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
body.querySelectorAll('.export-btn').forEach(btn => {
|
||||
btn.addEventListener('click', onExportCsvClick);
|
||||
});
|
||||
}
|
||||
|
||||
function exportRowHTML(q) {
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${esc(q.name)}</strong></td>
|
||||
<td>${esc(q.version || '—')}</td>
|
||||
<td><span class="badge badge-${(q.state || 'draft').toLowerCase()}">${esc(q.state || 'draft')}</span></td>
|
||||
<td>${q.questionCount || 0}</td>
|
||||
<td>${q.completedCount ?? 0}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary export-btn" data-id="${esc(q.questionnaireID)}" data-name="${esc(q.name)}" data-version="${esc(q.version)}">
|
||||
Export CSV
|
||||
</button>
|
||||
<a href="#/questionnaire/${esc(q.questionnaireID)}/results" class="btn btn-sm">View Results</a>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function wireExportActions() {
|
||||
document.getElementById('exportBundleBtn')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('exportBundleBtn');
|
||||
btn.disabled = true;
|
||||
@ -110,36 +174,35 @@ function renderExport(questionnaires) {
|
||||
btn.textContent = prev;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
container.querySelectorAll('.export-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Downloading...';
|
||||
try {
|
||||
const token = localStorage.getItem('qdb_token');
|
||||
const res = await fetch(`../api/export.php?questionnaireID=${encodeURIComponent(btn.dataset.id)}&format=csv`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Download failed' }));
|
||||
throw new Error(err.error);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${btn.dataset.name}_v${btn.dataset.version}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('Download started', 'success');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Export CSV';
|
||||
}
|
||||
async function onExportCsvClick(ev) {
|
||||
const btn = ev.currentTarget;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Downloading...';
|
||||
try {
|
||||
const token = localStorage.getItem('qdb_token');
|
||||
const res = await fetch(`../api/export.php?questionnaireID=${encodeURIComponent(btn.dataset.id)}&format=csv`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Download failed' }));
|
||||
throw new Error(err.error);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${btn.dataset.name}_v${btn.dataset.version}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('Download started', 'success');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Export CSV';
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
|
||||
@ -96,7 +96,7 @@ function renderResults() {
|
||||
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)}">
|
||||
<input type="search" id="filterSearch" class="filter-search" placeholder="Search client, coach, status…" value="${esc(filterSearch)}">
|
||||
<select id="filterTestData">
|
||||
<option value="">All clients</option>
|
||||
<option value="test" ${filterTestData === 'test' ? 'selected' : ''}>Test data only</option>
|
||||
@ -289,7 +289,15 @@ function getFilteredClients() {
|
||||
}
|
||||
if (filterSearch) {
|
||||
const q = filterSearch.toLowerCase();
|
||||
clients = clients.filter(c => (c.clientCode || '').toLowerCase().includes(q));
|
||||
clients = clients.filter(c => {
|
||||
const hay = [
|
||||
c.clientCode,
|
||||
c.coachUsername,
|
||||
c.supervisorUsername,
|
||||
c.status,
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}
|
||||
if (filterTestData === 'test') {
|
||||
clients = clients.filter(c => isDevTestClientCode(c.clientCode));
|
||||
|
||||
@ -11,9 +11,35 @@ const CREATEABLE_ROLES = {
|
||||
let usersList = [];
|
||||
let supervisorsList = [];
|
||||
let callerRole = '';
|
||||
let filterSearch = '';
|
||||
/** @type {Record<string, string>} */
|
||||
let filterSearchByRole = { admin: '', supervisor: '', coach: '' };
|
||||
let filterTestData = '';
|
||||
|
||||
function roleGroupsForCaller() {
|
||||
return callerRole === 'supervisor'
|
||||
? [{ label: 'Coaches', key: 'coach' }]
|
||||
: [
|
||||
{ label: 'Admins', key: 'admin' },
|
||||
{ label: 'Supervisors', key: 'supervisor' },
|
||||
{ label: 'Coaches', key: 'coach' },
|
||||
];
|
||||
}
|
||||
|
||||
function searchPlaceholderForRole(roleKey) {
|
||||
return roleKey === 'coach'
|
||||
? 'Search username or supervisor…'
|
||||
: 'Search username or location…';
|
||||
}
|
||||
|
||||
function usersByRoleMap() {
|
||||
const byRole = { admin: [], supervisor: [], coach: [] };
|
||||
usersList.forEach(u => {
|
||||
if (byRole[u.role]) byRole[u.role].push(u);
|
||||
else (byRole.other = byRole.other || []).push(u);
|
||||
});
|
||||
return byRole;
|
||||
}
|
||||
|
||||
export async function usersPage() {
|
||||
callerRole = getRole();
|
||||
const app = document.getElementById('app');
|
||||
@ -76,36 +102,34 @@ function renderUsers() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by role for a cleaner display
|
||||
const byRole = { admin: [], supervisor: [], coach: [] };
|
||||
usersList.forEach(u => (byRole[u.role] || (byRole.other = byRole.other || [])).push(u));
|
||||
const byRole = usersByRoleMap();
|
||||
const groups = roleGroupsForCaller();
|
||||
|
||||
// Which role groups to show
|
||||
const groups = callerRole === 'supervisor'
|
||||
? [{ label: 'Coaches', key: 'coach' }]
|
||||
: [
|
||||
{ label: 'Admins', key: 'admin' },
|
||||
{ label: 'Supervisors', key: 'supervisor' },
|
||||
{ label: 'Coaches', key: 'coach' },
|
||||
];
|
||||
|
||||
container.innerHTML = `
|
||||
const testFilterCard = `
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<div class="filter-bar">
|
||||
<input type="search" id="userListSearch" class="filter-search" placeholder="Search username…" value="${esc(filterSearch)}">
|
||||
<label style="font-size:.85rem;font-weight:500">Show:</label>
|
||||
<select id="userTestFilter">
|
||||
<option value="">All users</option>
|
||||
<option value="test" ${filterTestData === 'test' ? 'selected' : ''}>Test data only (dev_*)</option>
|
||||
<option value="hide-test" ${filterTestData === 'hide-test' ? 'selected' : ''}>Hide test data</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
` + groups.map(group => {
|
||||
const users = filterUsers(byRole[group.key] || []);
|
||||
if (!users.length) return '';
|
||||
</div>`;
|
||||
|
||||
container.innerHTML = testFilterCard + groups.map(group => {
|
||||
const allInRole = byRole[group.key] || [];
|
||||
if (!allInRole.length) return '';
|
||||
return `
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<h3 style="margin-bottom:12px">${group.label} (${users.length})</h3>
|
||||
<div class="card user-role-card" data-role="${group.key}" style="margin-bottom:16px">
|
||||
<h3 class="user-role-title" style="margin-bottom:12px">
|
||||
${group.label} (<span data-role-count="${group.key}">0</span>)
|
||||
</h3>
|
||||
<div class="filter-bar">
|
||||
<input type="search" class="filter-search user-role-search" data-role="${group.key}"
|
||||
placeholder="${searchPlaceholderForRole(group.key)}"
|
||||
value="${esc(filterSearchByRole[group.key] || '')}">
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
@ -117,33 +141,41 @@ function renderUsers() {
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${users.map(u => userRowHTML(u, myUsername)).join('')}
|
||||
</tbody>
|
||||
<tbody id="usersBody-${group.key}"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}).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));
|
||||
container.addEventListener('input', (e) => {
|
||||
const input = e.target.closest('.user-role-search');
|
||||
if (!input) return;
|
||||
const roleKey = input.dataset.role;
|
||||
if (!roleKey) return;
|
||||
filterSearchByRole[roleKey] = input.value;
|
||||
updateRoleGroupTable(roleKey, myUsername);
|
||||
});
|
||||
|
||||
groups.forEach(group => {
|
||||
if ((byRole[group.key] || []).length) {
|
||||
updateRoleGroupTable(group.key, myUsername);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function filterUsers(users) {
|
||||
function filterUsers(users, roleKey) {
|
||||
let list = users;
|
||||
if (filterSearch) {
|
||||
const q = filterSearch.toLowerCase();
|
||||
list = list.filter(u => (u.username || '').toLowerCase().includes(q));
|
||||
const q = (filterSearchByRole[roleKey] || '').trim().toLowerCase();
|
||||
if (q) {
|
||||
list = list.filter(u => {
|
||||
const hay = [u.username, u.location, u.supervisorUsername].filter(Boolean).join(' ').toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}
|
||||
if (filterTestData === 'test') {
|
||||
list = list.filter(u => isDevTestUsername(u.username));
|
||||
@ -153,6 +185,39 @@ function filterUsers(users) {
|
||||
return list;
|
||||
}
|
||||
|
||||
function updateRoleGroupTable(roleKey, myUsername) {
|
||||
const tbody = document.getElementById(`usersBody-${roleKey}`);
|
||||
const countEl = document.querySelector(`[data-role-count="${roleKey}"]`);
|
||||
if (!tbody) return;
|
||||
|
||||
const byRole = usersByRoleMap();
|
||||
const allInRole = byRole[roleKey] || [];
|
||||
const users = filterUsers(allInRole, roleKey);
|
||||
const colSpan = 5;
|
||||
|
||||
if (countEl) {
|
||||
const q = (filterSearchByRole[roleKey] || '').trim();
|
||||
countEl.textContent = q && users.length !== allInRole.length
|
||||
? `${users.length} of ${allInRole.length}`
|
||||
: String(users.length);
|
||||
}
|
||||
|
||||
if (!users.length) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="${colSpan}" style="text-align:center;color:var(--text-secondary);padding:24px">
|
||||
${allInRole.length ? 'No users match' : 'No users'}
|
||||
</td>
|
||||
</tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = users.map(u => userRowHTML(u, myUsername)).join('');
|
||||
tbody.querySelectorAll('.delete-user-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => deleteUser(btn.dataset.id, btn.dataset.name));
|
||||
});
|
||||
}
|
||||
|
||||
function userRowHTML(u, myUsername) {
|
||||
const isSelf = u.username === myUsername;
|
||||
const detail = u.role === 'coach'
|
||||
|
||||
Reference in New Issue
Block a user