import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; import { pageHeaderHTML, showToast } from '../app.js'; const PAGE_SIZE = 50; let clientsList = []; let coachesList = []; let scoringProfileCatalog = []; let filterSearch = ''; 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 = ` ${pageHeaderHTML('Clients', '')}
`; 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 || []; scoringProfileCatalog = clientsData.scoringProfiles || []; coachesList = assignData.coaches || []; renderClients(); } catch (e) { showToast(e.message, 'error'); document.getElementById('clientsContent').innerHTML = `

${esc(e.message)}

`; } } function renderClients() { const container = document.getElementById('clientsContent'); if (!clientsList.length) { container.innerHTML = `

No clients found

Create the first client above.

`; return; } const showProfileColumn = scoringProfileCatalog.length > 0; const profileLegend = showProfileColumn ? scoringLegendHTML() : ''; container.innerHTML = `
${profileLegend}

Expand a row to view questionnaire responses and upload version history.

${showProfileColumn ? '' : ''}
Client Code Current CoachProfile scoresActions
`; document.getElementById('clientListSearch').addEventListener('input', (e) => { filterSearch = e.target.value.trim(); page = 0; clearExpandIfHidden(); renderClientTableBody(); }); ensureCoachBandGlobalClick(); 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); }); } 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)); const colCount = scoringProfileCatalog.length > 0 ? 4 : 3; 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 = `No clients match`; } 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)); }); bindCoachBandPickerEvents(body); } 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 = ` Rows ${from}–${to} of ${filtered.length} `; 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 '

No answers recorded

'; } return ` ${rows.map(r => ` `).join('')}
QuestionAnswer
${esc(r.label)} ${esc(r.value || '—')}
`; } function clientDetailPanelHTML(clientCode) { const detail = detailByClient[clientCode]; if (!detail) { return '

Loading…

'; } const cl = detail.client || {}; const questionnaires = detail.questionnaires || []; if (!questionnaires.length) { return `

Coach: ${esc(cl.coachUsername || '—')} ${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''}

No questionnaire data for this client yet.

`; } return `

Coach: ${esc(cl.coachUsername || '—')} ${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''}

${renderClientScoringProfiles(detail.scoringProfiles || [], clientCode)} ${questionnaires.map(q => clientQnBlockHTML(clientCode, q)).join('')}`; } function scoringLegendHTML() { return `

How profile scores work

Each profile adds up questionnaire points (× weight) into a weighted total, then maps that total to green yellow red using the thresholds on the Questionnaires page.

Calculated
Automatic category from answers and profile rules (shown before coach review).
Coach
Category confirmed or changed by the coach in the app (“Review scores”).
Incomplete
Not all questionnaires in that profile are completed yet.
`; } function bandBadgeHTML(band, { pending = false, incomplete = false, label = '' } = {}) { const text = label || band || ''; if (incomplete) { return 'incomplete'; } if (pending) { return 'awaiting review'; } if (!band) { return ''; } return `${esc(text || band)}`; } const COACH_BANDS = ['green', 'yellow', 'red']; function bandLabel(band) { if (!band) return '—'; return band.charAt(0).toUpperCase() + band.slice(1); } function coachBandPickerHTML(p, clientCode) { const calcBand = p.calculatedBand || p.band; if (!calcBand || !clientCode || !p.profileID) { return ''; } const current = p.coachBand || ''; const coachPending = p.pendingReview !== false && !current; const triggerBadge = coachPending ? bandBadgeHTML(null, { pending: true }) : bandBadgeHTML(current); const showAgree = coachPending || (current && current !== calcBand); const options = COACH_BANDS.map(b => ` `).join(''); const agreeBtn = showAgree ? ` ` : ''; return `
`; } function profileScoreBlockHTML(p, clientCode = '') { const calcBand = p.calculatedBand || p.band; if (!calcBand) { return `
${esc(p.name)}

Profile incomplete — not all member questionnaires are done.

