diff --git a/src/app/(app)/orders/[id]/page.tsx b/src/app/(app)/orders/[id]/page.tsx index 490d653..a4ecf4f 100644 --- a/src/app/(app)/orders/[id]/page.tsx +++ b/src/app/(app)/orders/[id]/page.tsx @@ -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
e.stopPropagation()} > - | null | undefined) ?? null - } className="mt-0.5" /> {item.description} diff --git a/src/app/api/v1/order-items/[id]/context/route.test.ts b/src/app/api/v1/order-items/[id]/context/route.test.ts new file mode 100644 index 0000000..2830b69 --- /dev/null +++ b/src/app/api/v1/order-items/[id]/context/route.test.ts @@ -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"); + }); +}); diff --git a/src/app/api/v1/order-items/[id]/context/route.ts b/src/app/api/v1/order-items/[id]/context/route.ts new file mode 100644 index 0000000..a8780bc --- /dev/null +++ b/src/app/api/v1/order-items/[id]/context/route.ts @@ -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 }, + ); + } +} diff --git a/src/components/order-item-context-panel.test.tsx b/src/components/order-item-context-panel.test.tsx new file mode 100644 index 0000000..e5089bc --- /dev/null +++ b/src/components/order-item-context-panel.test.tsx @@ -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( {}} />); + await waitFor(() => { + expect(screen.getByText(/Last quote/)).toBeInTheDocument(); + expect(screen.getByText(/Last order/)).toBeInTheDocument(); + expect(screen.getByText(/Last invoice/)).toBeInTheDocument(); + expect(screen.getByText(/Stock/)).toBeInTheDocument(); + }); + }); + + it("renders the dashed empty-state card when context is all-null", async () => { + const empty: OrderItemContextResponse = { + ...fullContext, + last_quote: null, last_order: null, last_invoiced: null, stock: null, suppliers: [], + }; + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => empty, + } as Response); + + render( {}} />); + await waitFor(() => { + expect(screen.getByText(/No context for this item yet/)).toBeInTheDocument(); + }); + }); + + it("fires onApply with the row's cost and source when Apply is clicked", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => fullContext, + } as Response); + const onApply = vi.fn(); + + render(); + await waitFor(() => screen.getByText(/Last quote/)); + // 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( {}} />); + 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(); + }); +}); diff --git a/src/components/order-item-context-panel.tsx b/src/components/order-item-context-panel.tsx new file mode 100644 index 0000000..84d9d20 --- /dev/null +++ b/src/components/order-item-context-panel.tsx @@ -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 ( +
+
+ {icon} + {label} +
+ {badge} +
+ costR {cost.toFixed(2)} + + selling + {selling != null ? `R ${selling.toFixed(2)}` : "—"} + + + markup + {markup != null ? `${markup.toFixed(1)}%` : "—"} + +
+ +
+ ); +} + +export function OrderItemContextPanel({ itemId, defaultOpen = true, onApply }: Props) { + const [open, setOpen] = useState(defaultOpen); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchOnce = useCallback(async () => { + if (data || loading) return; + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/v1/order-items/${itemId}/context`); + if (res.status === 404) { setData(null); return; } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = (await res.json()) as OrderItemContextResponse; + setData(body); + if (body.error) setError(body.error); + } catch (err) { + setError(err instanceof Error ? err.message : "Lookup failed"); + } finally { + setLoading(false); + } + }, [itemId, data, loading]); + + useEffect(() => { + // 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 ( +
+ + + {open && ( +
+ {error && ( +
+ Context lookup failed: {error} +
+ )} + + {data?.match.match_kind === "description_fuzzy" && ( +
+ ⚠ best-guess match · {Math.round(data.match.match_confidence * 100)}% confidence +
+ )} + + {data?.last_quote && ( + } + badge={`${data.last_quote.quote_number} · ${relTime(data.last_quote.days_ago)}`} + cost={data.last_quote.cost_price} + selling={data.last_quote.selling_price} + markup={data.last_quote.markup_percent} + onApply={() => onApply({ + source: "last_quote", + cost_price: round2(data.last_quote!.cost_price), + source_label: data.last_quote!.quote_number, + })} + /> + )} + + {data?.last_order && ( + } + badge={`${data.last_order.order_number} · ${relTime(data.last_order.days_ago)}`} + cost={data.last_order.cost_price} + selling={data.last_order.selling_price} + markup={data.last_order.markup_percent} + onApply={() => onApply({ + source: "last_order", + cost_price: round2(data.last_order!.cost_price), + source_label: data.last_order!.order_number, + })} + /> + )} + + {data?.last_invoiced && ( + } + badge={`${data.last_invoiced.supplier_invoice_number} · ${relTime(data.last_invoiced.days_ago)}`} + cost={data.last_invoiced.cost_price} + selling={null} + markup={null} + sellingDimmed + onApply={() => onApply({ + source: "last_invoiced", + cost_price: round2(data.last_invoiced!.cost_price), + source_label: data.last_invoiced!.supplier_invoice_number, + })} + /> + )} + + {data?.stock && ( + } + badge={`${data.stock.current_qty} units (${data.stock.status.replace("_", " ")})`} + cost={data.stock.selling_price ?? 0} + selling={data.stock.selling_price ?? null} + markup={data.stock.markup_percent ?? null} + applyDisabled={data.stock.selling_price == null} + onApply={data.stock.selling_price != null ? () => onApply({ + source: "stock", + cost_price: round2(data.stock!.selling_price!), + source_label: data.stock!.name, + }) : undefined} + /> + )} + + {noData && !loading && !error && ( +
+
No context for this item yet
+
First time we’re seeing this SKU — pick a supplier, set cost, save.
+
+ )} +
+ )} +
+ ); +} diff --git a/src/components/order-item-context-popover-trigger.test.tsx b/src/components/order-item-context-popover-trigger.test.tsx new file mode 100644 index 0000000..b09e4de --- /dev/null +++ b/src/components/order-item-context-popover-trigger.test.tsx @@ -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(); + expect(screen.getByRole("button", { name: /View item context/ })).toBeInTheDocument(); + }); + + it("fetches on first open, not on render", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + generated_at: "2026-08-07T10:00:00Z", + match: { sku: "X", match_kind: "sku_exact", match_confidence: 1 }, + last_quote: null, last_order: null, last_invoiced: null, stock: null, + suppliers: [], cheapest: null, + }), + } as Response); + + render(); + expect(fetchSpy).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: /View item context/ })); + await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1)); + expect(fetchSpy.mock.calls[0][0]).toContain("/api/v1/order-items/abc/context"); + }); +}); diff --git a/src/components/order-item-context-popover-trigger.tsx b/src/components/order-item-context-popover-trigger.tsx new file mode 100644 index 0000000..30722a3 --- /dev/null +++ b/src/components/order-item-context-popover-trigger.tsx @@ -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 ( + + + + + + { + // 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); + }} + /> + + + ); +} diff --git a/src/components/order-sprint-process-modal.test.tsx b/src/components/order-sprint-process-modal.test.tsx index f66fc13..025efe2 100644 --- a/src/components/order-sprint-process-modal.test.tsx +++ b/src/components/order-sprint-process-modal.test.tsx @@ -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( - "", - ); - }); - }); }); \ No newline at end of file diff --git a/src/components/order-sprint-process-modal.tsx b/src/components/order-sprint-process-modal.tsx index a0a07f9..c2acee0 100644 --- a/src/components/order-sprint-process-modal.tsx +++ b/src/components/order-sprint-process-modal.tsx @@ -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 | 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(null); const [suppliersExpanded, setSuppliersExpanded] = useState(true); - const [aiInsightsExpanded, setAiInsightsExpanded] = useState(true); const [orderHistory, setOrderHistory] = useState([]); const [loadingHistory, setLoadingHistory] = useState(false); const [availableSuppliers, setAvailableSuppliers] = useState([]); 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 ( <>
- {/* 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. */} -
-
- - -
- {aiInsightsExpanded && ( -
- {aiInsights ? ( - - ) : ( - - )} -
- )} -
+ {/* Context — deterministic history surfaced from PB. Replaces the + AI Insights section. Selling stays locked; Apply copies cost. */} + {/* Order History */}
diff --git a/src/lib/server/item-history-service.ts b/src/lib/server/item-history-service.ts index 41a66a9..7a173c2 100644 --- a/src/lib/server/item-history-service.ts +++ b/src/lib/server/item-history-service.ts @@ -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 ): ItemHistorySupplier[] { diff --git a/src/lib/server/order-item-context-service.test.ts b/src/lib/server/order-item-context-service.test.ts new file mode 100644 index 0000000..4ebca20 --- /dev/null +++ b/src/lib/server/order-item-context-service.test.ts @@ -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; + getList: ReturnType; +} + +function makePb(collections: Record) { + return { + collection: (name: string) => { + const c = collections[name]; + if (!c) throw new Error(`unexpected collection: ${name}`); + return c; + }, + }; +} + +const ORD = "order_items"; +const Q = "quote_items"; +const STK = "stock_items"; +const SUP = "suppliers"; + +describe("getOrderItemContext", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns all-null context when the target has no SKU and no description", async () => { + const pb = makePb({ + [ORD]: { getOne: vi.fn().mockResolvedValue({ id: "x", sku: "", description: "" }), getList: vi.fn() }, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited>); + + const ctx = await getOrderItemContext("x"); + expect(ctx.last_quote).toBeNull(); + expect(ctx.last_order).toBeNull(); + expect(ctx.last_invoiced).toBeNull(); + expect(ctx.stock).toBeNull(); + expect(ctx.suppliers).toEqual([]); + expect(ctx.cheapest).toBeNull(); + expect(ctx.match).toEqual({ sku: "", match_kind: "none", match_confidence: 0 }); + }); + + it("populates last_quote from the SKU-based quote_items join", async () => { + const target = { id: "x", sku: "DRL-6MM", description: "Drill 6mm", quote_item: "qi0" }; + const quoteList = { + items: [{ + id: "qi1", + quote: "q1", + cost_price: 100, + selling_price: 150, + created: "2026-08-01T10:00:00Z", + expand: { quote: { quote_number: "Q-26080101", status: "approved", customer_name: "Acme", company: "Acme Ltd" } }, + }], + }; + const emptyList = { items: [] }; + const supplierList = { items: [{ id: "sup1", suppliername: "Fastener Co" }] }; + + const pb = makePb({ + [ORD]: { getOne: vi.fn().mockResolvedValue(target), getList: vi.fn().mockResolvedValue(emptyList) }, + [Q]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(quoteList) }, + [STK]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) }, + [SUP]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(supplierList) }, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited>); + + const ctx = await getOrderItemContext("x"); + expect(ctx.match).toEqual({ sku: "DRL-6MM", match_kind: "sku_exact", match_confidence: 1 }); + expect(ctx.last_quote?.quote_number).toBe("Q-26080101"); + expect(ctx.last_quote?.cost_price).toBe(100); + expect(ctx.last_quote?.selling_price).toBe(150); + expect(ctx.last_quote?.markup_percent).toBe(50); + }); + + it("marks stock status as 'low' when current_qty <= min_qty and > 0", async () => { + const target = { id: "x", sku: "DRL-6MM", description: "Drill", quote_item: "" }; + const emptyList = { items: [] }; + const supplierList = { items: [] }; + const stockList = { + items: [{ id: "s1", name: "Drill 6mm", sku: "DRL-6MM", current_qty: 5, min_qty: 10, selling_price: 140, markup: 40 }], + }; + const pb = makePb({ + [ORD]: { getOne: vi.fn().mockResolvedValue(target), getList: vi.fn().mockResolvedValue(emptyList) }, + [Q]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) }, + [STK]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(stockList) }, + [SUP]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(supplierList) }, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited>); + + const ctx = await getOrderItemContext("x"); + expect(ctx.stock?.status).toBe("low"); + }); + + it("marks stock status as 'out' when current_qty <= 0", async () => { + const target = { id: "x", sku: "DRL-6MM", description: "Drill", quote_item: "" }; + const emptyList = { items: [] }; + const supplierList = { items: [] }; + const stockList = { + items: [{ id: "s1", name: "Drill 6mm", sku: "DRL-6MM", current_qty: 0, min_qty: 5, selling_price: 140, markup: 40 }], + }; + const pb = makePb({ + [ORD]: { getOne: vi.fn().mockResolvedValue(target), getList: vi.fn().mockResolvedValue(emptyList) }, + [Q]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) }, + [STK]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(stockList) }, + [SUP]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(supplierList) }, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited>); + + const ctx = await getOrderItemContext("x"); + expect(ctx.stock?.status).toBe("out"); + }); + + it("populates last_invoiced when an invoiced row exists", async () => { + const target = { id: "x", sku: "DRL-6MM", description: "Drill", quote_item: "" }; + const emptyList = { items: [] }; + const supplierList = { items: [] }; + const invoicedList = { + items: [{ + id: "oi2", + order: "o1", + supplier_invoice_number: "INV-99", + customer_invoice_number: "C-INV-7", + invoiced_date: "2026-08-02T10:00:00Z", + cost_price: 105, + quantity: 10, + qty_invoiced: 10, + expand: { order: { order_number: "ORD-26080201" } }, + }], + }; + const pb = makePb({ + [ORD]: { getOne: vi.fn().mockResolvedValue(target), getList: vi.fn((_n: string, _p: unknown, opts: { sort?: string }) => opts?.sort === "-invoiced_date" ? invoicedList : emptyList) }, + [Q]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) }, + [STK]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) }, + [SUP]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(supplierList) }, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited>); + + const ctx = await getOrderItemContext("x"); + expect(ctx.last_invoiced?.supplier_invoice_number).toBe("INV-99"); + expect(ctx.last_invoiced?.cost_price).toBe(105); + }); +}); diff --git a/src/lib/server/order-item-context-service.ts b/src/lib/server/order-item-context-service.ts new file mode 100644 index 0000000..69e67f0 --- /dev/null +++ b/src/lib/server/order-item-context-service.ts @@ -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 { + 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; +}