Job type attributes now support a "Customer Type" pseudo-type: selecting a customer type expands its attribute definitions inline as real rows, letting job types reuse field configurations without manual re-entry. The "customer" placeholder type is stripped server-side before persisting. Also update production Keycloak redirect URIs from IP to hostname and ignore .claude/ and .abacusai/ tool directories.Customer_Type_Lookup
| @@ -15,5 +15,5 @@ KEYCLOAK_BASE_URL=http://kci-app01.ntp.kentcommunications.com:8180/ | |||||
| KEYCLOAK_REALM=KCI | KEYCLOAK_REALM=KCI | ||||
| KEYCLOAK_CLIENT_ID=canopy-web | KEYCLOAK_CLIENT_ID=canopy-web | ||||
| KEYCLOAK_CLIENT_SECRET=LHWXp5UUuES00Dz2iCjTJJgX9su6co0y | KEYCLOAK_CLIENT_SECRET=LHWXp5UUuES00Dz2iCjTJJgX9su6co0y | ||||
| KEYCLOAK_REDIRECT_URI=http://192.168.1.200:8801/auth/callback | |||||
| KEYCLOAK_LOGOUT_REDIRECT_URI=http://192.168.1.200:8801/login | |||||
| KEYCLOAK_REDIRECT_URI=http://ct.ntp.kentcommunications.com:8801/auth/callback | |||||
| KEYCLOAK_LOGOUT_REDIRECT_URI=http:ct.ntp.kentcommunications.com:8801/login | |||||
| @@ -45,3 +45,6 @@ desktop.ini | |||||
| node_modules/ | node_modules/ | ||||
| npm-debug.log* | npm-debug.log* | ||||
| yarn-error.log* | yarn-error.log* | ||||
| .claude/ | |||||
| .abacusai/ | |||||
| @@ -5,6 +5,7 @@ declare(strict_types=1); | |||||
| namespace App\Controllers; | namespace App\Controllers; | ||||
| use App\Models\JobType; | use App\Models\JobType; | ||||
| use App\Repositories\CustomerTypeRepository; | |||||
| use App\Repositories\JobTypeAuditRepository; | use App\Repositories\JobTypeAuditRepository; | ||||
| use App\Repositories\JobTypeRepository; | use App\Repositories\JobTypeRepository; | ||||
| use App\ViewModels\JobTypeViewModel; | use App\ViewModels\JobTypeViewModel; | ||||
| @@ -52,7 +53,8 @@ class JobTypeController extends Controller | |||||
| public function create(): Response | public function create(): Response | ||||
| { | { | ||||
| $model = new JobTypeViewModel(); | $model = new JobTypeViewModel(); | ||||
| $model->title = 'New Job Type'; | |||||
| $model->title = 'New Job Type'; | |||||
| $model->customerTypes = $this->loadCustomerTypes(); | |||||
| return $this->view('job-types.create', [ | return $this->view('job-types.create', [ | ||||
| 'model' => $model, | 'model' => $model, | ||||
| @@ -71,9 +73,10 @@ class JobTypeController extends Controller | |||||
| if (!empty($errors)) { | if (!empty($errors)) { | ||||
| $model = new JobTypeViewModel(); | $model = new JobTypeViewModel(); | ||||
| $model->title = 'New Job Type'; | |||||
| $model->form = $form; | |||||
| $model->errors = $errors; | |||||
| $model->title = 'New Job Type'; | |||||
| $model->form = $form; | |||||
| $model->errors = $errors; | |||||
| $model->customerTypes = $this->loadCustomerTypes(); | |||||
| return $this->view('job-types.create', [ | return $this->view('job-types.create', [ | ||||
| 'model' => $model, | 'model' => $model, | ||||
| @@ -104,9 +107,10 @@ class JobTypeController extends Controller | |||||
| } | } | ||||
| $model = new JobTypeViewModel(); | $model = new JobTypeViewModel(); | ||||
| $model->title = 'Edit Job Type'; | |||||
| $model->jobType = $row; | |||||
| $model->saved = Request::capture()->input('saved') === '1'; | |||||
| $model->title = 'Edit Job Type'; | |||||
| $model->jobType = $row; | |||||
| $model->saved = Request::capture()->input('saved') === '1'; | |||||
| $model->customerTypes = $this->loadCustomerTypes(); | |||||
| $model->form = [ | $model->form = [ | ||||
| 'name' => (string) $row['name'], | 'name' => (string) $row['name'], | ||||
| 'attributes' => json_decode((string) ($row['attributes'] ?? '[]'), true) ?? [], | 'attributes' => json_decode((string) ($row['attributes'] ?? '[]'), true) ?? [], | ||||
| @@ -138,10 +142,11 @@ class JobTypeController extends Controller | |||||
| if (!empty($errors)) { | if (!empty($errors)) { | ||||
| $model = new JobTypeViewModel(); | $model = new JobTypeViewModel(); | ||||
| $model->title = 'Edit Job Type'; | |||||
| $model->jobType = $row; | |||||
| $model->form = $form; | |||||
| $model->errors = $errors; | |||||
| $model->title = 'Edit Job Type'; | |||||
| $model->jobType = $row; | |||||
| $model->form = $form; | |||||
| $model->errors = $errors; | |||||
| $model->customerTypes = $this->loadCustomerTypes(); | |||||
| return $this->view('job-types.edit', [ | return $this->view('job-types.edit', [ | ||||
| 'model' => $model, | 'model' => $model, | ||||
| @@ -232,6 +237,10 @@ class JobTypeController extends Controller | |||||
| $attrType = trim((string) ($attributeTypes[$i] ?? 'text')); | $attrType = trim((string) ($attributeTypes[$i] ?? 'text')); | ||||
| if ($attrName === '') continue; | if ($attrName === '') continue; | ||||
| // 'customer' is a design-time placeholder that is expanded into real attribute rows | |||||
| // by the Job Type editor JS before submit. If one slips through, drop it. | |||||
| if ($attrType === 'customer') continue; | |||||
| $validatedType = in_array($attrType, ['text', 'number', 'date', 'boolean', 'api_lookup'], true) ? $attrType : 'text'; | $validatedType = in_array($attrType, ['text', 'number', 'date', 'boolean', 'api_lookup'], true) ? $attrType : 'text'; | ||||
| $attr = [ | $attr = [ | ||||
| @@ -274,4 +283,21 @@ class JobTypeController extends Controller | |||||
| { | { | ||||
| return new JobTypeAuditRepository(database()); | return new JobTypeAuditRepository(database()); | ||||
| } | } | ||||
| private function customerTypeRepo(): CustomerTypeRepository | |||||
| { | |||||
| return new CustomerTypeRepository(database()); | |||||
| } | |||||
| /** @return list<array{id: int, name: string, attributes: array}> */ | |||||
| private function loadCustomerTypes(): array | |||||
| { | |||||
| return array_map(static function (array $t): array { | |||||
| return [ | |||||
| 'id' => (int) $t['id'], | |||||
| 'name' => (string) $t['name'], | |||||
| 'attributes' => json_decode((string) ($t['attributes'] ?? '[]'), true) ?? [], | |||||
| ]; | |||||
| }, $this->customerTypeRepo()->allOrderedByName()); | |||||
| } | |||||
| } | } | ||||
| @@ -20,4 +20,7 @@ class JobTypeViewModel | |||||
| /** @var array<string, mixed>|null */ | /** @var array<string, mixed>|null */ | ||||
| public ?array $jobType = null; | public ?array $jobType = null; | ||||
| /** @var list<array{id: int, name: string, attributes: array}> */ | |||||
| public array $customerTypes = []; | |||||
| } | } | ||||
| @@ -1,4 +1,7 @@ | |||||
| <script>window.__jtAttributes = <?= json_encode($model->form['attributes'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;</script> | |||||
| <script> | |||||
| window.__jtAttributes = <?= json_encode($model->form['attributes'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||||
| window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||||
| </script> | |||||
| <section class="content-stack"> | <section class="content-stack"> | ||||
| @@ -10,7 +13,7 @@ | |||||
| <a class="button button-secondary" href="/job-types">← Back to list</a> | <a class="button button-secondary" href="/job-types">← Back to list</a> | ||||
| </div> | </div> | ||||
| <section class="section-panel" x-data="jobTypeForm(window.__jtAttributes)"> | |||||
| <section class="section-panel" x-data="jobTypeForm(window.__jtAttributes, window.__customerTypes)"> | |||||
| <?php if (isset($model->errors['_token'])): ?> | <?php if (isset($model->errors['_token'])): ?> | ||||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | ||||
| @@ -71,6 +74,7 @@ | |||||
| <option value="date">Date</option> | <option value="date">Date</option> | ||||
| <option value="boolean">True/False</option> | <option value="boolean">True/False</option> | ||||
| <option value="api_lookup">API Lookup</option> | <option value="api_lookup">API Lookup</option> | ||||
| <option value="customer">Customer Type</option> | |||||
| </select> | </select> | ||||
| </label> | </label> | ||||
| <div class="attribute-remove"> | <div class="attribute-remove"> | ||||
| @@ -117,6 +121,22 @@ | |||||
| </select> | </select> | ||||
| </label> | </label> | ||||
| </div> | </div> | ||||
| <div class="customer-type-config" x-show="attr.type === 'customer'"> | |||||
| <label class="field"> | |||||
| <span>Customer Type</span> | |||||
| <select class="input" | |||||
| x-model.number="attr.customer_type_id" | |||||
| x-on:change="importCustomerTypeAttributes(index)"> | |||||
| <option :value="0">— Select a customer type —</option> | |||||
| <template x-for="ct in customerTypes" :key="ct.id"> | |||||
| <option :value="ct.id" x-text="ct.name"></option> | |||||
| </template> | |||||
| </select> | |||||
| </label> | |||||
| <p class="attributes-hint" x-show="customerTypes.length === 0"> | |||||
| No customer types exist yet. <a href="/customer-types/create">Create one</a> first. | |||||
| </p> | |||||
| </div> | |||||
| </div> | </div> | ||||
| </template> | </template> | ||||
| </div> | </div> | ||||
| @@ -1,5 +1,8 @@ | |||||
| <?php $jobTypeId = (int) ($model->jobType['id'] ?? 0); ?> | <?php $jobTypeId = (int) ($model->jobType['id'] ?? 0); ?> | ||||
| <script>window.__jtAttributes = <?= json_encode($model->form['attributes'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>;</script> | |||||
| <script> | |||||
| window.__jtAttributes = <?= json_encode($model->form['attributes'], JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||||
| window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_TAG | JSON_THROW_ON_ERROR) ?>; | |||||
| </script> | |||||
| <section class="content-stack"> | <section class="content-stack"> | ||||
| @@ -17,7 +20,7 @@ | |||||
| </div> | </div> | ||||
| <?php endif; ?> | <?php endif; ?> | ||||
| <section class="section-panel" x-data="jobTypeForm(window.__jtAttributes)"> | |||||
| <section class="section-panel" x-data="jobTypeForm(window.__jtAttributes, window.__customerTypes)"> | |||||
| <?php if (isset($model->errors['_token'])): ?> | <?php if (isset($model->errors['_token'])): ?> | ||||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | ||||
| @@ -78,6 +81,7 @@ | |||||
| <option value="date">Date</option> | <option value="date">Date</option> | ||||
| <option value="boolean">True/False</option> | <option value="boolean">True/False</option> | ||||
| <option value="api_lookup">API Lookup</option> | <option value="api_lookup">API Lookup</option> | ||||
| <option value="customer">Customer Type</option> | |||||
| </select> | </select> | ||||
| </label> | </label> | ||||
| <div class="attribute-remove"> | <div class="attribute-remove"> | ||||
| @@ -124,6 +128,22 @@ | |||||
| </select> | </select> | ||||
| </label> | </label> | ||||
| </div> | </div> | ||||
| <div class="customer-type-config" x-show="attr.type === 'customer'"> | |||||
| <label class="field"> | |||||
| <span>Customer Type</span> | |||||
| <select class="input" | |||||
| x-model.number="attr.customer_type_id" | |||||
| x-on:change="importCustomerTypeAttributes(index)"> | |||||
| <option :value="0">— Select a customer type —</option> | |||||
| <template x-for="ct in customerTypes" :key="ct.id"> | |||||
| <option :value="ct.id" x-text="ct.name"></option> | |||||
| </template> | |||||
| </select> | |||||
| </label> | |||||
| <p class="attributes-hint" x-show="customerTypes.length === 0"> | |||||
| No customer types exist yet. <a href="/customer-types/create">Create one</a> first. | |||||
| </p> | |||||
| </div> | |||||
| </div> | </div> | ||||
| </template> | </template> | ||||
| </div> | </div> | ||||
| @@ -1171,14 +1171,53 @@ window.deleteJobType = function (id) { | |||||
| _postDelete('/job-types/' + id + '/delete'); | _postDelete('/job-types/' + id + '/delete'); | ||||
| }; | }; | ||||
| window.jobTypeForm = function (initialAttributes) { | |||||
| window.jobTypeForm = function (initialAttributes, customerTypes) { | |||||
| return { | return { | ||||
| attributes: Array.isArray(initialAttributes) ? initialAttributes : [], | attributes: Array.isArray(initialAttributes) ? initialAttributes : [], | ||||
| customerTypes: Array.isArray(customerTypes) ? customerTypes : [], | |||||
| dragIndex: null, | dragIndex: null, | ||||
| dragOverIndex: null, | dragOverIndex: null, | ||||
| addAttribute() { | addAttribute() { | ||||
| this.attributes.push({ name: '', type: 'text', alias: '', order: this.attributes.length + 1, api_url: '', api_match_field: '', api_auto_fill: '', api_format: 'json', api_return_type: 'text' }); | |||||
| this.attributes.push({ | |||||
| name: '', type: 'text', alias: '', order: this.attributes.length + 1, | |||||
| api_url: '', api_match_field: '', api_auto_fill: '', api_format: 'json', api_return_type: 'text', | |||||
| customer_type_id: 0, | |||||
| }); | |||||
| }, | |||||
| importCustomerTypeAttributes(index) { | |||||
| var row = this.attributes[index]; | |||||
| if (!row) return; | |||||
| var ctId = Number(row.customer_type_id || 0); | |||||
| if (!ctId) return; | |||||
| var ct = this.customerTypes.find(function (c) { return Number(c.id) === ctId; }); | |||||
| if (!ct || !Array.isArray(ct.attributes) || ct.attributes.length === 0) { | |||||
| row.customer_type_id = 0; | |||||
| return; | |||||
| } | |||||
| var imported = ct.attributes.slice().sort(function (a, b) { | |||||
| return (a.order || 0) - (b.order || 0); | |||||
| }).map(function (a) { | |||||
| return { | |||||
| name: a.name || '', | |||||
| type: a.type || 'text', | |||||
| alias: a.alias || '', | |||||
| order: 0, | |||||
| api_url: a.api_url || '', | |||||
| api_match_field: a.api_match_field || '', | |||||
| api_auto_fill: a.api_auto_fill || '', | |||||
| api_format: a.api_format || 'json', | |||||
| api_return_type: a.api_return_type || 'text', | |||||
| customer_type_id: 0, | |||||
| }; | |||||
| }); | |||||
| this.attributes.splice.apply(this.attributes, [index, 1].concat(imported)); | |||||
| this.renumberOrder(); | |||||
| }, | }, | ||||
| removeAttribute(index) { | removeAttribute(index) { | ||||
Powered by TurnKey Linux.