Implementation plan for the spec at docs/superpowers/specs/2026-08-07-order-item-context-block-design.md. Tasks: backend service skeleton + impl + tests, /api/v1/.../context endpoint + tests, OrderItemContextPanel + tests, OrderItemContextPopoverTrigger + tests, sprint modal wire-in, orders page wire-in, full test suite + build smoke. Each task follows a TDD cycle: write failing test, run to confirm, implement minimal code, run to confirm pass, commit. Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Orca <help@stably.ai>
49 KiB
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 routesrc/app/api/v1/order-items/[id]/context/route.test.ts— route unit testssrc/components/order-item-context-panel.tsx— L1 inline expandable UIsrc/components/order-item-context-panel.test.tsx— UI testssrc/components/order-item-context-popover-trigger.tsx— L2 ⓘ popover triggersrc/components/order-item-context-popover-trigger.test.tsx— trigger tests
Modified:
src/components/order-sprint-process-modal.tsx— remove AI Insights section +applyAiSuggestion; mount<OrderItemContextPanel/>src/app/(app)/orders/[id]/page.tsx— swapAiItemHistoryTriggerforOrderItemContextPopoverTrigger
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:
/**
* 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<OrderItemContextResponse> {
// 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
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:
export async function getOrderItemContext(
orderItemId: string,
): Promise<OrderItemContextResponse> {
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
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:
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<typeof vi.fn>;
getList: ReturnType<typeof vi.fn>;
}
function makePb(collections: Record<string, CollectionMock>) {
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<ReturnType<typeof getAdminPocketBase>>);
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<ReturnType<typeof getAdminPocketBase>>);
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<ReturnType<typeof getAdminPocketBase>>);
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<ReturnType<typeof getAdminPocketBase>>);
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<ReturnType<typeof getAdminPocketBase>>);
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
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:
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
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:
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
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:
"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 (
<div className="flex items-center gap-3 rounded-lg border border-border/60 bg-muted/20 hover:bg-muted/40 transition-colors px-3 py-2">
<div className="flex items-center gap-1.5 text-xs font-medium shrink-0">
{icon}
<span>{label}</span>
</div>
<span className="text-[10px] text-muted-foreground shrink-0 font-mono">{badge}</span>
<div className="flex-1 grid grid-cols-3 gap-2 text-xs tabular-nums">
<span><span className="text-[10px] text-muted-foreground mr-1">cost</span>R {cost.toFixed(2)}</span>
<span className={cn(sellingDimmed && "text-muted-foreground/50")}>
<span className="text-[10px] text-muted-foreground mr-1">selling</span>
{selling != null ? `R ${selling.toFixed(2)}` : "—"}
</span>
<span>
<span className="text-[10px] text-muted-foreground mr-1">markup</span>
{markup != null ? `${markup.toFixed(1)}%` : "—"}
</span>
</div>
<button
type="button"
onClick={onApply}
disabled={applyDisabled || !onApply}
className="shrink-0 rounded-md border border-border/60 px-2 py-1 text-[11px] font-medium hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Apply
</button>
</div>
);
}
export function OrderItemContextPanel({ itemId, defaultOpen = true, onApply }: Props) {
const [open, setOpen] = useState(defaultOpen);
const [data, setData] = useState<OrderItemContextResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<section className="rounded-lg border border-indigo-500/30 bg-gradient-to-b from-indigo-50/40 to-transparent p-3 space-y-2 shadow-sm shadow-indigo-500/5 dark:from-indigo-950/20">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex items-center gap-2 text-left w-full"
>
{open ? <ChevronUp className="h-4 w-4 text-indigo-700 dark:text-indigo-300" /> : <ChevronDown className="h-4 w-4 text-indigo-700 dark:text-indigo-300" />}
<History className="h-4 w-4 text-indigo-700 dark:text-indigo-300" />
<span className="text-xs font-semibold uppercase tracking-wide text-indigo-700 dark:text-indigo-300">
Context
</span>
{data && (
<span className="ml-1 text-[10px] text-muted-foreground">
{[
data.last_quote && "quote",
data.last_order && "order",
data.last_invoiced && "invoice",
data.stock && "stock",
].filter(Boolean).join(" · ") || "no matches"}
</span>
)}
<span className="ml-2 text-[10px] font-medium text-muted-foreground hover:text-foreground underline-offset-2 hover:underline">
{open ? "Hide" : "Show"}
</span>
{loading && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground ml-auto" />}
</button>
{open && (
<div className="space-y-2 pl-6">
{error && (
<div className="rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-[10px] text-destructive">
Context lookup failed: {error}
</div>
)}
{data?.match.match_kind === "description_fuzzy" && (
<div className="rounded-md border border-warn/30 bg-warn/10 px-2 py-1 text-[10px] text-warn-foreground ring-1 ring-inset ring-warn/15">
⚠ best-guess match · {Math.round(data.match.match_confidence * 100)}% confidence
</div>
)}
{data?.last_quote && (
<Row
label="Last quote"
icon={<ShoppingBag className="h-3.5 w-3.5 text-muted-foreground" />}
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 && (
<Row
label="Last order"
icon={<History className="h-3.5 w-3.5 text-muted-foreground" />}
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 && (
<Row
label="Last invoice"
icon={<Receipt className="h-3.5 w-3.5 text-muted-foreground" />}
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 && (
<Row
label="Stock"
icon={<Package className="h-3.5 w-3.5 text-muted-foreground" />}
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 && (
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
<div className="font-medium">No context for this item yet</div>
<div className="mt-1">First time we’re seeing this SKU — pick a supplier, set cost, save.</div>
</div>
)}
</div>
)}
</section>
);
}
- Step 2: Verify the file compiles
Run: npx tsc --noEmit -p tsconfig.json 2>&1 | head -10
Expected: no errors.
- Step 3: Commit
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:
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(<OrderItemContextPanel itemId="x" onApply={() => {}} />);
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(<OrderItemContextPanel itemId="x" onApply={() => {}} />);
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(<OrderItemContextPanel itemId="x" onApply={onApply} />);
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(<OrderItemContextPanel itemId="x" onApply={() => {}} />);
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
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:
"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 (
<Popover open={open} onOpenChange={handleOpen}>
<PopoverTrigger
className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full",
"transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
"text-indigo-600 hover:bg-indigo-500/10",
className,
)}
aria-label="View item context"
title="Item context"
>
<Info className="h-3.5 w-3.5" />
</PopoverTrigger>
<PopoverContent
align="start"
side="right"
className="w-[420px] p-0"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<OrderItemContextPanel
itemId={itemId}
defaultOpen
onApply={(_p: ApplyPayload) => {
// 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);
}}
/>
</PopoverContent>
</Popover>
);
}
- Step 2: Verify it compiles
Run: npx tsc --noEmit -p tsconfig.json 2>&1 | head -10
Expected: no errors.
- Step 3: Commit
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:
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(<OrderItemContextPopoverTrigger itemId="x" />);
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(<OrderItemContextPopoverTrigger itemId="abc" />);
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
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:
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):
import { MarginApprovalDialog } from "@/components/margin-approval-dialog";
with:
import { MarginApprovalDialog } from "@/components/margin-approval-dialog";
import { OrderItemContextPanel, type ApplyPayload } from "@/components/order-item-context-panel";
- Step 2: Add the
applyContexthandler
Right after the existing applyFromHistory function (around line 205 in the current file), add:
// 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 <section> block (lines 410-465 in the current file, the one starting with <section className="rounded-lg border border-warn/40..."). Replace the entire <section>...</section> with:
{/* Context — deterministic history surfaced from PB. Replaces the
AI Insights section. Selling stays locked; Apply copies cost. */}
<OrderItemContextPanel
itemId={item.id}
onApply={applyContext}
/>
- Step 4: Delete the
applyAiSuggestioncallback
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
aiInsightsExpandedstate + theaiInsightsmemo
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
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:
import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger";
Replace with:
import { OrderItemContextPopoverTrigger } from "@/components/order-item-context-popover-trigger";
- Step 2: Swap the JSX usage
Find the <AiItemHistoryTrigger ... /> JSX (around line 505, including its wrapper <div> with the onClick={(e) => e.stopPropagation()} comment). Replace the <AiItemHistoryTrigger ... /> element with:
<OrderItemContextPopoverTrigger itemId={item.id} className="mt-0.5" />
Keep the surrounding <div> 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
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.
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:
ApplyPayloadis defined once inorder-item-context-panel.tsxand re-imported by the trigger + the sprint modal.OrderItemContextResponseis 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-historyroute). Caller pages already authenticate. - ✅ Selling stays locked: enforced by the modal's existing markup UI. The
applyContexthandler only touchessetCost.