- Add GET /api/customers, /api/customers/{id}, /api/customer-types endpoints
- Add allByTypeWithType() and include api_match_field in customer queries
- Add customer_lookup attribute type to job types: stores customer_type_id
and imports the customer type's attributes as real fields at design time
- Job form: customer_lookup renders a searchable dropdown that fetches
/api/customers?customer_type_id=N and auto-fills all matching attribute
values when a customer is selected
pull/3/head
| @@ -223,12 +223,13 @@ class JobTypeController extends Controller | |||
| ->maxLength('name', $name, 255, 'Name must be 255 characters or fewer.') | |||
| ->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 = []; | |||
| @@ -237,11 +238,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. | |||
| // 'customer' is a legacy design-time placeholder (now replaced by customer_lookup). Drop if it slips through. | |||
| 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 = [ | |||
| 'name' => $attrName, | |||
| @@ -262,6 +262,11 @@ class JobTypeController extends Controller | |||
| $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; | |||
| } | |||
| @@ -289,14 +294,15 @@ class JobTypeController extends Controller | |||
| 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 | |||
| { | |||
| return array_map(static function (array $t): array { | |||
| 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()); | |||
| } | |||
| @@ -74,7 +74,7 @@ window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_T | |||
| <option value="date">Date</option> | |||
| <option value="boolean">True/False</option> | |||
| <option value="api_lookup">API Lookup</option> | |||
| <option value="customer">Customer Type</option> | |||
| <option value="customer_lookup">Customer Lookup</option> | |||
| </select> | |||
| </label> | |||
| <div class="attribute-remove"> | |||
| @@ -121,7 +121,9 @@ window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_T | |||
| </select> | |||
| </label> | |||
| </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"> | |||
| <span>Customer Type</span> | |||
| <select class="input" | |||
| @@ -81,7 +81,7 @@ window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_T | |||
| <option value="date">Date</option> | |||
| <option value="boolean">True/False</option> | |||
| <option value="api_lookup">API Lookup</option> | |||
| <option value="customer">Customer Type</option> | |||
| <option value="customer_lookup">Customer Lookup</option> | |||
| </select> | |||
| </label> | |||
| <div class="attribute-remove"> | |||
| @@ -128,7 +128,9 @@ window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_T | |||
| </select> | |||
| </label> | |||
| </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"> | |||
| <span>Customer Type</span> | |||
| <select class="input" | |||
| @@ -76,7 +76,7 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_ | |||
| </div> | |||
| <div class="form-grid"> | |||
| <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> | |||
| <template x-if="attr.type === 'boolean'"> | |||
| <select class="input" | |||
| @@ -126,7 +126,45 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_ | |||
| </div> | |||
| </div> | |||
| </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)" | |||
| :name="`attribute_values[${attr.name}]`" | |||
| :value="attributeValues[attr.name] ?? ''" | |||
| @@ -73,7 +73,7 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_ | |||
| </div> | |||
| <div class="form-grid"> | |||
| <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> | |||
| <template x-if="attr.type === 'boolean'"> | |||
| <select class="input" | |||
| @@ -123,7 +123,45 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_ | |||
| </div> | |||
| </div> | |||
| </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)" | |||
| :name="`attribute_values[${attr.name}]`" | |||
| :value="attributeValues[attr.name] ?? ''" | |||
| @@ -1203,20 +1203,27 @@ window.jobTypeForm = function (initialAttributes, customerTypes) { | |||
| 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', | |||
| 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)); | |||
| 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(); | |||
| }, | |||
| @@ -1441,10 +1448,13 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) { | |||
| jobTypes: jobTypes, | |||
| selectedTypeId: String(initialTypeId || ''), | |||
| attributeValues: Object.assign({}, initialValues || {}), | |||
| apiLookupState: {}, | |||
| apiLookupError: {}, | |||
| apiLookupOptions: {}, | |||
| apiLookupOpen: {}, | |||
| apiLookupState: {}, | |||
| apiLookupError: {}, | |||
| apiLookupOptions: {}, | |||
| apiLookupOpen: {}, | |||
| customerLookupState: {}, | |||
| customerLookupOptions: {}, | |||
| customerLookupOpen: {}, | |||
| get currentType() { | |||
| var id = this.selectedTypeId; | |||
| @@ -1462,19 +1472,24 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) { | |||
| init() { | |||
| var self = this; | |||
| 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() { | |||
| 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; | |||
| this.$nextTick(function () { | |||
| 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 +1638,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) { | |||
| if (confirm('Delete this job? This cannot be undone.')) { | |||
| event.target.submit(); | |||
Powered by TurnKey Linux.