Merge feat/order-item-context-block: replace AI Insights with deterministic context block

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-07 07:17:35 +02:00
commit 30cc4fc5d2
12 changed files with 1089 additions and 199 deletions

View file

@ -63,7 +63,7 @@ import { ItemStatusPill } from "@/components/item-status-pill";
import { QuoteSearch } from "@/components/ui/quote-search";
import { SendPoModal } from "@/components/purchase-orders/send-po-modal";
import { AiInsightsPanel } from "@/components/ai-insights-panel";
import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger";
import { OrderItemContextPopoverTrigger } from "@/components/order-item-context-popover-trigger";
import type { Order, OrderItem, PurchaseOrder, Quote, ItemStatus } from "@/lib/types";
/**
@ -497,16 +497,13 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
<TableCell className="font-medium">
<div className="whitespace-pre-wrap break-words max-w-[420px] flex items-start gap-1.5"
// Stop the row's click handler from firing
// when the operator pops the AI history
// when the operator pops the context panel
// open — otherwise the row pushes to
// /orders/{id}/process on every ⓘ click.
onClick={(e) => e.stopPropagation()}
>
<AiItemHistoryTrigger
<OrderItemContextPopoverTrigger
itemId={item.id}
insights={
(item.ai_item_insights as Record<string, unknown> | null | undefined) ?? null
}
className="mt-0.5"
/>
<span>{item.description}</span>

View file

@ -0,0 +1,51 @@
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");
});
});

View file

@ -0,0 +1,35 @@
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 },
);
}
}

View file

@ -0,0 +1,107 @@
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/));
// Walk up two levels: span (Last quote) → label div → row div (which contains the Apply button).
const quoteRow = screen.getByText(/Last quote/).parentElement!.parentElement!;
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();
});
});

View file

@ -0,0 +1,232 @@
"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(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- data fetch on dep change
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>
);
}

View file

@ -0,0 +1,36 @@
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");
});
});

View file

@ -0,0 +1,58 @@
"use client";
import { useState } from "react";
import { Info } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { OrderItemContextPanel } from "@/components/order-item-context-panel";
import { cn } from "@/lib/utils";
interface Props {
itemId: string;
className?: string;
}
/**
* ⓘ popover trigger that renders the deterministic order-item
* context inside the orders detail page items table. Fetches lazily
* on first open.
*/
export function OrderItemContextPopoverTrigger({ itemId, className }: Props) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<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"
>
<OrderItemContextPanel
itemId={itemId}
defaultOpen
onApply={() => {
// 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>
);
}

View file

