diff --git a/src/components/order-sprint-process-modal.test.tsx b/src/components/order-sprint-process-modal.test.tsx index b9c6d65..c02a80a 100644 --- a/src/components/order-sprint-process-modal.test.tsx +++ b/src/components/order-sprint-process-modal.test.tsx @@ -56,15 +56,17 @@ const baseItem = { id: "item-1", description: "HSS drill bit", quantity: 2, + order_qty: 10, + selling_price: 50, supplier: "", cost_price: 0, - selling_price: 0, markup: 0, sku: "", notes: "", category: "", po_description: "", item_status: "pending", + ai_item_insights: null, }; const suppliers = [ @@ -210,4 +212,144 @@ describe("OrderSprintProcessModal", () => { expect(updateMock).not.toHaveBeenCalled(); }); }); + + it("locks the selling price as read-only (no input is rendered)", () => { + renderModal(); + // No for selling — the selling value is rendered as a + // styled div. Operators can't change what came in from the order. + expect(screen.queryByLabelText(/selling \(zar\)/i)).toBeNull(); + // The locked value still surfaces so the operator sees margin. + expect(screen.getByText(/R50\.00/)).toBeInTheDocument(); + }); + + it("defaults qty to order_qty and writes it to order_qty on save", async () => { + renderModal(); + // Default value comes from item.order_qty (10), not quantity (2). + expect( + (screen.getByLabelText(/po qty/i) as HTMLInputElement).value, + ).toBe("10"); + // Override and save — verify order_qty is in the payload. + fireEvent.change(screen.getByLabelText(/po qty/i), { + target: { value: "8" }, + }); + fireEvent.change(screen.getByLabelText("Supplier"), { + target: { value: "sup-1" }, + }); + await setField("Cost (ZAR)", "25"); + fireEvent.change( + screen.getByPlaceholderText("Description the supplier sees on the PO"), + { target: { value: "10mm HSS drill bit" } }, + ); + + fireEvent.click(screen.getByRole("button", { name: /process item/i })); + + await waitFor(() => { + expect(updateMock).toHaveBeenCalledTimes(1); + }); + expect(updateMock.mock.calls[0]![1]).toMatchObject({ + order_qty: 8, + }); + }); + + it("falls back to quantity when order_qty is missing", () => { + renderModal({ item: { ...baseItem, order_qty: undefined, quantity: 5 } }); + expect( + (screen.getByLabelText(/po qty/i) as HTMLInputElement).value, + ).toBe("5"); + }); + + it("computes markup from cost + selling at save time", async () => { + // baseItem.selling_price = 50. Cost = 25 → markup = ((50-25)/25)*100 = 100. + renderModal(); + fireEvent.change(screen.getByLabelText("Supplier"), { + target: { value: "sup-1" }, + }); + await setField("Cost (ZAR)", "25"); + fireEvent.change( + screen.getByPlaceholderText("Description the supplier sees on the PO"), + { target: { value: "ok" } }, + ); + + fireEvent.click(screen.getByRole("button", { name: /process item/i })); + + await waitFor(() => { + expect(updateMock).toHaveBeenCalledTimes(1); + }); + expect(updateMock.mock.calls[0]![1]).toMatchObject({ + markup: 100, + selling_price: 50, + cost_price: 25, + }); + }); + + 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 bde5b13..bf77bdc 100644 --- a/src/components/order-sprint-process-modal.tsx +++ b/src/components/order-sprint-process-modal.tsx @@ -6,13 +6,14 @@ import { Calendar, ChevronDown, ChevronUp, - Copy, - DollarSign, History, + Info, Loader2, + Lock, ShoppingCart, Sparkles, Truck, + Wand2, X, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -44,6 +45,7 @@ type OrderSprintItem = { id: string; description?: string; quantity?: number | null; + order_qty?: number | null; supplier?: string; cost_price?: number; selling_price?: number; @@ -56,15 +58,24 @@ 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 + // the price the customer was quoted. const [supplierId, setSupplierId] = useState(item?.supplier || ""); const [cost, setCost] = useState(item?.cost_price || 0); - const [markup, setMarkup] = useState(item?.markup || 0); - const [selling, setSelling] = useState(item?.selling_price || 0); - // PO description — what the supplier sees on the PO. Mirrors the - // canonical /orders/[id]/process gate: an item only transitions - // pending → processed when supplier + cost + po_description are all - // set, so the buyer-tasks Needs PO tab can find it. + // qty that goes onto the PO. Defaults to order_qty (the customer's + // requested qty from the original quote); the operator can adjust + // for partial fills. + const [qty, setQty] = useState(item?.order_qty ?? item?.quantity ?? 1); const [poDescription, setPoDescription] = useState(item?.po_description || item?.description || ""); const [notes, setNotes] = useState(item?.notes || ""); const [processing, setProcessing] = useState(false); @@ -76,6 +87,7 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc const [loadingSuppliers, setLoadingSuppliers] = useState(false); const aiInsights = useMemo(() => item?.ai_item_insights ?? null, [item]); + const selling = item?.selling_price ?? 0; // Load order history and available suppliers when modal opens useEffect(() => { @@ -158,53 +170,63 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc return () => { cancelled = true; }; }, [open, item, suppliers]); - // Copy to clipboard - const copyToClipboard = (value: string | number, label: string) => { - navigator.clipboard.writeText(String(value)); - toast.success(`Copied ${label}`); - }; - - // Pricing recompute - const onCostChange = useCallback((raw: string) => { - const value = Number(raw) || 0; - setCost(value); - setSelling(round2(value * (1 + markup / 100))); - }, [markup]); - - const onSellingChange = useCallback((raw: string) => { - const value = Number(raw) || 0; - setSelling(value); - setCost(markup > -100 ? round2(value / (1 + markup / 100)) : 0); - }, [markup]); - - const onMarkupChange = useCallback((raw: string) => { - const value = Number(raw) || 0; - setMarkup(value); - if (cost > 0) setSelling(round2(cost * (1 + value / 100))); - }, [cost]); - // Apply supplier from history const applySupplier = (s: AvailableSupplier) => { setSupplierId(s.id); if (s.lastCost) { setCost(s.lastCost); - setSelling(round2(s.lastCost * (1 + markup / 100))); } toast.success(`Applied supplier: ${s.name}`); }; - // Apply from order history + // Apply from order history — copy cost + selling as-is (selling is + // already fixed on the order, so we keep the historical selling; + // we only refresh cost if the item doesn't have one yet). const applyFromHistory = (h: OrderHistoryItem) => { const supplier = suppliers.find((s) => s.id === h.supplier); if (supplier) setSupplierId(supplier.id); - if (h.costPrice) { - setCost(h.costPrice); - if (h.sellingPrice != null) setSelling(h.sellingPrice); - if (h.markup != null) setMarkup(h.markup); - } + if (h.costPrice) setCost(h.costPrice); 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]); + // Process const handleProcess = async () => { if (!item) return; @@ -233,11 +255,20 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc ? "processed" : currentStatus; + // markup is derived from the now-locked selling vs the editable + // cost. We still write it so downstream reports (margin, etc.) + // stay consistent. + const computedMarkup = + cost > 0 && selling > 0 + ? round2(((selling - cost) / cost) * 100) + : 0; + await pb.collection("order_items").update(item.id, { supplier: supplierId, cost_price: round2(cost), selling_price: round2(selling), - markup: round2(markup), + markup: computedMarkup, + order_qty: qty, po_description: trimmedPoDescription, notes: notes, item_status: nextStatus, @@ -261,49 +292,108 @@ 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 ( -
{ if (e.target === e.currentTarget) onClose(); }}> - - +
{ if (e.target === e.currentTarget) onClose(); }} + > + +
- + Process Order Item

