427 lines
15 KiB
JavaScript
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">← 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;
|
|
}
|