@ -108,12 +108,28 @@ describe("OrderSprintProcessModal", () => {
// Order history + price list fetches resolve to empty lists so
// the modal mounts without throwing. We don't assert on them.
getListMock.mockResolvedValue({ items: [], totalItems: 0, totalPages: 1 });
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ webhookFired: true }), {
// The context panel + margin-approval endpoint both go through
// the global fetch. Route by URL so each gets a valid body —
// returning the approval payload for the context call would
// crash the panel's noData computation.
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/context")) {
return new Response(
JSON.stringify({
generated_at: "2026-08-07T00:00:00Z",
match: { sku: "", match_kind: "none", match_confidence: 0 },
last_quote: null, last_order: null, last_invoiced: null, stock: null,
suppliers: [], cheapest: null,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
return new Response(JSON.stringify({ webhookFired: true }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
});
});
vi.stubGlobal("fetch", fetchMock);
});
@ -170,13 +186,16 @@ describe("OrderSprintProcessModal", () => {
);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(onProcessed).toHaveBeenCalledTimes(1);
});
expect(fetchMock.mock.calls[0]![0]).toBe(
const approvalCall = fetchMock.mock.calls.find(
(c) => typeof c[0] === "string" && c[0].includes("/request-margin-approval"),
);
expect(approvalCall).toBeDefined();
expect(approvalCall![0]).toBe(
"/api/v1/order-items/item-1/request-margin-approval",
);
expect(JSON.parse(fetchMock.mock.calls[0]![1].body)).toMatchObject({
expect(JSON.parse(approvalCall![1].body)).toMatchObject({
source: "order_sprint",
draft: {
cost_price: 45,
@ -420,75 +439,4 @@ describe("OrderSprintProcessModal", () => {
// is NOT rendered anywhere.)
expect(screen.queryByText("sup-deleted")).toBeNull();
});
describe("'Use AI suggestion' button", () => {
it("is disabled when ai_item_insights has no top_historical_matches", () => {
renderModal({ item: { ...baseItem, ai_item_insights: { procurement_summary: "hi" } } });
const btn = screen.getByRole("button", { name: /use ai suggestion/i });
expect(btn).toBeDisabled();
});
it("applies supplier (resolved by name) and cost from the AI's top match", async () => {
renderModal({
item: {
...baseItem,
ai_item_insights: {
procurement_summary: "x".repeat(60),
top_historical_matches: [
{
cost_price: 42,
matched_description: "INGCO LASER DISTANCE METER 0.05-85M",
matched_item_id: "abc",
selling_price: 50,
supplier_id: "sup-1", // resolves to "Steel Co"
},
],
},
},
});
fireEvent.click(
screen.getByRole("button", { name: /use ai suggestion/i }),
);
// Supplier dropdown now reads "Steel Co" (sup-1's name).
expect((screen.getByLabelText("Supplier") as HTMLSelectElement).value).toBe(
"sup-1",
);
// Cost populated from AI's cost_price.
expect(
(screen.getByLabelText(/cost \(zar\)/i) as HTMLInputElement).value,
).toBe("42");
});
it("warns when the AI's supplier_id doesn't exist in the suppliers list", async () => {
renderModal({
item: {
...baseItem,
ai_item_insights: {
procurement_summary: "x".repeat(60),
top_historical_matches: [
{
cost_price: 42,
supplier_id: "ghost-supplier",
},
],
},
},
});
fireEvent.click(
screen.getByRole("button", { name: /use ai suggestion/i }),
);
// Cost still populated, supplier stays empty, the toast warns
// about the bad ID rather than silently dropping it.
expect(
(screen.getByLabelText(/cost \(zar\)/i) as HTMLInputElement).value,
).toBe("42");
expect((screen.getByLabelText("Supplier") as HTMLSelectElement).value).toBe(
"",
);
});
});
});

View file

@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import {
Building2,
ChevronDown,
@ -10,9 +10,7 @@ import {
Loader2,
Lock,
ShoppingCart,
Sparkles,
Truck,
Wand2,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@ -27,10 +25,7 @@ import { cn } from "@/lib/utils";
import { supplierColor } from "@/lib/supplier-color";
import type { Supplier } from "@/lib/types";
import { MarginApprovalDialog } from "@/components/margin-approval-dialog";
import {
AiItemHistoryPopover,
AiInsightsEmptyState,
} from "./ai-insights-renderer";
import { OrderItemContextPanel, type ApplyPayload } from "@/components/order-item-context-panel";
const round2 = (n: number) => Math.round(n * 100) / 100;
const MARKUP_APPROVAL_THRESHOLD = 22;
@ -60,14 +55,6 @@ type OrderSprintItem = {
ai_item_insights?: Record<string, unknown> | null;
};
interface AiTopMatch {
cost_price?: number;
matched_description?: string;
matched_item_id?: string;
selling_price?: number;
supplier_id?: string;
}
export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProcessed }: Props) {
// Selling comes from the order and is fixed — never edited here. We
// capture it on mount via `item.selling_price` so the operator sees
@ -84,13 +71,11 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
const [approvalPromptOpen, setApprovalPromptOpen] = useState(false);
const [approvalError, setApprovalError] = useState<string | null>(null);
const [suppliersExpanded, setSuppliersExpanded] = useState(true);
const [aiInsightsExpanded, setAiInsightsExpanded] = useState(true);
const [orderHistory, setOrderHistory] = useState<OrderHistoryItem[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const [availableSuppliers, setAvailableSuppliers] = useState<AvailableSupplier[]>([]);
const [loadingSuppliers, setLoadingSuppliers] = useState(false);
const aiInsights = useMemo(() => item?.ai_item_insights ?? null, [item]);
const selling = item?.selling_price ?? 0;
const liveMarkup =
cost > 0 && selling > 0
@ -204,43 +189,13 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
toast.success(`Applied from order ${h.orderNumber}`);
};
// "Use AI suggestion" — read the top match from ai_item_insights and
// set supplier (resolving the ID to the actual supplier name) + cost.
const applyAiSuggestion = useCallback(() => {
if (!aiInsights) {
toast.error("No AI insights available for this item");
return;
}
const matches = aiInsights.top_historical_matches;
if (!Array.isArray(matches) || matches.length === 0) {
toast.error("AI didn't return any supplier matches");
return;
}
const top = matches[0] as AiTopMatch;
const aiSupplierId = top.supplier_id;
const aiCost = top.cost_price;
let resolvedName: string | null = null;
if (aiSupplierId) {
const supplier = suppliers.find((s) => s.id === aiSupplierId);
if (supplier) {
setSupplierId(supplier.id);
resolvedName = supplier.suppliername || supplier.id;
} else {
// AI referenced a supplier ID that no longer exists — surface
// the bad ID so the operator can see it instead of silently
// dropping the suggestion.
toast.warning(`AI suggested supplier ${aiSupplierId} but it's not in your suppliers list`);
}
}
if (typeof aiCost === "number" && aiCost > 0) setCost(aiCost);
if (resolvedName || aiCost) {
toast.success(
`AI suggestion applied${resolvedName ? `: ${resolvedName}` : ""}${aiCost ? ` @ R${aiCost.toFixed(2)}` : ""}`,
);
}
}, [aiInsights, suppliers]);
// 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);
if (p.supplier_id) setSupplierId(p.supplier_id);
toast.success(`Applied cost from ${p.source_label}: R${p.cost_price.toFixed(2)}`);
};
// Process
const handleProcess = async () => {
@ -375,10 +330,6 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
if (!open || !item) return null;
const aiHasMatches =
Array.isArray(aiInsights?.top_historical_matches) &&
(aiInsights.top_historical_matches as unknown[]).length > 0;
return (
<>
<div
@ -407,62 +358,12 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
</CardHeader>
<CardContent className="flex-1 overflow-y-auto space-y-5 p-4 sm:p-5">
{/* AI Insights — tinted section so it reads as distinct from
the deterministic history below it. Warm warn tones
(yellow/amber) match the modal's outer border and the
popover's AI card, so the warm accent reads as a
cohesive theme across the buying flow. */}
<section className="rounded-lg border border-warn/40 bg-warn/10 p-3 space-y-2 shadow-sm shadow-warn/10 dark:bg-warn/15 dark:border-warn/30">
<div className="flex items-center justify-between gap-2">
<button
type="button"
onClick={() => setAiInsightsExpanded(v => !v)}
aria-expanded={aiInsightsExpanded}
className="flex items-center gap-2 text-left group/ai-toggle"
>
{aiInsightsExpanded ? (
<ChevronUp className="h-4 w-4 text-warn/70" />
) : (
<ChevronDown className="h-4 w-4 text-warn/70" />
)}
<Sparkles className="h-4 w-4 text-warn-foreground" />
<span className="text-xs font-semibold uppercase tracking-wide text-warn-foreground dark:text-warn">
AI Insights
</span>
{aiHasMatches && (
<span className="ml-1 rounded-full bg-warn/25 px-1.5 py-0.5 text-[10px] font-medium text-warn-foreground ring-1 ring-inset ring-warn/40 dark:bg-warn/25 dark:text-warn dark:ring-warn/40">
suggestion available
</span>
)}
<span className="ml-2 text-[10px] font-medium text-warn-foreground/80 group-hover/ai-toggle:text-warn-foreground dark:text-warn/80 dark:group-hover/ai-toggle:text-warn underline-offset-2 group-hover/ai-toggle:underline transition-colors">
{aiInsightsExpanded ? "Hide" : "Show"}
</span>
</button>
<Button
size="xs"
variant="outline"
onClick={applyAiSuggestion}
disabled={!aiHasMatches}
className="border-warn/40 text-warn-foreground hover:bg-warn/20 hover:text-warn-foreground dark:border-warn/40 dark:text-warn dark:hover:bg-warn/20 dark:hover:text-warn"
title={aiHasMatches ? "Pull supplier + cost from AI's top match" : "No AI matches to apply"}
>
<Wand2 className="h-3 w-3 mr-1" />
Use AI suggestion
</Button>
</div>
{aiInsightsExpanded && (
<div className="pl-6">
{aiInsights ? (
<AiItemHistoryPopover insights={aiInsights} />
) : (
<AiInsightsEmptyState
title="No AI analysis"
message="Process without AI suggestions."
/>
)}
</div>
)}
</section>
{/* Context — deterministic history surfaced from PB. Replaces the
AI Insights section. Selling stays locked; Apply copies cost. */}
<OrderItemContextPanel
itemId={item.id}
onApply={applyContext}
/>
{/* Order History */}
<section className="space-y-2">

View file

@ -318,7 +318,7 @@ function emptyHistory(generated_at: string, match: ItemHistoryMatch): ItemHistor
};
}
function aggregateSuppliers(
export function aggregateSuppliers(
rows: Array<{ id: string; supplier: string; cost_price: number; updated: string }>,
nameById: Map<string, string>
): ItemHistorySupplier[] {

View file

@ -0,0 +1,148 @@
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);
});
});

View file

@ -0,0 +1,377 @@
/**
* 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 (`findBestDescriptionMatch`, `aggregateSuppliers`,
* `computeCheapest`, `daysAgo`, `isValidPrice`) are imported from
* `item-history-service.ts` — DO NOT duplicate them.
*/
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import {
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> {
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, sku: "", 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;
expand?: {
quote?: {
quote_number: string;
status: string;
customer_name?: string;
company?: 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;
markup_percent?: number;
item_status: string; updated: string;
expand?: { order?: { order_number: string; company?: 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;
expand?: { order?: { order_number: string } };
}>,
}),
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;
}