feat(stock-quick-draft): modal search + supplier-history regions

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-30 22:21:10 +02:00
parent cdbf91526f
commit b099e2b42e
2 changed files with 197 additions and 2 deletions

View file

@ -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(<QuickDraftModal open onOpenChange={() => {}} />);
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(<QuickDraftModal open onOpenChange={() => {}} />);
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(<QuickDraftModal open onOpenChange={() => {}} />);
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<typeof vi.fn>).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();
});
});

View file

@ -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<StockItemSearchResult[]>(
[],
);
const [selectedItem, setSelectedItem] = useState<StockItemSearchResult | null>(
null,
);
const [history, setHistory] = useState<SupplierHistoryEntry[]>([]);
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 (
<ModalShell.Dialog
open={open}
@ -55,8 +112,64 @@ export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) {
/>
</div>
<div className="space-y-3">
<Input
data-testid="quick-draft-search"
placeholder="Search stock item (e.g. '115 cutting disc')"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchResults.length > 0 && (
<div className="rounded-md border divide-y">
{searchResults.map((item) => (
<button
key={item.id}
type="button"
className="w-full px-3 py-2 text-left text-sm hover:bg-muted/40"
onClick={() => onPickItem(item)}
>
<div className="font-medium">{item.name}</div>
<div className="text-xs text-muted-foreground">
SKU: {item.sku}
</div>
</button>
))}
</div>
)}
{selectedItem && (
<div className="rounded-md border bg-muted/20 p-3 space-y-3">
<div className="text-sm font-medium">{selectedItem.name}</div>
{historyLoading && (
<div className="text-xs text-muted-foreground">
Loading supplier history…
</div>
)}
{!historyLoading && history.length === 0 && (
<div className="text-xs text-muted-foreground">
No prior history for this item.
</div>
)}
{!historyLoading && history.length > 0 && (
<div className="text-xs space-y-1">
{history.map((entry) => (
<div
key={entry.supplierId}
className="flex justify-between"
>
<span>{entry.supplierName}</span>
<span className="text-muted-foreground">
last paid: <strong>R {entry.lastCost.toFixed(2)}</strong>
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Regions wired up in later tasks */}
<div data-testid="region-search" hidden />
<div data-testid="region-cards" hidden />
</ModalShell.Dialog>
);