From b099e2b42ea282df4eb23ddf352363732dcfc15d Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Thu, 30 Jul 2026 22:21:10 +0200 Subject: [PATCH] feat(stock-quick-draft): modal search + supplier-history regions Co-authored-by: Orca --- .../stock-orders/quick-draft-modal.test.tsx | 82 ++++++++++++ .../stock-orders/quick-draft-modal.tsx | 117 +++++++++++++++++- 2 files changed, 197 insertions(+), 2 deletions(-) diff --git a/src/components/stock-orders/quick-draft-modal.test.tsx b/src/components/stock-orders/quick-draft-modal.test.tsx index 9ecf66c..8afa3ad 100644 --- a/src/components/stock-orders/quick-draft-modal.test.tsx +++ b/src/components/stock-orders/quick-draft-modal.test.tsx @@ -49,4 +49,86 @@ describe("QuickDraftModal — header", () => { await user.type(input, "Jul 2026 run"); expect(input.value).toBe("Jul 2026 run"); }); +}); + +describe("QuickDraftModal — search", () => { + beforeEach(() => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + ok: true, + items: [ + { + id: "item-1", + sku: "STK-CD-115", + name: "115mm Cutting Disc", + description: "For angle grinder", + unit: "ea", + }, + ], + }), + }); + }); + + it("debounces search and calls the search endpoint", async () => { + const user = userEvent.setup(); + render( {}} />); + const input = screen.getByTestId("quick-draft-search"); + await user.type(input, "cutting"); + // Wait for debounce (200ms in implementation + a margin). + await new Promise((r) => setTimeout(r, 250)); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/stock-quick-draft/search"), + expect.objectContaining({}), + ); + }); + + it("renders a search result", async () => { + const user = userEvent.setup(); + render( {}} />); + await user.type(screen.getByTestId("quick-draft-search"), "cutting"); + await new Promise((r) => setTimeout(r, 250)); + expect(await screen.findByText(/115mm Cutting Disc/)).toBeInTheDocument(); + }); + + it("fetches supplier history when a result is clicked", async () => { + const user = userEvent.setup(); + global.fetch = vi + .fn() + .mockImplementationOnce(async () => ({ + ok: true, + json: async () => ({ + ok: true, + items: [ + { id: "item-1", sku: "STK-CD-115", name: "115mm Cutting Disc" }, + ], + }), + })) + .mockImplementationOnce(async () => ({ + ok: true, + json: async () => ({ + ok: true, + history: [ + { + supplierId: "sup-1", + supplierName: "Steel & Pipe Co", + lastCost: 18.5, + lastDate: "2026-07-14T08:00:00.000Z", + timesBought: 2, + }, + ], + }), + })); + + render( {}} />); + await user.type(screen.getByTestId("quick-draft-search"), "cutting"); + await new Promise((r) => setTimeout(r, 250)); + await user.click(await screen.findByText(/115mm Cutting Disc/)); + + expect(global.fetch).toHaveBeenCalledTimes(2); + const secondUrl = (global.fetch as ReturnType).mock.calls[1][0]; + expect(secondUrl).toContain("/supplier-history"); + expect(await screen.findByText(/Steel & Pipe Co/)).toBeInTheDocument(); + expect(await screen.findByText(/R 18\.50/)).toBeInTheDocument(); + }); }); \ No newline at end of file diff --git a/src/components/stock-orders/quick-draft-modal.tsx b/src/components/stock-orders/quick-draft-modal.tsx index 77df9d0..2d2bc88 100644 --- a/src/components/stock-orders/quick-draft-modal.tsx +++ b/src/components/stock-orders/quick-draft-modal.tsx @@ -1,8 +1,13 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; import { Input } from "@/components/ui/input"; import { ModalShell } from "@/components/ui/modal-shell"; +import type { + StockItemSearchResult, + SupplierHistoryEntry, +} from "@/lib/server/stock-quick-draft-service"; /** * Quick-Draft Modal — item-first stock-order entry on /stock-orders. @@ -38,6 +43,58 @@ export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) { () => `Stock order entry — ${formatMonthYear(new Date())}`, ); + // Search state. + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState( + [], + ); + const [selectedItem, setSelectedItem] = useState( + null, + ); + const [history, setHistory] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + + // Debounced search. + useEffect(() => { + if (searchQuery.trim().length < 2) { + setSearchResults([]); + return; + } + const handle = setTimeout(async () => { + try { + const res = await fetch( + `/api/v1/stock-quick-draft/search?q=${encodeURIComponent( + searchQuery.trim(), + )}&limit=10`, + {}, + ); + const data = (await res.json()) as { ok: boolean; items?: StockItemSearchResult[] }; + if (data.ok && data.items) setSearchResults(data.items); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Search failed"); + } + }, 200); + return () => clearTimeout(handle); + }, [searchQuery]); + + const onPickItem = async (item: StockItemSearchResult) => { + setSelectedItem(item); + setHistoryLoading(true); + setHistory([]); + try { + const res = await fetch( + `/api/v1/stock-quick-draft/supplier-history?stockItemId=${encodeURIComponent(item.id)}`, + {}, + ); + const data = (await res.json()) as { ok: boolean; history?: SupplierHistoryEntry[] }; + if (data.ok && data.history) setHistory(data.history); + } catch (err) { + toast.error(err instanceof Error ? err.message : "History fetch failed"); + } finally { + setHistoryLoading(false); + } + }; + return ( +
+ setSearchQuery(e.target.value)} + /> + {searchResults.length > 0 && ( +
+ {searchResults.map((item) => ( + + ))} +
+ )} + + {selectedItem && ( +
+
{selectedItem.name}
+ {historyLoading && ( +
+ Loading supplier history… +
+ )} + {!historyLoading && history.length === 0 && ( +
+ No prior history for this item. +
+ )} + {!historyLoading && history.length > 0 && ( +
+ {history.map((entry) => ( +
+ {entry.supplierName} + + last paid: R {entry.lastCost.toFixed(2)} + +
+ ))} +
+ )} +
+ )} +
+ {/* Regions wired up in later tasks */} -