`; } const coachPending = p.pendingReview !== false && !p.coachBand; const coachDiffers = p.coachBand && p.coachBand !== calcBand; const coachControl = clientCode ? coachBandPickerHTML(p, clientCode) : (coachPending ? bandBadgeHTML(null, { pending: true }) : bandBadgeHTML(p.coachBand)); return `
${esc(p.name)}
Calculated ${bandBadgeHTML(calcBand)} ${esc(String(p.weightedTotal))}
Coach ${coachControl} ${coachDiffers ? 'override' : ''}
`; } function renderClientScoringProfiles(profiles, clientCode = '') { if (!profiles.length) return ''; return `
Scoring profiles

Weighted totals and calculated vs coach categories for this client.

${profiles.map(p => { const calc = p.calculatedBand || p.band; if (!calc) return profileScoreBlockHTML(p, clientCode); const coachPending = p.pendingReview !== false && !p.coachBand; const computedAt = p.computedAt ? `Last computed ${esc(p.computedAt)}` : ''; const reviewedAt = p.coachReviewedAt ? `Coach reviewed ${esc(p.coachReviewedAt)}` : ''; const meta = [computedAt, reviewedAt].filter(Boolean).join(' · '); return `
${profileScoreBlockHTML(p, clientCode)} ${meta ? `

${meta}

` : ''} ${coachPending ? '

Coach has not reviewed this profile in the app yet.

' : ''}
`; }).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 `
Version ${s.version} ${s.changedCount > 0 ? `${s.changedCount} diff vs live` : 'Same as live'} ${vMeta ? `${esc(vMeta)}` : ''}
${vExpanded ? `
${s.changedCount > 0 ? '

Highlighted rows differ from current live data.

' : ''} ${answerTableHTML(s.answers, { highlightLiveDiff: true })}
` : ''}
`; }).join(''); return `
${esc(q.name)} ${q.questionnaireVersion ? `v${esc(q.questionnaireVersion)}` : ''} ${meta ? `${esc(meta)}` : ''}
${qnExpanded ? `

Current data (live)

