This commit is contained in:
@ -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.",
|
||||
|
||||
@ -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
74
lib/client_code_range.php
Normal 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;
|
||||
}
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
67
tests/Unit/ClientCodeRangeTest.php
Normal file
67
tests/Unit/ClientCodeRangeTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -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="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 = '';
|
||||
|
||||
Reference in New Issue
Block a user