- 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.') | ->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()); | ||||
| } | } | ||||
| @@ -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] ?? ''" | ||||
| @@ -1203,20 +1203,27 @@ window.jobTypeForm = function (initialAttributes, customerTypes) { | |||||
| return (a.order || 0) - (b.order || 0); | return (a.order || 0) - (b.order || 0); | ||||
| }).map(function (a) { | }).map(function (a) { | ||||
| 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: 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, | 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 +1448,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 +1472,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 +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) { | 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(); | ||||
Powered by TurnKey Linux.