${answerTableHTML(q.currentAnswers)} ${(q.submissions || []).length ? `

Upload versions

${versionsHTML} ` : '

No archived upload versions.

'}
` : ''}
`; } function profileDotsHTML(profiles, clientCode) { if (!profiles?.length) { return ''; } const hasAny = profiles.some(p => p.calculatedBand || p.band); if (!hasAny) { return ``; } return `
${profiles.map(p => profileScoreBlockHTML(p, clientCode)).join('')}
`; } function patchClientScoringBand(clientCode, profileID, coachBand) { const client = clientsList.find(c => c.clientCode === clientCode); if (client?.scoringProfiles) { const profile = client.scoringProfiles.find(p => p.profileID === profileID); if (profile) { profile.coachBand = coachBand; profile.pendingReview = false; } } const detail = detailByClient[clientCode]; if (detail?.scoringProfiles) { const profile = detail.scoringProfiles.find(p => p.profileID === profileID); if (profile) { profile.coachBand = coachBand; profile.pendingReview = false; } } } function resetCoachBandMenuPosition(menu) { if (!menu) return; menu.style.position = ''; menu.style.left = ''; menu.style.top = ''; menu.style.minWidth = ''; menu.style.zIndex = ''; menu.style.visibility = ''; } function positionCoachBandMenu(picker) { const trigger = picker.querySelector('.coach-band-trigger'); const menu = picker.querySelector('.coach-band-menu'); if (!trigger || !menu) return; menu.hidden = false; menu.style.visibility = 'hidden'; const rect = trigger.getBoundingClientRect(); const menuWidth = Math.max(rect.width, 184); const left = Math.min(rect.left, window.innerWidth - menuWidth - 8); menu.style.position = 'fixed'; menu.style.left = `${Math.max(8, left)}px`; menu.style.minWidth = `${menuWidth}px`; menu.style.zIndex = '10001'; const menuHeight = menu.offsetHeight; let top = rect.bottom + 6; if (top + menuHeight > window.innerHeight - 8) { top = rect.top - menuHeight - 6; } menu.style.top = `${Math.max(8, top)}px`; menu.style.visibility = ''; } function closeAllCoachBandMenus() { document.querySelectorAll('.coach-band-picker.is-open').forEach(picker => { picker.classList.remove('is-open'); const menu = picker.querySelector('.coach-band-menu'); const trigger = picker.querySelector('.coach-band-trigger'); resetCoachBandMenuPosition(menu); if (menu) menu.hidden = true; if (trigger) trigger.setAttribute('aria-expanded', 'false'); }); } function toggleCoachBandMenu(picker) { const isOpen = picker.classList.contains('is-open'); closeAllCoachBandMenus(); if (isOpen) return; picker.classList.add('is-open'); const trigger = picker.querySelector('.coach-band-trigger'); positionCoachBandMenu(picker); if (trigger) trigger.setAttribute('aria-expanded', 'true'); } function openCoachBandConfirmModal({ clientCode, profileName, calculatedBand, coachBand }) { return new Promise(resolve => { const overlay = document.createElement('div'); overlay.className = 'modal-overlay coach-band-confirm-modal'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.innerHTML = ` `; document.body.appendChild(overlay); document.body.classList.add('modal-open'); const close = (confirmed) => { overlay.remove(); document.body.classList.remove('modal-open'); resolve(confirmed); }; overlay.querySelector('[data-coach-cancel]').addEventListener('click', () => close(false)); overlay.querySelector('[data-coach-confirm]').addEventListener('click', () => close(true)); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); }); overlay.querySelector('.modal-dialog').addEventListener('click', (e) => e.stopPropagation()); const onKey = (e) => { if (e.key === 'Escape') close(false); }; document.addEventListener('keydown', onKey, { once: true }); overlay.querySelector('[data-coach-confirm]').focus(); }); } async function promptCoachBandSave(picker, coachBand) { const clientCode = picker.dataset.client; const profileID = picker.dataset.profile; const profileName = picker.dataset.profileName || profileID; const calculatedBand = picker.dataset.calculated; const current = picker.dataset.coach || ''; if (!coachBand || coachBand === current) return; const confirmed = await openCoachBandConfirmModal({ clientCode, profileName, calculatedBand, coachBand, }); if (!confirmed) return; const weightedRaw = picker.dataset.weighted; const weightedTotal = weightedRaw !== '' ? Number(weightedRaw) : null; picker.classList.add('is-saving'); try { await apiPut('clients.php', { clientCode, profileID, coachBand, calculatedBand, weightedTotal, }); patchClientScoringBand(clientCode, profileID, coachBand); showToast(`Coach category saved for ${profileName}`, 'success'); renderClientTableBody(); } catch (e) { showToast(e.message, 'error'); } finally { picker.classList.remove('is-saving'); } } function bindCoachBandPickerEvents(root = document) { root.querySelectorAll('.coach-band-trigger').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); toggleCoachBandMenu(btn.closest('.coach-band-picker')); }); }); root.querySelectorAll('.coach-band-option').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const picker = btn.closest('.coach-band-picker'); closeAllCoachBandMenus(); promptCoachBandSave(picker, btn.dataset.band); }); }); } let coachBandGlobalClickBound = false; function ensureCoachBandGlobalClick() { if (coachBandGlobalClickBound) return; coachBandGlobalClickBound = true; document.addEventListener('click', () => closeAllCoachBandMenus()); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeAllCoachBandMenus(); }); } function clientHasResponseData(c) { return Number(c.hasResponseData) === 1; } function clientRowHTML(c) { const coach = c.coachUsername ? `${esc(c.coachUsername)}` : `Unassigned`; const canExpand = clientHasResponseData(c); const expanded = canExpand && expandedClientCode === c.clientCode; const expandControl = canExpand ? ` ` : ''; const profileCol = scoringProfileCatalog.length > 0 ? `${profileDotsHTML(c.scoringProfiles || [], c.clientCode)}` : ''; const colCount = scoringProfileCatalog.length > 0 ? 4 : 3; return ` ${expandControl}${esc(c.clientCode)} ${coach} ${profileCol} ${expanded ? `
${clientDetailPanelHTML(c.clientCode)}
` : ''}`; } 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 = `

Create New Client

`; 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; }