Files
nat-as-server/website/js/pages/assignments.js
Tom Hempel 3211d35b43
Some checks failed
PHPUnit / test (push) Has been cancelled
improved scoring ui + removed test data filters
2026-06-08 19:38:37 +02:00

535 lines
21 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, apiPost } from '../api.js';
import { pageHeaderHTML, showToast } from '../app.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 = `
${pageHeaderHTML('Assign Clients')}
<div id="assignContent"><div class="spinner"></div></div>
`;
try {
const data = await apiGet('assignments.php');
coaches = data.coaches || [];
clients = data.clients || [];
rebuildClientsByCoach();
renderAssignments();
} catch (e) {
showToast(e.message, 'error');
document.getElementById('assignContent').innerHTML =
`<p class="error-text" style="padding:16px">${esc(e.message)}</p>`;
}
}
function rebuildClientsByCoach() {
clientsByCoach = {};
clients.forEach(c => {
if (c.coachID) {
(clientsByCoach[c.coachID] = clientsByCoach[c.coachID] || []).push(c);
}
});
}
function renderAssignments() {
const container = document.getElementById('assignContent');
if (!coaches.length) {
container.innerHTML = `
<div class="card empty-state">
<h3>No coaches found</h3>
<p>Create coaches first before assigning clients.</p>
</div>`;
return;
}
const unassignedCount = clients.filter(c => !c.coachID).length;
container.innerHTML = `
<div class="assign-page">
<div class="assign-layout">
<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>
<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="search" id="clientSearch" class="filter-search"
placeholder="Search client or coach…">
</div>
</div>
<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 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>
`;
document.getElementById('coachSearch').addEventListener('input', (e) => {
filterCoachSearch = e.target.value;
renderCoachList();
});
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('selectAllBtn').addEventListener('click', () => {
document.querySelectorAll('#clientTableBody .client-cb').forEach(cb => {
cb.checked = true;
selectedClientCodes.add(cb.value);
});
updateSelectionInfo();
});
document.getElementById('clearSelBtn').addEventListener('click', () => {
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;
if (e.target.checked) selectedClientCodes.add(cb.value);
else selectedClientCodes.delete(cb.value);
});
updateSelectionInfo();
});
document.getElementById('assignBtn').addEventListener('click', doAssign);
renderCoachList();
renderClientRows();
setupAssignPanelHeightSync();
}
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 active = selectedCoachID === c.coachID ? ' active' : '';
return `
<li class="coach-list-item${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() ?? '';
let visible = clients;
if (coachFilter === '__unassigned__') {
visible = clients.filter(c => !c.coachID);
} else if (coachFilter) {
visible = clients.filter(c => c.coachID === coachFilter);
}
if (search) {
visible = visible.filter(c => {
const hay = `${c.clientCode} ${c.coachUsername || ''}`.toLowerCase();
return hay.includes(search);
});
}
return visible;
}
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 hasFilter = search || coachFilter;
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;
}
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>
<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('');
body.querySelectorAll('.client-cb').forEach(cb => {
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 [...selectedClientCodes];
}
function updateSelectionInfo() {
const selected = getSelectedCodes();
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';
card.style.display = 'none';
return;
}
info.style.display = '';
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;
if (!selected.length) { showToast('Select at least one client', '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;
const btn = document.getElementById('assignBtn');
btn.disabled = true;
btn.textContent = 'Assigning...';
try {
const data = await apiPost('assignments.php', { clientCodes: selected, coachID });
if (data.success) {
selected.forEach(code => {
const c = clients.find(x => x.clientCode === code);
if (c) {
c.coachID = coachID;
c.coachUsername = coachName;
}
});
selectedClientCodes.clear();
rebuildClientsByCoach();
showToast(`${data.assigned} client(s) assigned to ${coachName}`, 'success');
renderAssignments();
}
} catch (e) {
showToast(e.message, 'error');
btn.disabled = false;
btn.textContent = 'Assign';
}
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}