From b049a9ab9fd8eba9afa4c0204bdf71e95bad05f5 Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Thu, 28 May 2026 10:58:31 +0200 Subject: [PATCH] better ux with search functionality --- lib/dev_fixture.php | 65 +++- scripts/generate_dev_fixture.py | 45 ++- website/css/style.css | 228 +++++++++++++- website/js/pages/assignments.js | 516 ++++++++++++++++++++++++-------- website/js/pages/clients.js | 7 +- website/js/pages/dev.js | 2 +- website/js/pages/export.js | 189 ++++++++---- website/js/pages/results.js | 12 +- website/js/pages/users.js | 133 +++++--- website/js/table-filter.js | 52 ++++ 10 files changed, 997 insertions(+), 252 deletions(-) create mode 100644 website/js/table-filter.js 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 = ` -
+
+
- -
-

Coaches

-
    - ${coaches.map(c => { - const count = (clientsByCoach[c.coachID] || []).length; - const coachTest = isDevTestUsername(c.username) ? ' row-test-data' : ''; - return ` -
  • -
    ${esc(c.username)}
    - ${c.supervisorUsername - ? `
    ${esc(c.supervisorUsername)}
    ` - : ''} - ${count} client${count === 1 ? '' : 's'} -
  • `; - }).join('')} -
-
+ - -
-
-
-
-

Clients

-
- + + + ${coaches.map(c => { + const n = (clientsByCoach[c.coachID] || []).length; + return ``; + }).join('')} - - +
-
- - +
+
+ + + + + + + + + +
+ + Client CodeCurrent Coach
+
-
-
- - - - - - - - - -
Client CodeCurrent Coach
-
- -
- - - + +
+ +
`; - // 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 = `
    - + - Use dev-test-fixture.json from the project root (generate via scripts/generate_dev_fixture.py). + Use dev-test-fixture.json from the repo root (generate via nat-as-server/scripts/generate_dev_fixture.py; reads questionnaires_bundle_2026-05-28.json, 5× scale).
    diff --git a/website/js/pages/export.js b/website/js/pages/export.js index 1d6bd9a..14b8663 100644 --- a/website/js/pages/export.js +++ b/website/js/pages/export.js @@ -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 = `

    ${esc(e.message)}

    `; } } -function renderExport(questionnaires) { +function renderExport() { const container = document.getElementById('exportContent'); - if (!questionnaires.length) { + if (!questionnairesList.length) { container.innerHTML = `

    No questionnaires

    @@ -46,40 +49,101 @@ function renderExport(questionnaires) { container.innerHTML = ` ${bundleBlock} -

    - Select a questionnaire and click Export to download a CSV file with all client responses (filtered by your role's visibility). -

    - - - - - - - - - - - - - ${questionnaires.map(q => ` - - - - - - - - - `).join('')} - -
    QuestionnaireVersionStateQuestionsCompletedActions
    ${esc(q.name)}${esc(q.version || '—')}${esc(q.state || 'draft')}${q.questionCount || 0}${q.completedCount ?? 0} - - View Results -
    +
    +

    + Select a questionnaire and click Export to download a CSV file with all client responses (filtered by your role's visibility). +

    +
    + + +
    +
    + + + + + + + + + + + + +
    QuestionnaireVersionStateQuestionsCompletedActions
    +
    +
    `; + 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 = ` + + + No questionnaires match + + `; + 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 ` + + ${esc(q.name)} + ${esc(q.version || '—')} + ${esc(q.state || 'draft')} + ${q.questionCount || 0} + ${q.completedCount ?? 0} + + + View Results + + `; +} + +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) { diff --git a/website/js/pages/results.js b/website/js/pages/results.js index 2872b76..674536e 100644 --- a/website/js/pages/results.js +++ b/website/js/pages/results.js @@ -96,7 +96,7 @@ function renderResults() { to - + +
    -
    - ` + groups.map(group => { - const users = filterUsers(byRole[group.key] || []); - if (!users.length) return ''; +
    `; + + container.innerHTML = testFilterCard + groups.map(group => { + const allInRole = byRole[group.key] || []; + if (!allInRole.length) return ''; return ` -
    -

    ${group.label} (${users.length})

    +
    +

    + ${group.label} (0) +

    +
    + +
    @@ -117,33 +141,41 @@ function renderUsers() { - - ${users.map(u => userRowHTML(u, myUsername)).join('')} - +
    Actions
    `; }).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 = ` + + + ${allInRole.length ? 'No users match' : 'No users'} + + `; + 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' diff --git a/website/js/table-filter.js b/website/js/table-filter.js new file mode 100644 index 0000000..8536871 --- /dev/null +++ b/website/js/table-filter.js @@ -0,0 +1,52 @@ +/** + * Client-side search for .data-table bodies. + * @param {HTMLTableSectionElement} tbody + * @param {string} query + * @param {(row: HTMLTableRowElement) => string} getRowText + * @returns {number} visible row count (excludes placeholder rows) + */ +export function filterTableBodyRows(tbody, query, getRowText) { + if (!tbody) return 0; + const q = query.trim().toLowerCase(); + let visible = 0; + tbody.querySelectorAll('tr').forEach(row => { + if (row.dataset.filterPlaceholder === '1') { + row.style.display = q ? 'none' : ''; + return; + } + const text = getRowText(row).toLowerCase(); + const show = !q || text.includes(q); + row.style.display = show ? '' : 'none'; + if (show) visible++; + }); + return visible; +} + +/** + * @param {HTMLInputElement} input + * @param {HTMLTableSectionElement} tbody + * @param {(row: HTMLTableRowElement) => string} getRowText + * @param {HTMLElement|null} countEl + * @param {{ total: number, noneLabel?: string }} [countOpts] + */ +export function wireTableSearch(input, tbody, getRowText, countEl, countOpts = {}) { + if (!input || !tbody) return; + const update = () => { + const visible = filterTableBodyRows(tbody, input.value, getRowText); + const total = countOpts.total ?? tbody.querySelectorAll('tr:not([data-filter-placeholder])').length; + if (!countEl) return; + const q = input.value.trim(); + if (!q) { + countEl.textContent = countOpts.noneLabel + ? `${total} ${countOpts.noneLabel}` + : ''; + return; + } + const noun = countOpts.noneLabel || 'rows'; + countEl.textContent = visible + ? `${visible} of ${total} ${noun}` + : `No ${noun} match`; + }; + input.addEventListener('input', update); + update(); +}