Add customer-search disambiguation implementation plan

This commit is contained in:
SJCoetz 2026-07-09 21:33:07 +02:00
parent 1a3ce560c6
commit 87a59fc579

View file

@ -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(
<CustomerSearch
value={johnAcme.id}
onValueChange={vi.fn()}
customers={customers}
/>,
);
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(
<CustomerSearch
value=""
onValueChange={vi.fn()}
customers={customers}
/>,
);
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(
<CustomerSearch
value=""
onValueChange={vi.fn()}
customers={customers}
/>,
);
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(
<CustomerSearch
value=""
onValueChange={vi.fn()}
customers={customers}
/>,
);
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(
<CustomerSearch
value=""
onValueChange={vi.fn()}
customers={customers}
/>,
);
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(
<CustomerSearch
value=""
onValueChange={vi.fn()}
customers={customers}
/>,
);
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 `<div className="flex flex-col min-w-0">` block of the `ordered.map` `CommandItem` so it renders:
```tsx
<div className="flex flex-col min-w-0">
<span className="truncate font-medium">{resolveCompanyName(c)}</span>
<span className="truncate">{c.customer}</span>
{(c.email || c.number) && (
<span className="text-xs text-muted-foreground truncate">
{[c.email, c.number].filter(Boolean).join(" · ")}
</span>
)}
</div>
```
- [ ] **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<Customer[]>([])`).
- [ ] **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.