WFDASH-WEBAPP/docs/superpowers/plans/2026-08-07-order-item-context-block.md
Claude 357decbb2b docs(plan): order-item context block — 12 TDD tasks
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>
2026-08-07 02:18:28 +02:00

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 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 <OrderItemContextPanel/>
  • 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:

/**
 * 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&rsquo;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 applyContext handler

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 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
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: 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.