From 87a59fc5793337297a1742f6cb28544267735339 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Thu, 9 Jul 2026 21:33:07 +0200 Subject: [PATCH] Add customer-search disambiguation implementation plan --- ...-customer-search-company-disambiguation.md | 385 ++++++++++++++++++ 1 file changed, 385 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-customer-search-company-disambiguation.md diff --git a/docs/superpowers/plans/2026-07-09-customer-search-company-disambiguation.md b/docs/superpowers/plans/2026-07-09-customer-search-company-disambiguation.md new file mode 100644 index 0000000..529d34f --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-customer-search-company-disambiguation.md @@ -0,0 +1,385 @@ +# CustomerSearch: Company-Name Disambiguation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every `CustomerSearch` instance in the app disambiguate customers that share a name but live at different companies, by showing the company name as the primary row in dropdowns and as a prefix on the trigger. + +**Architecture:** Single-component change in `src/components/ui/customer-search.tsx`, plus a one-line fetch tweak on the Process Quote page so the dropdown has company data to render. No schema changes, no persisted-data changes. + +**Tech Stack:** React 19, shadcn/ui (Command + Popover primitives), Vitest + Testing Library, PocketBase expand. + +**Spec:** [docs/superpowers/specs/2026-07-09-customer-search-company-disambiguation-design.md](file:///Volumes/EXT/DEVELOPMENT/WFDASH_NEXTJS/docs/superpowers/specs/2026-07-09-customer-search-company-disambiguation-design.md) + +--- + +## File Structure + +| File | Responsibility | Touched? | +| --- | --- | --- | +| `src/components/ui/customer-search.tsx` | Render trigger + dropdown; resolve company names | Modify | +| `src/components/ui/customer-search.test.tsx` | New component tests | Create | +| `src/app/(app)/quotes/[id]/process/page.tsx` | Load customers with `expand=company` so the picker has company names | Modify | +| `src/app/(app)/quotes/[id]/process/page.test.tsx` | Existing process-page tests — assert new expand shape if they assert the old one | Modify if needed | + +--- + +## Task 1: Failing tests for the new picker shape + +**Files:** +- Create: `src/components/ui/customer-search.test.tsx` + +- [ ] **Step 1: Write the failing test file** + +```tsx +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { CustomerSearch } from "./customer-search"; +import type { Customer } from "@/lib/types"; + +const johnAcme: Customer = { + id: "john-acme", + customer: "John Smith", + email: "john@acme.co.za", + number: "+27 11 555 0100", + company: "co-acme", + // customer-search treats `expand` as a deep-optional; only the + // fields the component reads need to be populated. + expand: { company: { id: "co-acme", company: "Acme (Pty) Ltd" } }, +} as unknown as Customer; + +const johnBeta: Customer = { + id: "john-beta", + customer: "John Smith", + email: "john@beta.co.za", + number: "+27 11 555 0200", + company: "co-beta", + expand: { company: { id: "co-beta", company: "Beta Holdings" } }, +} as unknown as Customer; + +const orphan: Customer = { + id: "orphan-1", + customer: "Orphan Person", + email: "orphan@nowhere.co.za", + number: "", + company: "", + expand: { company: null }, +} as unknown as Customer; + +const customers = [johnAcme, johnBeta, orphan]; + +describe("CustomerSearch — company disambiguation", () => { + beforeEach(() => { + class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} + } + vi.stubGlobal("ResizeObserver", ResizeObserverMock); + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + }); + + it("renders the trigger as 'Company · Customer' for the selected customer", () => { + render( + , + ); + expect( + screen.getByRole("combobox", { name: /customer/i }), + ).toHaveTextContent("Acme (Pty) Ltd · John Smith"); + }); + + it("renders dropdown rows with company on top and customer name underneath", async () => { + render( + , + ); + fireEvent.click(screen.getByRole("combobox", { name: /customer/i })); + const acmeRow = await screen.findByRole("option", { name: /john smith/i }); + expect(within(acmeRow).getByText("Acme (Pty) Ltd")).toBeInTheDocument(); + expect(within(acmeRow).getByText("John Smith")).toBeInTheDocument(); + }); + + it("matches by company name in search", async () => { + render( + , + ); + fireEvent.click(screen.getByRole("combobox", { name: /customer/i })); + fireEvent.change(screen.getByPlaceholderText(/search customers/i), { + target: { value: "Beta" }, + }); + expect( + await screen.findByRole("option", { name: /beta holdings/i }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("option", { name: /acme \(pty\) ltd/i }), + ).not.toBeInTheDocument(); + }); + + it("matches by partial customer name across companies", async () => { + render( + , + ); + fireEvent.click(screen.getByRole("combobox", { name: /customer/i })); + fireEvent.change(screen.getByPlaceholderText(/search customers/i), { + target: { value: "john" }, + }); + const options = await screen.findAllByRole("option", { name: /john smith/i }); + expect(options).toHaveLength(2); + }); + + it("matches by email and phone", async () => { + render( + , + ); + fireEvent.click(screen.getByRole("combobox", { name: /customer/i })); + fireEvent.change(screen.getByPlaceholderText(/search customers/i), { + target: { value: "+27 11 555 0100" }, + }); + expect( + await screen.findByRole("option", { name: /john smith/i }), + ).toBeInTheDocument(); + }); + + it("renders '(no company)' for orphan customers", async () => { + render( + , + ); + fireEvent.click(screen.getByRole("combobox", { name: /customer/i })); + const row = await screen.findByRole("option", { name: /orphan person/i }); + expect(within(row).getByText("(no company)")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run the new tests to verify they fail for the right reasons** + +Run: +```bash +npm run test:run -- src/components/ui/customer-search.test.tsx +``` + +Expected: +- `uses trigger as 'Company · Customer'` fails: current trigger shows only `customer.customer`. +- `renders dropdown rows with company on top` fails: current dropdown rows don't show company. +- `matches by company name in search` fails: company name is not in the search index. +- `matches by partial customer name across companies` may pass; update if it does. +- `matches by email and phone` passes today. +- `renders '(no company)' for orphan customers` fails: orphan renders without that fallback. + +Adjust the expectations as needed so the failure output reflects the missing redesign, not broken test setup. Do not modify `src/components/ui/customer-search.tsx` in this task. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/ui/customer-search.test.tsx +git commit -m "test(customer-search): add failing tests for company disambiguation" +``` + +--- + +## Task 2: Implement the picker shape + +**Files:** +- Modify: `src/components/ui/customer-search.tsx` + +- [ ] **Step 1: Add a small helper for resolving the company name** + +In `src/components/ui/customer-search.tsx`, just below `isPlaceholderCustomer`: + +```tsx +/** + * Resolve the company name for a customer row. Reads the PB-expanded + * relation first; falls back to "(no company)" so the gap is visible + * even if a record is mid-migration. + */ +function resolveCompanyName(c: Customer): string { + const expanded = (c.expand?.company?.company || "").trim(); + return expanded || "(no company)"; +} +``` + +- [ ] **Step 2: Update the search index to include the company** + +Replace the `CommandItem value={...}` computation: + +```tsx +value={[ + c.customer, + c.email, + c.number, + resolveCompanyName(c), +] + .filter(Boolean) + .join(" ") + .toLowerCase()} +``` + +- [ ] **Step 3: Update the dropdown row markup** + +Replace the inner `
` block of the `ordered.map` `CommandItem` so it renders: + +```tsx +
+ {resolveCompanyName(c)} + {c.customer} + {(c.email || c.number) && ( + + {[c.email, c.number].filter(Boolean).join(" · ")} + + )} +
+``` + +- [ ] **Step 4: Update the trigger label for the selected customer** + +Replace the trigger's selected branch so the label reads `Company · Customer`: + +```tsx +{showSelected && selected + ? selectedIsPlaceholder + ? `${selected.customer || "(blank)"} — pick real customer` + : `${resolveCompanyName(selected)} · ${selected.customer || ""}` + : placeholder} +``` + +- [ ] **Step 5: Run the failing test file** + +Run: +```bash +npm run test:run -- src/components/ui/customer-search.test.tsx +``` + +Expected: all 6 tests pass. + +- [ ] **Step 6: Run lint** + +Run: +```bash +npx eslint src/components/ui/customer-search.tsx src/components/ui/customer-search.test.tsx +``` + +Expected: exit 0. + +- [ ] **Step 7: Commit** + +```bash +git add src/components/ui/customer-search.tsx +git commit -m "feat(customer-search): show company first to disambiguate shared names" +``` + +--- + +## Task 3: Process Quote page passes company data to the picker + +**Files:** +- Modify: `src/app/(app)/quotes/[id]/process/page.tsx` + +- [ ] **Step 1: Find the customers loader** + +The Process Quote page calls `getAllCustomers()` to populate the +picker. Confirm the call site (`src/app/(app)/quotes/[id]/process/page.tsx` +around line 56, `const [customers, setCustomers] = useState([])`). + +- [ ] **Step 2: Add a companies load so the picker can resolve names** + +The cleanest path is to fetch customers with `expand=company`. Check +`getAllCustomers()` in `src/lib/pb-helpers.ts` to see whether it +already supports an expand arg. If it does, pass `expand: "company"`. +If not, add a new helper `getAllCustomersWithCompany()` that calls +the same PB list with `expand=company`. + +Implementation choice: +- If `getAllCustomers` already has an `expand` option (most likely), + pass `expand: "company"` at the call site. +- Otherwise, copy the body into a new helper `getAllCustomersWithCompany` + and use it on the process page only. + +- [ ] **Step 3: Verify the `Customer.expand.company` type exists** + +In `src/lib/types.ts`, the `Customer` interface should already declare +`expand?: { company?: Company }`. If it does not, add it. Do not +introduce new optional fields beyond what the picker reads. + +- [ ] **Step 4: Run any existing process-page tests** + +Run: +```bash +npm run test:run -- src/app/'(app)'/quotes/'[id]'/process +``` + +Expected: existing tests pass. If any test asserts the old customer +load shape (no expand), update it to expect the expanded shape. + +- [ ] **Step 5: Run lint** + +Run: +```bash +npx eslint src/app/'(app)'/quotes/'[id]'/process/page.tsx src/lib/pb-helpers.ts +``` + +Expected: exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/'(app)'/quotes/'[id]'/process/page.tsx src/lib/pb-helpers.ts src/lib/types.ts +git commit -m "feat(quotes/process): load customers with company expanded for picker" +``` + +--- + +## Task 4: Final verification + +- [ ] **Step 1: Run targeted tests** + +```bash +npm run test:run -- src/components/ui/customer-search src/app/'(app)'/quotes/'[id]'/process +``` + +Expected: all targeted tests pass. + +- [ ] **Step 2: Run full suite** + +```bash +npm run test:run +``` + +Expected: same passing set as before plus the new tests. Pre-existing +failures unrelated to this change (the `collection-pool` failures +we documented earlier) are acceptable. + +- [ ] **Step 3: Manual browser check** + +1. Visit `/quotes/[id]/process` for any quote. Open the customer + picker. +2. Confirm dropdown rows show `Company` on top, `Customer` underneath. +3. Confirm the trigger for the selected customer reads + `Company · Customer`. +4. Search by company name — confirm the filter narrows correctly. +5. Search by partial customer name — confirm both same-name + customers at different companies appear. +6. Visit `/quotes` and `/customers/[id]` to confirm the picker is + rendered identically everywhere. \ No newline at end of file