From 9ed66ca327110ea6500763bb5a57e9756f020163 Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Mon, 1 Jun 2026 13:49:56 +0200 Subject: [PATCH] good client and coach data viewing --- handlers/coaches.php | 6 +- lib/analytics.php | 32 +++- website/css/style.css | 59 ++++++++ website/js/pages/coaches.js | 284 ++++++++++++++++++++++++++++++++---- 4 files changed, 347 insertions(+), 34 deletions(-) diff --git a/handlers/coaches.php b/handlers/coaches.php index 7898e30..fa72e46 100644 --- a/handlers/coaches.php +++ b/handlers/coaches.php @@ -13,9 +13,11 @@ if ($method !== 'GET') { try { $coachID = trim((string)($_GET['coachID'] ?? '')); if ($coachID !== '' && !empty($_GET['recent'])) { - $recent = qdb_coach_recent_submissions($pdo, $tokenRec, $coachID); + $limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 100; + $offset = isset($_GET['offset']) ? (int)$_GET['offset'] : 0; + $recent = qdb_coach_recent_submissions($pdo, $tokenRec, $coachID, $limit, $offset); qdb_discard($tmpDb, $lockFp); - json_success(['submissions' => $recent]); + json_success($recent); } $coaches = qdb_coach_activity_list($pdo, $tokenRec); diff --git a/lib/analytics.php b/lib/analytics.php index 4da067c..4fd611b 100644 --- a/lib/analytics.php +++ b/lib/analytics.php @@ -251,7 +251,13 @@ function qdb_coach_activity_list(PDO $pdo, array $tokenRec): array { return $out; } -function qdb_coach_recent_submissions(PDO $pdo, array $tokenRec, string $coachID, int $limit = 25): array { +function qdb_coach_recent_submissions( + PDO $pdo, + array $tokenRec, + string $coachID, + int $limit = 100, + int $offset = 0 +): array { $coachID = trim($coachID); if ($coachID === '') { json_error('INVALID_FIELD', 'coachID is required', 400); @@ -271,7 +277,18 @@ function qdb_coach_recent_submissions(PDO $pdo, array $tokenRec, string $coachID } [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); - $limit = max(1, min($limit, 100)); + $limit = max(1, min($limit, 200)); + $offset = max(0, $offset); + + $countSql = " + SELECT COUNT(*) + FROM questionnaire_submission qs + INNER JOIN client cl ON cl.clientCode = qs.clientCode + WHERE cl.coachID = :cid AND ($rbacClause) + "; + $countStmt = $pdo->prepare($countSql); + $countStmt->execute(array_merge([':cid' => $coachID], $rbacParams)); + $total = (int)$countStmt->fetchColumn(); $sql = " SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByRole, @@ -281,13 +298,13 @@ function qdb_coach_recent_submissions(PDO $pdo, array $tokenRec, string $coachID LEFT JOIN questionnaire qn ON qn.questionnaireID = qs.questionnaireID WHERE cl.coachID = :cid AND ($rbacClause) ORDER BY qs.submittedAt DESC - LIMIT $limit + LIMIT $limit OFFSET $offset "; $stmt = $pdo->prepare($sql); $stmt->execute(array_merge([':cid' => $coachID], $rbacParams)); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); - return array_map(static function ($r) { + $submissions = array_map(static function ($r) { return [ 'submissionID' => $r['submissionID'], 'version' => (int)$r['version'], @@ -298,4 +315,11 @@ function qdb_coach_recent_submissions(PDO $pdo, array $tokenRec, string $coachID 'questionnaireName'=> $r['questionnaireName'] ?? $r['questionnaireID'], ]; }, $rows); + + return [ + 'submissions' => $submissions, + 'total' => $total, + 'limit' => $limit, + 'offset' => $offset, + ]; } diff --git a/website/css/style.css b/website/css/style.css index de9e1b1..e19aa3b 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -1872,6 +1872,65 @@ table.data-table tbody tr.row-orphan-category td { border-radius: 8px; margin: 4px 0 8px; } +.coach-recent-scroll { + max-height: min(420px, 55vh); + display: flex; + flex-direction: column; + overflow: hidden; +} +.coach-recent-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 16px; + margin-bottom: 10px; + flex-shrink: 0; +} +.coach-recent-toolbar .filter-search { + flex: 1; + min-width: 180px; + max-width: 320px; +} +.coach-client-groups { + flex: 1; + overflow-y: auto; + padding-right: 4px; + margin-bottom: 8px; +} +.coach-client-group { + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 8px; + background: var(--surface); + overflow: hidden; +} +.coach-client-toggle { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + width: 100%; + text-align: left; + padding: 10px 12px !important; + justify-content: flex-start; +} +.coach-client-meta { + font-size: 0.8rem; + color: var(--text-secondary); + font-weight: normal; +} +.coach-client-uploads { + padding: 0 12px 12px 32px; + border-top: 1px solid var(--border); +} +.coach-recent-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + flex-shrink: 0; + padding-top: 4px; + border-top: 1px solid var(--border); +} .data-table-compact td, .data-table-compact th { padding: 8px 10px; diff --git a/website/js/pages/coaches.js b/website/js/pages/coaches.js index 98af45b..563a9ad 100644 --- a/website/js/pages/coaches.js +++ b/website/js/pages/coaches.js @@ -1,9 +1,16 @@ import { apiGet } from '../api.js'; import { showToast } from '../app.js'; +const FETCH_LIMIT = 100; +const CLIENT_GROUP_PAGE = 10; +const UPLOADS_VISIBLE = 5; + let coachesList = []; let expandedCoachId = null; +/** @type {Record} */ let recentByCoach = {}; +/** @type {Record, showAllUploads: Set, filter: string }>} */ +let coachDetailUi = {}; let filterSearch = ''; export async function coachesPage() { @@ -41,6 +48,149 @@ function filteredCoaches() { return coachesList.filter(c => coachSearchText(c).toLowerCase().includes(q)); } +function getCoachUi(coachID) { + if (!coachDetailUi[coachID]) { + coachDetailUi[coachID] = { + clientLimit: CLIENT_GROUP_PAGE, + expandedClients: new Set(), + showAllUploads: new Set(), + filter: '', + }; + } + return coachDetailUi[coachID]; +} + +function groupSubmissionsByClient(submissions) { + const map = new Map(); + for (const s of submissions) { + const code = s.clientCode || ''; + if (!map.has(code)) { + map.set(code, []); + } + map.get(code).push(s); + } + const groups = []; + for (const [clientCode, uploads] of map) { + uploads.sort((a, b) => String(b.submittedAt).localeCompare(String(a.submittedAt))); + groups.push({ + clientCode, + uploads, + count: uploads.length, + lastAt: uploads[0]?.submittedAt || '', + }); + } + groups.sort((a, b) => String(b.lastAt).localeCompare(String(a.lastAt))); + return groups; +} + +function filterClientGroups(groups, q) { + if (!q) return groups; + const needle = q.toLowerCase(); + return groups.filter(g => { + if (g.clientCode.toLowerCase().includes(needle)) return true; + return g.uploads.some(u => + (u.questionnaireName || '').toLowerCase().includes(needle) + || (u.questionnaireID || '').toLowerCase().includes(needle) + ); + }); +} + +function uploadsTableHTML(uploads) { + if (!uploads.length) { + return '

No uploads

'; + } + return ` + + + + + + ${uploads.map(s => ` + + + + + + `).join('')} + +
WhenQuestionnaireVer.
${esc(s.submittedAt)}${esc(s.questionnaireName)}${s.version}
`; +} + +function coachRecentPanelHTML(coachID) { + const data = recentByCoach[coachID]; + if (!data) { + return '

Loading…

'; + } + const ui = getCoachUi(coachID); + const allGroups = groupSubmissionsByClient(data.submissions || []); + const groups = filterClientGroups(allGroups, ui.filter.trim()); + const visibleGroups = groups.slice(0, ui.clientLimit); + const hasMoreClients = groups.length > ui.clientLimit; + const hasMoreUploads = (data.submissions?.length ?? 0) < (data.total ?? 0); + + const summary = data.total + ? `${data.total} upload${data.total === 1 ? '' : 's'} · ${allGroups.length} client${allGroups.length === 1 ? '' : 's'}` + : 'No uploads'; + + let groupsHTML = ''; + if (!groups.length) { + groupsHTML = `

${ui.filter.trim() ? 'No matches' : 'No uploads'}

`; + } else { + groupsHTML = visibleGroups.map(g => { + const expanded = ui.expandedClients.has(g.clientCode); + const showAll = ui.showAllUploads.has(g.clientCode); + const visibleUploads = showAll ? g.uploads : g.uploads.slice(0, UPLOADS_VISIBLE); + const hiddenCount = g.uploads.length - UPLOADS_VISIBLE; + return ` +
+ + ${expanded ? ` +
+ ${uploadsTableHTML(visibleUploads)} + ${!showAll && hiddenCount > 0 ? ` + + ` : ''} +
+ ` : ''} +
`; + }).join(''); + } + + return ` +
+
+ + ${esc(summary)}${ui.filter.trim() && groups.length !== allGroups.length + ? ` · ${groups.length} shown` + : ''} +
+
${groupsHTML}
+
+ ${hasMoreClients ? ` + + ` : ''} + ${hasMoreUploads ? ` + + ` : ''} +
+
`; +} + function renderCoaches() { const el = document.getElementById('coachesContent'); @@ -51,7 +201,9 @@ function renderCoaches() { el.innerHTML = `
-

Track uploads and client load per coach. Expand a row for recent submissions.

+

+ Track uploads and client load per coach. Expand a row to browse uploads by client. +

@@ -89,7 +241,6 @@ function renderCoaches() { function coachRowHTML(c) { const canExpand = coachHasUploads(c); const expanded = canExpand && expandedCoachId === c.coachID; - const recent = recentByCoach[c.coachID] || []; const expandControl = canExpand ? ` ` : ''; @@ -105,30 +256,50 @@ function coachRowHTML(c) { ${expanded ? ` - -
- ${recent.length ? ` - - - - - - ${recent.map(s => ` - - - - - - - `).join('')} - -
WhenClientQuestionnaireVer.
${esc(s.submittedAt)}${esc(s.clientCode)}${esc(s.questionnaireName)}${s.version}
- ` : '

Loading…

'} -
- + ${coachRecentPanelHTML(c.coachID)} ` : ''}`; } +function wireCoachDetailPanel(coachID) { + const panel = document.querySelector(`[data-coach-panel="${CSS.escape(coachID)}"]`); + if (!panel) return; + + panel.querySelector('.coach-detail-search')?.addEventListener('input', (e) => { + getCoachUi(coachID).filter = e.target.value; + getCoachUi(coachID).clientLimit = CLIENT_GROUP_PAGE; + renderCoachTableBody(); + }); + + panel.querySelectorAll('.coach-client-toggle').forEach(btn => { + btn.addEventListener('click', () => { + const ui = getCoachUi(coachID); + const client = btn.dataset.client; + if (ui.expandedClients.has(client)) { + ui.expandedClients.delete(client); + } else { + ui.expandedClients.add(client); + } + renderCoachTableBody(); + }); + }); + + panel.querySelectorAll('.coach-show-all-uploads').forEach(btn => { + btn.addEventListener('click', () => { + getCoachUi(coachID).showAllUploads.add(btn.dataset.client); + renderCoachTableBody(); + }); + }); + + panel.querySelector('.coach-more-clients')?.addEventListener('click', () => { + getCoachUi(coachID).clientLimit += CLIENT_GROUP_PAGE; + renderCoachTableBody(); + }); + + panel.querySelector('.coach-more-uploads')?.addEventListener('click', () => { + loadMoreCoachUploads(coachID); + }); +} + function renderCoachTableBody() { const body = document.getElementById('coachTableBody'); const countEl = document.getElementById('coachListCount'); @@ -163,6 +334,58 @@ function renderCoachTableBody() { body.querySelectorAll('.coach-expand-btn').forEach(btn => { btn.addEventListener('click', () => toggleCoach(btn.dataset.coach)); }); + + if (expandedCoachId) { + wireCoachDetailPanel(expandedCoachId); + } +} + +async function fetchCoachRecent(coachID, offset = 0, append = false) { + const data = await apiGet( + `coaches.php?coachID=${encodeURIComponent(coachID)}&recent=1` + + `&limit=${FETCH_LIMIT}&offset=${offset}` + ); + const submissions = data.submissions || []; + if (append && recentByCoach[coachID]) { + const existing = recentByCoach[coachID].submissions || []; + const seen = new Set(existing.map(s => s.submissionID)); + for (const s of submissions) { + if (!seen.has(s.submissionID)) { + existing.push(s); + } + } + recentByCoach[coachID] = { + submissions: existing, + total: data.total ?? existing.length, + }; + } else { + recentByCoach[coachID] = { + submissions, + total: data.total ?? submissions.length, + }; + } +} + +async function loadMoreCoachUploads(coachID) { + const data = recentByCoach[coachID]; + if (!data) return; + const btn = document.querySelector( + `[data-coach-panel="${CSS.escape(coachID)}"] .coach-more-uploads` + ); + if (btn) { + btn.disabled = true; + btn.textContent = 'Loading…'; + } + try { + await fetchCoachRecent(coachID, data.submissions.length, true); + renderCoachTableBody(); + } catch (e) { + showToast(e.message, 'error'); + if (btn) { + btn.disabled = false; + btn.textContent = 'Load more uploads'; + } + } } async function toggleCoach(coachID) { @@ -176,16 +399,21 @@ async function toggleCoach(coachID) { return; } expandedCoachId = coachID; + if (!coachDetailUi[coachID]) { + coachDetailUi[coachID] = { + clientLimit: CLIENT_GROUP_PAGE, + expandedClients: new Set(), + showAllUploads: new Set(), + filter: '', + }; + } renderCoachTableBody(); if (!recentByCoach[coachID]) { try { - const data = await apiGet( - `coaches.php?coachID=${encodeURIComponent(coachID)}&recent=1` - ); - recentByCoach[coachID] = data.submissions || []; + await fetchCoachRecent(coachID, 0, false); } catch (e) { showToast(e.message, 'error'); - recentByCoach[coachID] = []; + recentByCoach[coachID] = { submissions: [], total: 0 }; } renderCoachTableBody(); }