good client and coach data viewing

This commit is contained in:
2026-06-01 13:49:56 +02:00
parent 3d56abd124
commit 9ed66ca327
4 changed files with 347 additions and 34 deletions

View File

@ -13,9 +13,11 @@ if ($method !== 'GET') {
try { try {
$coachID = trim((string)($_GET['coachID'] ?? '')); $coachID = trim((string)($_GET['coachID'] ?? ''));
if ($coachID !== '' && !empty($_GET['recent'])) { 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); qdb_discard($tmpDb, $lockFp);
json_success(['submissions' => $recent]); json_success($recent);
} }
$coaches = qdb_coach_activity_list($pdo, $tokenRec); $coaches = qdb_coach_activity_list($pdo, $tokenRec);

View File

@ -251,7 +251,13 @@ function qdb_coach_activity_list(PDO $pdo, array $tokenRec): array {
return $out; 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); $coachID = trim($coachID);
if ($coachID === '') { if ($coachID === '') {
json_error('INVALID_FIELD', 'coachID is required', 400); 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'); [$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 = " $sql = "
SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByRole, 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 LEFT JOIN questionnaire qn ON qn.questionnaireID = qs.questionnaireID
WHERE cl.coachID = :cid AND ($rbacClause) WHERE cl.coachID = :cid AND ($rbacClause)
ORDER BY qs.submittedAt DESC ORDER BY qs.submittedAt DESC
LIMIT $limit LIMIT $limit OFFSET $offset
"; ";
$stmt = $pdo->prepare($sql); $stmt = $pdo->prepare($sql);
$stmt->execute(array_merge([':cid' => $coachID], $rbacParams)); $stmt->execute(array_merge([':cid' => $coachID], $rbacParams));
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map(static function ($r) { $submissions = array_map(static function ($r) {
return [ return [
'submissionID' => $r['submissionID'], 'submissionID' => $r['submissionID'],
'version' => (int)$r['version'], 'version' => (int)$r['version'],
@ -298,4 +315,11 @@ function qdb_coach_recent_submissions(PDO $pdo, array $tokenRec, string $coachID
'questionnaireName'=> $r['questionnaireName'] ?? $r['questionnaireID'], 'questionnaireName'=> $r['questionnaireName'] ?? $r['questionnaireID'],
]; ];
}, $rows); }, $rows);
return [
'submissions' => $submissions,
'total' => $total,
'limit' => $limit,
'offset' => $offset,
];
} }

View File

@ -1872,6 +1872,65 @@ table.data-table tbody tr.row-orphan-category td {
border-radius: 8px; border-radius: 8px;
margin: 4px 0 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 td,
.data-table-compact th { .data-table-compact th {
padding: 8px 10px; padding: 8px 10px;

View File

@ -1,9 +1,16 @@
import { apiGet } from '../api.js'; import { apiGet } from '../api.js';
import { showToast } from '../app.js'; import { showToast } from '../app.js';
const FETCH_LIMIT = 100;
const CLIENT_GROUP_PAGE = 10;
const UPLOADS_VISIBLE = 5;
let coachesList = []; let coachesList = [];
let expandedCoachId = null; let expandedCoachId = null;
/** @type {Record<string, { submissions: object[], total: number }>} */
let recentByCoach = {}; let recentByCoach = {};
/** @type {Record<string, { clientLimit: number, expandedClients: Set<string>, showAllUploads: Set<string>, filter: string }>} */
let coachDetailUi = {};
let filterSearch = ''; let filterSearch = '';
export async function coachesPage() { export async function coachesPage() {
@ -41,6 +48,149 @@ function filteredCoaches() {
return coachesList.filter(c => coachSearchText(c).toLowerCase().includes(q)); 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 '<p class="data-toolbar-hint">No uploads</p>';
}
return `
<table class="data-table data-table-compact">
<thead>
<tr><th>When</th><th>Questionnaire</th><th>Ver.</th></tr>
</thead>
<tbody>
${uploads.map(s => `
<tr>
<td>${esc(s.submittedAt)}</td>
<td>${esc(s.questionnaireName)}</td>
<td>${s.version}</td>
</tr>
`).join('')}
</tbody>
</table>`;
}
function coachRecentPanelHTML(coachID) {
const data = recentByCoach[coachID];
if (!data) {
return '<p class="data-toolbar-hint">Loading…</p>';
}
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 = `<p class="data-toolbar-hint">${ui.filter.trim() ? 'No matches' : 'No uploads'}</p>`;
} 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 `
<div class="coach-client-group" data-client="${esc(g.clientCode)}">
<button type="button" class="coach-client-toggle btn btn-sm btn-link"
data-coach="${esc(coachID)}" data-client="${esc(g.clientCode)}">
${expanded ? '▼' : '▶'}
<code>${esc(g.clientCode)}</code>
<span class="coach-client-meta">${g.count} upload${g.count === 1 ? '' : 's'} · last ${esc(g.lastAt || '—')}</span>
</button>
${expanded ? `
<div class="coach-client-uploads">
${uploadsTableHTML(visibleUploads)}
${!showAll && hiddenCount > 0 ? `
<button type="button" class="btn btn-sm coach-show-all-uploads"
data-coach="${esc(coachID)}" data-client="${esc(g.clientCode)}">
Show all ${g.count} uploads
</button>
` : ''}
</div>
` : ''}
</div>`;
}).join('');
}
return `
<div class="coach-recent-panel coach-recent-scroll" data-coach-panel="${esc(coachID)}">
<div class="coach-recent-toolbar">
<input type="search" class="filter-search coach-detail-search"
placeholder="Filter client or questionnaire…"
value="${esc(ui.filter)}"
data-coach="${esc(coachID)}">
<span class="data-toolbar-hint">${esc(summary)}${ui.filter.trim() && groups.length !== allGroups.length
? ` · ${groups.length} shown`
: ''}</span>
</div>
<div class="coach-client-groups">${groupsHTML}</div>
<div class="coach-recent-actions">
${hasMoreClients ? `
<button type="button" class="btn btn-sm coach-more-clients" data-coach="${esc(coachID)}">
Show more clients (${groups.length - ui.clientLimit} remaining)
</button>
` : ''}
${hasMoreUploads ? `
<button type="button" class="btn btn-sm coach-more-uploads" data-coach="${esc(coachID)}">
Load more uploads (${(data.total ?? 0) - (data.submissions?.length ?? 0)} remaining)
</button>
` : ''}
</div>
</div>`;
}
function renderCoaches() { function renderCoaches() {
const el = document.getElementById('coachesContent'); const el = document.getElementById('coachesContent');
@ -51,7 +201,9 @@ function renderCoaches() {
el.innerHTML = ` el.innerHTML = `
<div class="card"> <div class="card">
<p class="data-toolbar-hint" style="margin:0 0 12px">Track uploads and client load per coach. Expand a row for recent submissions.</p> <p class="data-toolbar-hint" style="margin:0 0 12px">
Track uploads and client load per coach. Expand a row to browse uploads by client.
</p>
<div class="filter-bar"> <div class="filter-bar">
<input type="search" id="coachListSearch" class="filter-search" <input type="search" id="coachListSearch" class="filter-search"
placeholder="Search coach or supervisor…" value="${esc(filterSearch)}"> placeholder="Search coach or supervisor…" value="${esc(filterSearch)}">
@ -89,7 +241,6 @@ function renderCoaches() {
function coachRowHTML(c) { function coachRowHTML(c) {
const canExpand = coachHasUploads(c); const canExpand = coachHasUploads(c);
const expanded = canExpand && expandedCoachId === c.coachID; const expanded = canExpand && expandedCoachId === c.coachID;
const recent = recentByCoach[c.coachID] || [];
const expandControl = canExpand const expandControl = canExpand
? `<button type="button" class="btn btn-sm btn-link coach-expand-btn" data-coach="${esc(c.coachID)}">${expanded ? '▼' : '▶'}</button> ` ? `<button type="button" class="btn btn-sm btn-link coach-expand-btn" data-coach="${esc(c.coachID)}">${expanded ? '▼' : '▶'}</button> `
: ''; : '';
@ -105,30 +256,50 @@ function coachRowHTML(c) {
</tr> </tr>
${expanded ? ` ${expanded ? `
<tr class="coach-detail-row"> <tr class="coach-detail-row">
<td colspan="7"> <td colspan="7">${coachRecentPanelHTML(c.coachID)}</td>
<div class="coach-recent-panel">
${recent.length ? `
<table class="data-table data-table-compact">
<thead>
<tr><th>When</th><th>Client</th><th>Questionnaire</th><th>Ver.</th></tr>
</thead>
<tbody>
${recent.map(s => `
<tr>
<td>${esc(s.submittedAt)}</td>
<td><code>${esc(s.clientCode)}</code></td>
<td>${esc(s.questionnaireName)}</td>
<td>${s.version}</td>
</tr>
`).join('')}
</tbody>
</table>
` : '<p class="data-toolbar-hint">Loading…</p>'}
</div>
</td>
</tr>` : ''}`; </tr>` : ''}`;
} }
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() { function renderCoachTableBody() {
const body = document.getElementById('coachTableBody'); const body = document.getElementById('coachTableBody');
const countEl = document.getElementById('coachListCount'); const countEl = document.getElementById('coachListCount');
@ -163,6 +334,58 @@ function renderCoachTableBody() {
body.querySelectorAll('.coach-expand-btn').forEach(btn => { body.querySelectorAll('.coach-expand-btn').forEach(btn => {
btn.addEventListener('click', () => toggleCoach(btn.dataset.coach)); 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) { async function toggleCoach(coachID) {
@ -176,16 +399,21 @@ async function toggleCoach(coachID) {
return; return;
} }
expandedCoachId = coachID; expandedCoachId = coachID;
if (!coachDetailUi[coachID]) {
coachDetailUi[coachID] = {
clientLimit: CLIENT_GROUP_PAGE,
expandedClients: new Set(),
showAllUploads: new Set(),
filter: '',
};
}
renderCoachTableBody(); renderCoachTableBody();
if (!recentByCoach[coachID]) { if (!recentByCoach[coachID]) {
try { try {
const data = await apiGet( await fetchCoachRecent(coachID, 0, false);
`coaches.php?coachID=${encodeURIComponent(coachID)}&recent=1`
);
recentByCoach[coachID] = data.submissions || [];
} catch (e) { } catch (e) {
showToast(e.message, 'error'); showToast(e.message, 'error');
recentByCoach[coachID] = []; recentByCoach[coachID] = { submissions: [], total: 0 };
} }
renderCoachTableBody(); renderCoachTableBody();
} }