import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; import { pageHeaderHTML, showToast } from '../app.js'; import { isClientArchived, confirmAndPatchClientArchive, clientTableActionsHTML, } from '../client-archive.js'; const PAGE_SIZE = 50; const ARCHIVE_FILTER_KEY = 'clientsArchiveFilter'; let clientsList = []; let coachesList = []; let scoringProfileCatalog = []; let filterSearch = ''; let archiveFilter = localStorage.getItem(ARCHIVE_FILTER_KEY) || 'active'; 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 archiveQuery = archiveFilter !== 'active' ? `?archiveFilter=${encodeURIComponent(archiveFilter)}` : ''; const [clientsData, assignData] = await Promise.all([ apiGet(`clients.php${archiveQuery}`), 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) { const emptyMsg = archiveFilter === 'archived' ? '

No archived clients

Archived clients are hidden from assignments and follow-up.

' : archiveFilter === 'all' ? '

No clients found

Create the first client above.

' : '

No active clients

Create a client above or switch the filter to view archived clients.

'; container.innerHTML = `
${archiveFilterSelectHTML()}
${emptyMsg}
`; bindArchiveFilterControl(); document.getElementById('clientListSearch')?.addEventListener('input', (e) => { filterSearch = e.target.value.trim(); }); return; } const showProfileColumn = scoringProfileCatalog.length > 0; const profileLegend = showProfileColumn ? scoringLegendHTML() : ''; container.innerHTML = `
${archiveFilterSelectHTML()}
${profileLegend}

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

${showProfileColumn ? '' : ''}
Client Code Current CounselorProfile scoresActions
`; bindArchiveFilterControl(); document.getElementById('clientListSearch').addEventListener('input', (e) => { filterSearch = e.target.value.trim(); page = 0; clearExpandIfHidden(); renderClientTableBody(); }); ensureCoachBandGlobalClick(); renderClientTableBody(); } function archiveFilterSelectHTML() { return ` `; } function bindArchiveFilterControl() { const sel = document.getElementById('clientArchiveFilter'); if (!sel) return; sel.addEventListener('change', async (e) => { archiveFilter = e.target.value; localStorage.setItem(ARCHIVE_FILTER_KEY, archiveFilter); page = 0; expandedClientCode = null; expandedQnKey = null; expandedVersionKey = null; detailByClient = {}; document.getElementById('clientsContent').innerHTML = '
'; await loadClients(); }); } 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)); }); body.querySelectorAll('.archive-client-btn').forEach(btn => { btn.addEventListener('click', () => setClientArchived(btn.dataset.code, true)); }); body.querySelectorAll('.restore-client-btn').forEach(btn => { btn.addEventListener('click', () => setClientArchived(btn.dataset.code, false)); }); 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 `

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

No questionnaire data for this client yet.

`; } return `

Counselor: ${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 counselor review).
Counselor
Category confirmed or changed by the counselor 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)}
Calculated ${bandBadgeHTML(null, { incomplete: true })}

Not all questionnaires in this profile are completed yet.

`; } 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))}
Counselor ${coachControl} ${coachDiffers ? 'override' : ''}
`; } function renderClientScoringProfiles(profiles, clientCode = '') { if (!profiles.length) return ''; return `
Scoring profiles

Weighted totals and calculated vs counselor 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 ? `Counselor reviewed ${esc(p.coachReviewedAt)}` : ''; const meta = [computedAt, reviewedAt].filter(Boolean).join(' · '); return `
${profileScoreBlockHTML(p, clientCode)} ${meta ? `

${meta}

` : ''} ${coachPending ? '

Counselor 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.structureRevision ? `struct rev ${s.structureRevision}` : ''} ${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 'No scoring profiles'; } 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(`Counselor 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 clientArchivedLabel(c) { if (!isClientArchived(c) || !c.archivedAt) return ''; const d = new Date(Number(c.archivedAt) * 1000); const when = Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(); return when ? ` Archived` : ''; } function clientRowHTML(c) { const coach = c.coachUsername ? `${esc(c.coachUsername)}` : `Unassigned`; const archived = isClientArchived(c); 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; const actions = clientTableActionsHTML({ clientCode: esc(c.clientCode), archived, showDelete: true, }); return ` ${expandControl}${esc(c.clientCode)}${clientArchivedLabel(c)} ${coach} ${profileCol} ${actions} ${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 setClientArchived(clientCode, archived) { const ok = await confirmAndPatchClientArchive(clientCode, archived); if (!ok) return; if (archived && archiveFilter === 'active') { clientsList = clientsList.filter(c => c.clientCode !== clientCode); } else if (!archived && archiveFilter === 'archived') { clientsList = clientsList.filter(c => c.clientCode !== clientCode); } else { const row = clientsList.find(c => c.clientCode === clientCode); if (row) { row.archived = archived ? 1 : 0; row.archivedAt = archived ? Math.floor(Date.now() / 1000) : 0; } } if (expandedClientCode === clientCode && archived) { expandedClientCode = null; expandedQnKey = null; expandedVersionKey = null; } if (!clientsList.length) renderClients(); else 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 counselors available. Create a counselor 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 counselor'); 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; }