This commit is contained in:
@ -1823,6 +1823,61 @@ table.data-table tbody tr.row-orphan-category td {
|
||||
}
|
||||
.inline-form-card .form-group { margin-bottom: 10px; }
|
||||
|
||||
.client-create-card .client-create-tabs {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.client-create-card .form-hint {
|
||||
margin: 0 0 10px;
|
||||
font-size: .82rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.client-range-row {
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.client-range-row .form-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.client-range-arrow {
|
||||
flex: 0 0 auto;
|
||||
padding-bottom: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.client-range-preview {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: .85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.client-range-preview strong {
|
||||
color: var(--text);
|
||||
}
|
||||
.client-range-preview .range-codes {
|
||||
margin-top: 6px;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: .8rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
.client-range-preview-error {
|
||||
border-style: solid;
|
||||
border-color: var(--toast-error-border);
|
||||
background: var(--toast-error-bg);
|
||||
color: var(--toast-error-fg);
|
||||
}
|
||||
.client-create-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Type select */
|
||||
.type-select {
|
||||
width: 100%;
|
||||
|
||||
@ -851,24 +851,47 @@ function showCreateForm() {
|
||||
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 class="inline-form-card client-create-card" style="margin-bottom:20px">
|
||||
<h4>Create Clients</h4>
|
||||
<div class="tab-bar client-create-tabs" id="cc_tabs">
|
||||
<button type="button" class="active" data-tab="single">Single</button>
|
||||
<button type="button" data-tab="range">Range</button>
|
||||
</div>
|
||||
|
||||
<div id="cc_panel_single">
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:2">
|
||||
<label for="cc_code">Client Code</label>
|
||||
<input type="text" id="cc_code" autocomplete="off" placeholder="e.g. CLIENT-001">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
|
||||
<div id="cc_panel_range" style="display:none">
|
||||
<p class="form-hint">Use matching prefix and suffix with a numeric segment in between.</p>
|
||||
<div class="form-row client-range-row">
|
||||
<div class="form-group">
|
||||
<label for="cc_from">From</label>
|
||||
<input type="text" id="cc_from" autocomplete="off" placeholder="e.g. C001">
|
||||
</div>
|
||||
<div class="client-range-arrow" aria-hidden="true">→</div>
|
||||
<div class="form-group">
|
||||
<label for="cc_to">To</label>
|
||||
<input type="text" id="cc_to" autocomplete="off" placeholder="e.g. C050">
|
||||
</div>
|
||||
</div>
|
||||
<div id="cc_range_preview" class="client-range-preview" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top:4px">
|
||||
<label for="cc_coach">Assign to counselor</label>
|
||||
<select id="cc_coach">
|
||||
<option value="">— select counselor —</option>
|
||||
${coachSelectOptions()}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="client-create-actions">
|
||||
<button class="btn btn-primary btn-sm" id="cc_submit">Create Client</button>
|
||||
<button class="btn btn-sm" id="cc_cancel">Cancel</button>
|
||||
</div>
|
||||
@ -876,17 +899,168 @@ function showCreateForm() {
|
||||
</div>
|
||||
`;
|
||||
|
||||
const input = document.getElementById('cc_code');
|
||||
input.focus();
|
||||
wrapper.querySelectorAll('input').forEach(inp => {
|
||||
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') submitCreateClient();
|
||||
if (e.key === 'Enter') {
|
||||
if (createMode === 'range') submitCreateClientRange();
|
||||
else submitCreateClient();
|
||||
}
|
||||
if (e.key === 'Escape') hideCreateForm();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('cc_submit').addEventListener('click', submitCreateClient);
|
||||
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) =>
|
||||
`<option value="${c.coachID}">${esc(c.username)}${c.supervisorUsername ? ` (${esc(c.supervisorUsername)})` : ''}</option>`
|
||||
).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 = `<span>${esc(parsed.error)}</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
preview.className = 'client-range-preview';
|
||||
preview.innerHTML = `
|
||||
<strong>${parsed.count} client${parsed.count === 1 ? '' : 's'}</strong> will be created
|
||||
<div class="range-codes">${esc(formatRangePreview(parsed.codes))}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function hideCreateForm() {
|
||||
@ -926,6 +1100,66 @@ async function submitCreateClient() {
|
||||
}
|
||||
}
|
||||
|
||||
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 = '';
|
||||
|
||||
Reference in New Issue
Block a user