From 4a6d34e0d2870590ac2a64d44644b8dbf0127b44 Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Mon, 18 May 2026 13:08:35 -0400 Subject: [PATCH] Add customer lookup to job type attributes with multi-field match support - 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 path --- .claude/settings.local.json | 3 +- app/Controllers/CustomerController.php | 68 +++++++ app/Controllers/JobTypeController.php | 24 ++- app/Repositories/CustomerRepository.php | 14 ++ app/Views/job-types/create.php | 39 +++- app/Views/job-types/edit.php | 39 +++- app/Views/jobs/create.php | 8 +- app/Views/jobs/edit.php | 8 +- public/js/app.js | 229 +++++++++++++++++++----- routes/web.php | 1 + 10 files changed, 373 insertions(+), 60 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9a03ddb..c3aa87d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,8 @@ "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)", "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)" ] } } diff --git a/app/Controllers/CustomerController.php b/app/Controllers/CustomerController.php index 37e82a4..1e31385 100644 --- a/app/Controllers/CustomerController.php +++ b/app/Controllers/CustomerController.php @@ -187,6 +187,74 @@ class CustomerController extends Controller 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 ─────────────────────────────────────────────────────────────── private function loadCustomerTypes(): array diff --git a/app/Controllers/JobTypeController.php b/app/Controllers/JobTypeController.php index 9d27ce0..41dad49 100644 --- a/app/Controllers/JobTypeController.php +++ b/app/Controllers/JobTypeController.php @@ -223,12 +223,15 @@ 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') ?? []); + $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 = []; @@ -241,7 +244,8 @@ class JobTypeController extends Controller // by the Job Type editor JS before submit. If one slips through, drop it. if ($attrType === 'customer') continue; - $validatedType = in_array($attrType, ['text', 'number', 'date', 'boolean', 'api_lookup'], true) ? $attrType : 'text'; + if ($attrType === 'database_lookup') $attrType = 'customer_lookup'; // renamed + $validatedType = in_array($attrType, ['text', 'number', 'date', 'boolean', 'api_lookup', 'customer_lookup'], true) ? $attrType : 'text'; $attr = [ 'name' => $attrName, @@ -262,6 +266,12 @@ class JobTypeController extends Controller $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; } diff --git a/app/Repositories/CustomerRepository.php b/app/Repositories/CustomerRepository.php index a2d2309..4515642 100644 --- a/app/Repositories/CustomerRepository.php +++ b/app/Repositories/CustomerRepository.php @@ -69,6 +69,20 @@ class CustomerRepository extends Repository ); } + /** Used after INSERT to recover the generated id for audit logging. */ + /** @return list> */ + 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. */ public function findLatestByType(int $typeId): ?array { diff --git a/app/Views/job-types/create.php b/app/Views/job-types/create.php index 844331e..4036305 100644 --- a/app/Views/job-types/create.php +++ b/app/Views/job-types/create.php @@ -19,7 +19,7 @@ window.__customerTypes = customerTypes, JSON_HEX_T
errors['_token'][0]) ?>
-
+
@@ -68,13 +68,14 @@ window.__customerTypes = customerTypes, JSON_HEX_T
@@ -137,6 +138,40 @@ window.__customerTypes = customerTypes, JSON_HEX_T No customer types exist yet. Create one first.

+
+

+ Fill in the attribute name above before saving. +

+ + +

+ Auto-fills: +

+

+ This customer type has no attributes defined. +

+ + customerTypes)): ?> +

No customer types exist yet. Create one first.

+ +
diff --git a/app/Views/job-types/edit.php b/app/Views/job-types/edit.php index bbcfe3b..f14b8a4 100644 --- a/app/Views/job-types/edit.php +++ b/app/Views/job-types/edit.php @@ -26,7 +26,7 @@ window.__customerTypes = customerTypes, JSON_HEX_T
errors['_token'][0]) ?>
- +
@@ -75,13 +75,14 @@ window.__customerTypes = customerTypes, JSON_HEX_T
@@ -144,6 +145,40 @@ window.__customerTypes = customerTypes, JSON_HEX_T No customer types exist yet. Create one first.

+
+

+ Fill in the attribute name above before saving. +

+ + +

+ Auto-fills: +

+

+ This customer type has no attributes defined. +

+ + customerTypes)): ?> +

No customer types exist yet. Create one first.

+ +
diff --git a/app/Views/jobs/create.php b/app/Views/jobs/create.php index f814aa1..d4dcf91 100644 --- a/app/Views/jobs/create.php +++ b/app/Views/jobs/create.php @@ -76,7 +76,7 @@ window.__initialJtVals = form['attribute_values'], JSON_