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

@ -113,6 +113,7 @@
"points",
"previous",
"question",
"questionnaire_progress_answered",
"questions_filled",
"refresh",
"review_required_before_continue",
@ -255,18 +256,19 @@
"ok": "OK",
"online": "Online",
"password_hint": "Passwort",
"password_too_short": "Mindestens 6 Zeichen",
"password_too_short": "Mindestens 8 Zeichen, mit Großbuchstabe, Zahl und Sonderzeichen",
"passwords_dont_match": "Passwörter stimmen nicht überein",
"please_client_code": "Bitte Klienten Code eingeben",
"please_username_password": "Bitte Benutzername und Passwort eingeben.",
"process_complete_body": "Dieser Klient hat den Prozess abgeschlossen — alle verfügbaren Fragebögen sind erledigt oder eine als abgeschlossen markierte Kategorie wurde gesetzt. Sie können die Kategorie ändern, um weitere Fragebögen freizuschalten.",
"process_complete_hub_body": "Alle verfügbaren Schritte für diesen Klienten sind abgeschlossen.",
"process_complete_ok": "OK",
"process_complete_set_category": "Andere Kategorie setzen",
"process_complete_set_category": "Einstufung einsehen",
"process_complete_title": "Prozess abgeschlossen",
"points": "Punkte",
"previous": "Zurück",
"question": "Frage",
"questionnaire_progress_answered": "{answered} von {total} beantwortet",
"questions_filled": "Antworten",
"refresh": "Aktualisieren",
"review_required_before_continue": "Bitte schließen Sie zuerst die Punkteprüfung ab, bevor Sie weitere Fragebögen ausfüllen.",

View File

