made client data browsable with dropdown and version diffs
This commit is contained in:
@ -9,6 +9,10 @@ let coachesList = [];
|
||||
let filterSearch = '';
|
||||
let filterTestData = '';
|
||||
let page = 0;
|
||||
let expandedClientCode = null;
|
||||
let expandedQnKey = null;
|
||||
let expandedVersionKey = null;
|
||||
let detailByClient = {};
|
||||
|
||||
export async function clientsPage() {
|
||||
const app = document.getElementById('app');
|
||||
@ -77,6 +81,9 @@ function renderClients() {
|
||||
</select>
|
||||
<span class="data-toolbar-hint" id="clientListCount"></span>
|
||||
</div>
|
||||
<p class="data-toolbar-hint" style="margin:0 0 12px">
|
||||
Expand a row to view questionnaire responses and upload version history.
|
||||
</p>
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
@ -95,11 +102,13 @@ function renderClients() {
|
||||
document.getElementById('clientListSearch').addEventListener('input', (e) => {
|
||||
filterSearch = e.target.value.trim();
|
||||
page = 0;
|
||||
clearExpandIfHidden();
|
||||
renderClientTableBody();
|
||||
});
|
||||
document.getElementById('clientTestFilter').addEventListener('change', (e) => {
|
||||
filterTestData = e.target.value;
|
||||
page = 0;
|
||||
clearExpandIfHidden();
|
||||
renderClientTableBody();
|
||||
});
|
||||
|
||||
@ -139,6 +148,19 @@ function renderClientTableBody() {
|
||||
body.innerHTML = `<tr><td colspan="3" style="text-align:center;color:var(--text-secondary);padding:24px">No clients match</td></tr>`;
|
||||
} else {
|
||||
body.innerHTML = slice.map(c => clientRowHTML(c)).join('');
|
||||
body.querySelectorAll('.client-expand-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => toggleClient(btn.dataset.code));
|
||||
});
|
||||
body.querySelectorAll('.client-qn-expand-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => toggleQn(btn.dataset.client, btn.dataset.qn));
|
||||
});
|
||||
body.querySelectorAll('.client-version-expand-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => toggleVersion(
|
||||
btn.dataset.client,
|
||||
btn.dataset.qn,
|
||||
btn.dataset.submission
|
||||
));
|
||||
});
|
||||
body.querySelectorAll('.delete-client-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => deleteClient(btn.dataset.code));
|
||||
});
|
||||
@ -157,23 +179,216 @@ function renderClientTableBody() {
|
||||
<span>Rows ${from}–${to} of ${filtered.length}</span>
|
||||
<button type="button" class="btn btn-sm" id="clientsPageNext" ${page >= totalPages - 1 ? 'disabled' : ''}>Next</button>
|
||||
`;
|
||||
document.getElementById('clientsPagePrev')?.addEventListener('click', () => { page--; renderClientTableBody(); });
|
||||
document.getElementById('clientsPageNext')?.addEventListener('click', () => { page++; renderClientTableBody(); });
|
||||
document.getElementById('clientsPagePrev')?.addEventListener('click', () => {
|
||||
page--;
|
||||
clearExpandIfHidden();
|
||||
renderClientTableBody();
|
||||
});
|
||||
document.getElementById('clientsPageNext')?.addEventListener('click', () => {
|
||||
page++;
|
||||
clearExpandIfHidden();
|
||||
renderClientTableBody();
|
||||
});
|
||||
}
|
||||
|
||||
function qnKey(clientCode, questionnaireID) {
|
||||
return `${clientCode}|${questionnaireID}`;
|
||||
}
|
||||
|
||||
function versionKey(clientCode, questionnaireID, submissionID) {
|
||||
return `${clientCode}|${questionnaireID}|${submissionID}`;
|
||||
}
|
||||
|
||||
function clearExpandIfHidden() {
|
||||
const filtered = getFilteredClientsList();
|
||||
const slice = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
if (expandedClientCode && !slice.some(c => c.clientCode === expandedClientCode)) {
|
||||
expandedClientCode = null;
|
||||
expandedQnKey = null;
|
||||
expandedVersionKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
function answerTableHTML(rows, { highlightLiveDiff = false } = {}) {
|
||||
if (!rows?.length) {
|
||||
return '<p class="data-toolbar-hint">No answers recorded</p>';
|
||||
}
|
||||
return `
|
||||
<table class="data-table data-table-compact">
|
||||
<thead><tr><th>Question</th><th>Answer</th></tr></thead>
|
||||
<tbody>
|
||||
${rows.map(r => `
|
||||
<tr class="${highlightLiveDiff && r.changedFromLive ? 'answer-changed' : ''}">
|
||||
<td>${esc(r.label)}</td>
|
||||
<td class="answer-value-cell">${esc(r.value || '—')}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function clientDetailPanelHTML(clientCode) {
|
||||
const detail = detailByClient[clientCode];
|
||||
if (!detail) {
|
||||
return '<p class="data-toolbar-hint">Loading…</p>';
|
||||
}
|
||||
const cl = detail.client || {};
|
||||
const questionnaires = detail.questionnaires || [];
|
||||
if (!questionnaires.length) {
|
||||
return `
|
||||
<p class="client-detail-meta">
|
||||
Coach: ${esc(cl.coachUsername || '—')}
|
||||
${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''}
|
||||
</p>
|
||||
<p class="data-toolbar-hint">No questionnaire data for this client yet.</p>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<p class="client-detail-meta">
|
||||
Coach: ${esc(cl.coachUsername || '—')}
|
||||
${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''}
|
||||
</p>
|
||||
${questionnaires.map(q => clientQnBlockHTML(clientCode, q)).join('')}`;
|
||||
}
|
||||
|
||||
function clientQnBlockHTML(clientCode, q) {
|
||||
const key = qnKey(clientCode, q.questionnaireID);
|
||||
const qnExpanded = expandedQnKey === key;
|
||||
const meta = [
|
||||
q.status ? `Status: ${q.status}` : '',
|
||||
q.sumPoints != null ? `Points: ${q.sumPoints}` : '',
|
||||
q.completedAt ? `Completed: ${q.completedAt}` : '',
|
||||
q.submissionCount ? `${q.submissionCount} upload(s)` : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
const versionsHTML = (q.submissions || []).map(s => {
|
||||
const vKey = versionKey(clientCode, q.questionnaireID, s.submissionID);
|
||||
const vExpanded = expandedVersionKey === vKey;
|
||||
const vMeta = [
|
||||
s.submittedAt ? `Uploaded: ${s.submittedAt}` : '',
|
||||
s.status ? `Status: ${s.status}` : '',
|
||||
s.sumPoints != null ? `Points: ${s.sumPoints}` : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
return `
|
||||
<div class="client-qn-block">
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-link client-version-expand-btn"
|
||||
data-client="${esc(clientCode)}"
|
||||
data-qn="${esc(q.questionnaireID)}"
|
||||
data-submission="${esc(s.submissionID)}">${vExpanded ? '▼' : '▶'}</button>
|
||||
<strong>Version ${s.version}</strong>
|
||||
${s.changedCount > 0
|
||||
? `<span class="badge badge-warn">${s.changedCount} diff vs live</span>`
|
||||
: '<span class="client-qn-summary">Same as live</span>'}
|
||||
${vMeta ? `<span class="client-qn-summary">${esc(vMeta)}</span>` : ''}
|
||||
</div>
|
||||
${vExpanded ? `
|
||||
<div class="client-version-panel">
|
||||
${s.changedCount > 0
|
||||
? '<p class="data-toolbar-hint">Highlighted rows differ from current live data.</p>'
|
||||
: ''}
|
||||
${answerTableHTML(s.answers, { highlightLiveDiff: true })}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="client-qn-block" style="margin-bottom:10px">
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-link client-qn-expand-btn"
|
||||
data-client="${esc(clientCode)}"
|
||||
data-qn="${esc(q.questionnaireID)}">${qnExpanded ? '▼' : '▶'}</button>
|
||||
<strong>${esc(q.name)}</strong>
|
||||
${q.questionnaireVersion ? `<span class="badge badge-q">v${esc(q.questionnaireVersion)}</span>` : ''}
|
||||
${meta ? `<span class="client-qn-summary">${esc(meta)}</span>` : ''}
|
||||
</div>
|
||||
${qnExpanded ? `
|
||||
<div class="client-qn-panel">
|
||||
<h4 style="margin:0 0 8px;font-size:0.9rem">Current data (live)</h4>
|
||||
${answerTableHTML(q.currentAnswers)}
|
||||
${(q.submissions || []).length ? `
|
||||
<h4 style="margin:16px 0 8px;font-size:0.9rem">Upload versions</h4>
|
||||
${versionsHTML}
|
||||
` : '<p class="data-toolbar-hint" style="margin-top:12px">No archived upload versions.</p>'}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function clientHasResponseData(c) {
|
||||
return Number(c.hasResponseData) === 1;
|
||||
}
|
||||
|
||||
function clientRowHTML(c) {
|
||||
const coach = c.coachUsername
|
||||
? `<strong>${esc(c.coachUsername)}</strong>`
|
||||
: `<span style="color:var(--text-secondary);font-style:italic">Unassigned</span>`;
|
||||
const canExpand = clientHasResponseData(c);
|
||||
const expanded = canExpand && expandedClientCode === c.clientCode;
|
||||
const expandControl = canExpand
|
||||
? `<button type="button" class="btn btn-sm btn-link client-expand-btn" data-code="${esc(c.clientCode)}">${expanded ? '▼' : '▶'}</button> `
|
||||
: '';
|
||||
|
||||
return `
|
||||
<tr class="${testDataRowClassForClient(c.clientCode).trim()}">
|
||||
<td><strong>${esc(c.clientCode)}</strong></td>
|
||||
<td>
|
||||
${expandControl}<strong>${esc(c.clientCode)}</strong>
|
||||
</td>
|
||||
<td>${coach}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger delete-client-btn" data-code="${esc(c.clientCode)}">Delete</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
</tr>
|
||||
${expanded ? `
|
||||
<tr class="client-detail-row">
|
||||
<td colspan="3">
|
||||
<div class="client-detail-panel">${clientDetailPanelHTML(c.clientCode)}</div>
|
||||
</td>
|
||||
</tr>` : ''}`;
|
||||
}
|
||||
|
||||
async function toggleClient(clientCode) {
|
||||
const row = clientsList.find(c => c.clientCode === clientCode);
|
||||
if (!row || !clientHasResponseData(row)) {
|
||||
return;
|
||||
}
|
||||
if (expandedClientCode === clientCode) {
|
||||
expandedClientCode = null;
|
||||
expandedQnKey = null;
|
||||
expandedVersionKey = null;
|
||||
renderClientTableBody();
|
||||
return;
|
||||
}
|
||||
expandedClientCode = clientCode;
|
||||
expandedQnKey = null;
|
||||
expandedVersionKey = null;
|
||||
renderClientTableBody();
|
||||
if (!detailByClient[clientCode]) {
|
||||
try {
|
||||
const data = await apiGet(
|
||||
`clients.php?clientCode=${encodeURIComponent(clientCode)}&detail=1`
|
||||
);
|
||||
detailByClient[clientCode] = data;
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
detailByClient[clientCode] = { client: {}, questionnaires: [] };
|
||||
}
|
||||
renderClientTableBody();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleQn(clientCode, questionnaireID) {
|
||||
const key = qnKey(clientCode, questionnaireID);
|
||||
expandedQnKey = expandedQnKey === key ? null : key;
|
||||
expandedVersionKey = null;
|
||||
renderClientTableBody();
|
||||
}
|
||||
|
||||
function toggleVersion(clientCode, questionnaireID, submissionID) {
|
||||
const key = versionKey(clientCode, questionnaireID, submissionID);
|
||||
expandedVersionKey = expandedVersionKey === key ? null : key;
|
||||
renderClientTableBody();
|
||||
}
|
||||
|
||||
async function deleteClient(clientCode) {
|
||||
@ -181,6 +396,12 @@ async function deleteClient(clientCode) {
|
||||
try {
|
||||
await apiDelete('clients.php', { clientCode });
|
||||
clientsList = clientsList.filter(c => c.clientCode !== clientCode);
|
||||
delete detailByClient[clientCode];
|
||||
if (expandedClientCode === clientCode) {
|
||||
expandedClientCode = null;
|
||||
expandedQnKey = null;
|
||||
expandedVersionKey = null;
|
||||
}
|
||||
showToast(`Client "${clientCode}" deleted`, 'success');
|
||||
if (!clientsList.length) renderClients();
|
||||
else renderClientTableBody();
|
||||
|
||||
@ -36,7 +36,7 @@ export function devPage() {
|
||||
<div class="form-group">
|
||||
<label for="devFixtureFile">Fixture JSON</label>
|
||||
<input type="file" id="devFixtureFile" accept=".json,application/json">
|
||||
<span class="field-hint">Use <code>dev-test-fixture.json</code> from the repo root (generate via <code>nat-as-server/scripts/generate_dev_fixture.py</code>; reads <code>questionnaires_bundle_2026-05-28.json</code>, 5× scale).</span>
|
||||
<span class="field-hint">Use <code>dev-test-fixture.json</code> from the repo root (generate via <code>nat-as-server/scripts/generate_dev_fixture.py</code>; uneven coach load, multi-version uploads, natural timestamps).</span>
|
||||
</div>
|
||||
<div id="devFixtureSummary" class="field-hint" style="margin:12px 0;display:none"></div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
|
||||
@ -77,7 +77,8 @@ export function devPage() {
|
||||
${parsedFixture.supervisors?.length ?? st.supervisors ?? 0} supervisors,
|
||||
${parsedFixture.coaches?.length ?? st.coaches ?? 0} coaches,
|
||||
${parsedFixture.clients?.length ?? st.clients ?? 0} clients,
|
||||
${parsedFixture.completions?.length ?? st.completions ?? 0} completions
|
||||
${parsedFixture.completions?.length ?? st.completionRecords ?? st.completions ?? 0} completions,
|
||||
${st.totalUploads ?? '—'} uploads (${st.multiVersionPairs ?? '—'} re-uploaded)
|
||||
`;
|
||||
summary.style.display = '';
|
||||
btn.disabled = false;
|
||||
@ -184,8 +185,9 @@ export function devPage() {
|
||||
const imp = res.imported || {};
|
||||
const sk = res.skipped || {};
|
||||
showToast(
|
||||
`Imported: ${imp.completions ?? 0} completions, ${imp.clients ?? 0} clients, `
|
||||
+ `${imp.coaches ?? 0} coaches (skipped ${sk.completions ?? 0} existing)`,
|
||||
`Imported: ${imp.completions ?? 0} completions, ${imp.submissions ?? 0} uploads, `
|
||||
+ `${imp.clients ?? 0} clients, ${imp.coaches ?? 0} coaches `
|
||||
+ `(skipped ${sk.completions ?? 0} existing)`,
|
||||
'success'
|
||||
);
|
||||
if (res.errors?.length) {
|
||||
|
||||
Reference in New Issue
Block a user