initial prototype based on nat-as-server
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
tom.hempel
2026-06-29 12:39:55 +02:00
commit f1caa9e681
148 changed files with 34905 additions and 0 deletions

View File

@ -0,0 +1,461 @@
import { apiGet, apiPost } from '../api.js';
import { pageHeaderHTML, showToast } from '../app.js';
import { confirmAction } from '../confirm-modal.js';
const CLIENT_PAGE_SIZE = 50;
let coaches = [];
let clients = [];
let selectedCoachID = null;
let filterCoachSearch = '';
let clientsByCoach = {};
let clientPage = 0;
let selectedClientCodes = new Set();
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 counselors found</h3>
<p>Create counselors 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="Counselors">
<div class="assign-panel-header">
<h3>Counselors <span class="data-toolbar-hint">(${coaches.length})</span></h3>
<div class="filter-bar">
<input type="search" id="coachSearch" class="filter-search"
placeholder="Search counselor or supervisor…" value="${esc(filterCoachSearch)}">
</div>
</div>
<div class="assign-panel-body">
<ul class="coach-list" id="coachList" role="listbox" aria-label="Filter by counselor"></ul>
</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 counselor">
<option value="">All counselors (${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 counselor…">
</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 counselor</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 counselor">
<option value="">— select counselor —</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();
}
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 counselors match' : 'No counselors'}</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();
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();
}
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;
}
}
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 counselor', 'error'); return; }
const coachName = coaches.find(c => c.coachID === coachID)?.username || coachID;
if (!(await confirmAction({
title: 'Assign clients',
message: `Assign ${selected.length} client(s) to ${coachName}?`,
confirmLabel: 'Assign',
}))) 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;
}