diff --git a/lib/dev_fixture.php b/lib/dev_fixture.php
index 80c5a49..2f01987 100644
--- a/lib/dev_fixture.php
+++ b/lib/dev_fixture.php
@@ -6,6 +6,16 @@
const QDB_DEV_GLASS_LABELS = ['never_glass', 'little_glass', 'moderate_glass', 'much_glass', 'extreme_glass'];
+const QDB_DEV_GLASS_POINTS = [
+ 'never_glass' => 0,
+ 'little_glass' => 1,
+ 'moderate_glass' => 2,
+ 'much_glass' => 3,
+ 'extreme_glass' => 4,
+];
+
+require_once __DIR__ . '/app_answers.php';
+
/**
* @return array{imported: array, skipped: array, errors: string[]}
*/
@@ -338,11 +348,20 @@ function qdb_dev_build_answers_for_questionnaire(PDO $pdo, string $qnID, int $va
case 'multi_check_box_question':
$opts = $optionsByQuestion[$q['questionID']] ?? [];
if ($opts) {
- $count = min(2, count($opts));
- for ($i = 0; $i < $count; $i++) {
+ $pickCount = min(5, max(2, count($opts) > 20 ? 4 : 2), count($opts));
+ $keys = [];
+ for ($i = 0; $i < $pickCount; $i++) {
$pick = $opts[($v + $i) % count($opts)];
$key = qdb_option_key($pick) ?: $pick['defaultText'];
- $answers[] = ['questionID' => $localId, 'answerOptionKey' => $key];
+ if ($key !== '') {
+ $keys[] = $key;
+ }
+ }
+ if ($keys !== []) {
+ $answers[] = [
+ 'questionID' => $localId,
+ 'freeTextValue' => qdb_encode_multi_check_values($keys),
+ ];
}
}
break;
@@ -376,9 +395,15 @@ function qdb_dev_build_answers_for_questionnaire(PDO $pdo, string $qnID, int $va
$year = 2018 + ($v % 6);
$month = 1 + ($v % 12);
$day = 1 + ($v % 28);
+ $precision = $config['precision'] ?? 'full';
+ $dateValue = match ($precision) {
+ 'year' => sprintf('%04d', $year),
+ 'year_month' => sprintf('%04d-%02d', $year, $month),
+ default => sprintf('%04d-%02d-%02d', $year, $month, $day),
+ };
$answers[] = [
'questionID' => $localId,
- 'freeTextValue' => sprintf('%04d-%02d-%02d', $year, $month, $day),
+ 'freeTextValue' => $dateValue,
];
break;
@@ -419,11 +444,13 @@ function qdb_dev_persist_submission(
$qRows = $pdo->prepare('SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn');
$qRows->execute([':qn' => $qnID]);
$shortIdMap = [];
+ $typeByFullId = [];
$freeTextMaxLen = [];
foreach ($qRows->fetchAll(PDO::FETCH_ASSOC) as $qRow) {
$fullId = $qRow['questionID'];
$parts = explode('__', $fullId);
$shortIdMap[end($parts)] = $fullId;
+ $typeByFullId[$fullId] = $qRow['type'] ?? '';
if (($qRow['type'] ?? '') === 'free_text') {
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
$freeTextMaxLen[$fullId] = max(1, min((int)($cfg['maxLength'] ?? 500), 10000));
@@ -480,11 +507,31 @@ function qdb_dev_persist_submission(
}
$answerOptionID = null;
- $optionKey = $a['answerOptionKey'] ?? null;
- if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) {
- $opt = $optionMap[$fullQID][(string)$optionKey];
- $answerOptionID = $opt['answerOptionID'];
- $sumPoints += $opt['points'];
+ $qType = $typeByFullId[$fullQID] ?? '';
+
+ if ($qType === 'glass_scale_question' && $freeTextValue !== null && $freeTextValue !== '') {
+ $decoded = json_decode($freeTextValue, true);
+ if (is_array($decoded)) {
+ foreach ($decoded as $label) {
+ $label = trim((string)$label);
+ if ($label !== '') {
+ $sumPoints += QDB_DEV_GLASS_POINTS[$label] ?? 0;
+ }
+ }
+ }
+ } elseif ($qType === 'multi_check_box_question' && $freeTextValue !== null && $freeTextValue !== '') {
+ foreach (qdb_decode_multi_check_values($freeTextValue) as $mk) {
+ if (isset($optionMap[$fullQID][$mk])) {
+ $sumPoints += $optionMap[$fullQID][$mk]['points'];
+ }
+ }
+ } else {
+ $optionKey = $a['answerOptionKey'] ?? null;
+ if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) {
+ $opt = $optionMap[$fullQID][(string)$optionKey];
+ $answerOptionID = $opt['answerOptionID'];
+ $sumPoints += $opt['points'];
+ }
}
$answerInsert->execute([
diff --git a/scripts/generate_dev_fixture.py b/scripts/generate_dev_fixture.py
index 52cad3f..367b0ad 100644
--- a/scripts/generate_dev_fixture.py
+++ b/scripts/generate_dev_fixture.py
@@ -6,8 +6,12 @@ from pathlib import Path
PREFIX = "dev_"
PASSWORD = "socialvrlab"
CLIENT_PREFIX = "DEV-CL-"
+SCALE = 5
-QUESTIONNAIRE_IDS = [
+# Newest questionnaire bundle (repo root); IDs are read from this file when present.
+BUNDLE_PATH = Path(__file__).resolve().parents[2] / "questionnaires_bundle_2026-05-28.json"
+
+FALLBACK_QUESTIONNAIRE_IDS = [
"questionnaire_1_demographic_information",
"questionnaire_2_rhs",
"questionnaire_3_integration_index",
@@ -16,15 +20,29 @@ QUESTIONNAIRE_IDS = [
"questionnaire_6_follow_up_survey",
]
-PER_QUESTIONNAIRE = 40
-NUM_ADMINS = 2
-NUM_SUPERVISORS = 3
-NUM_COACHES = 20
-NUM_CLIENTS = 100
-CLIENTS_WITHOUT_COMPLETIONS = 15 # DEV-CL-0086 .. 0100
+PER_QUESTIONNAIRE = 40 * SCALE
+NUM_ADMINS = 2 * SCALE
+NUM_SUPERVISORS = 3 * SCALE
+NUM_COACHES = 20 * SCALE
+NUM_CLIENTS = 100 * SCALE
+CLIENTS_WITHOUT_COMPLETIONS = 15 * SCALE
+
+
+def load_questionnaire_ids() -> list[str]:
+ if not BUNDLE_PATH.is_file():
+ return FALLBACK_QUESTIONNAIRE_IDS
+ bundle = json.loads(BUNDLE_PATH.read_text(encoding="utf-8"))
+ ids = [
+ item["questionnaire"]["questionnaireID"]
+ for item in bundle.get("questionnaires", [])
+ if item.get("questionnaire", {}).get("questionnaireID")
+ ]
+ return ids or FALLBACK_QUESTIONNAIRE_IDS
def main():
+ questionnaire_ids = load_questionnaire_ids()
+
admins = [
{"username": f"{PREFIX}admin_{i}", "location": f"Dev Admin Standort {i}"}
for i in range(1, NUM_ADMINS + 1)
@@ -59,7 +77,7 @@ def main():
completions = []
slot = 0
- for qn_id in QUESTIONNAIRE_IDS:
+ for qn_id in questionnaire_ids:
for _ in range(PER_QUESTIONNAIRE):
client_code = clients_with_data[slot % len(clients_with_data)]
completions.append({
@@ -68,17 +86,25 @@ def main():
})
slot += 1
+ bundle_note = BUNDLE_PATH.name if BUNDLE_PATH.is_file() else "fallback questionnaire list"
+
fixture = {
"fixtureVersion": 1,
- "description": "Dev/test users, clients, and completed questionnaires. Skip-on-conflict import; password socialvrlab.",
+ "description": (
+ f"Dev/test users, clients, and completed questionnaires ({SCALE}x scale). "
+ f"Questionnaires aligned with {bundle_note}. Skip-on-conflict import; password socialvrlab."
+ ),
"prefix": PREFIX,
"defaultPassword": PASSWORD,
+ "sourceBundle": bundle_note,
+ "questionnaireIDs": questionnaire_ids,
"admins": admins,
"supervisors": supervisors,
"coaches": coaches,
"clients": clients,
"completions": completions,
"stats": {
+ "scale": SCALE,
"admins": len(admins),
"supervisors": len(supervisors),
"coaches": len(coaches),
@@ -86,6 +112,7 @@ def main():
"clientsWithoutCompletions": CLIENTS_WITHOUT_COMPLETIONS,
"completions": len(completions),
"perQuestionnaire": PER_QUESTIONNAIRE,
+ "questionnaires": len(questionnaire_ids),
},
}
diff --git a/website/css/style.css b/website/css/style.css
index 868a2b7..126e29e 100644
--- a/website/css/style.css
+++ b/website/css/style.css
@@ -757,42 +757,240 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; }
.badge-coach { background: var(--badge-coach-bg); color: var(--badge-coach-fg); }
.badge-you { background: var(--badge-you-bg); color: var(--badge-you-fg); font-size: .7rem; }
-/* Assignment page layout */
+/* Assignment page — client table sized to rows (max 50); coach panel matches */
+.assign-page {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
.assign-layout {
display: grid;
- grid-template-columns: 220px 1fr;
+ grid-template-columns: minmax(260px, min(32vw, 380px)) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
-@media (max-width: 900px) {
- .assign-layout { grid-template-columns: 1fr; }
+
+.assign-layout > .assign-coaches,
+.assign-layout > .assign-clients-card {
+ display: flex;
+ flex-direction: column;
+ padding: 0;
+ overflow: hidden;
}
-.assign-coaches { padding: 16px; }
-.assign-right { display: flex; flex-direction: column; gap: 16px; }
-.assign-clients-card { padding: 16px; }
-.assign-action-card { padding: 16px; }
+.assign-panel-header {
+ flex-shrink: 0;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface);
+}
+
+.assign-panel-header h3 {
+ margin: 0 0 10px;
+ font-size: 1rem;
+}
+
+.assign-panel-header .filter-bar {
+ margin-bottom: 0;
+}
+
+.assign-panel-body {
+ flex: 0 0 auto;
+ min-height: 0;
+ overflow: auto;
+ -webkit-overflow-scrolling: touch;
+ padding: 6px 8px 10px;
+}
+
+.assign-coaches .assign-panel-footer--align {
+ flex-shrink: 0;
+ margin-top: auto;
+ visibility: hidden;
+ pointer-events: none;
+}
+
+.assign-clients-toolbar {
+ flex-shrink: 0;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.assign-clients-toolbar-top {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.assign-clients-toolbar-top h3 {
+ margin: 0;
+ font-size: 1rem;
+ line-height: 1.4;
+}
+
+.assign-clients-actions {
+ display: flex;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.assign-clients-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+}
+
+.assign-clients-filters .filter-search {
+ flex: 1 1 200px;
+ min-width: 160px;
+ max-width: 100%;
+}
+
+.assign-clients-filters select {
+ flex: 0 1 auto;
+ min-width: 140px;
+ max-width: 100%;
+}
+
+.assign-table-panel {
+ flex: 0 0 auto;
+ display: flex;
+ flex-direction: column;
+}
+
+.assign-table-panel .table-wrapper {
+ flex: none;
+ overflow: auto;
+ box-sizing: border-box;
+ max-height: none;
+}
+
+.assign-panel-footer,
+.assign-clients-footer {
+ flex-shrink: 0;
+ padding: 10px 16px;
+ border-top: 1px solid var(--border);
+ background: var(--surface);
+}
+
+.assign-panel-footer .pagination-bar,
+.assign-clients-footer .pagination-bar {
+ margin-top: 0;
+}
+
+.assign-clients-footer .pagination-bar + .selection-info {
+ margin-top: 8px;
+}
+
+.assign-action-card {
+ flex-shrink: 0;
+ padding: 14px 16px;
+}
+
+.assign-action-row {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ flex-wrap: wrap;
+ margin-top: 10px;
+}
+
+.assign-action-row select {
+ flex: 1 1 220px;
+ min-width: 180px;
+ padding: 8px 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--surface);
+ color: inherit;
+}
+
+.assign-action-card h4 {
+ margin: 0;
+ font-size: .95rem;
+}
+
+@media (max-width: 1100px) {
+ .assign-layout {
+ grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
+ }
+}
+
+@media (max-width: 900px) {
+ .assign-layout {
+ grid-template-columns: 1fr;
+ }
+}
/* Coach list */
-.coach-list { list-style: none; }
+.coach-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
.coach-list-item {
padding: 10px 12px;
- border-radius: 6px;
+ border-radius: 8px;
cursor: pointer;
- transition: background .15s;
+ transition: background .15s, border-color .15s;
display: flex;
flex-direction: column;
gap: 2px;
border: 1px solid transparent;
margin-bottom: 4px;
}
-.coach-list-item:hover { background: var(--surface-hover); }
+
+.coach-list-item:last-child {
+ margin-bottom: 0;
+}
+
+.coach-list-item:hover {
+ background: var(--surface-hover);
+}
+
.coach-list-item.active {
background: var(--primary-subtle);
border-color: var(--primary);
}
-.coach-name { font-weight: 600; font-size: .9rem; }
-.coach-supervisor { font-size: .8rem; color: var(--text-secondary); }
+
+.coach-list-item.row-test-data {
+ background: var(--test-row-bg);
+}
+
+.coach-list-item.row-test-data:hover {
+ background: var(--test-row-hover-bg);
+}
+
+.coach-list-item.row-test-data.active {
+ background: var(--primary-subtle);
+}
+
+.coach-list-empty {
+ padding: 20px 12px;
+ text-align: center;
+ color: var(--text-secondary);
+ font-size: .875rem;
+}
+
+.coach-name {
+ font-weight: 600;
+ font-size: .9rem;
+ word-break: break-word;
+}
+
+.coach-supervisor {
+ font-size: .8rem;
+ color: var(--text-secondary);
+ word-break: break-word;
+}
+
.coach-client-count {
font-size: .75rem;
color: var(--primary);
@@ -802,7 +1000,7 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; }
/* Selection info bar */
.selection-info {
- margin-top: 10px;
+ margin: 0;
padding: 8px 12px;
background: var(--primary-subtle);
border: 1px solid var(--primary-border);
diff --git a/website/js/pages/assignments.js b/website/js/pages/assignments.js
index a7968aa..a36e110 100644
--- a/website/js/pages/assignments.js
+++ b/website/js/pages/assignments.js
@@ -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 = `
-
+
+
-
-
+
-
-
-
-
-
-
Clients
-
-
-
-
-
-
Assign selected clients to:
-
-
-
-
-
+
+
+
+
+ Assign selected clients to
+
+
+
+
+
`;
- // 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 = `
${q ? 'No coaches match' : 'No coaches'}`;
+ 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 `
+
+ ${esc(c.username)}
+ ${c.supervisorUsername
+ ? `${esc(c.supervisorUsername)}
`
+ : ''}
+ ${count} client${count === 1 ? '' : 's'}
+ `;
+}
+
+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 = `
| No clients match the current filter |
`;
- 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 = `
${totalRows} row${totalRows === 1 ? '' : 's'}`;
+ return;
+ }
+
+ const from = currentPage * pageSize + 1;
+ const to = Math.min((currentPage + 1) * pageSize, totalRows);
+ const prevId = `${barId}Prev`;
+ const nextId = `${barId}Next`;
+ pag.innerHTML = `
+
+
Rows ${from}–${to} of ${totalRows} (page ${currentPage + 1}/${totalPages})
+
+ `;
+ 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 = `
+
+ |
+ No clients match the current filters
+ |
+
`;
+ 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 `
- |
+
+
+ |
${esc(c.clientCode)} |
- ${c.coachUsername ? esc(c.coachUsername) : 'Unassigned'} |
-
- `).join('');
+
${c.coachUsername
+ ? esc(c.coachUsername)
+ : 'Unassigned'} |
+ `;
+ }).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 = `
${selected.length} client${selected.length === 1 ? '' : 's'} selected`;
+ const extra = selected.length > onPage
+ ? `
(${onPage} on this page)`
+ : '';
+ info.innerHTML = `
${selected.length} 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();
}
diff --git a/website/js/pages/clients.js b/website/js/pages/clients.js
index af0569f..8a9f679 100644
--- a/website/js/pages/clients.js
+++ b/website/js/pages/clients.js
@@ -69,7 +69,7 @@ function renderClients() {
container.innerHTML = `
-
+