feat(stock-quick-draft): qty/cost row + Add & next + new-SKU toggle

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-30 22:43:17 +02:00
parent 275d17b76a
commit 75342ed88c
2 changed files with 354 additions and 46 deletions

View file

@ -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 ? <div data-testid="modal">{children}</div> : null,
Dialog: ({
children,
open,
}: {
children: React.ReactNode;
open: boolean;
}) => (open ? <div data-testid="modal">{children}</div> : null),
},
}));
@ -17,19 +22,12 @@ beforeEach(() => {
describe("QuickDraftModal — header", () => {
it("renders nothing when closed", () => {
render(
<QuickDraftModal
open={false}
onOpenChange={() => {}}
/>,
);
render(<QuickDraftModal open={false} onOpenChange={() => {}} />);
expect(screen.queryByTestId("modal")).toBeNull();
});
it("renders the Stock Order Reference input with a default month label", () => {
render(
<QuickDraftModal open onOpenChange={() => {}} />,
);
render(<QuickDraftModal open onOpenChange={() => {}} />);
const input = screen.getByTestId("stock-order-reference");
expect(input).toBeInTheDocument();
// Value defaults to "Stock order entry — <MonthYear>" (e.g. "Stock order entry — Jul 2026").
@ -37,9 +35,7 @@ describe("QuickDraftModal — header", () => {
});
it("lets the buyer rename the reference", () => {
render(
<QuickDraftModal open onOpenChange={() => {}} />,
);
render(<QuickDraftModal open onOpenChange={() => {}} />);
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(<QuickDraftModal open onOpenChange={() => {}} />);
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(<QuickDraftModal open onOpenChange={() => {}} />);
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(<QuickDraftModal open onOpenChange={() => {}} />);
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<typeof vi.fn>
).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(<QuickDraftModal open onOpenChange={() => {}} />);
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();
});
});

View file

@ -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<StockItemSearchResult[]>(
[],
);
const [selectedItem, setSelectedItem] = useState<StockItemSearchResult | null>(
null,
);
const [selectedItem, setSelectedItem] =
useState<StockItemSearchResult | null>(null);
const [history, setHistory] = useState<SupplierHistoryEntry[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [supplierChoice, setSupplierChoice] = useState<string | null>(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<string, unknown> = {
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 (
<ModalShell.Dialog
open={open}
@ -142,31 +208,35 @@ 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>
{!newSkuMode && (
<>
<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 && (
{selectedItem && !newSkuMode && (
<div className="rounded-md border bg-muted/20 p-3 space-y-3">
<div className="text-sm font-medium">{selectedItem.name}</div>
{historyLoading && (
@ -180,26 +250,115 @@ export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) {
</div>
)}
{!historyLoading && history.length > 0 && (
<div className="text-xs space-y-1">
<div className="text-xs space-y-1" role="radiogroup">
{history.map((entry) => (
<div
<label
key={entry.supplierId}
className="flex justify-between"
className="flex cursor-pointer items-center justify-between gap-2"
>
<span>{entry.supplierName}</span>
<span className="flex items-center gap-2">
<input
type="radio"
name="quick-draft-supplier"
value={entry.supplierId}
checked={supplierChoice === entry.supplierId}
onChange={() => setSupplierChoice(entry.supplierId)}
className="size-4 accent-primary"
/>
<span>{entry.supplierName}</span>
</span>
<span className="text-muted-foreground">
last paid: <strong>R {entry.lastCost.toFixed(2)}</strong>
</span>
</div>
</label>
))}
</div>
)}
</div>
)}
{(selectedItem || newSkuMode) && (
<div className="grid grid-cols-2 gap-2">
<div>
<Label htmlFor="quick-draft-qty">Qty</Label>
<Input
id="quick-draft-qty"
data-testid="quick-draft-qty"
type="number"
inputMode="numeric"
min="0"
value={qty}
onChange={(e) => setQty(e.target.value)}
/>
</div>
<div>
<Label htmlFor="quick-draft-cost">Cost</Label>
<Input
id="quick-draft-cost"
data-testid="quick-draft-cost"
type="number"
inputMode="decimal"
min="0"
step="0.01"
value={cost}
onChange={(e) => setCost(e.target.value)}
/>
</div>
</div>
)}
{!newSkuMode ? (
<button
type="button"
data-testid="quick-draft-toggle-new-sku"
className="text-xs underline text-muted-foreground"
onClick={() => setNewSkuMode(true)}
>
+ New SKU not in stock yet
</button>
) : (
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
<div>
<Label htmlFor="quick-draft-new-sku-name">Name</Label>
<Input
id="quick-draft-new-sku-name"
data-testid="quick-draft-new-sku-name"
value={newSkuName}
onChange={(e) => setNewSkuName(e.target.value)}
/>
</div>
<div>
<Label htmlFor="quick-draft-new-sku-sku">SKU</Label>
<Input
id="quick-draft-new-sku-sku"
data-testid="quick-draft-new-sku-sku"
value={newSkuSku}
onChange={(e) => setNewSkuSku(e.target.value)}
/>
</div>
<button
type="button"
className="text-xs underline text-muted-foreground"
onClick={() => setNewSkuMode(false)}
>
Cancel — search existing SKUs
</button>
</div>
)}
<Button
type="button"
data-testid="quick-draft-add"
disabled={!canSubmit}
onClick={onAddAndNext}
className="w-full"
>
Add &amp; next
</Button>
</div>
{/* Regions wired up in later tasks */}
<div data-testid="region-cards" hidden />
</ModalShell.Dialog>
);
}
}