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..cf3c6ae 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 { useCallback, 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 */}