diff --git a/data/app_ui_strings.json b/data/app_ui_strings.json index 97d7e97..f32ec71 100644 --- a/data/app_ui_strings.json +++ b/data/app_ui_strings.json @@ -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.", diff --git a/handlers/clients.php b/handlers/clients.php index 594e0d4..305a038 100644 --- a/handlers/clients.php +++ b/handlers/clients.php @@ -62,18 +62,17 @@ case 'GET': break; case 'POST': - $body = read_json_body(); - $clientCode = trim($body['clientCode'] ?? ''); - $coachID = trim($body['coachID'] ?? ''); + $body = read_json_body(); + $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()) { diff --git a/lib/client_code_range.php b/lib/client_code_range.php new file mode 100644 index 0000000..5e38a6e --- /dev/null +++ b/lib/client_code_range.php @@ -0,0 +1,74 @@ + $match[1], + 'digits' => $match[2], + 'suffix' => $suffix, + ]; +} + +/** + * Expand a client-code range such as C001…C050 or A01Demo…A10Demo. + * + * @return list + */ +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; +} diff --git a/tests/Integration/ClientsLifecycleTest.php b/tests/Integration/ClientsLifecycleTest.php index 2225b9d..5870053 100644 --- a/tests/Integration/ClientsLifecycleTest.php +++ b/tests/Integration/ClientsLifecycleTest.php @@ -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']); + } } diff --git a/tests/Unit/ClientCodeRangeTest.php b/tests/Unit/ClientCodeRangeTest.php new file mode 100644 index 0000000..fb9cab3 --- /dev/null +++ b/tests/Unit/ClientCodeRangeTest.php @@ -0,0 +1,67 @@ +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'); + } +} diff --git a/website/css/style.css b/website/css/style.css index d6d7ea5..e8a6dfd 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -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%; diff --git a/website/js/pages/clients.js b/website/js/pages/clients.js index 4e87451..d58813c 100644 --- a/website/js/pages/clients.js +++ b/website/js/pages/clients.js @@ -851,24 +851,47 @@ function showCreateForm() { const wrapper = document.getElementById('createClientWrapper'); wrapper.style.display = ''; wrapper.innerHTML = ` -
-

Create New Client

-
-
- - -
-
- - +
+

Create Clients

+
+ + +
+ +
+
+
+ + +
-
+ + + +
+ + +
+ +
@@ -876,17 +899,168 @@ function showCreateForm() {
`; - 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) => + `` + ).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() { @@ -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 = '';