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_CLIENT_ID=canopy-web | |||
| 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/ | |||
| npm-debug.log* | |||
| yarn-error.log* | |||
| .claude/ | |||
| .abacusai/ | |||
| @@ -5,6 +5,7 @@ declare(strict_types=1); | |||
| namespace App\Controllers; | |||
| use App\Models\JobType; | |||
| use App\Repositories\CustomerTypeRepository; | |||
| use App\Repositories\JobTypeAuditRepository; | |||
| use App\Repositories\JobTypeRepository; | |||
| use App\ViewModels\JobTypeViewModel; | |||
| @@ -52,7 +53,8 @@ class JobTypeController extends Controller | |||
| public function create(): Response | |||
| { | |||
| $model = new JobTypeViewModel(); | |||
| $model->title = 'New Job Type'; | |||
| $model->title = 'New Job Type'; | |||
| $model->customerTypes = $this->loadCustomerTypes(); | |||
| return $this->view('job-types.create', [ | |||
| 'model' => $model, | |||
| @@ -71,9 +73,10 @@ class JobTypeController extends Controller | |||
| if (!empty($errors)) { | |||
| $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', [ | |||
| 'model' => $model, | |||
| @@ -104,9 +107,10 @@ class JobTypeController extends Controller | |||
| } | |||
| $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 = [ | |||
| 'name' => (string) $row['name'], | |||
| 'attributes' => json_decode((string) ($row['attributes'] ?? '[]'), true) ?? [], | |||
| @@ -138,10 +142,11 @@ class JobTypeController extends Controller | |||
| if (!empty($errors)) { | |||
| $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', [ | |||
| 'model' => $model, | |||
| @@ -232,6 +237,10 @@ class JobTypeController extends Controller | |||
| $attrType = trim((string) ($attributeTypes[$i] ?? 'text')); | |||
| 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'; | |||
| $attr = [ | |||
| @@ -274,4 +283,21 @@ class JobTypeController extends Controller | |||
| { | |||
| 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 */ | |||
| 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"> | |||
| @@ -10,7 +13,7 @@ | |||
| <a class="button button-secondary" href="/job-types">← Back to list</a> | |||
| </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'])): ?> | |||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | |||
| @@ -71,6 +74,7 @@ | |||
| <option value="date">Date</option> | |||
| <option value="boolean">True/False</option> | |||
| <option value="api_lookup">API Lookup</option> | |||
| <option value="customer">Customer Type</option> | |||
| </select> | |||
| </label> | |||
| <div class="attribute-remove"> | |||
| @@ -117,6 +121,22 @@ | |||
| </select> | |||
| </label> | |||
| </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> | |||
| </template> | |||
| </div> | |||
| @@ -1,5 +1,8 @@ | |||
| <?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"> | |||
| @@ -17,7 +20,7 @@ | |||
| </div> | |||
| <?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'])): ?> | |||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | |||
| @@ -78,6 +81,7 @@ | |||
| <option value="date">Date</option> | |||
| <option value="boolean">True/False</option> | |||
| <option value="api_lookup">API Lookup</option> | |||
| <option value="customer">Customer Type</option> | |||
| </select> | |||
| </label> | |||
| <div class="attribute-remove"> | |||
| @@ -124,6 +128,22 @@ | |||
| </select> | |||
| </label> | |||
| </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> | |||
| </template> | |||
| </div> | |||
| @@ -1171,14 +1171,53 @@ window.deleteJobType = function (id) { | |||
| _postDelete('/job-types/' + id + '/delete'); | |||
| }; | |||
| window.jobTypeForm = function (initialAttributes) { | |||
| window.jobTypeForm = function (initialAttributes, customerTypes) { | |||
| return { | |||
| attributes: Array.isArray(initialAttributes) ? initialAttributes : [], | |||
| customerTypes: Array.isArray(customerTypes) ? customerTypes : [], | |||
| dragIndex: null, | |||
| dragOverIndex: null, | |||
| 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) { | |||
Powered by TurnKey Linux.