| @@ -0,0 +1,76 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Controllers; | |||||
| use App\Repositories\CustomerRepository; | |||||
| use App\Repositories\CustomerTypeRepository; | |||||
| use Core\Controller; | |||||
| use Core\Request; | |||||
| use Core\Response; | |||||
| class CustomerApiController extends Controller | |||||
| { | |||||
| public function customers(): Response | |||||
| { | |||||
| $request = Request::capture(); | |||||
| $customerTypeId = (int) ($request->input('customer_type_id') ?? 0); | |||||
| $repo = new CustomerRepository(database()); | |||||
| $rows = $customerTypeId > 0 | |||||
| ? $repo->allByTypeWithType($customerTypeId) | |||||
| : $repo->allWithType(); | |||||
| return $this->json(array_map([$this, 'formatCustomer'], $rows)); | |||||
| } | |||||
| public function customer(string $id): Response | |||||
| { | |||||
| $repo = new CustomerRepository(database()); | |||||
| $row = $repo->findWithType((int) $id); | |||||
| if ($row === null) { | |||||
| return Response::json(['error' => 'Not found'], 404); | |||||
| } | |||||
| return $this->json($this->formatCustomer($row)); | |||||
| } | |||||
| public function customerTypes(): Response | |||||
| { | |||||
| $repo = new CustomerTypeRepository(database()); | |||||
| $rows = $repo->allOrderedByName(); | |||||
| $data = array_map(static function (array $row): array { | |||||
| $attributes = []; | |||||
| if (!empty($row['attributes'])) { | |||||
| $attributes = json_decode((string) $row['attributes'], true) ?? []; | |||||
| } | |||||
| return [ | |||||
| 'id' => (int) $row['id'], | |||||
| 'name' => (string) $row['name'], | |||||
| 'api_match_field' => (string) ($row['api_match_field'] ?? ''), | |||||
| 'attributes' => $attributes, | |||||
| ]; | |||||
| }, $rows); | |||||
| return $this->json($data); | |||||
| } | |||||
| private function formatCustomer(array $row): array | |||||
| { | |||||
| $attributeValues = []; | |||||
| if (!empty($row['attribute_values'])) { | |||||
| $attributeValues = json_decode((string) $row['attribute_values'], true) ?? []; | |||||
| } | |||||
| return [ | |||||
| 'id' => (int) $row['id'], | |||||
| 'customer_type_id' => (int) $row['customer_type_id'], | |||||
| 'customer_type_name' => (string) ($row['customer_type_name'] ?? ''), | |||||
| 'attribute_values' => $attributeValues, | |||||
| ]; | |||||
| } | |||||
| } | |||||
| @@ -223,12 +223,13 @@ class JobTypeController extends Controller | |||||
| ->maxLength('name', $name, 255, 'Name must be 255 characters or fewer.') | ->maxLength('name', $name, 255, 'Name must be 255 characters or fewer.') | ||||
| ->errors()); | ->errors()); | ||||
| $attributeAliases = (array) ($request->input('attribute_alias') ?? []); | |||||
| $attributeApiUrls = (array) ($request->input('attribute_api_url') ?? []); | |||||
| $attributeApiFormats = (array) ($request->input('attribute_api_format') ?? []); | |||||
| $attributeApiReturnTypes = (array) ($request->input('attribute_api_return_type') ?? []); | |||||
| $attributeApiMatchFields = (array) ($request->input('attribute_api_match_field') ?? []); | |||||
| $attributeApiAutoFills = (array) ($request->input('attribute_api_auto_fill') ?? []); | |||||
| $attributeAliases = (array) ($request->input('attribute_alias') ?? []); | |||||
| $attributeApiUrls = (array) ($request->input('attribute_api_url') ?? []); | |||||
| $attributeApiFormats = (array) ($request->input('attribute_api_format') ?? []); | |||||
| $attributeApiReturnTypes = (array) ($request->input('attribute_api_return_type') ?? []); | |||||
| $attributeApiMatchFields = (array) ($request->input('attribute_api_match_field') ?? []); | |||||
| $attributeApiAutoFills = (array) ($request->input('attribute_api_auto_fill') ?? []); | |||||
| $attributeCustomerTypeIds = (array) ($request->input('attribute_customer_type_id') ?? []); | |||||
| $attributes = []; | $attributes = []; | ||||
| @@ -237,11 +238,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. | |||||
| // 'customer' is a legacy design-time placeholder (now replaced by customer_lookup). Drop if it slips through. | |||||
| if ($attrType === 'customer') continue; | 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', 'customer_lookup'], true) ? $attrType : 'text'; | |||||
| $attr = [ | $attr = [ | ||||
| 'name' => $attrName, | 'name' => $attrName, | ||||
| @@ -262,6 +262,11 @@ class JobTypeController extends Controller | |||||
| $attr['api_auto_fill'] = trim((string) ($attributeApiAutoFills[$i] ?? '')); | $attr['api_auto_fill'] = trim((string) ($attributeApiAutoFills[$i] ?? '')); | ||||
| } | } | ||||
| if ($validatedType === 'customer_lookup') { | |||||
| $attr['customer_type_id'] = (int) ($attributeCustomerTypeIds[$i] ?? 0); | |||||
| $attr['api_match_field'] = trim((string) ($attributeApiMatchFields[$i] ?? '')); | |||||
| } | |||||
| $attributes[] = $attr; | $attributes[] = $attr; | ||||
| } | } | ||||
| @@ -289,14 +294,15 @@ class JobTypeController extends Controller | |||||
| return new CustomerTypeRepository(database()); | return new CustomerTypeRepository(database()); | ||||
| } | } | ||||
| /** @return list<array{id: int, name: string, attributes: array}> */ | |||||
| /** @return list<array{id: int, name: string, api_match_field: string, attributes: array}> */ | |||||
| private function loadCustomerTypes(): array | private function loadCustomerTypes(): array | ||||
| { | { | ||||
| return array_map(static function (array $t): array { | return array_map(static function (array $t): array { | ||||
| return [ | return [ | ||||
| 'id' => (int) $t['id'], | |||||
| 'name' => (string) $t['name'], | |||||
| 'attributes' => json_decode((string) ($t['attributes'] ?? '[]'), true) ?? [], | |||||
| 'id' => (int) $t['id'], | |||||
| 'name' => (string) $t['name'], | |||||
| 'api_match_field' => (string) ($t['api_match_field'] ?? ''), | |||||
| 'attributes' => json_decode((string) ($t['attributes'] ?? '[]'), true) ?? [], | |||||
| ]; | ]; | ||||
| }, $this->customerTypeRepo()->allOrderedByName()); | }, $this->customerTypeRepo()->allOrderedByName()); | ||||
| } | } | ||||
| @@ -48,20 +48,39 @@ class CustomerRepository extends Repository | |||||
| 'SELECT c.id, c.customer_type_id, c.attribute_values, | 'SELECT c.id, c.customer_type_id, c.attribute_values, | ||||
| c.created_at, c.updated_at, | c.created_at, c.updated_at, | ||||
| ct.name AS customer_type_name, | ct.name AS customer_type_name, | ||||
| ct.attributes AS customer_type_attributes | |||||
| ct.attributes AS customer_type_attributes, | |||||
| ct.api_match_field | |||||
| FROM customer c | FROM customer c | ||||
| INNER JOIN customer_type ct ON c.customer_type_id = ct.id | INNER JOIN customer_type ct ON c.customer_type_id = ct.id | ||||
| ORDER BY c.id DESC' | ORDER BY c.id DESC' | ||||
| ); | ); | ||||
| } | } | ||||
| /** @return list<array<string, mixed>> */ | |||||
| public function allByTypeWithType(int $typeId): array | |||||
| { | |||||
| return $this->database->query( | |||||
| 'SELECT c.id, c.customer_type_id, c.attribute_values, | |||||
| c.created_at, c.updated_at, | |||||
| ct.name AS customer_type_name, | |||||
| ct.attributes AS customer_type_attributes, | |||||
| ct.api_match_field | |||||
| FROM customer c | |||||
| INNER JOIN customer_type ct ON c.customer_type_id = ct.id | |||||
| WHERE c.customer_type_id = :type_id | |||||
| ORDER BY c.id DESC', | |||||
| ['type_id' => $typeId] | |||||
| ); | |||||
| } | |||||
| public function findWithType(int $id): ?array | public function findWithType(int $id): ?array | ||||
| { | { | ||||
| return $this->database->first( | return $this->database->first( | ||||
| 'SELECT c.id, c.customer_type_id, c.attribute_values, | 'SELECT c.id, c.customer_type_id, c.attribute_values, | ||||
| c.created_at, c.updated_at, | c.created_at, c.updated_at, | ||||
| ct.name AS customer_type_name, | ct.name AS customer_type_name, | ||||
| ct.attributes AS customer_type_attributes | |||||
| ct.attributes AS customer_type_attributes, | |||||
| ct.api_match_field | |||||
| FROM customer c | FROM customer c | ||||
| INNER JOIN customer_type ct ON c.customer_type_id = ct.id | INNER JOIN customer_type ct ON c.customer_type_id = ct.id | ||||
| WHERE c.id = :id', | WHERE c.id = :id', | ||||
| @@ -74,7 +74,7 @@ window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_T | |||||
| <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> | |||||
| <option value="customer_lookup">Customer Lookup</option> | |||||
| </select> | </select> | ||||
| </label> | </label> | ||||
| <div class="attribute-remove"> | <div class="attribute-remove"> | ||||
| @@ -121,7 +121,9 @@ window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_T | |||||
| </select> | </select> | ||||
| </label> | </label> | ||||
| </div> | </div> | ||||
| <div class="customer-type-config" x-show="attr.type === 'customer'"> | |||||
| <div class="customer-type-config" x-show="attr.type === 'customer_lookup'"> | |||||
| <input type="hidden" :name="`attribute_customer_type_id[${index}]`" :value="attr.customer_type_id || 0"> | |||||
| <input type="hidden" :name="`attribute_api_match_field[${index}]`" :value="attr.api_match_field || ''"> | |||||
| <label class="field"> | <label class="field"> | ||||
| <span>Customer Type</span> | <span>Customer Type</span> | ||||
| <select class="input" | <select class="input" | ||||
| @@ -81,7 +81,7 @@ window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_T | |||||
| <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> | |||||
| <option value="customer_lookup">Customer Lookup</option> | |||||
| </select> | </select> | ||||
| </label> | </label> | ||||
| <div class="attribute-remove"> | <div class="attribute-remove"> | ||||
| @@ -128,7 +128,9 @@ window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_T | |||||
| </select> | </select> | ||||
| </label> | </label> | ||||
| </div> | </div> | ||||
| <div class="customer-type-config" x-show="attr.type === 'customer'"> | |||||
| <div class="customer-type-config" x-show="attr.type === 'customer_lookup'"> | |||||
| <input type="hidden" :name="`attribute_customer_type_id[${index}]`" :value="attr.customer_type_id || 0"> | |||||
| <input type="hidden" :name="`attribute_api_match_field[${index}]`" :value="attr.api_match_field || ''"> | |||||
| <label class="field"> | <label class="field"> | ||||
| <span>Customer Type</span> | <span>Customer Type</span> | ||||
| <select class="input" | <select class="input" | ||||
| @@ -76,7 +76,7 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_ | |||||
| </div> | </div> | ||||
| <div class="form-grid"> | <div class="form-grid"> | ||||
| <template x-for="attr in currentAttributes" :key="attr.name"> | <template x-for="attr in currentAttributes" :key="attr.name"> | ||||
| <label class="field" :class="{ 'api-lookup-label': attr.type === 'api_lookup' }"> | |||||
| <label class="field" :class="{ 'api-lookup-label': attr.type === 'api_lookup' || attr.type === 'customer_lookup' }"> | |||||
| <span x-text="attr.name"></span> | <span x-text="attr.name"></span> | ||||
| <template x-if="attr.type === 'boolean'"> | <template x-if="attr.type === 'boolean'"> | ||||
| <select class="input" | <select class="input" | ||||
| @@ -126,7 +126,45 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_ | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </template> | </template> | ||||
| <template x-if="attr.type !== 'boolean' && attr.type !== 'api_lookup'"> | |||||
| <template x-if="attr.type === 'customer_lookup'"> | |||||
| <div class="api-lookup-field"> | |||||
| <input class="input" type="text" | |||||
| :name="`attribute_values[${attr.name}]`" | |||||
| x-model="attributeValues[attr.name]" | |||||
| placeholder="Search customers…" | |||||
| autocomplete="off" | |||||
| x-on:focus="openCustomerLookup(attr.name)" | |||||
| x-on:blur="closeCustomerLookup(attr.name)" | |||||
| x-on:keydown.escape.prevent="closeCustomerLookup(attr.name)"> | |||||
| <div class="api-lookup-dropdown" | |||||
| x-show="customerLookupOpen[attr.name]" | |||||
| x-on:mousedown.prevent> | |||||
| <p class="api-lookup-loading" x-show="customerLookupState[attr.name] === 'loading'">Loading…</p> | |||||
| <div class="api-lookup-table" x-show="customerLookupState[attr.name] !== 'loading'"> | |||||
| <div class="api-lookup-thead" | |||||
| :style="`grid-template-columns: repeat(${getCustomerOptions(attr.name).fields.length || 1}, 1fr)`"> | |||||
| <template x-for="f in getCustomerOptions(attr.name).fields" :key="f"> | |||||
| <span x-text="f"></span> | |||||
| </template> | |||||
| </div> | |||||
| <div class="api-lookup-tbody"> | |||||
| <template x-for="rec in getFilteredCustomers(attr.name, attributeValues[attr.name])" :key="rec._primary"> | |||||
| <div class="api-lookup-tr" | |||||
| :class="{ 'is-selected': attributeValues[attr.name] === rec._primary }" | |||||
| :style="`grid-template-columns: repeat(${getCustomerOptions(attr.name).fields.length || 1}, 1fr)`" | |||||
| x-on:click="selectCustomer(attr, rec)"> | |||||
| <template x-for="(v, i) in rec._display" :key="i"> | |||||
| <span x-text="v"></span> | |||||
| </template> | |||||
| </div> | |||||
| </template> | |||||
| <div class="api-lookup-empty" x-show="!getFilteredCustomers(attr.name, attributeValues[attr.name]).length">No customers found.</div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <template x-if="attr.type !== 'boolean' && attr.type !== 'api_lookup' && attr.type !== 'customer_lookup'"> | |||||
| <input class="input" :type="inputType(attr.type)" | <input class="input" :type="inputType(attr.type)" | ||||
| :name="`attribute_values[${attr.name}]`" | :name="`attribute_values[${attr.name}]`" | ||||
| :value="attributeValues[attr.name] ?? ''" | :value="attributeValues[attr.name] ?? ''" | ||||
| @@ -73,7 +73,7 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_ | |||||
| </div> | </div> | ||||
| <div class="form-grid"> | <div class="form-grid"> | ||||
| <template x-for="attr in currentAttributes" :key="attr.name"> | <template x-for="attr in currentAttributes" :key="attr.name"> | ||||
| <label class="field" :class="{ 'api-lookup-label': attr.type === 'api_lookup' }"> | |||||
| <label class="field" :class="{ 'api-lookup-label': attr.type === 'api_lookup' || attr.type === 'customer_lookup' }"> | |||||
| <span x-text="attr.name"></span> | <span x-text="attr.name"></span> | ||||
| <template x-if="attr.type === 'boolean'"> | <template x-if="attr.type === 'boolean'"> | ||||
| <select class="input" | <select class="input" | ||||
| @@ -123,7 +123,45 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_ | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </template> | </template> | ||||
| <template x-if="attr.type !== 'boolean' && attr.type !== 'api_lookup'"> | |||||
| <template x-if="attr.type === 'customer_lookup'"> | |||||
| <div class="api-lookup-field"> | |||||
| <input class="input" type="text" | |||||
| :name="`attribute_values[${attr.name}]`" | |||||
| x-model="attributeValues[attr.name]" | |||||
| placeholder="Search customers…" | |||||
| autocomplete="off" | |||||
| x-on:focus="openCustomerLookup(attr.name)" | |||||
| x-on:blur="closeCustomerLookup(attr.name)" | |||||
| x-on:keydown.escape.prevent="closeCustomerLookup(attr.name)"> | |||||
| <div class="api-lookup-dropdown" | |||||
| x-show="customerLookupOpen[attr.name]" | |||||
| x-on:mousedown.prevent> | |||||
| <p class="api-lookup-loading" x-show="customerLookupState[attr.name] === 'loading'">Loading…</p> | |||||
| <div class="api-lookup-table" x-show="customerLookupState[attr.name] !== 'loading'"> | |||||
| <div class="api-lookup-thead" | |||||
| :style="`grid-template-columns: repeat(${getCustomerOptions(attr.name).fields.length || 1}, 1fr)`"> | |||||
| <template x-for="f in getCustomerOptions(attr.name).fields" :key="f"> | |||||
| <span x-text="f"></span> | |||||
| </template> | |||||
| </div> | |||||
| <div class="api-lookup-tbody"> | |||||
| <template x-for="rec in getFilteredCustomers(attr.name, attributeValues[attr.name])" :key="rec._primary"> | |||||
| <div class="api-lookup-tr" | |||||
| :class="{ 'is-selected': attributeValues[attr.name] === rec._primary }" | |||||
| :style="`grid-template-columns: repeat(${getCustomerOptions(attr.name).fields.length || 1}, 1fr)`" | |||||
| x-on:click="selectCustomer(attr, rec)"> | |||||
| <template x-for="(v, i) in rec._display" :key="i"> | |||||
| <span x-text="v"></span> | |||||
| </template> | |||||
| </div> | |||||
| </template> | |||||
| <div class="api-lookup-empty" x-show="!getFilteredCustomers(attr.name, attributeValues[attr.name]).length">No customers found.</div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <template x-if="attr.type !== 'boolean' && attr.type !== 'api_lookup' && attr.type !== 'customer_lookup'"> | |||||
| <input class="input" :type="inputType(attr.type)" | <input class="input" :type="inputType(attr.type)" | ||||
| :name="`attribute_values[${attr.name}]`" | :name="`attribute_values[${attr.name}]`" | ||||
| :value="attributeValues[attr.name] ?? ''" | :value="attributeValues[attr.name] ?? ''" | ||||
| @@ -0,0 +1,32 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $columnExists = $database->first( | |||||
| "SELECT 1 AS col FROM INFORMATION_SCHEMA.COLUMNS | |||||
| WHERE TABLE_NAME = 'customer_type' AND COLUMN_NAME = 'api_match_field'" | |||||
| ); | |||||
| if ($columnExists) { | |||||
| return; | |||||
| } | |||||
| $database->execute( | |||||
| "ALTER TABLE customer_type ADD api_match_field NVARCHAR(255) NULL" | |||||
| ); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| $database->execute( | |||||
| "ALTER TABLE customer_type DROP COLUMN api_match_field" | |||||
| ); | |||||
| } | |||||
| }; | |||||
| @@ -1202,21 +1202,29 @@ window.jobTypeForm = function (initialAttributes, customerTypes) { | |||||
| var imported = ct.attributes.slice().sort(function (a, b) { | var imported = ct.attributes.slice().sort(function (a, b) { | ||||
| return (a.order || 0) - (b.order || 0); | return (a.order || 0) - (b.order || 0); | ||||
| }).map(function (a) { | }).map(function (a) { | ||||
| var importedType = (a.type === 'api_lookup') ? 'text' : (a.type || 'text'); | |||||
| return { | 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', | |||||
| name: a.name || '', | |||||
| type: importedType, | |||||
| alias: a.alias || '', | |||||
| order: 0, | |||||
| api_url: '', | |||||
| api_match_field: '', | |||||
| api_auto_fill: '', | |||||
| api_format: a.api_format || 'json', | |||||
| api_return_type: a.api_return_type || 'text', | |||||
| customer_type_id: 0, | customer_type_id: 0, | ||||
| }; | }; | ||||
| }); | }); | ||||
| this.attributes.splice.apply(this.attributes, [index, 1].concat(imported)); | |||||
| if (row.type === 'customer_lookup') { | |||||
| // Keep the lookup row; stamp api_match_field from the customer type, then insert attrs after it. | |||||
| row.api_match_field = ct.api_match_field || ''; | |||||
| this.attributes.splice.apply(this.attributes, [index + 1, 0].concat(imported)); | |||||
| } else { | |||||
| // Legacy customer placeholder — replace with expanded attrs. | |||||
| this.attributes.splice.apply(this.attributes, [index, 1].concat(imported)); | |||||
| } | |||||
| this.renumberOrder(); | this.renumberOrder(); | ||||
| }, | }, | ||||
| @@ -1441,10 +1449,13 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) { | |||||
| jobTypes: jobTypes, | jobTypes: jobTypes, | ||||
| selectedTypeId: String(initialTypeId || ''), | selectedTypeId: String(initialTypeId || ''), | ||||
| attributeValues: Object.assign({}, initialValues || {}), | attributeValues: Object.assign({}, initialValues || {}), | ||||
| apiLookupState: {}, | |||||
| apiLookupError: {}, | |||||
| apiLookupOptions: {}, | |||||
| apiLookupOpen: {}, | |||||
| apiLookupState: {}, | |||||
| apiLookupError: {}, | |||||
| apiLookupOptions: {}, | |||||
| apiLookupOpen: {}, | |||||
| customerLookupState: {}, | |||||
| customerLookupOptions: {}, | |||||
| customerLookupOpen: {}, | |||||
| get currentType() { | get currentType() { | ||||
| var id = this.selectedTypeId; | var id = this.selectedTypeId; | ||||
| @@ -1462,19 +1473,24 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) { | |||||
| init() { | init() { | ||||
| var self = this; | var self = this; | ||||
| this.currentAttributes.forEach(function (attr) { | this.currentAttributes.forEach(function (attr) { | ||||
| if (attr.type === 'api_lookup') { self.fetchApiValue(attr); } | |||||
| if (attr.type === 'api_lookup') { self.fetchApiValue(attr); } | |||||
| if (attr.type === 'customer_lookup') { self.fetchCustomers(attr); } | |||||
| }); | }); | ||||
| }, | }, | ||||
| onTypeChange() { | onTypeChange() { | ||||
| this.attributeValues = {}; | |||||
| this.apiLookupOptions = {}; | |||||
| this.apiLookupState = {}; | |||||
| this.apiLookupOpen = {}; | |||||
| this.attributeValues = {}; | |||||
| this.apiLookupOptions = {}; | |||||
| this.apiLookupState = {}; | |||||
| this.apiLookupOpen = {}; | |||||
| this.customerLookupOptions = {}; | |||||
| this.customerLookupState = {}; | |||||
| this.customerLookupOpen = {}; | |||||
| var self = this; | var self = this; | ||||
| this.$nextTick(function () { | this.$nextTick(function () { | ||||
| self.currentAttributes.forEach(function (attr) { | self.currentAttributes.forEach(function (attr) { | ||||
| if (attr.type === 'api_lookup') { self.fetchApiValue(attr); } | |||||
| if (attr.type === 'api_lookup') { self.fetchApiValue(attr); } | |||||
| if (attr.type === 'customer_lookup') { self.fetchCustomers(attr); } | |||||
| }); | }); | ||||
| }); | }); | ||||
| }, | }, | ||||
| @@ -1623,6 +1639,79 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) { | |||||
| }); | }); | ||||
| }, | }, | ||||
| getCustomerOptions(name) { | |||||
| return this.customerLookupOptions[name] || { fields: [], records: [] }; | |||||
| }, | |||||
| openCustomerLookup(name) { | |||||
| var o = {}; o[name] = true; | |||||
| this.customerLookupOpen = Object.assign({}, this.customerLookupOpen, o); | |||||
| }, | |||||
| closeCustomerLookup(name) { | |||||
| var o = {}; o[name] = false; | |||||
| this.customerLookupOpen = Object.assign({}, this.customerLookupOpen, o); | |||||
| }, | |||||
| getFilteredCustomers(name, search) { | |||||
| var records = this.getCustomerOptions(name).records; | |||||
| if (!search) return records; | |||||
| var term = String(search).toLowerCase(); | |||||
| return records.filter(function (rec) { | |||||
| return rec._display.some(function (v) { | |||||
| return String(v).toLowerCase().indexOf(term) !== -1; | |||||
| }); | |||||
| }); | |||||
| }, | |||||
| selectCustomer(attr, customer) { | |||||
| var newValues = Object.assign({}, this.attributeValues); | |||||
| newValues[attr.name] = customer._primary; | |||||
| var av = customer._raw.attribute_values || {}; | |||||
| Object.keys(av).forEach(function (key) { | |||||
| newValues[key] = String(av[key] !== null && av[key] !== undefined ? av[key] : ''); | |||||
| }); | |||||
| this.attributeValues = newValues; | |||||
| this.closeCustomerLookup(attr.name); | |||||
| }, | |||||
| fetchCustomers(attr) { | |||||
| var self = this; | |||||
| var ctId = Number(attr.customer_type_id || 0); | |||||
| var matchField = attr.api_match_field || ''; | |||||
| if (!ctId) return; | |||||
| var s = {}; s[attr.name] = 'loading'; | |||||
| var o = {}; o[attr.name] = { fields: [], records: [] }; | |||||
| self.customerLookupState = Object.assign({}, self.customerLookupState, s); | |||||
| self.customerLookupOptions = Object.assign({}, self.customerLookupOptions, o); | |||||
| fetch('/api/customers?customer_type_id=' + ctId, { headers: { Accept: 'application/json' } }) | |||||
| .then(function (res) { return res.json(); }) | |||||
| .then(function (rows) { | |||||
| var ss = {}; ss[attr.name] = 'idle'; | |||||
| if (!Array.isArray(rows) || rows.length === 0) { | |||||
| self.customerLookupState = Object.assign({}, self.customerLookupState, ss); | |||||
| return; | |||||
| } | |||||
| var attrKeys = Object.keys(rows[0].attribute_values || {}); | |||||
| var records = rows.map(function (c) { | |||||
| var av = c.attribute_values || {}; | |||||
| var display = attrKeys.map(function (k) { return av[k] !== undefined && av[k] !== null ? String(av[k]) : ''; }); | |||||
| var primary = matchField && av[matchField] !== undefined ? String(av[matchField]) : String(c.id); | |||||
| return { _primary: primary, _display: display, _raw: c }; | |||||
| }); | |||||
| var oo = {}; oo[attr.name] = { fields: attrKeys, records: records }; | |||||
| self.customerLookupOptions = Object.assign({}, self.customerLookupOptions, oo); | |||||
| self.customerLookupState = Object.assign({}, self.customerLookupState, ss); | |||||
| }) | |||||
| .catch(function (err) { | |||||
| console.error('[customer-lookup] fetch failed:', err); | |||||
| var cs = {}; cs[attr.name] = 'error'; | |||||
| self.customerLookupState = Object.assign({}, self.customerLookupState, cs); | |||||
| }); | |||||
| }, | |||||
| confirmDelete(event) { | confirmDelete(event) { | ||||
| if (confirm('Delete this job? This cannot be undone.')) { | if (confirm('Delete this job? This cannot be undone.')) { | ||||
| event.target.submit(); | event.target.submit(); | ||||
| @@ -6,6 +6,7 @@ use App\Controllers\ApiProxyController; | |||||
| use App\Controllers\AuthController; | use App\Controllers\AuthController; | ||||
| use App\Controllers\CampaignController; | use App\Controllers\CampaignController; | ||||
| use App\Controllers\CampaignTypeController; | use App\Controllers\CampaignTypeController; | ||||
| use App\Controllers\CustomerApiController; | |||||
| use App\Controllers\CustomerController; | use App\Controllers\CustomerController; | ||||
| use App\Controllers\CustomerTypeController; | use App\Controllers\CustomerTypeController; | ||||
| use App\Controllers\HealthController; | use App\Controllers\HealthController; | ||||
| @@ -13,6 +14,11 @@ use App\Controllers\HomeController; | |||||
| use App\Controllers\JobController; | use App\Controllers\JobController; | ||||
| use App\Controllers\JobTypeController; | use App\Controllers\JobTypeController; | ||||
| // ── Customer API (public JSON endpoints) ────────────────────────────────────── | |||||
| $router->get('/api/customers', [CustomerApiController::class, 'customers']); | |||||
| $router->get('/api/customers/{id}', [CustomerApiController::class, 'customer']); | |||||
| $router->get('/api/customer-types', [CustomerApiController::class, 'customerTypes']); | |||||
| // ── API Proxy ───────────────────────────────────────────────────────────────── | // ── API Proxy ───────────────────────────────────────────────────────────────── | ||||
| $router->get('/api/proxy', [ApiProxyController::class, 'fetch'])->middleware('auth'); | $router->get('/api/proxy', [ApiProxyController::class, 'fetch'])->middleware('auth'); | ||||
Powered by TurnKey Linux.