feat(context): swap sprint modal AI Insights for context panel
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
c5fbb7422f
commit
995b7ed675
2 changed files with 41 additions and 192 deletions
|
|
@ -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(
|
||||
"",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue