feat(context): swap sprint modal AI Insights for context panel

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-07 02:58:47 +02:00
parent c5fbb7422f
commit 995b7ed675
2 changed files with 41 additions and 192 deletions

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 { 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<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">