diff --git a/src/components/stock-orders/quick-draft-modal.test.tsx b/src/components/stock-orders/quick-draft-modal.test.tsx index 8b5b374..6299784 100644 --- a/src/components/stock-orders/quick-draft-modal.test.tsx +++ b/src/components/stock-orders/quick-draft-modal.test.tsx @@ -6,8 +6,13 @@ import { QuickDraftModal } from "./quick-draft-modal"; // The component test environment stubs it via a passthrough wrapper. vi.mock("@/components/ui/modal-shell", () => ({ ModalShell: { - Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) => - open ?
{children}
: null, + Dialog: ({ + children, + open, + }: { + children: React.ReactNode; + open: boolean; + }) => (open ?
{children}
: null), }, })); @@ -17,19 +22,12 @@ beforeEach(() => { describe("QuickDraftModal — header", () => { it("renders nothing when closed", () => { - render( - {}} - />, - ); + render( {}} />); expect(screen.queryByTestId("modal")).toBeNull(); }); it("renders the Stock Order Reference input with a default month label", () => { - render( - {}} />, - ); + render( {}} />); const input = screen.getByTestId("stock-order-reference"); expect(input).toBeInTheDocument(); // Value defaults to "Stock order entry — " (e.g. "Stock order entry — Jul 2026"). @@ -37,9 +35,7 @@ describe("QuickDraftModal — header", () => { }); it("lets the buyer rename the reference", () => { - render( - {}} />, - ); + render( {}} />); const input = screen.getByTestId( "stock-order-reference", ) as HTMLInputElement; @@ -143,3 +139,156 @@ describe("QuickDraftModal — search", () => { expect(screen.getByText(/R 18\.50/)).toBeInTheDocument(); }); }); + +describe("QuickDraftModal — add & next", () => { + beforeEach(() => { + vi.useFakeTimers(); + + global.fetch = vi + .fn() + // search + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + ok: true, + items: [ + { id: "item-1", sku: "STK-CD-115", name: "115mm Cutting Disc" }, + ], + }), + }) + // history + .mockResolvedValueOnce({ + 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, + }, + ], + }), + }) + // add-line + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + ok: true, + purchaseId: "po-1", + purchaseNumber: "PO26073001STK", + lineId: "line-1", + createdDraft: true, + }), + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders qty + cost inputs after a supplier is picked", async () => { + render( {}} />); + fireEvent.change(screen.getByTestId("quick-draft-search"), { + target: { value: "cutting" }, + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + fireEvent.click(screen.getByText(/115mm Cutting Disc/)); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByTestId("quick-draft-qty")).toBeInTheDocument(); + expect(screen.getByTestId("quick-draft-cost")).toBeInTheDocument(); + }); + + it("disables the Add button until supplier + qty + cost are valid", async () => { + render( {}} />); + fireEvent.change(screen.getByTestId("quick-draft-search"), { + target: { value: "cutting" }, + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + fireEvent.click(screen.getByText(/115mm Cutting Disc/)); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + const addBtn = screen.getByTestId("quick-draft-add"); + expect(addBtn).toBeDisabled(); + + fireEvent.click(screen.getByLabelText(/Steel & Pipe Co/)); + expect(addBtn).toBeDisabled(); + + fireEvent.change(screen.getByTestId("quick-draft-qty"), { + target: { value: "0" }, + }); + fireEvent.change(screen.getByTestId("quick-draft-cost"), { + target: { value: "20" }, + }); + expect(addBtn).toBeDisabled(); + + fireEvent.change(screen.getByTestId("quick-draft-qty"), { + target: { value: "50" }, + }); + expect(addBtn).not.toBeDisabled(); + }); + + it("POSTs to add-line and toasts success", async () => { + const { toast } = await import("sonner"); + const successSpy = vi.spyOn(toast, "success").mockImplementation(() => ""); + + render( {}} />); + fireEvent.change(screen.getByTestId("quick-draft-search"), { + target: { value: "cutting" }, + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + fireEvent.click(screen.getByText(/115mm Cutting Disc/)); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + fireEvent.click(screen.getByLabelText(/Steel & Pipe Co/)); + fireEvent.change(screen.getByTestId("quick-draft-qty"), { + target: { value: "50" }, + }); + fireEvent.change(screen.getByTestId("quick-draft-cost"), { + target: { value: "20" }, + }); + fireEvent.click(screen.getByTestId("quick-draft-add")); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + const addCalls = ( + global.fetch as ReturnType + ).mock.calls.filter( + (c) => typeof c[0] === "string" && c[0].includes("/add-line"), + ); + expect(addCalls.length).toBeGreaterThan(0); + const [, init] = addCalls[0]; + const body = JSON.parse(init.body); + expect(body).toMatchObject({ + supplierId: "sup-1", + stockItemId: "item-1", + quantity: 50, + costPrice: 20, + }); + expect(successSpy).toHaveBeenCalled(); + successSpy.mockRestore(); + }); + + it("+ New SKU toggle flips the form to name + SKU inputs", async () => { + render( {}} />); + fireEvent.click(screen.getByTestId("quick-draft-toggle-new-sku")); + expect(screen.getByTestId("quick-draft-new-sku-name")).toBeInTheDocument(); + expect(screen.getByTestId("quick-draft-new-sku-sku")).toBeInTheDocument(); + // Search region is hidden in new-SKU mode. + expect(screen.queryByTestId("quick-draft-search")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/stock-orders/quick-draft-modal.tsx b/src/components/stock-orders/quick-draft-modal.tsx index b417632..6478688 100644 --- a/src/components/stock-orders/quick-draft-modal.tsx +++ b/src/components/stock-orders/quick-draft-modal.tsx @@ -2,7 +2,9 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { ModalShell } from "@/components/ui/modal-shell"; import type { StockItemSearchResult, @@ -48,11 +50,30 @@ export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) { const [searchResults, setSearchResults] = useState( [], ); - const [selectedItem, setSelectedItem] = useState( - null, - ); + const [selectedItem, setSelectedItem] = + useState(null); const [history, setHistory] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); + const [supplierChoice, setSupplierChoice] = useState(null); + const [qty, setQty] = useState(""); + const [cost, setCost] = useState(""); + const [newSkuMode, setNewSkuMode] = useState(false); + const [newSkuName, setNewSkuName] = useState(""); + const [newSkuSku, setNewSkuSku] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const parsedQty = Number.parseFloat(qty); + const parsedCost = Number.parseFloat(cost); + const newSkuValid = + newSkuName.trim().length > 0 && newSkuSku.trim().length > 0; + const canSubmit = + !submitting && + supplierChoice !== null && + Number.isFinite(parsedQty) && + parsedQty > 0 && + Number.isFinite(parsedCost) && + parsedCost >= 0 && + (newSkuMode ? newSkuValid : selectedItem !== null); // Debounced search. useEffect(() => { @@ -97,6 +118,7 @@ export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) { const onPickItem = async (item: StockItemSearchResult) => { setSelectedItem(item); + setSupplierChoice(null); setHistoryLoading(true); setHistory([]); try { @@ -124,6 +146,50 @@ export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) { } }; + const onAddAndNext = async () => { + if (!canSubmit) return; + setSubmitting(true); + try { + const body: Record = { + supplierId: supplierChoice, + quantity: parsedQty, + costPrice: parsedCost, + }; + if (newSkuMode) { + body.newStockItem = { + name: newSkuName.trim(), + sku: newSkuSku.trim(), + }; + } else { + body.stockItemId = selectedItem?.id; + } + + const res = await fetch("/api/v1/stock-quick-draft/add-line", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = (await res.json()) as { + ok: boolean; + purchaseNumber?: string; + message?: string; + }; + if (!res.ok || !data.ok) { + toast.error(data.message ?? "Could not add line"); + return; + } + toast.success(`Added to PO ${data.purchaseNumber}`); + setQty(""); + setCost(""); + setNewSkuName(""); + setNewSkuSku(""); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Network error"); + } finally { + setSubmitting(false); + } + }; + return (
- setSearchQuery(e.target.value)} - /> - {searchResults.length > 0 && ( -
- {searchResults.map((item) => ( - - ))} -
+ {!newSkuMode && ( + <> + setSearchQuery(e.target.value)} + /> + {searchResults.length > 0 && ( +
+ {searchResults.map((item) => ( + + ))} +
+ )} + )} - {selectedItem && ( + {selectedItem && !newSkuMode && (
{selectedItem.name}
{historyLoading && ( @@ -180,26 +250,115 @@ export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) {
)} {!historyLoading && history.length > 0 && ( -
+
{history.map((entry) => ( -
- {entry.supplierName} + + setSupplierChoice(entry.supplierId)} + className="size-4 accent-primary" + /> + {entry.supplierName} + last paid: R {entry.lastCost.toFixed(2)} -
+ ))}
)}
)} + + {(selectedItem || newSkuMode) && ( +
+
+ + setQty(e.target.value)} + /> +
+
+ + setCost(e.target.value)} + /> +
+
+ )} + + {!newSkuMode ? ( + + ) : ( +
+
+ + setNewSkuName(e.target.value)} + /> +
+
+ + setNewSkuSku(e.target.value)} + /> +
+ +
+ )} + +
{/* Regions wired up in later tasks */}