feat(stock-quick-draft): modal search + supplier-history regions
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
cdbf91526f
commit
b099e2b42e
2 changed files with 197 additions and 2 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue