507 lines
19 KiB
JavaScript
507 lines
19 KiB
JavaScript
import { apiGet, apiPost, apiDelete } from '../api.js';
|
||
import { showToast } from '../app.js';
|
||
import { isDevTestClientCode, testDataRowClassForClient } from '../test-data.js';
|
||
|
||
const PAGE_SIZE = 50;
|
||
|
||
let clientsList = [];
|
||
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');
|
||
|
||
app.innerHTML = `
|
||
<div class="page-header">
|
||
<h1>Clients</h1>
|
||
<div class="actions">
|
||
<button class="btn btn-primary" id="showCreateFormBtn">+ Create Client</button>
|
||
</div>
|
||
</div>
|
||
<div id="createClientWrapper" style="display:none"></div>
|
||
<div id="clientsContent"><div class="spinner"></div></div>
|
||
`;
|
||
|
||
document.getElementById('showCreateFormBtn').addEventListener('click', () => {
|
||
const wrapper = document.getElementById('createClientWrapper');
|
||
if (wrapper.style.display === 'none') {
|
||
showCreateForm();
|
||
} else {
|
||
hideCreateForm();
|
||
}
|
||
});
|
||
|
||
await loadClients();
|
||
}
|
||
|
||
async function loadClients() {
|
||
try {
|
||
const [clientsData, assignData] = await Promise.all([
|
||
apiGet('clients.php'),
|
||
apiGet('assignments.php'),
|
||
]);
|
||
clientsList = clientsData.clients || [];
|
||
coachesList = assignData.coaches || [];
|
||
renderClients();
|
||
} catch (e) {
|
||
showToast(e.message, 'error');
|
||
document.getElementById('clientsContent').innerHTML =
|
||
`<p class="error-text" style="padding:16px">${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
function renderClients() {
|
||
const container = document.getElementById('clientsContent');
|
||
|
||
if (!clientsList.length) {
|
||
container.innerHTML = `
|
||
<div class="card">
|
||
<div class="empty-state">
|
||
<h3>No clients found</h3>
|
||
<p>Create the first client above.</p>
|
||
</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<div class="card">
|
||
<div class="filter-bar">
|
||
<input type="search" id="clientListSearch" class="filter-search" placeholder="Search client or coach…" value="${esc(filterSearch)}">
|
||
<select id="clientTestFilter">
|
||
<option value="">All clients</option>
|
||
<option value="test" ${filterTestData === 'test' ? 'selected' : ''}>Test data only</option>
|
||
<option value="hide-test" ${filterTestData === 'hide-test' ? 'selected' : ''}>Hide test data</option>
|
||
</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>
|
||
<tr>
|
||
<th>Client Code</th>
|
||
<th>Current Coach</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="clientsTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="pagination-bar" id="clientsPagination"></div>
|
||
</div>`;
|
||
|
||
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();
|
||
});
|
||
|
||
renderClientTableBody();
|
||
}
|
||
|
||
function getFilteredClientsList() {
|
||
let list = clientsList;
|
||
if (filterSearch) {
|
||
const q = filterSearch.toLowerCase();
|
||
list = list.filter(c => {
|
||
const hay = `${c.clientCode || ''} ${c.coachUsername || ''}`.toLowerCase();
|
||
return hay.includes(q);
|
||
});
|
||
}
|
||
if (filterTestData === 'test') {
|
||
list = list.filter(c => isDevTestClientCode(c.clientCode));
|
||
} else if (filterTestData === 'hide-test') {
|
||
list = list.filter(c => !isDevTestClientCode(c.clientCode));
|
||
}
|
||
return list;
|
||
}
|
||
|
||
function renderClientTableBody() {
|
||
const body = document.getElementById('clientsTableBody');
|
||
const countEl = document.getElementById('clientListCount');
|
||
const filtered = getFilteredClientsList();
|
||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||
if (page >= totalPages) page = totalPages - 1;
|
||
const slice = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||
|
||
if (countEl) {
|
||
countEl.textContent = `${filtered.length} client${filtered.length === 1 ? '' : 's'}`;
|
||
}
|
||
|
||
if (!slice.length) {
|
||
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));
|
||
});
|
||
}
|
||
|
||
const pag = document.getElementById('clientsPagination');
|
||
if (!pag) return;
|
||
if (filtered.length <= PAGE_SIZE) {
|
||
pag.innerHTML = '';
|
||
return;
|
||
}
|
||
const from = page * PAGE_SIZE + 1;
|
||
const to = Math.min((page + 1) * PAGE_SIZE, filtered.length);
|
||
pag.innerHTML = `
|
||
<button type="button" class="btn btn-sm" id="clientsPagePrev" ${page <= 0 ? 'disabled' : ''}>Prev</button>
|
||
<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--;
|
||
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>
|
||
${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>
|
||
${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) {
|
||
if (!confirm(`Delete client "${clientCode}"? This will also remove all their answers and results. This cannot be undone.`)) return;
|
||
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();
|
||
} catch (e) {
|
||
showToast(e.message, 'error');
|
||
}
|
||
}
|
||
|
||
function showCreateForm() {
|
||
if (!coachesList.length) {
|
||
showToast('No coaches available. Create a coach first.', 'error');
|
||
return;
|
||
}
|
||
|
||
const wrapper = document.getElementById('createClientWrapper');
|
||
wrapper.style.display = '';
|
||
wrapper.innerHTML = `
|
||
<div class="inline-form-card" style="margin-bottom:20px">
|
||
<h4>Create New Client</h4>
|
||
<div class="form-row">
|
||
<div class="form-group" style="flex:2">
|
||
<label>Client Code</label>
|
||
<input type="text" id="cc_code" autocomplete="off" placeholder="Enter unique client code">
|
||
</div>
|
||
<div class="form-group" style="flex:2">
|
||
<label>Assign to Coach</label>
|
||
<select id="cc_coach">
|
||
<option value="">— select coach —</option>
|
||
${coachesList.map(c =>
|
||
`<option value="${c.coachID}">${esc(c.username)}${c.supervisorUsername ? ` (${esc(c.supervisorUsername)})` : ''}</option>`
|
||
).join('')}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<button class="btn btn-primary btn-sm" id="cc_submit">Create Client</button>
|
||
<button class="btn btn-sm" id="cc_cancel">Cancel</button>
|
||
</div>
|
||
<p id="cc_error" class="error-text" style="display:none;margin-top:8px"></p>
|
||
</div>
|
||
`;
|
||
|
||
const input = document.getElementById('cc_code');
|
||
input.focus();
|
||
wrapper.querySelectorAll('input').forEach(inp => {
|
||
inp.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') submitCreateClient();
|
||
if (e.key === 'Escape') hideCreateForm();
|
||
});
|
||
});
|
||
|
||
document.getElementById('cc_submit').addEventListener('click', submitCreateClient);
|
||
document.getElementById('cc_cancel').addEventListener('click', hideCreateForm);
|
||
}
|
||
|
||
function hideCreateForm() {
|
||
const wrapper = document.getElementById('createClientWrapper');
|
||
if (wrapper) { wrapper.style.display = 'none'; wrapper.innerHTML = ''; }
|
||
}
|
||
|
||
async function submitCreateClient() {
|
||
const errEl = document.getElementById('cc_error');
|
||
errEl.style.display = 'none';
|
||
|
||
const clientCode = document.getElementById('cc_code').value.trim();
|
||
const coachID = document.getElementById('cc_coach').value;
|
||
|
||
if (!clientCode) { showError(errEl, 'Client code is required'); return; }
|
||
if (!coachID) { showError(errEl, 'Please select a coach'); return; }
|
||
|
||
const btn = document.getElementById('cc_submit');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Creating...';
|
||
|
||
try {
|
||
const data = await apiPost('clients.php', { clientCode, coachID });
|
||
const coach = coachesList.find(c => c.coachID === coachID);
|
||
clientsList.push({
|
||
clientCode: data.clientCode,
|
||
coachID: coachID,
|
||
coachUsername: coach?.username ?? null,
|
||
});
|
||
showToast(`Client "${data.clientCode}" created`, 'success');
|
||
hideCreateForm();
|
||
renderClients();
|
||
} catch (e) {
|
||
showError(errEl, e.message);
|
||
btn.disabled = false;
|
||
btn.textContent = 'Create Client';
|
||
}
|
||
}
|
||
|
||
function showError(el, msg) {
|
||
el.textContent = msg;
|
||
el.style.display = '';
|
||
}
|
||
|
||
function esc(s) {
|
||
const d = document.createElement('div');
|
||
d.textContent = s ?? '';
|
||
return d.innerHTML;
|
||
}
|