{item.description || "(no description)"}

-
- - {/* AI Insights */} -
- + + {/* AI Insights — tinted section so it reads as distinct from + the deterministic history below it. */} +
+
+ + +
{aiInsightsExpanded && ( -
{aiInsights ? : }
+
+ {aiInsights ? ( + + ) : ( + + )} +
)} -
+ {/* Order History */} -
+
- Recent Orders ({orderHistory.length}) + + Recent Orders ({orderHistory.length}) +
- {loadingHistory && } + {loadingHistory && ( + + )}
{orderHistory.length > 0 ? (
{orderHistory.slice(0, 5).map((h) => ( -
+
{h.description}
@@ -312,89 +402,173 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc R{h.costPrice?.toFixed(2) || "—"} · R{h.sellingPrice?.toFixed(2) || "—"} - · - {h.markup}% + {h.markup != null && ( + <> + · + {h.markup}% + + )}
- +
))}
- ) : !loadingHistory &&

No order history found.

} -
+ ) : ( + !loadingHistory && ( +

+ No order history found. +

+ ) + )} +
{/* Available Suppliers */} -
- {suppliersExpanded && (
{loadingSuppliers ? ( -
Searching...
+
+ + Searching… +
) : availableSuppliers.length > 0 ? (
{availableSuppliers.map((s) => ( -
+
{s.name}
- {s.lastCost &&
Last cost: R{s.lastCost.toFixed(2)}{s.brand && ` · ${s.brand}`}
} + {s.lastCost && ( +
+ Last cost: R{s.lastCost.toFixed(2)} + {s.brand && ` · ${s.brand}`} +
+ )}
- +
))}
- ) :

No suppliers found in price list.

} + ) : ( +

+ No suppliers found in price list. +

+ )}
)} -
+ - {/* Pricing */} -
-
-
- - -
- onCostChange(e.target.value)} className="h-9 tabular-nums" placeholder="0.00" /> + {/* Pricing — qty + cost + selling (read-only). Selling is + locked because it comes from the order; we still surface + it so the operator sees the margin they're pricing. */} +
+
+ + setQty(Math.max(1, Number(e.target.value) || 1))} + className="h-9 tabular-nums" + placeholder="1" + /> +

+ From order: {item.order_qty ?? item.quantity ?? 1} +

-
-
- - -
- onMarkupChange(e.target.value)} className="h-9 tabular-nums" placeholder="20" /> +
+ + setCost(Number(e.target.value) || 0)} + className="h-9 tabular-nums" + placeholder="0.00" + />
-
-
- - +
+ +
+ {selling > 0 ? `R${selling.toFixed(2)}` : not set}
- onSellingChange(e.target.value)} className="h-9 tabular-nums" placeholder="0.00" /> +

+ + From order · not editable +

-
+
{/* Supplier */} -
+
- -
+ + {/* PO description — what the supplier sees on the PO. Defaults to the order description so the buyer-tasks pending → processed transition fires for the common no-op case. Mirrors the canonical /orders/[id]/process gate. */} -
+
{poDescription.trim() !== (item.description || "").trim() && ( @@ -407,21 +581,47 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc className="text-sm" placeholder="Description the supplier sees on the PO" /> -
+
{/* Notes */} -
+
-