Add customer-search disambiguation implementation plan
This commit is contained in:
parent
1a3ce560c6
commit
87a59fc579
1 changed files with 385 additions and 0 deletions
|
|
@ -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.
|
||||
Loading…
Reference in a new issue