good client and coach data viewing
This commit is contained in:
@ -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;
|
||||
|
||||
@ -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<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() {
|
||||
@ -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 '<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');
|
||||
|
||||
@ -51,7 +201,9 @@ function renderCoaches() {
|
||||
|
||||
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 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">
|
||||
<input type="search" id="coachListSearch" class="filter-search"
|
||||
placeholder="Search coach or supervisor…" value="${esc(filterSearch)}">
|
||||
@ -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
|
||||
? `<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>
|
||||
${expanded ? `
|
||||
<tr class="coach-detail-row">
|
||||
<td colspan="7">
|
||||
<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>
|
||||
<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');
|
||||
@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user