Files
nat-as-server/website/js/pages/coaches.js

427 lines
15 KiB
JavaScript

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<string, { submissions: object[], total: number }>} */
let recentByCoach = {};
/** @type {Record<string, { clientLimit: number, expandedClients: Set<string>, showAllUploads: Set<string>, filter: string }>} */
let coachDetailUi = {};
let filterSearch = '';
export async function coachesPage() {
const app = document.getElementById('app');
app.innerHTML = `
<div class="page-header">
<h1>Coach activity</h1>
<div class="actions"><a href="#/" class="btn">&larr; Dashboard</a></div>
</div>
<div id="coachesContent"><div class="spinner"></div></div>
`;
try {
const data = await apiGet('coaches.php');
coachesList = data.coaches || [];
renderCoaches();
} catch (e) {
showToast(e.message, 'error');
document.getElementById('coachesContent').innerHTML =
`<p class="error-text">${esc(e.message)}</p>`;
}
}
function coachSearchText(c) {
return [c.username, c.coachID, c.supervisorUsername].filter(Boolean).join(' ');
}
function coachHasUploads(c) {
return (c.totalSubmissions ?? 0) > 0;
}
function filteredCoaches() {
const q = filterSearch.trim().toLowerCase();
if (!q) return coachesList;
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() {
const el = document.getElementById('coachesContent');
if (!coachesList.length) {
el.innerHTML = `<div class="card empty-state"><h3>No coaches</h3></div>`;
return;
}
el.innerHTML = `
<div class="card">
<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">
<input type="search" id="coachListSearch" class="filter-search"
placeholder="Search coach or supervisor…" value="${esc(filterSearch)}">
<span class="data-toolbar-hint" id="coachListCount"></span>
</div>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Coach</th><th>Supervisor</th><th>Clients</th>
<th>Total uploads</th><th>7d</th><th>30d</th><th>Last upload</th>
</tr>
</thead>
<tbody id="coachTableBody"></tbody>
</table>
</div>
</div>
`;
document.getElementById('coachListSearch').addEventListener('input', (e) => {
filterSearch = e.target.value;
const filtered = filteredCoaches();
if (
expandedCoachId &&
!filtered.some(c => c.coachID === expandedCoachId && coachHasUploads(c))
) {
expandedCoachId = null;
}
renderCoachTableBody();
});
renderCoachTableBody();
}
function coachRowHTML(c) {
const canExpand = coachHasUploads(c);
const expanded = canExpand && expandedCoachId === c.coachID;
const expandControl = canExpand
? `<button type="button" class="btn btn-sm btn-link coach-expand-btn" data-coach="${esc(c.coachID)}">${expanded ? '▼' : '▶'}</button> `
: '';
return `
<tr class="coach-row" data-coach="${esc(c.coachID)}">
<td>${expandControl}<strong>${esc(c.username)}</strong></td>
<td>${esc(c.supervisorUsername || '—')}</td>
<td>${c.clientCount ?? 0}</td>
<td>${c.totalSubmissions ?? 0}</td>
<td>${c.submissionsLast7d ?? 0}</td>
<td>${c.submissionsLast30d ?? 0}</td>
<td>${esc(c.lastSubmissionAt || '—')}</td>
</tr>
${expanded ? `
<tr class="coach-detail-row">
<td colspan="7">${coachRecentPanelHTML(c.coachID)}</td>
</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() {
const body = document.getElementById('coachTableBody');
const countEl = document.getElementById('coachListCount');
if (!body) return;
const filtered = filteredCoaches();
if (!filtered.length) {
body.innerHTML = `
<tr>
<td colspan="7" style="text-align:center;color:var(--text-secondary);padding:24px">
No coaches match
</td>
</tr>`;
if (countEl) {
countEl.textContent = filterSearch.trim()
? `0 of ${coachesList.length} coaches`
: '';
}
return;
}
body.innerHTML = filtered.map(c => coachRowHTML(c)).join('');
if (countEl) {
const q = filterSearch.trim();
countEl.textContent = q
? `${filtered.length} of ${coachesList.length} coaches`
: `${coachesList.length} coach${coachesList.length === 1 ? '' : 'es'}`;
}
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) {
const coach = coachesList.find(c => c.coachID === coachID);
if (!coach || !coachHasUploads(coach)) {
return;
}
if (expandedCoachId === coachID) {
expandedCoachId = null;
renderCoachTableBody();
return;
}
expandedCoachId = coachID;
if (!coachDetailUi[coachID]) {
coachDetailUi[coachID] = {
clientLimit: CLIENT_GROUP_PAGE,
expandedClients: new Set(),
showAllUploads: new Set(),
filter: '',
};
}
renderCoachTableBody();
if (!recentByCoach[coachID]) {
try {
await fetchCoachRecent(coachID, 0, false);
} catch (e) {
showToast(e.message, 'error');
recentByCoach[coachID] = { submissions: [], total: 0 };
}
renderCoachTableBody();
}
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}