- Replace customer_lookup "Search/display field" single dropdown with a free-text "Match field name(s)" input supporting multiple fields (semicolon-separated), matching the api_lookup UX pattern - When customer_lookup type is selected in the job type editor, any existing api_lookup attributes are automatically removed (customer_lookup supersedes api_lookup) - In the job create/edit form, api_lookup attributes are suppressed when the job type includes a customer_lookup (customer_lookup takes the place of api_lookup) - Backend /customers/lookup endpoint already handled multi-field match_field; no PHP changes required for that pathCustomer_Type_Lookup
| @@ -14,7 +14,8 @@ | |||||
| "Bash(findstr \"^app\")", | "Bash(findstr \"^app\")", | ||||
| "PowerShell(cd \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\"; Get-ChildItem -Recurse -Directory | Select-Object -ExpandProperty FullName | Where-Object { $_ -notmatch '\\\\.git|\\\\.claude|node_modules' } | Sort-Object | Select-Object -First 30)", | "PowerShell(cd \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\"; Get-ChildItem -Recurse -Directory | Select-Object -ExpandProperty FullName | Where-Object { $_ -notmatch '\\\\.git|\\\\.claude|node_modules' } | Sort-Object | Select-Object -First 30)", | ||||
| "Bash(git -C \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\" remote get-url origin)", | "Bash(git -C \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\" remote get-url origin)", | ||||
| "PowerShell(Get-ChildItem -Path \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\" -Recurse -Directory -ErrorAction SilentlyContinue | Select-Object -First 30 | ForEach-Object { $_.FullName })" | |||||
| "PowerShell(Get-ChildItem -Path \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\" -Recurse -Directory -ErrorAction SilentlyContinue | Select-Object -First 30 | ForEach-Object { $_.FullName })", | |||||
| "Bash(Get-ChildItem -Path \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\" -Directory)" | |||||
| ] | ] | ||||
| } | } | ||||
| } | } | ||||
| @@ -187,6 +187,74 @@ class CustomerController extends Controller | |||||
| return $this->redirect('/customers?deleted=1'); | return $this->redirect('/customers?deleted=1'); | ||||
| } | } | ||||
| public function lookup(): Response | |||||
| { | |||||
| $request = Request::capture(); | |||||
| $typeId = (int) $request->input('type_id', 0); | |||||
| $matchParam = trim((string) $request->input('match_field', '')); | |||||
| if ($typeId <= 0) { | |||||
| return $this->json(['fields' => [], 'records' => []]); | |||||
| } | |||||
| // match_field is an attribute NAME (not alias) | |||||
| $matchNames = $matchParam !== '' | |||||
| ? array_values(array_filter(array_map('trim', explode(';', $matchParam)))) | |||||
| : []; | |||||
| $rows = $this->repo()->searchByType($typeId); | |||||
| if (empty($rows)) { | |||||
| return $this->json(['fields' => [], 'records' => []]); | |||||
| } | |||||
| $typeAttrs = []; | |||||
| if (!empty($rows[0]['type_attributes'])) { | |||||
| $typeAttrs = json_decode((string) $rows[0]['type_attributes'], true) ?? []; | |||||
| } | |||||
| // Collect all attribute names in order | |||||
| $attrNames = []; | |||||
| foreach ($typeAttrs as $attr) { | |||||
| $name = trim((string) ($attr['name'] ?? '')); | |||||
| if ($name !== '') { | |||||
| $attrNames[] = $name; | |||||
| } | |||||
| } | |||||
| if (empty($matchNames) && !empty($attrNames)) { | |||||
| $matchNames = [$attrNames[0]]; | |||||
| } | |||||
| $records = []; | |||||
| foreach ($rows as $row) { | |||||
| $attrValues = !empty($row['attribute_values']) | |||||
| ? (json_decode((string) $row['attribute_values'], true) ?? []) | |||||
| : []; | |||||
| // _row keyed by attribute NAME — matches attribute_values storage format | |||||
| $rowByName = []; | |||||
| foreach ($attrNames as $name) { | |||||
| $rowByName[$name] = (string) ($attrValues[$name] ?? ''); | |||||
| } | |||||
| $display = array_map(fn($n) => $rowByName[$n] ?? '', $matchNames); | |||||
| $primary = $display[0] ?? ''; | |||||
| if ($primary === '') { | |||||
| continue; | |||||
| } | |||||
| $records[] = [ | |||||
| '_primary' => $primary, | |||||
| '_display' => array_values($display), | |||||
| '_row' => $rowByName, | |||||
| ]; | |||||
| } | |||||
| return $this->json(['fields' => $matchNames, 'records' => $records]); | |||||
| } | |||||
| // ── Helpers ─────────────────────────────────────────────────────────────── | // ── Helpers ─────────────────────────────────────────────────────────────── | ||||
| private function loadCustomerTypes(): array | private function loadCustomerTypes(): array | ||||
| @@ -223,12 +223,15 @@ 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') ?? []); | |||||
| $attributeDbMatchFields = (array) ($request->input('attribute_db_match_field') ?? []); | |||||
| $attributeDbAutoFills = (array) ($request->input('attribute_db_auto_fill') ?? []); | |||||
| $attributeDbCustomerTypeIds = (array) ($request->input('attribute_db_customer_type_id') ?? []); | |||||
| $attributes = []; | $attributes = []; | ||||
| @@ -241,7 +244,8 @@ class JobTypeController extends Controller | |||||
| // by the Job Type editor JS before submit. If one slips through, drop it. | // by the Job Type editor JS before submit. If one slips through, drop it. | ||||
| if ($attrType === 'customer') continue; | if ($attrType === 'customer') continue; | ||||
| $validatedType = in_array($attrType, ['text', 'number', 'date', 'boolean', 'api_lookup'], true) ? $attrType : 'text'; | |||||
| if ($attrType === 'database_lookup') $attrType = 'customer_lookup'; // renamed | |||||
| $validatedType = in_array($attrType, ['text', 'number', 'date', 'boolean', 'api_lookup', 'customer_lookup'], true) ? $attrType : 'text'; | |||||
| $attr = [ | $attr = [ | ||||
| 'name' => $attrName, | 'name' => $attrName, | ||||
| @@ -262,6 +266,12 @@ 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['db_match_field'] = trim((string) ($attributeDbMatchFields[$i] ?? '')); | |||||
| $attr['db_auto_fill'] = trim((string) ($attributeDbAutoFills[$i] ?? '')); | |||||
| $attr['db_customer_type_id'] = max(0, (int) ($attributeDbCustomerTypeIds[$i] ?? 0)); | |||||
| } | |||||
| $attributes[] = $attr; | $attributes[] = $attr; | ||||
| } | } | ||||
| @@ -69,6 +69,20 @@ class CustomerRepository extends Repository | |||||
| ); | ); | ||||
| } | } | ||||
| /** Used after INSERT to recover the generated id for audit logging. */ | |||||
| /** @return list<array<string, mixed>> */ | |||||
| public function searchByType(int $typeId): array | |||||
| { | |||||
| return $this->database->query( | |||||
| 'SELECT c.id, c.attribute_values, ct.attributes AS type_attributes | |||||
| 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 ASC', | |||||
| ['type_id' => $typeId] | |||||
| ); | |||||
| } | |||||
| /** Used after INSERT to recover the generated id for audit logging. */ | /** Used after INSERT to recover the generated id for audit logging. */ | ||||
| public function findLatestByType(int $typeId): ?array | public function findLatestByType(int $typeId): ?array | ||||
| { | { | ||||
| @@ -19,7 +19,7 @@ window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_T | |||||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | ||||
| <?php endif; ?> | <?php endif; ?> | ||||
| <form method="post" action="/job-types" class="ct-form" novalidate> | |||||
| <form method="post" action="/job-types" class="ct-form" novalidate x-on:submit="guardSubmit($event)"> | |||||
| <?= csrf_field() ?> | <?= csrf_field() ?> | ||||
| <div class="form-section"> | <div class="form-section"> | ||||
| @@ -68,13 +68,14 @@ window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_T | |||||
| </label> | </label> | ||||
| <label class="field attribute-type-field"> | <label class="field attribute-type-field"> | ||||
| <span>Type</span> | <span>Type</span> | ||||
| <select class="input" :name="`attribute_type[${index}]`" x-model="attr.type"> | |||||
| <select class="input" :name="`attribute_type[${index}]`" x-model="attr.type" x-on:change="onAttributeTypeChange(index)"> | |||||
| <option value="text">Text</option> | <option value="text">Text</option> | ||||
| <option value="number">Number</option> | <option value="number">Number</option> | ||||
| <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">Customer Type</option> | ||||
| <option value="customer_lookup">Customer Lookup</option> | |||||
| </select> | </select> | ||||
| </label> | </label> | ||||
| <div class="attribute-remove"> | <div class="attribute-remove"> | ||||
| @@ -137,6 +138,40 @@ window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_T | |||||
| No customer types exist yet. <a href="/customer-types/create">Create one</a> first. | No customer types exist yet. <a href="/customer-types/create">Create one</a> first. | ||||
| </p> | </p> | ||||
| </div> | </div> | ||||
| <div class="api-lookup-config" x-show="attr.type === 'customer_lookup'"> | |||||
| <p class="field-error" x-show="!attr.name.trim()" style="margin:0 0 8px"> | |||||
| Fill in the attribute name above before saving. | |||||
| </p> | |||||
| <label class="field"> | |||||
| <span>Customer Type to search</span> | |||||
| <select class="input" | |||||
| :name="`attribute_db_customer_type_id[${index}]`" | |||||
| x-model.number="attr.db_customer_type_id" | |||||
| x-on:change="onDbCustomerTypeChange(attr)"> | |||||
| <option value="0">— Select a customer type —</option> | |||||
| <?php foreach ($model->customerTypes as $ct): ?> | |||||
| <option value="<?= e((string) $ct['id']) ?>"><?= e($ct['name']) ?></option> | |||||
| <?php endforeach; ?> | |||||
| </select> | |||||
| </label> | |||||
| <label class="field api-lookup-match-field" x-show="attr.db_customer_type_id > 0"> | |||||
| <span>Match field name(s)</span> | |||||
| <input class="input" type="text" | |||||
| :name="`attribute_db_match_field[${index}]`" | |||||
| x-model="attr.db_match_field" | |||||
| placeholder="e.g. FirstName or FirstName;LastName"> | |||||
| </label> | |||||
| <p class="attributes-hint" x-show="attr.db_match_field && attr.db_auto_fill"> | |||||
| Auto-fills: <strong x-text="attr.db_auto_fill.split(';').filter(Boolean).join(', ')"></strong> | |||||
| </p> | |||||
| <p class="attributes-hint" x-show="attr.db_customer_type_id > 0 && getCustomerTypeAttrs(attr.db_customer_type_id).length === 0"> | |||||
| This customer type has no attributes defined. | |||||
| </p> | |||||
| <input type="hidden" :name="`attribute_db_auto_fill[${index}]`" :value="attr.db_auto_fill"> | |||||
| <?php if (empty($model->customerTypes)): ?> | |||||
| <p class="attributes-hint">No customer types exist yet. <a href="/customer-types/create">Create one</a> first.</p> | |||||
| <?php endif; ?> | |||||
| </div> | |||||
| </div> | </div> | ||||
| </template> | </template> | ||||
| </div> | </div> | ||||
| @@ -26,7 +26,7 @@ window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_T | |||||
| <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | <div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div> | ||||
| <?php endif; ?> | <?php endif; ?> | ||||
| <form method="post" action="/job-types/<?= e((string) $jobTypeId) ?>/update" class="ct-form" novalidate> | |||||
| <form method="post" action="/job-types/<?= e((string) $jobTypeId) ?>/update" class="ct-form" novalidate x-on:submit="guardSubmit($event)"> | |||||
| <?= csrf_field() ?> | <?= csrf_field() ?> | ||||
| <div class="form-section"> | <div class="form-section"> | ||||
| @@ -75,13 +75,14 @@ window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_T | |||||
| </label> | </label> | ||||
| <label class="field attribute-type-field"> | <label class="field attribute-type-field"> | ||||
| <span>Type</span> | <span>Type</span> | ||||
| <select class="input" :name="`attribute_type[${index}]`" x-model="attr.type"> | |||||
| <select class="input" :name="`attribute_type[${index}]`" x-model="attr.type" x-on:change="onAttributeTypeChange(index)"> | |||||
| <option value="text">Text</option> | <option value="text">Text</option> | ||||
| <option value="number">Number</option> | <option value="number">Number</option> | ||||
| <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">Customer Type</option> | ||||
| <option value="customer_lookup">Customer Lookup</option> | |||||
| </select> | </select> | ||||
| </label> | </label> | ||||
| <div class="attribute-remove"> | <div class="attribute-remove"> | ||||
| @@ -144,6 +145,40 @@ window.__customerTypes = <?= json_encode($model->customerTypes, JSON_HEX_T | |||||
| No customer types exist yet. <a href="/customer-types/create">Create one</a> first. | No customer types exist yet. <a href="/customer-types/create">Create one</a> first. | ||||
| </p> | </p> | ||||
| </div> | </div> | ||||
| <div class="api-lookup-config" x-show="attr.type === 'customer_lookup'"> | |||||
| <p class="field-error" x-show="!attr.name.trim()" style="margin:0 0 8px"> | |||||
| Fill in the attribute name above before saving. | |||||
| </p> | |||||
| <label class="field"> | |||||
| <span>Customer Type to search</span> | |||||
| <select class="input" | |||||
| :name="`attribute_db_customer_type_id[${index}]`" | |||||
| x-model.number="attr.db_customer_type_id" | |||||
| x-on:change="onDbCustomerTypeChange(attr)"> | |||||
| <option value="0">— Select a customer type —</option> | |||||
| <?php foreach ($model->customerTypes as $ct): ?> | |||||
| <option value="<?= e((string) $ct['id']) ?>"><?= e($ct['name']) ?></option> | |||||
| <?php endforeach; ?> | |||||
| </select> | |||||
| </label> | |||||
| <label class="field api-lookup-match-field" x-show="attr.db_customer_type_id > 0"> | |||||
| <span>Match field name(s)</span> | |||||
| <input class="input" type="text" | |||||
| :name="`attribute_db_match_field[${index}]`" | |||||
| x-model="attr.db_match_field" | |||||
| placeholder="e.g. FirstName or FirstName;LastName"> | |||||
| </label> | |||||
| <p class="attributes-hint" x-show="attr.db_match_field && attr.db_auto_fill"> | |||||
| Auto-fills: <strong x-text="attr.db_auto_fill.split(';').filter(Boolean).join(', ')"></strong> | |||||
| </p> | |||||
| <p class="attributes-hint" x-show="attr.db_customer_type_id > 0 && getCustomerTypeAttrs(attr.db_customer_type_id).length === 0"> | |||||
| This customer type has no attributes defined. | |||||
| </p> | |||||
| <input type="hidden" :name="`attribute_db_auto_fill[${index}]`" :value="attr.db_auto_fill"> | |||||
| <?php if (empty($model->customerTypes)): ?> | |||||
| <p class="attributes-hint">No customer types exist yet. <a href="/customer-types/create">Create one</a> first.</p> | |||||
| <?php endif; ?> | |||||
| </div> | |||||
| </div> | </div> | ||||
| </template> | </template> | ||||
| </div> | </div> | ||||
| @@ -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" | ||||
| @@ -87,12 +87,12 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_ | |||||
| <option value="false" :selected="attributeValues[attr.name] === 'false'">False</option> | <option value="false" :selected="attributeValues[attr.name] === 'false'">False</option> | ||||
| </select> | </select> | ||||
| </template> | </template> | ||||
| <template x-if="attr.type === 'api_lookup'"> | |||||
| <template x-if="attr.type === 'api_lookup' || attr.type === 'customer_lookup'"> | |||||
| <div class="api-lookup-field"> | <div class="api-lookup-field"> | ||||
| <input class="input" type="text" | <input class="input" type="text" | ||||
| :name="`attribute_values[${attr.name}]`" | :name="`attribute_values[${attr.name}]`" | ||||
| x-model="attributeValues[attr.name]" | x-model="attributeValues[attr.name]" | ||||
| placeholder="Type to search…" | |||||
| :placeholder="attr.type === 'customer_lookup' ? 'Type to search customers…' : 'Type to search…'" | |||||
| autocomplete="off" | autocomplete="off" | ||||
| x-on:focus="openApiLookup(attr.name)" | x-on:focus="openApiLookup(attr.name)" | ||||
| x-on:blur="closeApiLookup(attr.name)" | x-on:blur="closeApiLookup(attr.name)" | ||||
| @@ -126,7 +126,7 @@ 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 !== '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" | ||||
| @@ -84,12 +84,12 @@ window.__initialJtVals = <?= json_encode($model->form['attribute_values'], JSON_ | |||||
| <option value="false" :selected="attributeValues[attr.name] === 'false'">False</option> | <option value="false" :selected="attributeValues[attr.name] === 'false'">False</option> | ||||
| </select> | </select> | ||||
| </template> | </template> | ||||
| <template x-if="attr.type === 'api_lookup'"> | |||||
| <template x-if="attr.type === 'api_lookup' || attr.type === 'customer_lookup'"> | |||||
| <div class="api-lookup-field"> | <div class="api-lookup-field"> | ||||
| <input class="input" type="text" | <input class="input" type="text" | ||||
| :name="`attribute_values[${attr.name}]`" | :name="`attribute_values[${attr.name}]`" | ||||
| x-model="attributeValues[attr.name]" | x-model="attributeValues[attr.name]" | ||||
| placeholder="Type to search…" | |||||
| :placeholder="attr.type === 'customer_lookup' ? 'Type to search customers…' : 'Type to search…'" | |||||
| autocomplete="off" | autocomplete="off" | ||||
| x-on:focus="openApiLookup(attr.name)" | x-on:focus="openApiLookup(attr.name)" | ||||
| x-on:blur="closeApiLookup(attr.name)" | x-on:blur="closeApiLookup(attr.name)" | ||||
| @@ -123,7 +123,7 @@ 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 !== '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] ?? ''" | ||||
| @@ -1183,6 +1183,8 @@ window.jobTypeForm = function (initialAttributes, customerTypes) { | |||||
| name: '', type: 'text', alias: '', order: this.attributes.length + 1, | name: '', type: 'text', alias: '', order: this.attributes.length + 1, | ||||
| api_url: '', api_match_field: '', api_auto_fill: '', api_format: 'json', api_return_type: 'text', | api_url: '', api_match_field: '', api_auto_fill: '', api_format: 'json', api_return_type: 'text', | ||||
| customer_type_id: 0, | customer_type_id: 0, | ||||
| db_match_field: '', db_auto_fill: '', db_customer_type_id: 0, | |||||
| _uid: Math.random().toString(36).slice(2), _imported_by: '', | |||||
| }); | }); | ||||
| }, | }, | ||||
| @@ -1203,16 +1205,20 @@ 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', | |||||
| customer_type_id: 0, | |||||
| 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, | |||||
| db_match_field: a.db_match_field || '', | |||||
| db_auto_fill: a.db_auto_fill || '', | |||||
| db_customer_type_id: 0, | |||||
| _uid: Math.random().toString(36).slice(2), _imported_by: '', | |||||
| }; | }; | ||||
| }); | }); | ||||
| @@ -1258,6 +1264,84 @@ window.jobTypeForm = function (initialAttributes, customerTypes) { | |||||
| event.target.submit(); | event.target.submit(); | ||||
| } | } | ||||
| }, | }, | ||||
| getCustomerTypeAttrs(customerTypeId) { | |||||
| var ct = this.customerTypes.find(function (c) { return Number(c.id) === Number(customerTypeId); }); | |||||
| if (!ct || !Array.isArray(ct.attributes)) return []; | |||||
| return ct.attributes | |||||
| .filter(function (a) { return a.name && a.name.trim(); }) | |||||
| .slice() | |||||
| .sort(function (a, b) { return (a.order || 0) - (b.order || 0); }); | |||||
| }, | |||||
| onDbCustomerTypeChange(attr) { | |||||
| var self = this; | |||||
| if (!attr._uid) { attr._uid = Math.random().toString(36).slice(2); } | |||||
| var uid = attr._uid; | |||||
| // Remove rows previously imported by this lookup (current session only) | |||||
| for (var i = self.attributes.length - 1; i >= 0; i--) { | |||||
| if (self.attributes[i]._imported_by === uid) { | |||||
| self.attributes.splice(i, 1); | |||||
| } | |||||
| } | |||||
| attr.db_match_field = ''; | |||||
| attr.db_auto_fill = ''; | |||||
| var ct = self.customerTypes.find(function (c) { return Number(c.id) === Number(attr.db_customer_type_id); }); | |||||
| if (!ct) return; | |||||
| if (!attr.name.trim()) { attr.name = ct.name; } | |||||
| var ctAttrs = self.getCustomerTypeAttrs(attr.db_customer_type_id); | |||||
| if (ctAttrs.length === 0) return; | |||||
| // db_match_field and db_auto_fill use attribute NAMES (not aliases) | |||||
| // so they work regardless of whether aliases are configured | |||||
| attr.db_match_field = ctAttrs[0].name; | |||||
| attr.db_auto_fill = ctAttrs.map(function (a) { return a.name; }).join(';'); | |||||
| // Import ALL CT attributes as regular rows after this lookup row | |||||
| var lookupIndex = self.attributes.indexOf(attr); | |||||
| var rows = ctAttrs.map(function (a) { | |||||
| var type = a.type || 'text'; | |||||
| if (type === 'customer_lookup' || type === 'database_lookup') { type = 'text'; } | |||||
| return { | |||||
| name: a.name || '', type: type, 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, db_match_field: '', db_auto_fill: '', db_customer_type_id: 0, | |||||
| _uid: Math.random().toString(36).slice(2), _imported_by: uid, | |||||
| }; | |||||
| }); | |||||
| self.attributes.splice.apply(self.attributes, [lookupIndex + 1, 0].concat(rows)); | |||||
| self.renumberOrder(); | |||||
| }, | |||||
| onAttributeTypeChange(index) { | |||||
| var self = this; | |||||
| var attr = self.attributes[index]; | |||||
| if (!attr || attr.type !== 'customer_lookup') return; | |||||
| for (var i = self.attributes.length - 1; i >= 0; i--) { | |||||
| if (self.attributes[i] !== attr && self.attributes[i].type === 'api_lookup') { | |||||
| self.attributes.splice(i, 1); | |||||
| } | |||||
| } | |||||
| self.renumberOrder(); | |||||
| }, | |||||
| guardSubmit(event) { | |||||
| var missing = this.attributes.filter(function (a) { | |||||
| return a.type === 'customer_lookup' && !a.name.trim(); | |||||
| }); | |||||
| if (missing.length > 0) { | |||||
| event.preventDefault(); | |||||
| alert('One or more Database Lookup attributes are missing a name. Fill in the attribute name field before saving.'); | |||||
| } | |||||
| }, | |||||
| }; | }; | ||||
| }; | }; | ||||
| @@ -1454,15 +1538,21 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) { | |||||
| get currentAttributes() { | get currentAttributes() { | ||||
| if (!this.currentType) return []; | if (!this.currentType) return []; | ||||
| return this.currentType.attributes.slice().sort(function (a, b) { | |||||
| var attrs = this.currentType.attributes.slice().sort(function (a, b) { | |||||
| return (a.order || 0) - (b.order || 0); | return (a.order || 0) - (b.order || 0); | ||||
| }); | }); | ||||
| var hasCustomerLookup = attrs.some(function (a) { return a.type === 'customer_lookup'; }); | |||||
| if (hasCustomerLookup) { | |||||
| attrs = attrs.filter(function (a) { return a.type !== 'api_lookup'; }); | |||||
| } | |||||
| return attrs; | |||||
| }, | }, | ||||
| 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.fetchDbLookupValue(attr); } | |||||
| }); | }); | ||||
| }, | }, | ||||
| @@ -1474,7 +1564,8 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) { | |||||
| 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.fetchDbLookupValue(attr); } | |||||
| }); | }); | ||||
| }); | }); | ||||
| }, | }, | ||||
| @@ -1512,19 +1603,31 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) { | |||||
| var newValues = Object.assign({}, this.attributeValues); | var newValues = Object.assign({}, this.attributeValues); | ||||
| newValues[attr.name] = rec._primary; | newValues[attr.name] = rec._primary; | ||||
| var autoFill = (attr.api_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean); | |||||
| var attrs = this.currentAttributes; | |||||
| autoFill.forEach(function (alias) { | |||||
| var target = null; | |||||
| for (var i = 0; i < attrs.length; i++) { | |||||
| if (attrs[i].alias === alias) { target = attrs[i]; break; } | |||||
| } | |||||
| if (!target) return; | |||||
| var rowVal = rec._row[alias]; | |||||
| if (rowVal !== undefined && rowVal !== null) { | |||||
| newValues[target.name] = String(rowVal); | |||||
| } | |||||
| }); | |||||
| if (attr.type === 'customer_lookup') { | |||||
| // Auto-fill by attribute NAME: _row is name-keyed, db_auto_fill stores names | |||||
| var names = (attr.db_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean); | |||||
| names.forEach(function (name) { | |||||
| var rowVal = rec._row[name]; | |||||
| if (rowVal !== undefined && rowVal !== null) { | |||||
| newValues[name] = String(rowVal); | |||||
| } | |||||
| }); | |||||
| } else { | |||||
| // api_lookup: auto-fill by alias | |||||
| var autoFill = (attr.api_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean); | |||||
| var attrs = this.currentAttributes; | |||||
| autoFill.forEach(function (alias) { | |||||
| var target = null; | |||||
| for (var i = 0; i < attrs.length; i++) { | |||||
| if (attrs[i].alias === alias) { target = attrs[i]; break; } | |||||
| } | |||||
| if (!target) return; | |||||
| var rowVal = rec._row[alias]; | |||||
| if (rowVal !== undefined && rowVal !== null) { | |||||
| newValues[target.name] = String(rowVal); | |||||
| } | |||||
| }); | |||||
| } | |||||
| this.attributeValues = newValues; | this.attributeValues = newValues; | ||||
| this.closeApiLookup(attr.name); | this.closeApiLookup(attr.name); | ||||
| @@ -1623,6 +1726,40 @@ window.jobForm = function (jobTypes, initialTypeId, initialValues) { | |||||
| }); | }); | ||||
| }, | }, | ||||
| fetchDbLookupValue(attr) { | |||||
| var self = this; | |||||
| var typeId = Number(attr.db_customer_type_id || 0); | |||||
| if (!typeId) return; | |||||
| var matchField = attr.db_match_field || ''; | |||||
| var url = '/customers/lookup?type_id=' + typeId; | |||||
| if (matchField) { url += '&match_field=' + encodeURIComponent(matchField); } | |||||
| // Feed into the same apiLookup* state so customer_lookup uses the api_lookup UI | |||||
| var s = {}; s[attr.name] = 'loading'; | |||||
| var e = {}; e[attr.name] = ''; | |||||
| var o = {}; o[attr.name] = { fields: [], records: [] }; | |||||
| self.apiLookupState = Object.assign({}, self.apiLookupState, s); | |||||
| self.apiLookupError = Object.assign({}, self.apiLookupError, e); | |||||
| self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, o); | |||||
| fetch(url, { headers: { Accept: 'application/json' } }) | |||||
| .then(function (res) { return res.json(); }) | |||||
| .then(function (data) { | |||||
| var oo = {}; oo[attr.name] = { fields: data.fields || [], records: data.records || [] }; | |||||
| var os = {}; os[attr.name] = 'idle'; | |||||
| self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, oo); | |||||
| self.apiLookupState = Object.assign({}, self.apiLookupState, os); | |||||
| }) | |||||
| .catch(function (err) { | |||||
| console.error('[customer-lookup] fetch failed:', err); | |||||
| var ce = {}; ce[attr.name] = 'Network error — see browser console.'; | |||||
| var cs = {}; cs[attr.name] = 'error'; | |||||
| self.apiLookupError = Object.assign({}, self.apiLookupError, ce); | |||||
| self.apiLookupState = Object.assign({}, self.apiLookupState, 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(); | ||||
| @@ -1712,7 +1849,7 @@ window.customerTypeForm = function (initialAttributes) { | |||||
| 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', db_match_field: '', db_auto_fill: '' }); | |||||
| }, | }, | ||||
| removeAttribute(index) { | removeAttribute(index) { | ||||
| @@ -2002,19 +2139,31 @@ window.customerForm = function (customerTypes, initialTypeId, initialValues) { | |||||
| var newValues = Object.assign({}, this.attributeValues); | var newValues = Object.assign({}, this.attributeValues); | ||||
| newValues[attr.name] = rec._primary; | newValues[attr.name] = rec._primary; | ||||
| var autoFill = (attr.api_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean); | |||||
| var attrs = this.currentAttributes; | |||||
| autoFill.forEach(function (alias) { | |||||
| var target = null; | |||||
| for (var i = 0; i < attrs.length; i++) { | |||||
| if (attrs[i].alias === alias) { target = attrs[i]; break; } | |||||
| } | |||||
| if (!target) return; | |||||
| var rowVal = rec._row[alias]; | |||||
| if (rowVal !== undefined && rowVal !== null) { | |||||
| newValues[target.name] = String(rowVal); | |||||
| } | |||||
| }); | |||||
| if (attr.type === 'customer_lookup') { | |||||
| // Auto-fill by attribute NAME: _row is name-keyed, db_auto_fill stores names | |||||
| var names = (attr.db_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean); | |||||
| names.forEach(function (name) { | |||||
| var rowVal = rec._row[name]; | |||||
| if (rowVal !== undefined && rowVal !== null) { | |||||
| newValues[name] = String(rowVal); | |||||
| } | |||||
| }); | |||||
| } else { | |||||
| // api_lookup: auto-fill by alias | |||||
| var autoFill = (attr.api_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean); | |||||
| var attrs = this.currentAttributes; | |||||
| autoFill.forEach(function (alias) { | |||||
| var target = null; | |||||
| for (var i = 0; i < attrs.length; i++) { | |||||
| if (attrs[i].alias === alias) { target = attrs[i]; break; } | |||||
| } | |||||
| if (!target) return; | |||||
| var rowVal = rec._row[alias]; | |||||
| if (rowVal !== undefined && rowVal !== null) { | |||||
| newValues[target.name] = String(rowVal); | |||||
| } | |||||
| }); | |||||
| } | |||||
| this.attributeValues = newValues; | this.attributeValues = newValues; | ||||
| this.closeApiLookup(attr.name); | this.closeApiLookup(attr.name); | ||||
| @@ -74,6 +74,7 @@ $router->post('/job-types/{id}/delete', [JobTypeController::class, 'destroy'])-> | |||||
| // ── Customers ───────────────────────────────────────────────────────────────── | // ── Customers ───────────────────────────────────────────────────────────────── | ||||
| $router->get('/customers', [CustomerController::class, 'index']) ->middleware('auth'); | $router->get('/customers', [CustomerController::class, 'index']) ->middleware('auth'); | ||||
| $router->get('/customers/data', [CustomerController::class, 'data']) ->middleware('auth'); | $router->get('/customers/data', [CustomerController::class, 'data']) ->middleware('auth'); | ||||
| $router->get('/customers/lookup', [CustomerController::class, 'lookup']) ->middleware('auth'); | |||||
| $router->get('/customers/create', [CustomerController::class, 'create']) ->middleware('auth'); | $router->get('/customers/create', [CustomerController::class, 'create']) ->middleware('auth'); | ||||
| $router->post('/customers', [CustomerController::class, 'store']) ->middleware('auth'); | $router->post('/customers', [CustomerController::class, 'store']) ->middleware('auth'); | ||||
| $router->get('/customers/{id}/edit', [CustomerController::class, 'edit']) ->middleware('auth'); | $router->get('/customers/{id}/edit', [CustomerController::class, 'edit']) ->middleware('auth'); | ||||
Powered by TurnKey Linux.