bulk client creation
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-07-07 16:53:35 +02:00
parent 181ca2b7d4
commit 4afab336ee
7 changed files with 566 additions and 29 deletions

View File

@ -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%;

View File

@ -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 = '';