@ -63,17 +63,16 @@ case 'GET':
case 'POST':
$body = read_json_body();
$clientCode = trim($body['clientCode'] ?? '');
$coachID = trim($body['coachID'] ?? '');
$bulk = !empty($body['bulk']);
if ($clientCode === '' || $coachID === '') {
json_error('MISSING_FIELDS', 'clientCode and coachID are required', 400);
if ($coachID === '') {
json_error('MISSING_FIELDS', 'coachID is required', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
// Validate coach exists and caller is allowed to assign to them
if ($callerRole === 'supervisor') {
$chkCoach = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :sid");
$chkCoach->execute([':cid' => $coachID, ':sid' => $callerEntityID]);
@ -86,6 +85,57 @@ case 'POST':
json_error('NOT_FOUND', 'Counselor not found or not authorized', 404);
}
if ($bulk) {
$fromCode = trim($body['fromCode'] ?? '');
$toCode = trim($body['toCode'] ?? '');
if ($fromCode === '' || $toCode === '') {
qdb_discard($tmpDb, $lockFp);
json_error('MISSING_FIELDS', 'fromCode and toCode are required for bulk creation', 400);
}
require_once __DIR__ . '/../lib/client_code_range.php';
try {
$codes = qdb_expand_client_code_range($fromCode, $toCode);
} catch (InvalidArgumentException $e) {
qdb_discard($tmpDb, $lockFp);
json_error('INVALID_RANGE', $e->getMessage(), 400);
}
$chk = $pdo->prepare("SELECT clientCode FROM client WHERE clientCode = :cc");
$insert = $pdo->prepare(
"INSERT INTO client (clientCode, coachID, archived, archivedAt) VALUES (:cc, :cid, 0, 0)"
);
$created = [];
$skipped = [];
foreach ($codes as $code) {
$chk->execute([':cc' => $code]);
if ($chk->fetch()) {
$skipped[] = ['clientCode' => $code, 'reason' => 'duplicate'];
continue;
}
$insert->execute([':cc' => $code, ':cid' => $coachID]);
$created[] = $code;
}
qdb_save($tmpDb, $lockFp);
json_success([
'coachID' => $coachID,
'fromCode' => $fromCode,
'toCode' => $toCode,
'created' => $created,
'skipped' => $skipped,
'createdCount' => count($created),
'skippedCount' => count($skipped),
]);
}
$clientCode = trim($body['clientCode'] ?? '');
if ($clientCode === '') {
qdb_discard($tmpDb, $lockFp);
json_error('MISSING_FIELDS', 'clientCode is required', 400);
}
$chk = $pdo->prepare("SELECT clientCode FROM client WHERE clientCode = :cc");
$chk->execute([':cc' => $clientCode]);
if ($chk->fetch()) {

74
lib/client_code_range.php Normal file
View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
/**
* @return array{prefix: string, digits: string, suffix: string}
*/
function qdb_parse_client_code_pattern(string $code): array
{
if (!preg_match('/\d/u', $code)) {
throw new InvalidArgumentException(
'Code must contain a numeric segment (e.g. C001 or A01Demo)',
);
}
$suffix = '';
if (preg_match('/[^0-9]+$/u', $code, $suffixMatch)) {
$suffix = $suffixMatch[0];
$core = substr($code, 0, -strlen($suffix));
} else {
$core = $code;
}
if (!preg_match('/^(.*?)(\d+)$/u', $core, $match) || $match[2] === '') {
throw new InvalidArgumentException(
'Code must contain a numeric segment (e.g. C001 or A01Demo)',
);
}
return [
'prefix' => $match[1],
'digits' => $match[2],
'suffix' => $suffix,
];
}
/**
* Expand a client-code range such as C001…C050 or A01Demo…A10Demo.
*
* @return list<string>
*/
function qdb_expand_client_code_range(string $from, string $to, int $maxCount = 500): array
{
$fromParts = qdb_parse_client_code_pattern($from);
$toParts = qdb_parse_client_code_pattern($to);
if ($fromParts['prefix'] !== $toParts['prefix'] || $fromParts['suffix'] !== $toParts['suffix']) {
throw new InvalidArgumentException(
'Prefix and suffix must match between from and to (e.g. C001…C050 or A01Demo…A10Demo)',
);
}
$prefix = $fromParts['prefix'];
$suffix = $fromParts['suffix'];
$padWidth = strlen($fromParts['digits']);
$start = (int) $fromParts['digits'];
$end = (int) $toParts['digits'];
if ($start > $end) {
throw new InvalidArgumentException('From number must be less than or equal to to');
}
$count = $end - $start + 1;
if ($count > $maxCount) {
throw new InvalidArgumentException("Range exceeds maximum of $maxCount clients");
}
$codes = [];
for ($n = $start; $n <= $end; $n++) {
$codes[] = $prefix . str_pad((string) $n, $padWidth, '0', STR_PAD_LEFT) . $suffix;
}
return $codes;
}

View File

@ -111,4 +111,59 @@ final class ClientsLifecycleTest extends QdbTestCase
$codes = array_column($list['data']['clients'], 'clientCode');
$this->assertNotContains($code, $codes);
}
public function testAdminCreatesClientRange(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$prefix = 'RANGE-' . bin2hex(random_bytes(2)) . '-';
$res = $this->api()->withToken($token, 'POST', 'clients', [
'bulk' => true,
'fromCode' => $prefix . '001',
'toCode' => $prefix . '003',
'coachID' => $this->fixture()->coachId,
]);
$this->assertApiOk($res);
$this->assertSame(3, $res['data']['createdCount']);
$this->assertSame(
[$prefix . '001', $prefix . '002', $prefix . '003'],
$res['data']['created'],
);
$list = $this->api()->withToken($token, 'GET', 'clients');
$codes = array_column($list['data']['clients'], 'clientCode');
foreach ([$prefix . '001', $prefix . '002', $prefix . '003'] as $code) {
$this->assertContains($code, $codes);
}
}
public function testBulkCreateSkipsDuplicates(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$prefix = 'DUP-' . bin2hex(random_bytes(2)) . '-';
$existing = $prefix . '002';
$single = $this->api()->withToken($token, 'POST', 'clients', [
'clientCode' => $existing,
'coachID' => $this->fixture()->coachId,
]);
$this->assertApiOk($single);
$res = $this->api()->withToken($token, 'POST', 'clients', [
'bulk' => true,
'fromCode' => $prefix . '001',
'toCode' => $prefix . '003',
'coachID' => $this->fixture()->coachId,
]);
$this->assertApiOk($res);
$this->assertSame(2, $res['data']['createdCount']);
$this->assertSame(1, $res['data']['skippedCount']);
$this->assertSame($existing, $res['data']['skipped'][0]['clientCode']);
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
final class ClientCodeRangeTest extends TestCase
{
protected function setUp(): void
{
require_once dirname(__DIR__, 2) . '/lib/client_code_range.php';
}
public function testExpandsZeroPaddedPrefixRange(): void
{
$codes = qdb_expand_client_code_range('C001', 'C005');
$this->assertSame(['C001', 'C002', 'C003', 'C004', 'C005'], $codes);
}
public function testExpandsSuffixRange(): void
{
$codes = qdb_expand_client_code_range('A01Demo', 'A03Demo');
$this->assertSame(['A01Demo', 'A02Demo', 'A03Demo'], $codes);
}
public function testExpandsDemoSuffixWithTwoDigitPadding(): void
{
$codes = qdb_expand_client_code_range('C01Demo', 'C05Demo');
$this->assertSame(['C01Demo', 'C02Demo', 'C03Demo', 'C04Demo', 'C05Demo'], $codes);
$this->assertSame(
array_map(static fn (int $n): string => 'C' . str_pad((string) $n, 2, '0', STR_PAD_LEFT) . 'Demo', range(1, 35)),
qdb_expand_client_code_range('C01Demo', 'C35Demo'),
);
}
public function testUsesLastNumericSegmentWhenMultiplePresent(): void
{
$codes = qdb_expand_client_code_range('RANGE-ab12-001', 'RANGE-ab12-003');
$this->assertSame(['RANGE-ab12-001', 'RANGE-ab12-002', 'RANGE-ab12-003'], $codes);
}
public function testExpandsSingleCodeRange(): void
{
$codes = qdb_expand_client_code_range('X9Y', 'X9Y');
$this->assertSame(['X9Y'], $codes);
}
public function testRejectsMismatchedPrefix(): void
{
$this->expectException(\InvalidArgumentException::class);
qdb_expand_client_code_range('C001', 'D001');
}
public function testRejectsDescendingRange(): void
{
$this->expectException(\InvalidArgumentException::class);
qdb_expand_client_code_range('C050', 'C001');
}
public function testRejectsRangeWithoutDigits(): void
{
$this->expectException(\InvalidArgumentException::class);
qdb_expand_client_code_range('CLIENT', 'CLIENT');
}
}

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="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>Client Code</label>
<input type="text" id="cc_code" autocomplete="off" placeholder="Enter unique client code">
<label for="cc_code">Client Code</label>
<input type="text" id="cc_code" autocomplete="off" placeholder="e.g. CLIENT-001">
</div>
<div class="form-group" style="flex:2">
<label>Assign to counselor</label>
</div>
</div>
<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>
${coachesList.map(c =>
`<option value="${c.coachID}">${esc(c.username)}${c.supervisorUsername ? ` (${esc(c.supervisorUsername)})` : ''}</option>`
).join('')}
${coachSelectOptions()}
</select>
</div>
</div>
<div style="display:flex;gap:8px">
<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 = '';