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', '+ Create Client ')}
`;
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 = `
`;
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.
Client Code
Current counselor
Note
${showProfileColumn ? 'Profile scores ' : ''}
Actions
`;
bindArchiveFilterControl();
document.getElementById('clientListSearch').addEventListener('input', (e) => {
filterSearch = e.target.value.trim();
page = 0;
clearExpandIfHidden();
renderClientTableBody();
});
ensureCoachBandGlobalClick();
renderClientTableBody();
}
function archiveFilterSelectHTML() {
return `
Active
Archived
All
`;
}
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 || ''} ${c.note || ''}`.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 ? 5 : 4;
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));
});
body.querySelectorAll('.edit-client-note-btn').forEach(btn => {
btn.addEventListener('click', () => openClientNoteEditor(btn.dataset.client));
});
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 = `
Prev
Rows ${from}–${to} of ${filtered.length}
= totalPages - 1 ? 'disabled' : ''}>Next
`;
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 `
Question Answer
${rows.map(r => `
${esc(r.label)}
${esc(r.value || '—')}
`).join('')}
`;
}
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 => `
${bandBadgeHTML(b, { label: bandLabel(b) })}
`).join('');
const agreeBtn = showAgree ? `
Match calculated
${bandBadgeHTML(calcBand, { label: bandLabel(calcBand) })}
` : '';
return `
${triggerBadge}
▾
Counselor category
${options}
${agreeBtn}
`;
}
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 ' : ''}
${coachDiffers && p.coachReviewedByUsername
? `by ${esc(p.coachReviewedByUsername)}${p.coachReviewedAt ? ` · ${esc(p.coachReviewedAt)}` : ''} `
: ''}
`;
}
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 overrideMeta = p.coachOverride && p.coachReviewedByUsername
? `Overridden by ${esc(p.coachReviewedByUsername)}${p.coachReviewedAt ? ` · ${esc(p.coachReviewedAt)}` : ''}`
: '';
const meta = [computedAt, reviewedAt, overrideMeta].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 `
${vExpanded ? '▼' : '▶'}
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 `
${qnExpanded ? '▼' : '▶'}
${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 = `
Set Counselor category
${esc(profileName)} · ${esc(clientCode)}
Calculated
${bandBadgeHTML(calculatedBand, { label: bandLabel(calculatedBand) })}
→
Counselor
${bandBadgeHTML(coachBand, { label: bandLabel(coachBand) })}
This overrides the Counselor category for this client and profile.
Cancel
Save category
`;
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
? `${expanded ? '▼' : '▶'} `
: '';
const profileCol = scoringProfileCatalog.length > 0
? `${profileDotsHTML(c.scoringProfiles || [], c.clientCode)} `
: '';
const colCount = scoringProfileCatalog.length > 0 ? 5 : 4;
const actions = clientTableActionsHTML({
clientCode: esc(c.clientCode),
archived,
showDelete: true,
});
const note = String(c.note || '').trim();
const noteCell = `
${note
? `${esc(note)} `
: `— `}
Edit
`;
return `
${expandControl}${esc(c.clientCode)} ${clientArchivedLabel(c)}
${coach}
${noteCell}
${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 openClientNoteEditor(clientCode) {
const row = clientsList.find(c => c.clientCode === clientCode);
if (!row) return;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay client-note-modal';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.innerHTML = `
Edit note
${esc(clientCode)}
Note (max 200 characters)
Cancel
Save
`;
document.body.appendChild(overlay);
document.body.classList.add('modal-open');
const ta = overlay.querySelector('#clientNoteModalInput');
const countEl = overlay.querySelector('#clientNoteModalCount');
const saveBtn = overlay.querySelector('[data-note-save]');
const updateCount = () => {
const len = ta.value.length;
countEl.textContent = `${len} / 200`;
};
updateCount();
ta.addEventListener('input', updateCount);
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
const close = () => {
overlay.remove();
document.body.classList.remove('modal-open');
document.removeEventListener('keydown', onKey);
};
const onKey = (e) => {
if (e.key === 'Escape') close();
};
document.addEventListener('keydown', onKey);
overlay.querySelector('[data-note-cancel]').addEventListener('click', close);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close();
});
overlay.querySelector('.modal-dialog').addEventListener('click', (e) => e.stopPropagation());
saveBtn.addEventListener('click', async () => {
const note = ta.value.trim();
if (note.length > 200) {
showToast('Note must be at most 200 characters', 'error');
return;
}
saveBtn.disabled = true;
saveBtn.textContent = 'Saving…';
try {
const res = await apiPut('analytics.php', { clientCode, note });
const saved = res?.note ?? note;
row.note = saved;
showToast('Note saved', 'success');
close();
renderClientTableBody();
} catch (e) {
showToast(e.message, 'error');
saveBtn.disabled = false;
saveBtn.textContent = 'Save';
}
});
}
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 = `
`;
let createMode = 'single';
const setMode = (mode) => {
createMode = mode;
document.querySelectorAll('#cc_tabs button').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.tab === mode);
});
document.getElementById('cc_panel_single').style.display = mode === 'single' ? '' : 'none';
document.getElementById('cc_panel_range').style.display = mode === 'range' ? '' : 'none';
const submitBtn = document.getElementById('cc_submit');
if (mode === 'range') {
updateRangePreview();
submitBtn.textContent = rangePreviewCount()
? `Create ${rangePreviewCount()} Clients`
: 'Create Clients';
document.getElementById('cc_from').focus();
} else {
submitBtn.textContent = 'Create Client';
document.getElementById('cc_code').focus();
}
document.getElementById('cc_error').style.display = 'none';
};
document.querySelectorAll('#cc_tabs button').forEach((btn) => {
btn.addEventListener('click', () => setMode(btn.dataset.tab));
});
['cc_from', 'cc_to'].forEach((id) => {
document.getElementById(id).addEventListener('input', () => {
if (createMode !== 'range') return;
updateRangePreview();
const count = rangePreviewCount();
document.getElementById('cc_submit').textContent = count
? `Create ${count} Clients`
: 'Create Clients';
});
});
wrapper.querySelectorAll('input').forEach((inp) => {
inp.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
if (createMode === 'range') submitCreateClientRange();
else submitCreateClient();
}
if (e.key === 'Escape') hideCreateForm();
});
});
document.getElementById('cc_submit').addEventListener('click', () => {
if (createMode === 'range') submitCreateClientRange();
else submitCreateClient();
});
document.getElementById('cc_cancel').addEventListener('click', hideCreateForm);
document.getElementById('cc_code').focus();
}
function coachSelectOptions() {
return coachesList.map((c) =>
`${esc(c.username)}${c.supervisorUsername ? ` (${esc(c.supervisorUsername)})` : ''} `
).join('');
}
function parseClientCodePattern(code) {
const trimmed = String(code).trim();
if (!/\d/.test(trimmed)) {
return { error: 'Code must contain a numeric segment (e.g. C001 or A01Demo)' };
}
let suffix = '';
let core = trimmed;
const suffixMatch = trimmed.match(/[^0-9]+$/);
if (suffixMatch) {
suffix = suffixMatch[0];
core = trimmed.slice(0, -suffix.length);
}
const coreMatch = core.match(/^(.*?)(\d+)$/);
if (!coreMatch || !coreMatch[2]) {
return { error: 'Code must contain a numeric segment (e.g. C001 or A01Demo)' };
}
return {
prefix: coreMatch[1],
digits: coreMatch[2],
suffix,
};
}
function parseClientCodeRange(from, to) {
const fromParts = parseClientCodePattern(from);
if (fromParts.error) return fromParts;
const toParts = parseClientCodePattern(to);
if (toParts.error) return toParts;
if (fromParts.prefix !== toParts.prefix || fromParts.suffix !== toParts.suffix) {
return { error: 'Prefix and suffix must match (e.g. C001…C050 or A01Demo…A10Demo)' };
}
const prefix = fromParts.prefix;
const suffix = fromParts.suffix;
const padWidth = fromParts.digits.length;
const start = parseInt(fromParts.digits, 10);
const end = parseInt(toParts.digits, 10);
if (!Number.isFinite(start) || !Number.isFinite(end)) {
return { error: 'Invalid numeric segment' };
}
if (start > end) {
return { error: 'From number must be less than or equal to to' };
}
const count = end - start + 1;
const maxCount = 500;
if (count > maxCount) {
return { error: `Range exceeds maximum of ${maxCount} clients` };
}
const codes = [];
for (let n = start; n <= end; n++) {
codes.push(`${prefix}${String(n).padStart(padWidth, '0')}${suffix}`);
}
return { codes, count, prefix, suffix };
}
function rangePreviewCount() {
const from = document.getElementById('cc_from')?.value ?? '';
const to = document.getElementById('cc_to')?.value ?? '';
const parsed = parseClientCodeRange(from, to);
return parsed.codes?.length ?? 0;
}
function formatRangePreview(codes) {
if (!codes.length) return '';
if (codes.length <= 6) return codes.join(', ');
return `${codes.slice(0, 3).join(', ')} … ${codes.slice(-2).join(', ')}`;
}
function updateRangePreview() {
const preview = document.getElementById('cc_range_preview');
if (!preview) return;
const from = document.getElementById('cc_from').value.trim();
const to = document.getElementById('cc_to').value.trim();
if (!from && !to) {
preview.hidden = true;
preview.innerHTML = '';
return;
}
const parsed = parseClientCodeRange(from, to);
preview.hidden = false;
if (parsed.error) {
preview.className = 'client-range-preview client-range-preview-error';
preview.innerHTML = `${esc(parsed.error)} `;
return;
}
preview.className = 'client-range-preview';
preview.innerHTML = `
${parsed.count} client${parsed.count === 1 ? '' : 's'} will be created
${esc(formatRangePreview(parsed.codes))}
`;
}
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';
}
}
async function submitCreateClientRange() {
const errEl = document.getElementById('cc_error');
errEl.style.display = 'none';
const fromCode = document.getElementById('cc_from').value.trim();
const toCode = document.getElementById('cc_to').value.trim();
const coachID = document.getElementById('cc_coach').value;
if (!fromCode || !toCode) {
showError(errEl, 'From and to codes are required');
return;
}
if (!coachID) {
showError(errEl, 'Please select a counselor');
return;
}
const parsed = parseClientCodeRange(fromCode, toCode);
if (parsed.error) {
showError(errEl, parsed.error);
return;
}
const btn = document.getElementById('cc_submit');
btn.disabled = true;
btn.textContent = 'Creating...';
try {
const data = await apiPost('clients.php', {
bulk: true,
fromCode,
toCode,
coachID,
});
const coach = coachesList.find((c) => c.coachID === coachID);
const created = data.created ?? [];
created.forEach((clientCode) => {
clientsList.push({
clientCode,
coachID,
coachUsername: coach?.username ?? null,
});
});
const skipped = data.skippedCount ?? 0;
let message = `${data.createdCount ?? created.length} client${created.length === 1 ? '' : 's'} created`;
if (skipped > 0) {
message += ` · ${skipped} skipped (already exist)`;
}
showToast(message, 'success');
hideCreateForm();
renderClients();
} catch (e) {
showError(errEl, e.message);
btn.disabled = false;
const count = parsed.count ?? 0;
btn.textContent = count ? `Create ${count} Clients` : 'Create Clients';
}
}
function showError(el, msg) {
el.textContent = msg;
el.style.display = '';
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}