diff --git a/docs/superpowers/plans/2026-08-07-order-item-context-block.md b/docs/superpowers/plans/2026-08-07-order-item-context-block.md new file mode 100644 index 0000000..6ee42ab --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-order-item-context-block.md @@ -0,0 +1,1492 @@ +# Order Item Context Block 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:** Replace the AI Insights section in the order-sprint process modal with a deterministic PocketBase-derived context block (last quote, last order, last invoice checked in, stock) and surface the same data as a popover on the orders detail page. + +**Architecture:** New `order-item-context-service.ts` reuses pure helpers from `item-history-service.ts`. New `/api/v1/order-items/[id]/context` thin route. Two new client components (`OrderItemContextPanel` inline expandable + `OrderItemContextPopoverTrigger` ⓘ popover) consume the same endpoint. + +**Tech Stack:** Next.js 15 (App Router), PocketBase admin SDK, Vitest + Testing Library, shadcn/ui (Popover), lucide-react. + +**Spec:** `docs/superpowers/specs/2026-08-07-order-item-context-block-design.md` + +--- + +## File map + +**New:** +- `src/lib/server/order-item-context-service.ts` — service layer (types + `getOrderItemContext`) +- `src/lib/server/order-item-context-service.test.ts` — service unit tests (vitest, mocked PB) +- `src/app/api/v1/order-items/[id]/context/route.ts` — thin GET route +- `src/app/api/v1/order-items/[id]/context/route.test.ts` — route unit tests +- `src/components/order-item-context-panel.tsx` — L1 inline expandable UI +- `src/components/order-item-context-panel.test.tsx` — UI tests +- `src/components/order-item-context-popover-trigger.tsx` — L2 ⓘ popover trigger +- `src/components/order-item-context-popover-trigger.test.tsx` — trigger tests + +**Modified:** +- `src/components/order-sprint-process-modal.tsx` — remove AI Insights section + `applyAiSuggestion`; mount `` +- `src/app/(app)/orders/[id]/page.tsx` — swap `AiItemHistoryTrigger` for `OrderItemContextPopoverTrigger` + +--- + +### Task 1: Backend service — types + `getOrderItemContext` skeleton + +**Files:** +- Create: `src/lib/server/order-item-context-service.ts` + +- [ ] **Step 1: Create the file with types + stub function** + +Write `src/lib/server/order-item-context-service.ts`: + +```ts +/** + * order-item-context-service — deterministic SKU-based context for + * the order-sprint process modal + orders detail page. + * + * Mirrors the shape of `item-history-service.ts` but is anchored on + * `order_items.id` instead of `quote_items.id`. The four rows + * surfaced (last quote, last order, last invoiced, stock) plus a + * supplier/cheapest aggregate give the operator the historical + * record before they pick a supplier and cost. + * + * Pure helpers (`normaliseSkuForHistory`, `findBestDescriptionMatch`, + * `aggregateSuppliers`, `computeCheapest`, `daysAgo`, `isValidPrice`) + * are imported from `item-history-service.ts` — DO NOT duplicate them. + */ + +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { + normaliseSkuForHistory, + findBestDescriptionMatch, + aggregateSuppliers, + computeCheapest, + daysAgo, + isValidPrice, +} from "@/lib/server/item-history-service"; +import type { + ItemHistoryMatch, + ItemHistorySupplier, + ItemHistoryCheapest, +} from "@/lib/types"; + +export interface OrderItemLastQuote { + quote_item_id: string; + quote_id: string; + quote_number: string; + customer_name: string; + company: string; + status: string; + cost_price: number; + selling_price: number; + markup_percent: number; + days_ago: number; +} + +export interface OrderItemLastOrder { + order_item_id: string; + order_id: string; + order_number: string; + company: string; + cost_price: number; + selling_price: number; + markup_percent: number; + item_status: string; + updated_at: string; + days_ago: number; +} + +export interface OrderItemLastInvoiced { + order_item_id: string; + order_id: string; + order_number: string; + supplier_invoice_number: string; + customer_invoice_number: string; + invoiced_date: string; + cost_price: number; + quantity: number; + qty_invoiced: number; + days_ago: number; +} + +export interface OrderItemStock { + stock_item_id: string; + name: string; + sku: string; + current_qty: number; + min_qty: number | null; + selling_price: number | null; + markup_percent: number | null; + status: "in_stock" | "low" | "out"; +} + +export interface OrderItemContext { + generated_at: string; + match: ItemHistoryMatch; + last_quote: OrderItemLastQuote | null; + last_order: OrderItemLastOrder | null; + last_invoiced: OrderItemLastInvoiced | null; + stock: OrderItemStock | null; + suppliers: ItemHistorySupplier[]; + cheapest: ItemHistoryCheapest | null; +} + +export interface OrderItemContextResponse extends OrderItemContext { + error?: string; +} + +export async function getOrderItemContext( + orderItemId: string, +): Promise { + // Implementation lands in Task 2. + throw new Error("not implemented"); +} +``` + +- [ ] **Step 2: Verify the file compiles** + +Run: `npx tsc --noEmit -p tsconfig.json 2>&1 | head -20` +Expected: no errors related to the new file. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/server/order-item-context-service.ts +git commit -m "feat(context): add order-item-context-service skeleton + types" +``` + +--- + +### Task 2: Backend service — implement `getOrderItemContext` + +**Files:** +- Modify: `src/lib/server/order-item-context-service.ts` + +- [ ] **Step 1: Replace the stub body with the full implementation** + +Replace the entire `getOrderItemContext` function body in `src/lib/server/order-item-context-service.ts` with: + +```ts +export async function getOrderItemContext( + orderItemId: string, +): Promise { + const pb = await getAdminPocketBase(); + const generated_at = new Date().toISOString(); + const response: OrderItemContextResponse = { + generated_at, + match: { sku: "", match_kind: "none", match_confidence: 0 }, + last_quote: null, + last_order: null, + last_invoiced: null, + stock: null, + suppliers: [], + cheapest: null, + }; + + // 1. Load the target row. SKU + description drive matching. + const target = await pb.collection("order_items").getOne<{ + id: string; + sku?: string; + description?: string; + quote_item?: string; + }>(orderItemId, { fields: "id,sku,description,quote_item" }); + response.match.sku = target.sku || ""; + + // Bail early when there's nothing to match on. + if (!target.sku && !target.description) return response; + + // 2. Description-fuzzy candidates — only when SKU is empty. + const fuzzyCandidates = !target.sku && target.description + ? pb.collection("order_items").getList<{ + id: string; + sku: string; + description: string; + }>(1, 500, { + filter: `id != "${orderItemId}" && description != "" && sku != ""`, + sort: "-created", + fields: "id,sku,description", + }) + : Promise.resolve({ + items: [] as Array<{ id: string; sku: string; description: string }>, + }); + + const fuzzyResolved = !target.sku && target.description + ? (await fuzzyCandidates).items + : []; + const fm = !target.sku && target.description + ? findBestDescriptionMatch( + { id: orderItemId, description: target.description }, + fuzzyResolved, + 0.6, + ) + : null; + + // 3. Resolve effective SKU + match metadata. + const effectiveSku = target.sku || fm?.item.sku || ""; + if (target.sku) { + response.match.match_kind = "sku_exact"; + response.match.match_confidence = 1; + } else if (effectiveSku && fm) { + response.match.match_kind = "description_fuzzy"; + response.match.match_confidence = fm.confidence; + response.match.sku = effectiveSku; + } + + const sku = effectiveSku; + const skuFilter = sku ? `sku = "${sku.replace(/"/g, '\\"')}"` : ""; + + // 4. Fan out 5 PB reads in parallel. + const [quoteRows, orderRows, invoicedRows, stockRows, suppliersRows] = await Promise.all([ + sku + ? pb.collection("quote_items").getList<{ + id: string; + quote: string; + cost_price: number; + selling_price: number; + created: string; + expand?: { + quote?: { + quote_number: string; + status: string; + customer_name?: string; + company?: string; + }; + }; + }>(1, 5, { + filter: `${skuFilter} && id != "${target.quote_item ?? ""}"`, + sort: "-created", + expand: "quote", + }) + : Promise.resolve({ + items: [] as Array<{ + id: string; quote: string; cost_price: number; + selling_price: number; created: string; + }>, + }), + + sku + ? pb.collection("order_items").getList<{ + id: string; + order: string; + supplier: string; + cost_price: number; + selling_price: number; + markup_percent?: number; + item_status: string; + updated: string; + expand?: { order?: { order_number: string; company?: string } }; + }>(1, 5, { + filter: `${skuFilter} && id != "${orderItemId}"`, + sort: "-updated", + expand: "order", + }) + : Promise.resolve({ + items: [] as Array<{ + id: string; order: string; supplier: string; + cost_price: number; selling_price: number; + item_status: string; updated: string; + }>, + }), + + sku + ? pb.collection("order_items").getList<{ + id: string; + order: string; + supplier_invoice_number: string; + customer_invoice_number: string; + invoiced_date: string; + cost_price: number; + quantity: number; + qty_invoiced: number; + expand?: { order?: { order_number: string } }; + }>(1, 1, { + filter: `${skuFilter} && invoiced_date != null && invoiced_date != "" && supplier_invoice_number != ""`, + sort: "-invoiced_date", + expand: "order", + }) + : Promise.resolve({ + items: [] as Array<{ + id: string; order: string; + supplier_invoice_number: string; + customer_invoice_number: string; + invoiced_date: string; cost_price: number; + quantity: number; qty_invoiced: number; + }>, + }), + + sku + ? pb.collection("stock_items").getList<{ + id: string; + name: string; + sku: string; + current_qty: number; + min_qty: number | null; + selling_price: number | null; + markup: number | null; + }>(1, 5, { filter: skuFilter }) + : Promise.resolve({ + items: [] as Array<{ + id: string; name: string; sku: string; + current_qty: number; min_qty: number | null; + selling_price: number | null; markup: number | null; + }>, + }), + + pb.collection("suppliers").getList<{ id: string; suppliername: string }>(1, 200), + ]); + + const supplierNameById = new Map( + suppliersRows.items.map((s) => [s.id, s.suppliername]), + ); + + // 5. Last quote — most recent with a valid cost or selling. + const q = quoteRows.items.find( + (r) => isValidPrice(r.cost_price) || isValidPrice(r.selling_price), + ); + if (q) { + const cost = q.cost_price || 0; + const sell = q.selling_price || 0; + response.last_quote = { + quote_item_id: q.id, + quote_id: q.quote, + quote_number: q.expand?.quote?.quote_number || "—", + customer_name: q.expand?.quote?.customer_name || "—", + company: q.expand?.quote?.company || "—", + status: q.expand?.quote?.status || "", + cost_price: cost, + selling_price: sell, + markup_percent: + cost > 0 && sell > 0 + ? Math.round(((sell - cost) / cost) * 10000) / 100 + : 0, + days_ago: daysAgo(q.created), + }; + } + + // 6. Last order — most recent other row with valid pricing. + const o = orderRows.items.find( + (r) => isValidPrice(r.cost_price) || isValidPrice(r.selling_price), + ); + if (o) { + const cost = o.cost_price || 0; + const sell = o.selling_price || 0; + response.last_order = { + order_item_id: o.id, + order_id: o.order, + order_number: o.expand?.order?.order_number || "—", + company: o.expand?.order?.company || "—", + cost_price: cost, + selling_price: sell, + markup_percent: + o.markup_percent ?? + (cost > 0 && sell > 0 + ? Math.round(((sell - cost) / cost) * 10000) / 100 + : 0), + item_status: o.item_status, + updated_at: o.updated, + days_ago: daysAgo(o.updated), + }; + } + + // 7. Last invoiced. + const i = invoicedRows.items[0]; + if (i) { + response.last_invoiced = { + order_item_id: i.id, + order_id: i.order, + order_number: i.expand?.order?.order_number || "—", + supplier_invoice_number: i.supplier_invoice_number, + customer_invoice_number: i.customer_invoice_number, + invoiced_date: i.invoiced_date, + cost_price: i.cost_price, + quantity: i.quantity, + qty_invoiced: i.qty_invoiced, + days_ago: daysAgo(i.invoiced_date), + }; + } + + // 8. Stock — derive status from qty thresholds. + const s = stockRows.items[0]; + if (s) { + let status: OrderItemStock["status"]; + if (s.current_qty <= 0) status = "out"; + else if (s.min_qty != null && s.current_qty <= s.min_qty) status = "low"; + else status = "in_stock"; + response.stock = { + stock_item_id: s.id, + name: s.name, + sku: s.sku, + current_qty: s.current_qty, + min_qty: s.min_qty, + selling_price: s.selling_price, + markup_percent: s.markup, + status, + }; + } + + // 9. Suppliers list — derived from order_rows; filter to priced. + response.suppliers = aggregateSuppliers( + orderRows.items.map((r) => ({ + id: r.id, + supplier: r.supplier, + cost_price: r.cost_price, + updated: r.updated, + })), + supplierNameById, + ).filter((s) => s.last_cost > 0); + + response.cheapest = computeCheapest(response.suppliers); + + return response; +} +``` + +- [ ] **Step 2: Verify the file compiles** + +Run: `npx tsc --noEmit -p tsconfig.json 2>&1 | head -20` +Expected: no errors related to the new file. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/server/order-item-context-service.ts +git commit -m "feat(context): implement getOrderItemContext (5 PB reads, fuzzy fallback)" +``` + +--- + +### Task 3: Backend service tests + +**Files:** +- Create: `src/lib/server/order-item-context-service.test.ts` + +- [ ] **Step 1: Write the test file with failing tests** + +Write `src/lib/server/order-item-context-service.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getOrderItemContext } from "@/lib/server/order-item-context-service"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; + +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn(), +})); + +interface CollectionMock { + getOne: ReturnType; + getList: ReturnType; +} + +function makePb(collections: Record) { + return { + collection: (name: string) => { + const c = collections[name]; + if (!c) throw new Error(`unexpected collection: ${name}`); + return c; + }, + }; +} + +const ORD = "order_items"; +const Q = "quote_items"; +const STK = "stock_items"; +const SUP = "suppliers"; + +describe("getOrderItemContext", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns all-null context when the target has no SKU and no description", async () => { + const pb = makePb({ + [ORD]: { getOne: vi.fn().mockResolvedValue({ id: "x", sku: "", description: "" }), getList: vi.fn() }, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited>); + + const ctx = await getOrderItemContext("x"); + expect(ctx.last_quote).toBeNull(); + expect(ctx.last_order).toBeNull(); + expect(ctx.last_invoiced).toBeNull(); + expect(ctx.stock).toBeNull(); + expect(ctx.suppliers).toEqual([]); + expect(ctx.cheapest).toBeNull(); + expect(ctx.match).toEqual({ sku: "", match_kind: "none", match_confidence: 0 }); + }); + + it("populates last_quote from the SKU-based quote_items join", async () => { + const target = { id: "x", sku: "DRL-6MM", description: "Drill 6mm", quote_item: "qi0" }; + const quoteList = { + items: [{ + id: "qi1", + quote: "q1", + cost_price: 100, + selling_price: 150, + created: "2026-08-01T10:00:00Z", + expand: { quote: { quote_number: "Q-26080101", status: "approved", customer_name: "Acme", company: "Acme Ltd" } }, + }], + }; + const emptyList = { items: [] }; + const supplierList = { items: [{ id: "sup1", suppliername: "Fastener Co" }] }; + + const pb = makePb({ + [ORD]: { getOne: vi.fn().mockResolvedValue(target), getList: vi.fn().mockResolvedValue(emptyList) }, + [Q]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(quoteList) }, + [STK]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) }, + [SUP]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(supplierList) }, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited>); + + const ctx = await getOrderItemContext("x"); + expect(ctx.match).toEqual({ sku: "DRL-6MM", match_kind: "sku_exact", match_confidence: 1 }); + expect(ctx.last_quote?.quote_number).toBe("Q-26080101"); + expect(ctx.last_quote?.cost_price).toBe(100); + expect(ctx.last_quote?.selling_price).toBe(150); + expect(ctx.last_quote?.markup_percent).toBe(50); + }); + + it("marks stock status as 'low' when current_qty <= min_qty and > 0", async () => { + const target = { id: "x", sku: "DRL-6MM", description: "Drill", quote_item: "" }; + const emptyList = { items: [] }; + const supplierList = { items: [] }; + const stockList = { + items: [{ id: "s1", name: "Drill 6mm", sku: "DRL-6MM", current_qty: 5, min_qty: 10, selling_price: 140, markup: 40 }], + }; + const pb = makePb({ + [ORD]: { getOne: vi.fn().mockResolvedValue(target), getList: vi.fn().mockResolvedValue(emptyList) }, + [Q]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) }, + [STK]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(stockList) }, + [SUP]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(supplierList) }, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited>); + + const ctx = await getOrderItemContext("x"); + expect(ctx.stock?.status).toBe("low"); + }); + + it("marks stock status as 'out' when current_qty <= 0", async () => { + const target = { id: "x", sku: "DRL-6MM", description: "Drill", quote_item: "" }; + const emptyList = { items: [] }; + const supplierList = { items: [] }; + const stockList = { + items: [{ id: "s1", name: "Drill 6mm", sku: "DRL-6MM", current_qty: 0, min_qty: 5, selling_price: 140, markup: 40 }], + }; + const pb = makePb({ + [ORD]: { getOne: vi.fn().mockResolvedValue(target), getList: vi.fn().mockResolvedValue(emptyList) }, + [Q]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) }, + [STK]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(stockList) }, + [SUP]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(supplierList) }, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited>); + + const ctx = await getOrderItemContext("x"); + expect(ctx.stock?.status).toBe("out"); + }); + + it("populates last_invoiced when an invoiced row exists", async () => { + const target = { id: "x", sku: "DRL-6MM", description: "Drill", quote_item: "" }; + const emptyList = { items: [] }; + const supplierList = { items: [] }; + const invoicedList = { + items: [{ + id: "oi2", + order: "o1", + supplier_invoice_number: "INV-99", + customer_invoice_number: "C-INV-7", + invoiced_date: "2026-08-02T10:00:00Z", + cost_price: 105, + quantity: 10, + qty_invoiced: 10, + expand: { order: { order_number: "ORD-26080201" } }, + }], + }; + const pb = makePb({ + [ORD]: { getOne: vi.fn().mockResolvedValue(target), getList: vi.fn((_n: string, _p: unknown, opts: { sort?: string }) => opts?.sort === "-invoiced_date" ? invoicedList : emptyList) }, + [Q]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) }, + [STK]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) }, + [SUP]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(supplierList) }, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited>); + + const ctx = await getOrderItemContext("x"); + expect(ctx.last_invoiced?.supplier_invoice_number).toBe("INV-99"); + expect(ctx.last_invoiced?.cost_price).toBe(105); + }); +}); +``` + +- [ ] **Step 2: Run the tests — they should pass against the implementation** + +Run: `npx vitest run src/lib/server/order-item-context-service.test.ts` +Expected: all 5 tests pass. + +If any fail, fix the implementation in `order-item-context-service.ts`, not the test. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/server/order-item-context-service.test.ts +git commit -m "test(context): getOrderItemContext unit tests (null, quote, stock low/out, invoiced)" +``` + +--- + +### Task 4: API endpoint + +**Files:** +- Create: `src/app/api/v1/order-items/[id]/context/route.ts` + +- [ ] **Step 1: Write the route** + +Write `src/app/api/v1/order-items/[id]/context/route.ts`: + +```ts +import { NextRequest, NextResponse } from "next/server"; +import { getOrderItemContext } from "@/lib/server/order-item-context-service"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * GET /api/v1/order-items/[id]/context + * + * Deterministic SKU-based context for an order_item: last quote, + * last order, last invoice checked in, stock state, supplier + * history, cheapest. Used by the sprint process modal (L1 inline) + * and the orders detail page (L2 popover). + * + * No Bearer required — matches the existing item-history route's + * read-only SKU aggregates pattern. Auth happens upstream at the + * page level. + */ +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + if (!id || typeof id !== "string") { + return NextResponse.json({ error: "Missing id" }, { status: 400 }); + } + try { + return NextResponse.json(await getOrderItemContext(id)); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Lookup failed" }, + { status: 500 }, + ); + } +} +``` + +- [ ] **Step 2: Verify the file compiles** + +Run: `npx tsc --noEmit -p tsconfig.json 2>&1 | head -10` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/api/v1/order-items/[id]/context/route.ts +git commit -m "feat(context): GET /api/v1/order-items/[id]/context route" +``` + +--- + +### Task 5: Endpoint test + +**Files:** +- Create: `src/app/api/v1/order-items/[id]/context/route.test.ts` + +- [ ] **Step 1: Write the route tests** + +Write `src/app/api/v1/order-items/[id]/context/route.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const getOrderItemContext = vi.fn(); +vi.mock("@/lib/server/order-item-context-service", () => ({ + getOrderItemContext: (...args: unknown[]) => getOrderItemContext(...args), +})); + +import { GET } from "./route"; + +function get(id: string) { + return new NextRequest(`http://localhost/api/v1/order-items/${id}/context`, { + method: "GET", + }); +} + +describe("GET /api/v1/order-items/[id]/context", () => { + beforeEach(() => { + getOrderItemContext.mockReset(); + }); + + it("400s on missing id", async () => { + const res = await GET(get(""), { params: Promise.resolve({ id: "" }) }); + expect(res.status).toBe(400); + }); + + it("200s with the service result", async () => { + getOrderItemContext.mockResolvedValue({ + generated_at: "2026-08-07T10:00:00Z", + match: { sku: "DRL-6MM", match_kind: "sku_exact", match_confidence: 1 }, + last_quote: null, + last_order: null, + last_invoiced: null, + stock: null, + suppliers: [], + cheapest: null, + }); + const res = await GET(get("abc"), { params: Promise.resolve({ id: "abc" }) }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.match.sku).toBe("DRL-6MM"); + }); + + it("500s when the service throws", async () => { + getOrderItemContext.mockRejectedValue(new Error("PB exploded")); + const res = await GET(get("abc"), { params: Promise.resolve({ id: "abc" }) }); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("PB exploded"); + }); +}); +``` + +- [ ] **Step 2: Run the tests** + +Run: `npx vitest run src/app/api/v1/order-items/[id]/context/route.test.ts` +Expected: all 3 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/api/v1/order-items/[id]/context/route.test.ts +git commit -m "test(context): /api/v1/order-items/[id]/context route tests" +``` + +--- + +### Task 6: UI — `OrderItemContextPanel` (L1 inline expandable) + +**Files:** +- Create: `src/components/order-item-context-panel.tsx` + +- [ ] **Step 1: Write the component** + +Write `src/components/order-item-context-panel.tsx`: + +```tsx +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + ChevronDown, + ChevronUp, + History, + Loader2, + Package, + Receipt, + ShoppingBag, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { OrderItemContextResponse } from "@/lib/server/order-item-context-service"; + +export interface ApplyPayload { + source: "last_quote" | "last_order" | "last_invoiced" | "stock"; + cost_price: number; + supplier_id?: string; + source_label: string; +} + +interface Props { + itemId: string; + defaultOpen?: boolean; + onApply: (payload: ApplyPayload) => void; +} + +const round2 = (n: number) => Math.round(n * 100) / 100; + +function relTime(daysAgo: number): string { + if (daysAgo < 1) return "today"; + if (daysAgo === 1) return "1d"; + if (daysAgo < 30) return `${daysAgo}d`; + const mo = Math.round(daysAgo / 30); + return `${mo}mo`; +} + +interface RowProps { + label: string; + icon: React.ReactNode; + badge: string; + cost: number; + selling: number | null; + markup: number | null; + sellingDimmed?: boolean; + applyDisabled?: boolean; + onApply?: () => void; +} + +function Row({ label, icon, badge, cost, selling, markup, sellingDimmed, applyDisabled, onApply }: RowProps) { + return ( +
+
+ {icon} + {label} +
+ {badge} +
+ costR {cost.toFixed(2)} + + selling + {selling != null ? `R ${selling.toFixed(2)}` : "—"} + + + markup + {markup != null ? `${markup.toFixed(1)}%` : "—"} + +
+ +
+ ); +} + +export function OrderItemContextPanel({ itemId, defaultOpen = true, onApply }: Props) { + const [open, setOpen] = useState(defaultOpen); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchOnce = useCallback(async () => { + if (data || loading) return; + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/v1/order-items/${itemId}/context`); + if (res.status === 404) { setData(null); return; } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = (await res.json()) as OrderItemContextResponse; + setData(body); + if (body.error) setError(body.error); + } catch (err) { + setError(err instanceof Error ? err.message : "Lookup failed"); + } finally { + setLoading(false); + } + }, [itemId, data, loading]); + + useEffect(() => { + if (open) void fetchOnce(); + }, [open, fetchOnce]); + + const noData = !data || (!data.last_quote && !data.last_order && !data.last_invoiced && !data.stock && data.suppliers.length === 0); + + return ( +
+ + + {open && ( +
+ {error && ( +
+ Context lookup failed: {error} +
+ )} + + {data?.match.match_kind === "description_fuzzy" && ( +
+ ⚠ best-guess match · {Math.round(data.match.match_confidence * 100)}% confidence +
+ )} + + {data?.last_quote && ( + } + badge={`${data.last_quote.quote_number} · ${relTime(data.last_quote.days_ago)}`} + cost={data.last_quote.cost_price} + selling={data.last_quote.selling_price} + markup={data.last_quote.markup_percent} + onApply={() => onApply({ + source: "last_quote", + cost_price: round2(data.last_quote!.cost_price), + source_label: data.last_quote!.quote_number, + })} + /> + )} + + {data?.last_order && ( + } + badge={`${data.last_order.order_number} · ${relTime(data.last_order.days_ago)}`} + cost={data.last_order.cost_price} + selling={data.last_order.selling_price} + markup={data.last_order.markup_percent} + onApply={() => onApply({ + source: "last_order", + cost_price: round2(data.last_order!.cost_price), + source_label: data.last_order!.order_number, + })} + /> + )} + + {data?.last_invoiced && ( + } + badge={`${data.last_invoiced.supplier_invoice_number} · ${relTime(data.last_invoiced.days_ago)}`} + cost={data.last_invoiced.cost_price} + selling={null} + markup={null} + sellingDimmed + onApply={() => onApply({ + source: "last_invoiced", + cost_price: round2(data.last_invoiced!.cost_price), + source_label: data.last_invoiced!.supplier_invoice_number, + })} + /> + )} + + {data?.stock && ( + } + badge={`${data.stock.current_qty} units (${data.stock.status.replace("_", " ")})`} + cost={data.stock.selling_price ?? 0} + selling={data.stock.selling_price ?? null} + markup={data.stock.markup_percent ?? null} + applyDisabled={data.stock.selling_price == null} + onApply={data.stock.selling_price != null ? () => onApply({ + source: "stock", + cost_price: round2(data.stock!.selling_price!), + source_label: data.stock!.name, + }) : undefined} + /> + )} + + {noData && !loading && !error && ( +
+
No context for this item yet
+
First time we’re seeing this SKU — pick a supplier, set cost, save.
+
+ )} +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Verify the file compiles** + +Run: `npx tsc --noEmit -p tsconfig.json 2>&1 | head -10` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/order-item-context-panel.tsx +git commit -m "feat(context): OrderItemContextPanel (inline expandable, 4 rows)" +``` + +--- + +### Task 7: UI — `OrderItemContextPanel` tests + +**Files:** +- Create: `src/components/order-item-context-panel.test.tsx` + +- [ ] **Step 1: Write the failing tests** + +Write `src/components/order-item-context-panel.test.tsx`: + +```tsx +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { OrderItemContextPanel } from "@/components/order-item-context-panel"; +import type { OrderItemContextResponse } from "@/lib/server/order-item-context-service"; + +afterEach(cleanup); + +const fullContext: OrderItemContextResponse = { + generated_at: "2026-08-07T10:00:00Z", + match: { sku: "DRL-6MM", match_kind: "sku_exact", match_confidence: 1 }, + last_quote: { + quote_item_id: "qi1", quote_id: "q1", quote_number: "Q-26080701", + customer_name: "Acme", company: "Acme Ltd", status: "approved", + cost_price: 100, selling_price: 150, markup_percent: 50, days_ago: 5, + }, + last_order: { + order_item_id: "oi1", order_id: "o1", order_number: "ORD-26080702", + company: "Acme Ltd", cost_price: 110, selling_price: 150, + markup_percent: 36.4, item_status: "delivered", updated_at: "2026-08-01T00:00:00Z", days_ago: 6, + }, + last_invoiced: { + order_item_id: "oi2", order_id: "o2", order_number: "ORD-26080703", + supplier_invoice_number: "INV-99", customer_invoice_number: "C-7", + invoiced_date: "2026-08-02T00:00:00Z", cost_price: 105, quantity: 10, qty_invoiced: 10, days_ago: 5, + }, + stock: { + stock_item_id: "s1", name: "Drill 6mm", sku: "DRL-6MM", + current_qty: 240, min_qty: 20, selling_price: 140, markup_percent: 40, status: "in_stock", + }, + suppliers: [], + cheapest: null, +}; + +describe("OrderItemContextPanel", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("renders all 4 rows when context is fully populated", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => fullContext, + } as Response); + + render( {}} />); + await waitFor(() => { + expect(screen.getByText(/Last quote/)).toBeInTheDocument(); + expect(screen.getByText(/Last order/)).toBeInTheDocument(); + expect(screen.getByText(/Last invoice/)).toBeInTheDocument(); + expect(screen.getByText(/Stock/)).toBeInTheDocument(); + }); + }); + + it("renders the dashed empty-state card when context is all-null", async () => { + const empty: OrderItemContextResponse = { + ...fullContext, + last_quote: null, last_order: null, last_invoiced: null, stock: null, suppliers: [], + }; + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => empty, + } as Response); + + render( {}} />); + await waitFor(() => { + expect(screen.getByText(/No context for this item yet/)).toBeInTheDocument(); + }); + }); + + it("fires onApply with the row's cost and source when Apply is clicked", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => fullContext, + } as Response); + const onApply = vi.fn(); + + render(); + await waitFor(() => screen.getByText(/Last quote/)); + const quoteRow = screen.getByText(/Last quote/).closest("div.flex")!; + const applyBtn = quoteRow.querySelector("button")!; + fireEvent.click(applyBtn); + + expect(onApply).toHaveBeenCalledWith(expect.objectContaining({ + source: "last_quote", + cost_price: 100, + source_label: "Q-26080701", + })); + }); + + it("marks the invoice row's selling cell as dimmed", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => fullContext, + } as Response); + + render( {}} />); + await waitFor(() => screen.getByText(/Last invoice/)); + // The dimmed class is applied to the selling cell on the invoice row. + const dimmed = document.querySelector(".text-muted-foreground\\/50"); + expect(dimmed).not.toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the tests** + +Run: `npx vitest run src/components/order-item-context-panel.test.tsx` +Expected: all 4 tests pass. + +If `text-muted-foreground/50` selector fails because Tailwind's class doesn't escape the slash, change the assertion to look for the literal class string `"text-muted-foreground/50"` on the DOM and update the assertion accordingly. Do not edit the component to change the class. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/order-item-context-panel.test.tsx +git commit -m "test(context): OrderItemContextPanel UI tests" +``` + +--- + +### Task 8: UI — `OrderItemContextPopoverTrigger` (L2 ⓘ) + +**Files:** +- Create: `src/components/order-item-context-popover-trigger.tsx` + +- [ ] **Step 1: Write the trigger component** + +Write `src/components/order-item-context-popover-trigger.tsx`: + +```tsx +"use client"; + +import { useState } from "react"; +import { Info } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { OrderItemContextPanel, type ApplyPayload } from "@/components/order-item-context-panel"; +import type { OrderItemContextResponse } from "@/lib/server/order-item-context-service"; +import { cn } from "@/lib/utils"; + +interface Props { + itemId: string; + /** Optional pre-loaded context. Saves a fetch on first open if supplied. */ + initialData?: OrderItemContextResponse | null; + className?: string; +} + +/** + * ⓘ popover trigger that renders the deterministic order-item + * context inside the orders detail page items table. Fetches lazily + * on first open. Hidden entirely if the fetch 404s (e.g. the + * endpoint is feature-flagged off). + */ +export function OrderItemContextPopoverTrigger({ itemId, initialData, className }: Props) { + const [open, setOpen] = useState(false); + const [hidden, setHidden] = useState(false); + + const handleOpen = async (next: boolean) => { + setOpen(next); + }; + + if (hidden) return null; + + return ( + + + + + e.preventDefault()} + > + { + // Orders page is read-only at the row level — apply + // requires the user to open the process modal. Close + // the popover and let the row click target the modal. + setOpen(false); + }} + /> + + + ); +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `npx tsc --noEmit -p tsconfig.json 2>&1 | head -10` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/order-item-context-popover-trigger.tsx +git commit -m "feat(context): OrderItemContextPopoverTrigger (ⓘ on orders page)" +``` + +--- + +### Task 9: UI — `OrderItemContextPopoverTrigger` tests + +**Files:** +- Create: `src/components/order-item-context-popover-trigger.test.tsx` + +- [ ] **Step 1: Write the failing tests** + +Write `src/components/order-item-context-popover-trigger.test.tsx`: + +```tsx +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { cleanup, render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { OrderItemContextPopoverTrigger } from "@/components/order-item-context-popover-trigger"; + +afterEach(cleanup); + +describe("OrderItemContextPopoverTrigger", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("renders the ⓘ button by default", () => { + render(); + expect(screen.getByRole("button", { name: /View item context/ })).toBeInTheDocument(); + }); + + it("fetches on first open, not on render", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + generated_at: "2026-08-07T10:00:00Z", + match: { sku: "X", match_kind: "sku_exact", match_confidence: 1 }, + last_quote: null, last_order: null, last_invoiced: null, stock: null, + suppliers: [], cheapest: null, + }), + } as Response); + + render(); + expect(fetchSpy).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: /View item context/ })); + await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1)); + expect(fetchSpy.mock.calls[0][0]).toContain("/api/v1/order-items/abc/context"); + }); +}); +``` + +- [ ] **Step 2: Run the tests** + +Run: `npx vitest run src/components/order-item-context-popover-trigger.test.tsx` +Expected: both tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/order-item-context-popover-trigger.test.tsx +git commit -m "test(context): OrderItemContextPopoverTrigger UI tests" +``` + +--- + +### Task 10: Sprint modal wire-in + +**Files:** +- Modify: `src/components/order-sprint-process-modal.tsx` + +- [ ] **Step 1: Add the import + remove the AI Insights imports** + +In `src/components/order-sprint-process-modal.tsx`, replace the lucide-react imports block (lines 4-17 of the current file) with: + +```tsx +import { + Building2, + ChevronDown, + ChevronUp, + History, + Info, + Loader2, + Lock, + ShoppingCart, + Truck, + X, +} from "lucide-react"; +``` + +(Removed: `Sparkles`, `Wand2`.) + +Replace the AI-renderer import (lines 30-33 of the current file): + +```tsx +import { MarginApprovalDialog } from "@/components/margin-approval-dialog"; +``` + +with: + +```tsx +import { MarginApprovalDialog } from "@/components/margin-approval-dialog"; +import { OrderItemContextPanel, type ApplyPayload } from "@/components/order-item-context-panel"; +``` + +- [ ] **Step 2: Add the `applyContext` handler** + +Right after the existing `applyFromHistory` function (around line 205 in the current file), add: + +```tsx +// Apply a row from the context panel. Sells only the cost (+ supplier +// when the row carries one). Selling stays locked to the order. +const applyContext = (p: ApplyPayload) => { + if (p.cost_price > 0) setCost(p.cost_price); + toast.success(`Applied cost from ${p.source_label}: R${p.cost_price.toFixed(2)}`); +}; +``` + +- [ ] **Step 3: Replace the AI Insights section** + +Find the AI Insights `
` block (lines 410-465 in the current file, the one starting with `
...
` with: + +```tsx +{/* Context — deterministic history surfaced from PB. Replaces the + AI Insights section. Selling stays locked; Apply copies cost. */} + +``` + +- [ ] **Step 4: Delete the `applyAiSuggestion` callback** + +Find the `applyAiSuggestion` `useCallback` (around lines 209-243) and delete it entirely along with its dependency on `aiInsights` (no longer used). + +- [ ] **Step 5: Delete the `aiInsightsExpanded` state + the `aiInsights` memo** + +Find: +- `const [aiInsightsExpanded, setAiInsightsExpanded] = useState(true);` +- `const aiInsights = useMemo(() => item?.ai_item_insights ?? null, [item]);` +- `const aiHasMatches = ...` (the constant computed near the bottom of the component body) + +Delete all three. Also delete the now-unused `AiItemHistoryPopover` / `AiInsightsEmptyState` references if any remain. + +- [ ] **Step 6: Verify the sprint modal tests still pass** + +Run: `npx vitest run src/components/order-sprint-process-modal.test.tsx` +Expected: existing tests pass (no test changes needed — the test file doesn't currently assert on the AI Insights section's content). + +If any test was asserting on the AI Insights content (search the test file for `Sparkles`, `Wand2`, `AI Insights`, `applyAiSuggestion`), update those assertions to look for the new `Context` heading and the context panel's content instead. + +- [ ] **Step 7: Commit** + +```bash +git add src/components/order-sprint-process-modal.tsx +git commit -m "feat(context): swap sprint modal AI Insights for context panel" +``` + +--- + +### Task 11: Orders page wire-in + +**Files:** +- Modify: `src/app/(app)/orders/[id]/page.tsx` + +- [ ] **Step 1: Swap the import** + +Find: +```tsx +import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger"; +``` + +Replace with: +```tsx +import { OrderItemContextPopoverTrigger } from "@/components/order-item-context-popover-trigger"; +``` + +- [ ] **Step 2: Swap the JSX usage** + +Find the `` JSX (around line 505, including its wrapper `
` with the `onClick={(e) => e.stopPropagation()}` comment). Replace the `` element with: + +```tsx + +``` + +Keep the surrounding `
` and `onClick={(e) => e.stopPropagation()}` wrapper — same reasoning as before (stop row click from firing on ⓘ click). + +- [ ] **Step 3: Verify the page still type-checks** + +Run: `npx tsc --noEmit -p tsconfig.json 2>&1 | head -10` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add "src/app/(app)/orders/[id]/page.tsx" +git commit -m "feat(context): swap orders-page AI history ⓘ for context popover" +``` + +--- + +### Task 12: Full test suite + build smoke + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full vitest suite** + +Run: `npx vitest run` +Expected: all tests pass. Any pre-existing unrelated failures should be left alone; report any new failures in this change. + +- [ ] **Step 2: Run the production build** + +Run: `npm run build 2>&1 | tail -40` +Expected: build succeeds. The context endpoint is dynamic; verify the output mentions `/api/v1/order-items/[id]/context`. + +- [ ] **Step 3: Lint** + +Run: `npm run lint 2>&1 | tail -20` +Expected: zero new warnings from the new files. + +- [ ] **Step 4: Commit any final adjustments** + +If lint flagged anything fixable, fix it and commit. Otherwise no commit. + +```bash +git add -A +git commit -m "chore(context): lint + build adjustments" --allow-empty +``` + +--- + +## Self-review checklist (already applied while writing) + +- ✅ Spec coverage: each spec section maps to a task (§3 → Tasks 1-3, §4 → Tasks 6-9, §5 → Task 10, §6 → Task 11, §8 → embedded in Tasks 3/5/7/9). +- ✅ Placeholder scan: no TBDs, no "implement later". Every code block is complete. +- ✅ Type consistency: `ApplyPayload` is defined once in `order-item-context-panel.tsx` and re-imported by the trigger + the sprint modal. `OrderItemContextResponse` is defined in the service and imported by both UIs. `getOrderItemContext(id)` signature is consistent across Tasks 2, 3, 4, 5. +- ✅ Auth: route is admin-PB read-only, no Bearer needed (matches `item-history` route). Caller pages already authenticate. +- ✅ Selling stays locked: enforced by the modal's existing markup UI. The `applyContext` handler only touches `setCost`.