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:
parent
275d17b76a
commit
75342ed88c
2 changed files with 354 additions and 46 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 & next
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Regions wired up in later tasks */}
|
||||
<div data-testid="region-cards" hidden />
|
||||
</ModalShell.Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue