better ux with search functionality

This commit is contained in:
2026-05-28 10:58:31 +02:00
parent 8915c8d182
commit b049a9ab9f
10 changed files with 997 additions and 252 deletions

View File

@ -6,6 +6,16 @@
const QDB_DEV_GLASS_LABELS = ['never_glass', 'little_glass', 'moderate_glass', 'much_glass', 'extreme_glass']; 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[]} * @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': case 'multi_check_box_question':
$opts = $optionsByQuestion[$q['questionID']] ?? []; $opts = $optionsByQuestion[$q['questionID']] ?? [];
if ($opts) { if ($opts) {
$count = min(2, count($opts)); $pickCount = min(5, max(2, count($opts) > 20 ? 4 : 2), count($opts));
for ($i = 0; $i < $count; $i++) { $keys = [];
for ($i = 0; $i < $pickCount; $i++) {
$pick = $opts[($v + $i) % count($opts)]; $pick = $opts[($v + $i) % count($opts)];
$key = qdb_option_key($pick) ?: $pick['defaultText']; $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; break;
@ -376,9 +395,15 @@ function qdb_dev_build_answers_for_questionnaire(PDO $pdo, string $qnID, int $va
$year = 2018 + ($v % 6); $year = 2018 + ($v % 6);
$month = 1 + ($v % 12); $month = 1 + ($v % 12);
$day = 1 + ($v % 28); $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[] = [ $answers[] = [
'questionID' => $localId, 'questionID' => $localId,
'freeTextValue' => sprintf('%04d-%02d-%02d', $year, $month, $day), 'freeTextValue' => $dateValue,
]; ];
break; break;
@ -419,11 +444,13 @@ function qdb_dev_persist_submission(
$qRows = $pdo->prepare('SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn'); $qRows = $pdo->prepare('SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn');
$qRows->execute([':qn' => $qnID]); $qRows->execute([':qn' => $qnID]);
$shortIdMap = []; $shortIdMap = [];
$typeByFullId = [];
$freeTextMaxLen = []; $freeTextMaxLen = [];
foreach ($qRows->fetchAll(PDO::FETCH_ASSOC) as $qRow) { foreach ($qRows->fetchAll(PDO::FETCH_ASSOC) as $qRow) {
$fullId = $qRow['questionID']; $fullId = $qRow['questionID'];
$parts = explode('__', $fullId); $parts = explode('__', $fullId);
$shortIdMap[end($parts)] = $fullId; $shortIdMap[end($parts)] = $fullId;
$typeByFullId[$fullId] = $qRow['type'] ?? '';
if (($qRow['type'] ?? '') === 'free_text') { if (($qRow['type'] ?? '') === 'free_text') {
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: []; $cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
$freeTextMaxLen[$fullId] = max(1, min((int)($cfg['maxLength'] ?? 500), 10000)); $freeTextMaxLen[$fullId] = max(1, min((int)($cfg['maxLength'] ?? 500), 10000));
@ -480,11 +507,31 @@ function qdb_dev_persist_submission(
} }
$answerOptionID = null; $answerOptionID = null;
$optionKey = $a['answerOptionKey'] ?? null; $qType = $typeByFullId[$fullQID] ?? '';
if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) {
$opt = $optionMap[$fullQID][(string)$optionKey]; if ($qType === 'glass_scale_question' && $freeTextValue !== null && $freeTextValue !== '') {
$answerOptionID = $opt['answerOptionID']; $decoded = json_decode($freeTextValue, true);
$sumPoints += $opt['points']; 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([ $answerInsert->execute([

View File

@ -6,8 +6,12 @@ from pathlib import Path
PREFIX = "dev_" PREFIX = "dev_"
PASSWORD = "socialvrlab" PASSWORD = "socialvrlab"
CLIENT_PREFIX = "DEV-CL-" 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_1_demographic_information",
"questionnaire_2_rhs", "questionnaire_2_rhs",
"questionnaire_3_integration_index", "questionnaire_3_integration_index",
@ -16,15 +20,29 @@ QUESTIONNAIRE_IDS = [
"questionnaire_6_follow_up_survey", "questionnaire_6_follow_up_survey",
] ]
PER_QUESTIONNAIRE = 40 PER_QUESTIONNAIRE = 40 * SCALE
NUM_ADMINS = 2 NUM_ADMINS = 2 * SCALE
NUM_SUPERVISORS = 3 NUM_SUPERVISORS = 3 * SCALE
NUM_COACHES = 20 NUM_COACHES = 20 * SCALE
NUM_CLIENTS = 100 NUM_CLIENTS = 100 * SCALE
CLIENTS_WITHOUT_COMPLETIONS = 15 # DEV-CL-0086 .. 0100 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(): def main():
questionnaire_ids = load_questionnaire_ids()
admins = [ admins = [
{"username": f"{PREFIX}admin_{i}", "location": f"Dev Admin Standort {i}"} {"username": f"{PREFIX}admin_{i}", "location": f"Dev Admin Standort {i}"}
for i in range(1, NUM_ADMINS + 1) for i in range(1, NUM_ADMINS + 1)
@ -59,7 +77,7 @@ def main():
completions = [] completions = []
slot = 0 slot = 0
for qn_id in QUESTIONNAIRE_IDS: for qn_id in questionnaire_ids:
for _ in range(PER_QUESTIONNAIRE): for _ in range(PER_QUESTIONNAIRE):
client_code = clients_with_data[slot % len(clients_with_data)] client_code = clients_with_data[slot % len(clients_with_data)]
completions.append({ completions.append({
@ -68,17 +86,25 @@ def main():
}) })
slot += 1 slot += 1
bundle_note = BUNDLE_PATH.name if BUNDLE_PATH.is_file() else "fallback questionnaire list"
fixture = { fixture = {
"fixtureVersion": 1, "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, "prefix": PREFIX,
"defaultPassword": PASSWORD, "defaultPassword": PASSWORD,
"sourceBundle": bundle_note,
"questionnaireIDs": questionnaire_ids,
"admins": admins, "admins": admins,
"supervisors": supervisors, "supervisors": supervisors,
"coaches": coaches, "coaches": coaches,
"clients": clients, "clients": clients,
"completions": completions, "completions": completions,
"stats": { "stats": {
"scale": SCALE,
"admins": len(admins), "admins": len(admins),
"supervisors": len(supervisors), "supervisors": len(supervisors),
"coaches": len(coaches), "coaches": len(coaches),
@ -86,6 +112,7 @@ def main():
"clientsWithoutCompletions": CLIENTS_WITHOUT_COMPLETIONS, "clientsWithoutCompletions": CLIENTS_WITHOUT_COMPLETIONS,
"completions": len(completions), "completions": len(completions),
"perQuestionnaire": PER_QUESTIONNAIRE, "perQuestionnaire": PER_QUESTIONNAIRE,
"questionnaires": len(questionnaire_ids),
}, },
} }

View File

@ -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-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; } .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 { .assign-layout {
display: grid; display: grid;
grid-template-columns: 220px 1fr; grid-template-columns: minmax(260px, min(32vw, 380px)) minmax(0, 1fr);
gap: 16px; gap: 16px;
align-items: start; 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-panel-header {
.assign-right { display: flex; flex-direction: column; gap: 16px; } flex-shrink: 0;
.assign-clients-card { padding: 16px; } padding: 14px 16px;
.assign-action-card { padding: 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 */
.coach-list { list-style: none; } .coach-list {
list-style: none;
margin: 0;
padding: 0;
}
.coach-list-item { .coach-list-item {
padding: 10px 12px; padding: 10px 12px;
border-radius: 6px; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: background .15s; transition: background .15s, border-color .15s;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
border: 1px solid transparent; border: 1px solid transparent;
margin-bottom: 4px; 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 { .coach-list-item.active {
background: var(--primary-subtle); background: var(--primary-subtle);
border-color: var(--primary); 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 { .coach-client-count {
font-size: .75rem; font-size: .75rem;
color: var(--primary); color: var(--primary);
@ -802,7 +1000,7 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; }
/* Selection info bar */ /* Selection info bar */
.selection-info { .selection-info {
margin-top: 10px; margin: 0;
padding: 8px 12px; padding: 8px 12px;
background: var(--primary-subtle); background: var(--primary-subtle);
border: 1px solid var(--primary-border); border: 1px solid var(--primary-border);

View File

@ -2,12 +2,23 @@ import { apiGet, apiPost } from '../api.js';
import { showToast } from '../app.js'; import { showToast } from '../app.js';
import { isDevTestClientCode, isDevTestUsername, testDataRowClassForClient } from '../test-data.js'; import { isDevTestClientCode, isDevTestUsername, testDataRowClassForClient } from '../test-data.js';
const CLIENT_PAGE_SIZE = 50;
let coaches = []; let coaches = [];
let clients = []; let clients = [];
let selectedCoachID = null; let selectedCoachID = null;
let filterCoachSearch = '';
let clientsByCoach = {};
let clientPage = 0;
let selectedClientCodes = new Set();
let assignResizeObserver = null;
let assignClientRowHeight = 0;
export async function assignmentsPage() { export async function assignmentsPage() {
selectedCoachID = null; selectedCoachID = null;
filterCoachSearch = '';
clientPage = 0;
selectedClientCodes = new Set();
const app = document.getElementById('app'); const app = document.getElementById('app');
app.innerHTML = ` app.innerHTML = `
@ -22,6 +33,7 @@ export async function assignmentsPage() {
const data = await apiGet('assignments.php'); const data = await apiGet('assignments.php');
coaches = data.coaches || []; coaches = data.coaches || [];
clients = data.clients || []; clients = data.clients || [];
rebuildClientsByCoach();
renderAssignments(); renderAssignments();
} catch (e) { } catch (e) {
showToast(e.message, 'error'); 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() { function renderAssignments() {
const container = document.getElementById('assignContent'); const container = document.getElementById('assignContent');
@ -42,139 +63,283 @@ function renderAssignments() {
return; return;
} }
// Group clients by coachID for the filter badge counts const unassignedCount = clients.filter(c => !c.coachID).length;
const clientsByCoach = {};
const unassigned = [];
clients.forEach(c => {
if (c.coachID) {
(clientsByCoach[c.coachID] = clientsByCoach[c.coachID] || []).push(c);
} else {
unassigned.push(c);
}
});
container.innerHTML = ` container.innerHTML = `
<div class="assign-layout"> <div class="assign-page">
<div class="assign-layout">
<!-- Left: Coach list --> <aside class="card assign-coaches" aria-label="Coaches">
<div class="assign-coaches card"> <div class="assign-panel-header">
<h3 style="margin-bottom:12px">Coaches</h3> <h3>Coaches <span class="data-toolbar-hint">(${coaches.length})</span></h3>
<ul class="coach-list" id="coachList"> <div class="filter-bar">
${coaches.map(c => { <input type="search" id="coachSearch" class="filter-search"
const count = (clientsByCoach[c.coachID] || []).length; placeholder="Search coach or supervisor…" value="${esc(filterCoachSearch)}">
const coachTest = isDevTestUsername(c.username) ? ' row-test-data' : ''; </div>
return ` </div>
<li class="coach-list-item${coachTest}" data-id="${c.coachID}"> <div class="assign-panel-body">
<div class="coach-name">${esc(c.username)}</div> <ul class="coach-list" id="coachList" role="listbox" aria-label="Filter by coach"></ul>
${c.supervisorUsername </div>
? `<div class="coach-supervisor">${esc(c.supervisorUsername)}</div>` <div class="assign-panel-footer assign-panel-footer--align" aria-hidden="true"></div>
: ''} </aside>
<span class="coach-client-count">${count} client${count === 1 ? '' : 's'}</span>
</li>`;
}).join('')}
</ul>
</div>
<!-- Right: Client list + assign panel --> <section class="card assign-clients-card" aria-label="Clients">
<div class="assign-right"> <div class="assign-clients-toolbar">
<div class="card assign-clients-card"> <div class="assign-clients-toolbar-top">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;flex-wrap:wrap;gap:8px"> <h3>Clients <span class="data-toolbar-hint" id="clientListSummary"></span></h3>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"> <div class="assign-clients-actions">
<h3>Clients</h3> <button type="button" class="btn btn-sm" id="selectAllBtn">Select visible</button>
<div class="filter-bar" style="margin:0"> <button type="button" class="btn btn-sm" id="clearSelBtn">Clear</button>
<select id="filterByCoach"> </div>
<option value="">All coaches</option> </div>
<option value="__unassigned__">Unassigned</option> <div class="assign-clients-filters filter-bar">
${coaches.map(c => <select id="filterByCoach" aria-label="Filter by coach">
`<option value="${c.coachID}">${esc(c.username)}</option>` <option value="">All coaches (${clients.length})</option>
).join('')} <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> </select>
<input type="text" id="clientSearch" placeholder="Search client code..." style="width:160px"> <input type="search" id="clientSearch" class="filter-search"
<select id="clientTestFilter"> placeholder="Search client or coach…">
<select id="clientTestFilter" aria-label="Test data filter">
<option value="">All clients</option> <option value="">All clients</option>
<option value="test">Test only</option> <option value="test">Test only</option>
<option value="hide-test">Hide test</option> <option value="hide-test">Hide test</option>
</select> </select>
</div> </div>
</div> </div>
<div style="display:flex;gap:6px"> <div class="assign-table-panel">
<button class="btn btn-sm" id="selectAllBtn">Select all</button> <div class="table-wrapper assign-table-scroll">
<button class="btn btn-sm" id="clearSelBtn">Clear</button> <table class="data-table">
<thead>
<tr>
<th style="width:40px">
<input type="checkbox" id="selectAllCb" title="Toggle all visible rows" aria-label="Select all visible">
</th>
<th>Client Code</th>
<th>Current Coach</th>
</tr>
</thead>
<tbody id="clientTableBody"></tbody>
</table>
</div>
</div> </div>
</div> <div class="assign-clients-footer">
<div class="table-wrapper" style="max-height:420px"> <div class="pagination-bar" id="clientPagination"></div>
<table class="data-table"> <div id="selectionInfo" class="selection-info" style="display:none"></div>
<thead> </div>
<tr> </section>
<th style="width:36px"><input type="checkbox" id="selectAllCb" title="Toggle all visible"></th>
<th>Client Code</th>
<th>Current Coach</th>
</tr>
</thead>
<tbody id="clientTableBody"></tbody>
</table>
</div>
<div id="selectionInfo" class="selection-info" style="display:none"></div>
</div>
<!-- Assign action card -->
<div class="card assign-action-card" id="assignActionCard" style="display:none">
<h4>Assign selected clients to:</h4>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:10px">
<select id="targetCoachSelect" style="flex:1;min-width:180px;padding:8px 12px;border:1px solid var(--border);border-radius:6px">
<option value="">— select coach —</option>
${coaches.map(c =>
`<option value="${c.coachID}">${esc(c.username)}${c.supervisorUsername ? ` (${esc(c.supervisorUsername)})` : ''}</option>`
).join('')}
</select>
<button class="btn btn-primary" id="assignBtn">Assign</button>
</div>
</div>
</div> </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> </div>
`; `;
// Wire coach list click → filter clients document.getElementById('coachSearch').addEventListener('input', (e) => {
document.querySelectorAll('.coach-list-item').forEach(li => { filterCoachSearch = e.target.value;
li.addEventListener('click', () => { renderCoachList();
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();
});
}); });
// Wire filters document.getElementById('coachList').addEventListener('click', (e) => {
document.getElementById('filterByCoach').addEventListener('change', renderClientRows); const item = e.target.closest('.coach-list-item');
document.getElementById('clientSearch').addEventListener('input', renderClientRows); if (!item || item.dataset.action === 'clear-filter') return;
document.getElementById('clientTestFilter').addEventListener('change', renderClientRows); 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.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(); updateSelectionInfo();
}); });
document.getElementById('clearSelBtn').addEventListener('click', () => { 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(); updateSelectionInfo();
}); });
document.getElementById('selectAllCb').addEventListener('change', (e) => { 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(); updateSelectionInfo();
}); });
// Wire assign button
document.getElementById('assignBtn').addEventListener('click', doAssign); document.getElementById('assignBtn').addEventListener('click', doAssign);
renderCoachList();
renderClientRows(); renderClientRows();
setupAssignPanelHeightSync();
} }
function renderClientRows() { function setupAssignPanelHeightSync() {
const body = document.getElementById('clientTableBody'); assignResizeObserver?.disconnect();
const coachFilter = document.getElementById('filterByCoach').value; assignResizeObserver = null;
const search = document.getElementById('clientSearch').value.trim().toLowerCase();
const testMode = document.getElementById('clientTestFilter')?.value || ''; const clientCard = document.querySelector('.assign-clients-card');
if (!clientCard || typeof ResizeObserver === 'undefined') {
syncAssignLayoutHeights();
return;
}
assignResizeObserver = new ResizeObserver(() => syncAssignLayoutHeights());
assignResizeObserver.observe(clientCard);
syncAssignLayoutHeights();
}
function syncAssignClientTableHeight() {
const wrapper = document.querySelector('.assign-table-panel .table-wrapper');
const table = wrapper?.querySelector('table.data-table');
if (!wrapper || !table) return;
const headerH = table.querySelector('thead')?.offsetHeight ?? 40;
const bodyRows = [...table.querySelectorAll('tbody tr')];
const dataRows = bodyRows.filter(tr => !tr.querySelector('td[colspan]'));
let rowH = assignClientRowHeight;
if (dataRows.length) {
rowH = dataRows[0].offsetHeight;
assignClientRowHeight = rowH;
} else if (bodyRows.length) {
rowH = bodyRows[0].offsetHeight;
} else if (!rowH) {
rowH = 44;
}
const rowCount = dataRows.length || 1;
const bodyH = rowH * Math.min(rowCount, CLIENT_PAGE_SIZE);
const maxH = rowH * CLIENT_PAGE_SIZE;
wrapper.style.height = `${headerH + bodyH}px`;
wrapper.style.maxHeight = `${headerH + maxH}px`;
wrapper.style.overflowY = 'auto';
}
function syncAssignCoachPanelHeight() {
const coachCard = document.querySelector('.assign-coaches');
const clientCard = document.querySelector('.assign-clients-card');
const coachBody = document.querySelector('.assign-coaches .assign-panel-body');
const clientTablePanel = document.querySelector('.assign-table-panel');
const coachFooter = document.querySelector('.assign-coaches .assign-panel-footer--align');
const clientFooter = document.querySelector('.assign-clients-footer');
if (clientFooter && coachFooter) {
coachFooter.style.height = `${clientFooter.offsetHeight}px`;
}
if (coachBody && clientTablePanel) {
const h = clientTablePanel.offsetHeight;
coachBody.style.height = `${h}px`;
coachBody.style.maxHeight = `${h}px`;
}
if (coachCard && clientCard) {
coachCard.style.minHeight = `${clientCard.offsetHeight}px`;
}
}
function syncAssignLayoutHeights() {
requestAnimationFrame(() => {
syncAssignClientTableHeight();
requestAnimationFrame(() => syncAssignCoachPanelHeight());
});
}
function getFilteredCoaches() {
const q = filterCoachSearch.trim().toLowerCase();
if (!q) return coaches;
return coaches.filter(c => {
const hay = `${c.username} ${c.supervisorUsername || ''}`.toLowerCase();
return hay.includes(q);
});
}
function renderCoachList() {
const list = document.getElementById('coachList');
if (!list) return;
const filtered = getFilteredCoaches();
const q = filterCoachSearch.trim();
if (!filtered.length) {
list.innerHTML = `<li class="coach-list-empty">${q ? 'No coaches match' : 'No coaches'}</li>`;
return;
}
list.innerHTML = filtered.map(c => coachListItemHTML(c)).join('');
if (selectedCoachID) {
highlightCoachInList(selectedCoachID);
}
}
function coachListItemHTML(c) {
const count = (clientsByCoach[c.coachID] || []).length;
const coachTest = isDevTestUsername(c.username) ? ' row-test-data' : '';
const active = selectedCoachID === c.coachID ? ' active' : '';
return `
<li class="coach-list-item${coachTest}${active}" data-id="${c.coachID}" role="option" tabindex="0">
<div class="coach-name">${esc(c.username)}</div>
${c.supervisorUsername
? `<div class="coach-supervisor">${esc(c.supervisorUsername)}</div>`
: ''}
<span class="coach-client-count">${count} client${count === 1 ? '' : 's'}</span>
</li>`;
}
function highlightCoachInList(coachID) {
document.querySelectorAll('.coach-list-item').forEach(li => {
li.classList.toggle('active', li.dataset.id === coachID);
});
const active = document.querySelector(`.coach-list-item[data-id="${CSS.escape(coachID)}"]`);
active?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
function getVisibleClients() {
const coachFilter = document.getElementById('filterByCoach')?.value ?? '';
const search = document.getElementById('clientSearch')?.value.trim().toLowerCase() ?? '';
const testMode = document.getElementById('clientTestFilter')?.value || '';
let visible = clients; let visible = clients;
if (coachFilter === '__unassigned__') { if (coachFilter === '__unassigned__') {
@ -183,44 +348,152 @@ function renderClientRows() {
visible = clients.filter(c => c.coachID === coachFilter); visible = clients.filter(c => c.coachID === coachFilter);
} }
if (search) { 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') { if (testMode === 'test') {
visible = visible.filter(c => isDevTestClientCode(c.clientCode)); visible = visible.filter(c => isDevTestClientCode(c.clientCode));
} else if (testMode === 'hide-test') { } else if (testMode === 'hide-test') {
visible = visible.filter(c => !isDevTestClientCode(c.clientCode)); visible = visible.filter(c => !isDevTestClientCode(c.clientCode));
} }
return visible;
}
if (!visible.length) { function updateClientSummary(filteredCount, pageSlice) {
body.innerHTML = `<tr><td colspan="3" style="text-align:center;color:var(--text-secondary);padding:24px">No clients match the current filter</td></tr>`; const el = document.getElementById('clientListSummary');
updateSelectionInfo(); 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; return;
} }
body.innerHTML = visible.map(c => ` const totalPages = Math.max(1, Math.ceil(totalRows / pageSize));
if (totalRows <= pageSize) {
pag.innerHTML = `<span>${totalRows} row${totalRows === 1 ? '' : 's'}</span>`;
return;
}
const from = currentPage * pageSize + 1;
const to = Math.min((currentPage + 1) * pageSize, totalRows);
const prevId = `${barId}Prev`;
const nextId = `${barId}Next`;
pag.innerHTML = `
<button type="button" class="btn btn-sm" id="${prevId}" ${currentPage <= 0 ? 'disabled' : ''}>Prev</button>
<span>Rows ${from}${to} of ${totalRows} (page ${currentPage + 1}/${totalPages})</span>
<button type="button" class="btn btn-sm" id="${nextId}" ${currentPage >= totalPages - 1 ? 'disabled' : ''}>Next</button>
`;
document.getElementById(prevId)?.addEventListener('click', () => onPageChange(currentPage - 1));
document.getElementById(nextId)?.addEventListener('click', () => onPageChange(currentPage + 1));
}
function syncSelectAllCb(codesOnPage = []) {
const allCb = document.getElementById('selectAllCb');
if (!allCb) return;
if (!codesOnPage.length) {
allCb.checked = false;
allCb.indeterminate = false;
return;
}
const allSelected = codesOnPage.every(code => selectedClientCodes.has(code));
const someSelected = codesOnPage.some(code => selectedClientCodes.has(code));
allCb.checked = allSelected;
allCb.indeterminate = !allSelected && someSelected;
}
function renderClientRows() {
const body = document.getElementById('clientTableBody');
if (!body) return;
const visible = getVisibleClients();
const totalPages = Math.max(1, Math.ceil(visible.length / CLIENT_PAGE_SIZE));
if (clientPage >= totalPages) clientPage = totalPages - 1;
if (!visible.length) {
updateClientSummary(0, false);
body.innerHTML = `
<tr>
<td colspan="3" style="text-align:center;color:var(--text-secondary);padding:28px 16px">
No clients match the current filters
</td>
</tr>`;
renderPaginationBar('clientPagination', 0, clientPage, CLIENT_PAGE_SIZE, (p) => {
clientPage = p;
renderClientRows();
});
syncSelectAllCb();
updateSelectionInfo();
syncAssignCoachPanelHeight();
return;
}
const slice = visible.slice(clientPage * CLIENT_PAGE_SIZE, (clientPage + 1) * CLIENT_PAGE_SIZE);
updateClientSummary(visible.length, visible.length > CLIENT_PAGE_SIZE);
body.innerHTML = slice.map(c => {
const checked = selectedClientCodes.has(c.clientCode) ? ' checked' : '';
return `
<tr class="${testDataRowClassForClient(c.clientCode).trim()}"> <tr class="${testDataRowClassForClient(c.clientCode).trim()}">
<td><input type="checkbox" class="client-cb" data-code="${esc(c.clientCode)}" value="${esc(c.clientCode)}"></td> <td>
<input type="checkbox" class="client-cb" data-code="${esc(c.clientCode)}" value="${esc(c.clientCode)}"${checked} aria-label="Select ${esc(c.clientCode)}">
</td>
<td><strong>${esc(c.clientCode)}</strong></td> <td><strong>${esc(c.clientCode)}</strong></td>
<td>${c.coachUsername ? esc(c.coachUsername) : '<span style="color:var(--text-secondary);font-style:italic">Unassigned</span>'}</td> <td>${c.coachUsername
</tr> ? esc(c.coachUsername)
`).join(''); : '<span style="color:var(--text-secondary);font-style:italic">Unassigned</span>'}</td>
</tr>`;
}).join('');
body.querySelectorAll('.client-cb').forEach(cb => { 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(); updateSelectionInfo();
syncAssignLayoutHeights();
} }
function getSelectedCodes() { function getSelectedCodes() {
return [...document.querySelectorAll('#clientTableBody .client-cb:checked')] return [...selectedClientCodes];
.map(cb => cb.value);
} }
function updateSelectionInfo() { function updateSelectionInfo() {
const selected = getSelectedCodes(); const selected = getSelectedCodes();
const info = document.getElementById('selectionInfo'); const info = document.getElementById('selectionInfo');
const card = document.getElementById('assignActionCard'); const card = document.getElementById('assignActionCard');
const onPage = document.querySelectorAll('#clientTableBody .client-cb:checked').length;
if (!selected.length) { if (!selected.length) {
info.style.display = 'none'; info.style.display = 'none';
@ -228,16 +501,24 @@ function updateSelectionInfo() {
return; return;
} }
info.style.display = ''; info.style.display = '';
info.innerHTML = `<strong>${selected.length}</strong> client${selected.length === 1 ? '' : 's'} selected`; const extra = selected.length > onPage
? ` <span class="data-toolbar-hint">(${onPage} on this page)</span>`
: '';
info.innerHTML = `<strong>${selected.length}</strong> client${selected.length === 1 ? '' : 's'} selected${extra}`;
card.style.display = ''; card.style.display = '';
if (selectedCoachID) {
const sel = document.getElementById('targetCoachSelect');
if (sel) sel.value = selectedCoachID;
}
syncAssignLayoutHeights();
} }
async function doAssign() { async function doAssign() {
const selected = getSelectedCodes(); 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 (!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; const coachName = coaches.find(c => c.coachID === coachID)?.username || coachID;
if (!confirm(`Assign ${selected.length} client(s) to ${coachName}?`)) return; if (!confirm(`Assign ${selected.length} client(s) to ${coachName}?`)) return;
@ -249,7 +530,6 @@ async function doAssign() {
try { try {
const data = await apiPost('assignments.php', { clientCodes: selected, coachID }); const data = await apiPost('assignments.php', { clientCodes: selected, coachID });
if (data.success) { if (data.success) {
// Update local data
selected.forEach(code => { selected.forEach(code => {
const c = clients.find(x => x.clientCode === code); const c = clients.find(x => x.clientCode === code);
if (c) { if (c) {
@ -257,6 +537,8 @@ async function doAssign() {
c.coachUsername = coachName; c.coachUsername = coachName;
} }
}); });
selectedClientCodes.clear();
rebuildClientsByCoach();
showToast(`${data.assigned} client(s) assigned to ${coachName}`, 'success'); showToast(`${data.assigned} client(s) assigned to ${coachName}`, 'success');
renderAssignments(); renderAssignments();
} }

View File

@ -69,7 +69,7 @@ function renderClients() {
container.innerHTML = ` container.innerHTML = `
<div class="card"> <div class="card">
<div class="filter-bar"> <div class="filter-bar">
<input type="search" id="clientListSearch" class="filter-search" placeholder="Search client code…" value="${esc(filterSearch)}"> <input type="search" id="clientListSearch" class="filter-search" placeholder="Search client or coach…" value="${esc(filterSearch)}">
<select id="clientTestFilter"> <select id="clientTestFilter">
<option value="">All clients</option> <option value="">All clients</option>
<option value="test" ${filterTestData === 'test' ? 'selected' : ''}>Test data only</option> <option value="test" ${filterTestData === 'test' ? 'selected' : ''}>Test data only</option>
@ -110,7 +110,10 @@ function getFilteredClientsList() {
let list = clientsList; let list = clientsList;
if (filterSearch) { if (filterSearch) {
const q = filterSearch.toLowerCase(); const q = filterSearch.toLowerCase();
list = list.filter(c => (c.clientCode || '').toLowerCase().includes(q)); list = list.filter(c => {
const hay = `${c.clientCode || ''} ${c.coachUsername || ''}`.toLowerCase();
return hay.includes(q);
});
} }
if (filterTestData === 'test') { if (filterTestData === 'test') {
list = list.filter(c => isDevTestClientCode(c.clientCode)); list = list.filter(c => isDevTestClientCode(c.clientCode));

View File

@ -23,7 +23,7 @@ export function devPage() {
<div class="form-group"> <div class="form-group">
<label for="devFixtureFile">Fixture JSON</label> <label for="devFixtureFile">Fixture JSON</label>
<input type="file" id="devFixtureFile" accept=".json,application/json"> <input type="file" id="devFixtureFile" accept=".json,application/json">
<span class="field-hint">Use <code>dev-test-fixture.json</code> from the project root (generate via <code>scripts/generate_dev_fixture.py</code>).</span> <span class="field-hint">Use <code>dev-test-fixture.json</code> from the repo root (generate via <code>nat-as-server/scripts/generate_dev_fixture.py</code>; reads <code>questionnaires_bundle_2026-05-28.json</code>, 5× scale).</span>
</div> </div>
<div id="devFixtureSummary" class="field-hint" style="margin:12px 0;display:none"></div> <div id="devFixtureSummary" class="field-hint" style="margin:12px 0;display:none"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px"> <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">

View File

@ -1,5 +1,7 @@
import { apiGet } from '../api.js'; import { apiGet } from '../api.js';
import { canEdit, showToast } from '../app.js'; import { canEdit, showToast } from '../app.js';
let questionnairesList = [];
let filterSearch = '';
export async function exportPage() { export async function exportPage() {
const app = document.getElementById('app'); const app = document.getElementById('app');
@ -13,17 +15,18 @@ export async function exportPage() {
try { try {
const data = await apiGet('questionnaires.php'); const data = await apiGet('questionnaires.php');
renderExport(data.questionnaires || []); questionnairesList = data.questionnaires || [];
renderExport();
} catch (e) { } catch (e) {
showToast(e.message, 'error'); showToast(e.message, 'error');
document.getElementById('exportContent').innerHTML = `<p class="error-text">${esc(e.message)}</p>`; document.getElementById('exportContent').innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
} }
} }
function renderExport(questionnaires) { function renderExport() {
const container = document.getElementById('exportContent'); const container = document.getElementById('exportContent');
if (!questionnaires.length) { if (!questionnairesList.length) {
container.innerHTML = ` container.innerHTML = `
<div class="empty-state"> <div class="empty-state">
<h3>No questionnaires</h3> <h3>No questionnaires</h3>
@ -46,40 +49,101 @@ function renderExport(questionnaires) {
container.innerHTML = ` container.innerHTML = `
${bundleBlock} ${bundleBlock}
<p style="margin-bottom:16px;color:var(--text-secondary)"> <div class="card">
Select a questionnaire and click Export to download a CSV file with all client responses (filtered by your role's visibility). <p style="margin:0 0 12px;color:var(--text-secondary)">
</p> Select a questionnaire and click Export to download a CSV file with all client responses (filtered by your role's visibility).
<table class="data-table"> </p>
<thead> <div class="filter-bar">
<tr> <input type="search" id="exportListSearch" class="filter-search" placeholder="Search questionnaire name…" value="${esc(filterSearch)}">
<th>Questionnaire</th> <span class="data-toolbar-hint" id="exportListCount"></span>
<th>Version</th> </div>
<th>State</th> <div class="table-wrapper">
<th>Questions</th> <table class="data-table">
<th>Completed</th> <thead>
<th>Actions</th> <tr>
</tr> <th>Questionnaire</th>
</thead> <th>Version</th>
<tbody> <th>State</th>
${questionnaires.map(q => ` <th>Questions</th>
<tr> <th>Completed</th>
<td><strong>${esc(q.name)}</strong></td> <th>Actions</th>
<td>${esc(q.version || '—')}</td> </tr>
<td><span class="badge badge-${(q.state || 'draft').toLowerCase()}">${esc(q.state || 'draft')}</span></td> </thead>
<td>${q.questionCount || 0}</td> <tbody id="exportTableBody"></tbody>
<td>${q.completedCount ?? 0}</td> </table>
<td> </div>
<button class="btn btn-sm btn-primary export-btn" data-id="${q.questionnaireID}" data-name="${esc(q.name)}" data-version="${esc(q.version)}"> </div>
Export CSV
</button>
<a href="#/questionnaire/${q.questionnaireID}/results" class="btn btn-sm">View Results</a>
</td>
</tr>
`).join('')}
</tbody>
</table>
`; `;
document.getElementById('exportListSearch').addEventListener('input', (e) => {
filterSearch = e.target.value;
renderExportTableBody();
});
renderExportTableBody();
wireExportActions();
}
function exportRowSearchText(q) {
return [q.name, q.questionnaireID, q.version, q.state].filter(Boolean).join(' ');
}
function renderExportTableBody() {
const body = document.getElementById('exportTableBody');
const countEl = document.getElementById('exportListCount');
if (!body) return;
const q = filterSearch.trim().toLowerCase();
const filtered = q
? questionnairesList.filter(item => exportRowSearchText(item).toLowerCase().includes(q))
: questionnairesList;
if (!filtered.length) {
body.innerHTML = `
<tr data-filter-placeholder="1">
<td colspan="6" style="text-align:center;color:var(--text-secondary);padding:24px">
No questionnaires match
</td>
</tr>`;
if (countEl) {
countEl.textContent = q
? `0 of ${questionnairesList.length} questionnaires`
: '';
}
return;
}
body.innerHTML = filtered.map(item => exportRowHTML(item)).join('');
if (countEl) {
countEl.textContent = q
? `${filtered.length} of ${questionnairesList.length} questionnaires`
: `${questionnairesList.length} questionnaire${questionnairesList.length === 1 ? '' : 's'}`;
}
body.querySelectorAll('.export-btn').forEach(btn => {
btn.addEventListener('click', onExportCsvClick);
});
}
function exportRowHTML(q) {
return `
<tr>
<td><strong>${esc(q.name)}</strong></td>
<td>${esc(q.version || '—')}</td>
<td><span class="badge badge-${(q.state || 'draft').toLowerCase()}">${esc(q.state || 'draft')}</span></td>
<td>${q.questionCount || 0}</td>
<td>${q.completedCount ?? 0}</td>
<td>
<button class="btn btn-sm btn-primary export-btn" data-id="${esc(q.questionnaireID)}" data-name="${esc(q.name)}" data-version="${esc(q.version)}">
Export CSV
</button>
<a href="#/questionnaire/${esc(q.questionnaireID)}/results" class="btn btn-sm">View Results</a>
</td>
</tr>`;
}
function wireExportActions() {
document.getElementById('exportBundleBtn')?.addEventListener('click', async () => { document.getElementById('exportBundleBtn')?.addEventListener('click', async () => {
const btn = document.getElementById('exportBundleBtn'); const btn = document.getElementById('exportBundleBtn');
btn.disabled = true; btn.disabled = true;
@ -110,36 +174,35 @@ function renderExport(questionnaires) {
btn.textContent = prev; btn.textContent = prev;
} }
}); });
}
container.querySelectorAll('.export-btn').forEach(btn => { async function onExportCsvClick(ev) {
btn.addEventListener('click', async () => { const btn = ev.currentTarget;
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Downloading...'; btn.textContent = 'Downloading...';
try { try {
const token = localStorage.getItem('qdb_token'); const token = localStorage.getItem('qdb_token');
const res = await fetch(`../api/export.php?questionnaireID=${encodeURIComponent(btn.dataset.id)}&format=csv`, { const res = await fetch(`../api/export.php?questionnaireID=${encodeURIComponent(btn.dataset.id)}&format=csv`, {
headers: { 'Authorization': `Bearer ${token}` } 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';
}
}); });
}); 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) { function esc(s) {

View File

@ -96,7 +96,7 @@ function renderResults() {
to to
<input type="date" id="filterDateTo" value="${esc(filterDateTo)}"> <input type="date" id="filterDateTo" value="${esc(filterDateTo)}">
</label> </label>
<input type="search" id="filterSearch" class="filter-search" placeholder="Search client code…" value="${esc(filterSearch)}"> <input type="search" id="filterSearch" class="filter-search" placeholder="Search client, coach, status…" value="${esc(filterSearch)}">
<select id="filterTestData"> <select id="filterTestData">
<option value="">All clients</option> <option value="">All clients</option>
<option value="test" ${filterTestData === 'test' ? 'selected' : ''}>Test data only</option> <option value="test" ${filterTestData === 'test' ? 'selected' : ''}>Test data only</option>
@ -289,7 +289,15 @@ function getFilteredClients() {
} }
if (filterSearch) { if (filterSearch) {
const q = filterSearch.toLowerCase(); const q = filterSearch.toLowerCase();
clients = clients.filter(c => (c.clientCode || '').toLowerCase().includes(q)); clients = clients.filter(c => {
const hay = [
c.clientCode,
c.coachUsername,
c.supervisorUsername,
c.status,
].filter(Boolean).join(' ').toLowerCase();
return hay.includes(q);
});
} }
if (filterTestData === 'test') { if (filterTestData === 'test') {
clients = clients.filter(c => isDevTestClientCode(c.clientCode)); clients = clients.filter(c => isDevTestClientCode(c.clientCode));

View File

@ -11,9 +11,35 @@ const CREATEABLE_ROLES = {
let usersList = []; let usersList = [];
let supervisorsList = []; let supervisorsList = [];
let callerRole = ''; let callerRole = '';
let filterSearch = ''; /** @type {Record<string, string>} */
let filterSearchByRole = { admin: '', supervisor: '', coach: '' };
let filterTestData = ''; let filterTestData = '';
function roleGroupsForCaller() {
return callerRole === 'supervisor'
? [{ label: 'Coaches', key: 'coach' }]
: [
{ label: 'Admins', key: 'admin' },
{ label: 'Supervisors', key: 'supervisor' },
{ label: 'Coaches', key: 'coach' },
];
}
function searchPlaceholderForRole(roleKey) {
return roleKey === 'coach'
? 'Search username or supervisor…'
: 'Search username or location…';
}
function usersByRoleMap() {
const byRole = { admin: [], supervisor: [], coach: [] };
usersList.forEach(u => {
if (byRole[u.role]) byRole[u.role].push(u);
else (byRole.other = byRole.other || []).push(u);
});
return byRole;
}
export async function usersPage() { export async function usersPage() {
callerRole = getRole(); callerRole = getRole();
const app = document.getElementById('app'); const app = document.getElementById('app');
@ -76,36 +102,34 @@ function renderUsers() {
return; return;
} }
// Group by role for a cleaner display const byRole = usersByRoleMap();
const byRole = { admin: [], supervisor: [], coach: [] }; const groups = roleGroupsForCaller();
usersList.forEach(u => (byRole[u.role] || (byRole.other = byRole.other || [])).push(u));
// Which role groups to show const testFilterCard = `
const groups = callerRole === 'supervisor'
? [{ label: 'Coaches', key: 'coach' }]
: [
{ label: 'Admins', key: 'admin' },
{ label: 'Supervisors', key: 'supervisor' },
{ label: 'Coaches', key: 'coach' },
];
container.innerHTML = `
<div class="card" style="margin-bottom:16px"> <div class="card" style="margin-bottom:16px">
<div class="filter-bar"> <div class="filter-bar">
<input type="search" id="userListSearch" class="filter-search" placeholder="Search username…" value="${esc(filterSearch)}"> <label style="font-size:.85rem;font-weight:500">Show:</label>
<select id="userTestFilter"> <select id="userTestFilter">
<option value="">All users</option> <option value="">All users</option>
<option value="test" ${filterTestData === 'test' ? 'selected' : ''}>Test data only (dev_*)</option> <option value="test" ${filterTestData === 'test' ? 'selected' : ''}>Test data only (dev_*)</option>
<option value="hide-test" ${filterTestData === 'hide-test' ? 'selected' : ''}>Hide test data</option> <option value="hide-test" ${filterTestData === 'hide-test' ? 'selected' : ''}>Hide test data</option>
</select> </select>
</div> </div>
</div> </div>`;
` + groups.map(group => {
const users = filterUsers(byRole[group.key] || []); container.innerHTML = testFilterCard + groups.map(group => {
if (!users.length) return ''; const allInRole = byRole[group.key] || [];
if (!allInRole.length) return '';
return ` return `
<div class="card" style="margin-bottom:16px"> <div class="card user-role-card" data-role="${group.key}" style="margin-bottom:16px">
<h3 style="margin-bottom:12px">${group.label} (${users.length})</h3> <h3 class="user-role-title" style="margin-bottom:12px">
${group.label} (<span data-role-count="${group.key}">0</span>)
</h3>
<div class="filter-bar">
<input type="search" class="filter-search user-role-search" data-role="${group.key}"
placeholder="${searchPlaceholderForRole(group.key)}"
value="${esc(filterSearchByRole[group.key] || '')}">
</div>
<div class="table-wrapper"> <div class="table-wrapper">
<table class="data-table"> <table class="data-table">
<thead> <thead>
@ -117,33 +141,41 @@ function renderUsers() {
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="usersBody-${group.key}"></tbody>
${users.map(u => userRowHTML(u, myUsername)).join('')}
</tbody>
</table> </table>
</div> </div>
</div>`; </div>`;
}).join(''); }).join('');
document.getElementById('userListSearch')?.addEventListener('input', (e) => {
filterSearch = e.target.value.trim();
renderUsers();
});
document.getElementById('userTestFilter')?.addEventListener('change', (e) => { document.getElementById('userTestFilter')?.addEventListener('change', (e) => {
filterTestData = e.target.value; filterTestData = e.target.value;
renderUsers(); renderUsers();
}); });
container.querySelectorAll('.delete-user-btn').forEach(btn => { container.addEventListener('input', (e) => {
btn.addEventListener('click', () => deleteUser(btn.dataset.id, btn.dataset.name)); 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; let list = users;
if (filterSearch) { const q = (filterSearchByRole[roleKey] || '').trim().toLowerCase();
const q = filterSearch.toLowerCase(); if (q) {
list = list.filter(u => (u.username || '').toLowerCase().includes(q)); list = list.filter(u => {
const hay = [u.username, u.location, u.supervisorUsername].filter(Boolean).join(' ').toLowerCase();
return hay.includes(q);
});
} }
if (filterTestData === 'test') { if (filterTestData === 'test') {
list = list.filter(u => isDevTestUsername(u.username)); list = list.filter(u => isDevTestUsername(u.username));
@ -153,6 +185,39 @@ function filterUsers(users) {
return list; return list;
} }
function updateRoleGroupTable(roleKey, myUsername) {
const tbody = document.getElementById(`usersBody-${roleKey}`);
const countEl = document.querySelector(`[data-role-count="${roleKey}"]`);
if (!tbody) return;
const byRole = usersByRoleMap();
const allInRole = byRole[roleKey] || [];
const users = filterUsers(allInRole, roleKey);
const colSpan = 5;
if (countEl) {
const q = (filterSearchByRole[roleKey] || '').trim();
countEl.textContent = q && users.length !== allInRole.length
? `${users.length} of ${allInRole.length}`
: String(users.length);
}
if (!users.length) {
tbody.innerHTML = `
<tr>
<td colspan="${colSpan}" style="text-align:center;color:var(--text-secondary);padding:24px">
${allInRole.length ? 'No users match' : 'No users'}
</td>
</tr>`;
return;
}
tbody.innerHTML = users.map(u => userRowHTML(u, myUsername)).join('');
tbody.querySelectorAll('.delete-user-btn').forEach(btn => {
btn.addEventListener('click', () => deleteUser(btn.dataset.id, btn.dataset.name));
});
}
function userRowHTML(u, myUsername) { function userRowHTML(u, myUsername) {
const isSelf = u.username === myUsername; const isSelf = u.username === myUsername;
const detail = u.role === 'coach' const detail = u.role === 'coach'

View File

@ -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();
}