initial prototype based on nat-as-server
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
tom.hempel
2026-06-29 12:39:55 +02:00
commit f1caa9e681
148 changed files with 34905 additions and 0 deletions

938
website/js/pages/clients.js Normal file
View File

@ -0,0 +1,938 @@
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { pageHeaderHTML, showToast } from '../app.js';
import { confirmAction } from '../confirm-modal.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', '<button class="btn btn-primary" id="showCreateFormBtn">+ Create Client</button>')}
<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 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 =
`<p class="error-text" style="padding:16px">${esc(e.message)}</p>`;
}
}
function renderClients() {
const container = document.getElementById('clientsContent');
if (!clientsList.length) {
const emptyMsg = archiveFilter === 'archived'
? '<h3>No archived clients</h3><p>Archived clients are hidden from assignments and follow-up.</p>'
: archiveFilter === 'all'
? '<h3>No clients found</h3><p>Create the first client above.</p>'
: '<h3>No active clients</h3><p>Create a client above or switch the filter to view archived clients.</p>';
container.innerHTML = `
<div class="card">
<div class="filter-bar clients-list-filters">
${archiveFilterSelectHTML()}
<input type="search" id="clientListSearch" class="filter-search" placeholder="Search client or counselor…" value="${esc(filterSearch)}">
</div>
<div class="empty-state">${emptyMsg}</div>
</div>`;
bindArchiveFilterControl();
document.getElementById('clientListSearch')?.addEventListener('input', (e) => {
filterSearch = e.target.value.trim();
});
return;
}
const showProfileColumn = scoringProfileCatalog.length > 0;
const profileLegend = showProfileColumn ? scoringLegendHTML() : '';
container.innerHTML = `
<div class="card">
<div class="filter-bar clients-list-filters">
${archiveFilterSelectHTML()}
<input type="search" id="clientListSearch" class="filter-search" placeholder="Search client or counselor…" value="${esc(filterSearch)}">
<span class="data-toolbar-hint" id="clientListCount"></span>
</div>
${profileLegend}
<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 counselor</th>
${showProfileColumn ? '<th>Profile scores</th>' : ''}
<th>Actions</th>
</tr>
</thead>
<tbody id="clientsTableBody"></tbody>
</table>
</div>
<div class="pagination-bar" id="clientsPagination"></div>
</div>`;
bindArchiveFilterControl();
document.getElementById('clientListSearch').addEventListener('input', (e) => {
filterSearch = e.target.value.trim();
page = 0;
clearExpandIfHidden();
renderClientTableBody();
});
ensureCoachBandGlobalClick();
renderClientTableBody();
}
function archiveFilterSelectHTML() {
return `
<select id="clientArchiveFilter" class="clients-archive-filter" aria-label="Client status filter">
<option value="active" ${archiveFilter === 'active' ? 'selected' : ''}>Active</option>
<option value="archived" ${archiveFilter === 'archived' ? 'selected' : ''}>Archived</option>
<option value="all" ${archiveFilter === 'all' ? 'selected' : ''}>All</option>
</select>`;
}
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 = '<div class="spinner"></div>';
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 = `<tr><td colspan="${colCount}" 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));
});
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 = `
<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">
Counselor: ${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">
Counselor: ${esc(cl.coachUsername || '—')}
${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''}
</p>
${renderClientScoringProfiles(detail.scoringProfiles || [], clientCode)}
${questionnaires.map(q => clientQnBlockHTML(clientCode, q)).join('')}`;
}
function scoringLegendHTML() {
return `
<div class="client-scoring-legend">
<p class="client-scoring-legend-title"><strong>How profile scores work</strong></p>
<p class="data-toolbar-hint client-scoring-legend-body">
Each profile adds up questionnaire points (× weight) into a <strong>weighted total</strong>,
then maps that total to <span class="band-badge band-green">green</span>
<span class="band-badge band-yellow">yellow</span>
<span class="band-badge band-red">red</span> using the thresholds on the Session measures page.
</p>
<dl class="client-scoring-legend-dl">
<div>
<dt>Calculated</dt>
<dd>Automatic category from answers and profile rules (shown before Counselor review).</dd>
</div>
<div>
<dt>Counselor</dt>
<dd>Category confirmed or changed by the counselor in the app (“Review scores”).</dd>
</div>
<div>
<dt>Incomplete</dt>
<dd>Not all questionnaires in that profile are completed yet.</dd>
</div>
</dl>
</div>`;
}
function bandBadgeHTML(band, { pending = false, incomplete = false, label = '' } = {}) {
const text = label || band || '';
if (incomplete) {
return '<span class="band-badge band-none">incomplete</span>';
}
if (pending) {
return '<span class="band-badge band-none">awaiting review</span>';
}
if (!band) {
return '<span class="band-badge band-none">—</span>';
}
return `<span class="band-badge band-${esc(band)}">${esc(text || band)}</span>`;
}
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 => `
<button type="button" class="coach-band-option${current === b ? ' is-selected' : ''}"
data-band="${esc(b)}" role="option" aria-selected="${current === b}">
${bandBadgeHTML(b, { label: bandLabel(b) })}
</button>`).join('');
const agreeBtn = showAgree ? `
<button type="button" class="coach-band-option coach-band-option-agree${current === calcBand ? ' is-selected' : ''}"
data-band="${esc(calcBand)}" role="option">
<span class="coach-band-option-agree-label">Match calculated</span>
${bandBadgeHTML(calcBand, { label: bandLabel(calcBand) })}
</button>` : '';
return `
<div class="coach-band-picker"
data-client="${esc(clientCode)}"
data-profile="${esc(p.profileID)}"
data-profile-name="${esc(p.name || p.profileID)}"
data-calculated="${esc(calcBand)}"
data-weighted="${esc(String(p.weightedTotal ?? ''))}"
data-coach="${esc(current)}">
<button type="button" class="coach-band-trigger" aria-haspopup="listbox" aria-expanded="false"
title="Change Counselor category">
<span class="coach-band-trigger-badge">${triggerBadge}</span>
<span class="coach-band-trigger-caret" aria-hidden="true">▾</span>
</button>
<div class="coach-band-menu" role="listbox" hidden>
<p class="coach-band-menu-label">Counselor category</p>
<div class="coach-band-menu-options">${options}</div>
${agreeBtn}
</div>
</div>`;
}
function profileScoreBlockHTML(p, clientCode = '') {
const calcBand = p.calculatedBand || p.band;
if (!calcBand) {
return `
<div class="client-profile-score-block is-incomplete">
<div class="client-profile-score-block-title">${esc(p.name)}</div>
<div class="client-profile-score-row">
<span class="client-profile-score-label">Calculated</span>
${bandBadgeHTML(null, { incomplete: true })}
</div>
<p class="client-profile-score-hint">Not all questionnaires in this profile are completed yet.</p>
</div>`;
}
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 `
<div class="client-profile-score-block${coachDiffers ? ' has-coach-override' : ''}" data-profile-id="${esc(p.profileID || '')}">
<div class="client-profile-score-block-title">${esc(p.name)}</div>
<div class="client-profile-score-row">
<span class="client-profile-score-label">Calculated</span>
${bandBadgeHTML(calcBand)}
<span class="client-profile-score-total" title="Weighted total">${esc(String(p.weightedTotal))}</span>
</div>
<div class="client-profile-score-row">
<span class="client-profile-score-label">Counselor</span>
${coachControl}
${coachDiffers ? '<span class="client-profile-override-hint">override</span>' : ''}
</div>
</div>`;
}
function renderClientScoringProfiles(profiles, clientCode = '') {
if (!profiles.length) return '';
return `
<div class="client-scoring-profiles-detail">
<strong>Scoring profiles</strong>
<p class="data-toolbar-hint" style="margin:4px 0 10px">
Weighted totals and calculated vs Counselor categories for this client.
</p>
<div class="client-profile-scores">${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 `
<div class="scoring-profile-detail-card">
${profileScoreBlockHTML(p, clientCode)}
${meta ? `<p class="client-profile-score-meta">${meta}</p>` : ''}
${coachPending ? '<p class="client-profile-score-hint">Counselor has not reviewed this profile in the app yet.</p>' : ''}
</div>`;
}).join('')}</div>
</div>`;
}
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.structureRevision ? `<span class="badge badge-q">struct rev ${s.structureRevision}</span>` : ''}
${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 profileDotsHTML(profiles, clientCode) {
if (!profiles?.length) {
return '<span class="client-profile-dots-empty">No scoring profiles</span>';
}
return `<div class="client-profile-scores">${profiles.map(p => profileScoreBlockHTML(p, clientCode)).join('')}</div>`;
}
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 = `
<div class="modal-dialog">
<h2>Set Counselor category</h2>
<p class="modal-subtitle">
<strong>${esc(profileName)}</strong> · ${esc(clientCode)}
</p>
<div class="coach-band-confirm-compare">
<div class="coach-band-confirm-col">
<span class="coach-band-confirm-label">Calculated</span>
${bandBadgeHTML(calculatedBand, { label: bandLabel(calculatedBand) })}
</div>
<div class="coach-band-confirm-arrow" aria-hidden="true">→</div>
<div class="coach-band-confirm-col">
<span class="coach-band-confirm-label">Counselor</span>
${bandBadgeHTML(coachBand, { label: bandLabel(coachBand) })}
</div>
</div>
<p class="modal-hint">This overrides the Counselor category for this client and profile.</p>
<div class="modal-actions">
<button type="button" class="btn" data-coach-cancel>Cancel</button>
<button type="button" class="btn btn-primary" data-coach-confirm>Save category</button>
</div>
</div>`;
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 ? ` <span class="client-archived-badge" title="Archived ${esc(when)}">Archived</span>` : '';
}
function clientRowHTML(c) {
const coach = c.coachUsername
? `<strong>${esc(c.coachUsername)}</strong>`
: `<span style="color:var(--text-secondary);font-style:italic">Unassigned</span>`;
const archived = isClientArchived(c);
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> `
: '';
const profileCol = scoringProfileCatalog.length > 0
? `<td class="client-profile-dots-cell">${profileDotsHTML(c.scoringProfiles || [], c.clientCode)}</td>`
: '';
const colCount = scoringProfileCatalog.length > 0 ? 4 : 3;
const actions = clientTableActionsHTML({
clientCode: esc(c.clientCode),
archived,
showDelete: true,
});
return `
<tr class="${archived ? 'client-row-archived' : ''}">
<td>
${expandControl}<strong>${esc(c.clientCode)}</strong>${clientArchivedLabel(c)}
</td>
<td>${coach}</td>
${profileCol}
<td class="client-actions-cell">${actions}</td>
</tr>
${expanded ? `
<tr class="client-detail-row">
<td colspan="${colCount}">
<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 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 (!(await confirmAction({
title: 'Delete client',
message: `Delete client "${clientCode}"? This will also remove all their answers and results. This cannot be undone.`,
confirmLabel: 'Delete',
variant: 'danger',
}))) 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 = `
<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 counselor</label>
<select id="cc_coach">
<option value="">— select counselor —</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 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;
}