12-task TDD plan covering the service module, four API routes, the modal component (built incrementally across 4 tasks), and the page-hook. Each task ends with a commit; spec gaps surfaced during self-review already merged into task content. Co-authored-by: Orca <help@stably.ai>
92 KiB
Stock Quick-Draft Modal Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add an item-first quick-draft modal on the /stock-orders page that lets a buyer type a stock item, see which suppliers we've bought it from before, pick one, set qty + cost, and add the line — accumulating items into one draft stock_purchase per supplier across multiple modal sessions.
Architecture: A new QuickDraftModal client component is opened from a new button on the /stock-orders page header. The modal hits three new /api/v1/stock-quick-draft/* route handlers, each of which delegates to a new stock-quick-draft-service.ts admin-PB service module. The service is the source of truth for "is there an open draft for this supplier?" and reuses generateStockPurchaseNumber from the existing stock-purchases-service.ts. No schema changes, no new statuses.
Tech Stack: Next.js 16 (App Router), React 19, TypeScript, PocketBase, Tailwind, lucide-react, sonner toasts, @base-ui/react/dialog (via existing ModalShell). Tests run with Vitest + @testing-library/react.
File Structure
| File | Responsibility |
|---|---|
src/lib/server/stock-quick-draft-service.ts (new) |
Admin-PB service: search, supplier history, find-or-create open draft, append line, remove line, create stock_item. |
src/lib/server/stock-quick-draft-service.test.ts (new) |
Unit tests for the service functions. |
src/app/api/v1/stock-quick-draft/search/route.ts (new) |
GET ?q=&limit= typeahead for stock_items. |
src/app/api/v1/stock-quick-draft/supplier-history/route.ts (new) |
GET ?stockItemId= supplier history for an item. |
src/app/api/v1/stock-quick-draft/add-line/route.ts (new) |
POST add a line; supports inline newStockItem. |
src/app/api/v1/stock-quick-draft/line/[lineId]/route.ts (new) |
DELETE remove a line the modal added. |
src/components/stock-orders/quick-draft-modal.tsx (new) |
The modal component (search, history, qty/cost, cards, footer). |
src/components/stock-orders/quick-draft-modal.test.tsx (new) |
Component tests: search debounce, history render, add & next, cards refetch, remove. |
src/app/(app)/stock-orders/page.tsx (modify) |
Add the "New Stock Item" button to the header between "Reorder" and "+ New stock order"; wire the modal. |
Task 1: Service types + searchStockItemsForQuickDraft
Files:
-
Create:
src/lib/server/stock-quick-draft-service.ts -
Create:
src/lib/server/stock-quick-draft-service.test.ts -
Step 1: Write the failing test
Create src/lib/server/stock-quick-draft-service.test.ts:
import { describe, it, expect, beforeEach, vi } from "vitest";
const { store, pbMock, clearStore } = vi.hoisted(() => {
type StoreMap = Map<string, Record<string, unknown>>;
const store: Record<string, StoreMap> = {};
let seq = 0;
const nextId = () => `mock-${++seq}`;
const getOrCreateMap = (name: string): StoreMap => {
if (!store[name]) store[name] = new Map();
return store[name];
};
const where = (name: string, filter: string): Record<string, unknown>[] => {
const items = Array.from(getOrCreateMap(name).values());
if (!filter || !filter.trim()) return items;
const clauses = filter.split("&&").map((c) => c.trim());
return items.filter((item) =>
clauses.every((clause) => {
// Minimal parser: handles field op value. Op is `=` or `!=`.
// Value may be quoted (string) or unquoted (number).
const m = clause.match(/^(\w+)\s*(=|!=)\s*(.+)$/);
if (!m) return true;
const [, field, op, rawValue] = m;
const value =
rawValue.startsWith('"') && rawValue.endsWith('"')
? rawValue.slice(1, -1)
: Number(rawValue);
const actual = item[field];
if (op === "=") return actual == value;
if (op === "!=") return actual != value;
return true;
}),
);
};
const pbMock = {
collection: (name: string) => ({
async getList(
_page = 1,
perPage = 25,
opts: { filter?: string; sort?: string } = {},
) {
let items = where(name, opts.filter || "");
if (opts.sort) {
const desc = opts.sort.startsWith("-");
const field = desc ? opts.sort.slice(1) : opts.sort;
items = [...items].sort((a, b) => {
const av = a[field] as number | string | undefined;
const bv = b[field] as number | string | undefined;
if (av === bv) return 0;
if (av === undefined) return 1;
if (bv === undefined) return -1;
const cmp = av < bv ? -1 : 1;
return desc ? -cmp : cmp;
});
}
return { items, totalItems: items.length, page: 1, perPage };
},
async getFullList() {
return where(name, "");
},
async getOne(id: string) {
const item = getOrCreateMap(name).get(id);
if (!item) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
return item;
},
async create(data: Record<string, unknown>) {
const item = {
id: nextId(),
created: new Date().toISOString(),
updated: new Date().toISOString(),
...data,
};
getOrCreateMap(name).set(item.id, item);
return item;
},
async update(id: string, data: Record<string, unknown>) {
const map = getOrCreateMap(name);
const existing = map.get(id);
if (!existing) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
for (const [key, value] of Object.entries(data)) {
existing[key] = value;
}
existing.updated = new Date().toISOString();
return { ...existing };
},
async delete(id: string) {
const map = getOrCreateMap(name);
if (!map.has(id)) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
map.delete(id);
return { id };
},
}),
};
const clearStore = () => {
for (const key of Object.keys(store)) delete store[key];
seq = 0;
};
return { store, pbMock, clearStore };
});
vi.mock("pocketbase", () => ({ default: vi.fn().mockImplementation(() => pbMock) }));
vi.mock("./pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
}));
import { searchStockItemsForQuickDraft } from "./stock-quick-draft-service";
beforeEach(() => clearStore());
describe("searchStockItemsForQuickDraft", () => {
it("returns active stock_items whose sku or name matches the query", async () => {
await pbMock.collection("stock_items").create({
sku: "STK-CD-115",
name: "115mm Cutting Disc",
is_active: true,
});
await pbMock.collection("stock_items").create({
sku: "STK-WR-6",
name: "Wire rope 6mm",
is_active: true,
});
await pbMock.collection("stock_items").create({
sku: "STK-CD-230",
name: "230mm Cutting Disc",
is_active: true,
});
const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10);
expect(results).toHaveLength(2);
expect(results.map((r) => r.sku).sort()).toEqual(["STK-CD-115", "STK-CD-230"]);
});
it("excludes inactive items", async () => {
await pbMock.collection("stock_items").create({
sku: "STK-CD-115",
name: "115mm Cutting Disc",
is_active: false,
});
const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10);
expect(results).toEqual([]);
});
it("clamps limit to a max of 25", async () => {
for (let i = 0; i < 30; i++) {
await pbMock.collection("stock_items").create({
sku: `STK-X-${i}`,
name: `Cutting disc variant ${i}`,
is_active: true,
});
}
const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 100);
expect(results).toHaveLength(25);
});
it("returns narrow projection fields", async () => {
const created = await pbMock.collection("stock_items").create({
sku: "STK-CD-115",
name: "115mm Cutting Disc",
description: "For angle grinder",
unit: "ea",
is_active: true,
});
const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10);
expect(results[0]).toEqual({
id: created.id,
sku: "STK-CD-115",
name: "115mm Cutting Disc",
description: "For angle grinder",
unit: "ea",
});
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run src/lib/server/stock-quick-draft-service.test.ts
Expected: FAIL — searchStockItemsForQuickDraft is not exported.
- Step 3: Write minimal implementation
Create src/lib/server/stock-quick-draft-service.ts:
/**
* Stock Quick-Draft service — backs the /stock-orders modal that lets
* a buyer add items to draft stock_purchases in an item-first rhythm.
*
* Reuses `generateStockPurchaseNumber` from `stock-purchases-service.ts`
* for the find-or-create open-draft step; no schema changes, no new
* statuses. Admin PB only (consistent with the other service modules).
*/
import type PocketBase from "pocketbase";
import { getAdminPocketBase } from "./pocketbase-admin";
import { generateStockPurchaseNumber } from "./stock-purchases-service";
const STOCK_ITEMS = "stock_items";
const STOCK_PURCHASES = "stock_purchases";
const STOCK_PURCHASE_ITEMS = "stock_purchase_items";
const SEARCH_LIMIT_MAX = 25;
const SEARCH_LIMIT_DEFAULT = 10;
/* ============================================================================
* Minimal PB client shape this module needs.
*
* The route passes in the real `getAdminPocketBase()`; the unit test
* passes in a tiny mock. Only the methods we actually call are typed.
* ========================================================================== */
export interface StockQuickDraftPb {
collection: (name: string) => {
getList: (
page?: number,
perPage?: number,
opts?: { filter?: string; sort?: string; expand?: string }
) => Promise<{
items: Array<Record<string, unknown>>;
totalItems: number;
page: number;
perPage: number;
totalPages: number;
}>;
getFullList: (opts?: { filter?: string }) => Promise<Array<Record<string, unknown>>>;
getOne: (id: string, opts?: { expand?: string }) => Promise<Record<string, unknown>>;
create: (data: Record<string, unknown>) => Promise<Record<string, unknown>>;
update: (id: string, data: Record<string, unknown>) => Promise<Record<string, unknown>>;
delete: (id: string) => Promise<Record<string, unknown>>;
};
}
/* ============================================================================
* Public types
* ========================================================================== */
export interface StockItemSearchResult {
id: string;
sku: string;
name: string;
description: string;
unit?: string;
}
export interface SupplierHistoryEntry {
supplierId: string;
supplierName: string;
lastCost: number;
lastDate: string;
timesBought: number;
}
export interface AddQuickDraftLineNewStockItem {
name: string;
sku: string;
description?: string;
unit?: string;
}
export interface AddQuickDraftLineInput {
supplierId: string;
stockItemId?: string;
quantity: number;
costPrice: number;
newStockItem?: AddQuickDraftLineNewStockItem;
}
export type AddQuickDraftLineResult =
| { ok: true; purchaseId: string; purchaseNumber: string; lineId: string; createdDraft: boolean }
| { ok: false; reason: string; message: string };
export type RemoveQuickDraftLineResult =
| { ok: true }
| { ok: false; reason: string; message: string };
/* ============================================================================
* Helpers
* ========================================================================== */
async function resolvePb(pb?: StockQuickDraftPb): Promise<StockQuickDraftPb> {
if (pb) return pb;
return (await getAdminPocketBase()) as unknown as StockQuickDraftPb;
}
/* ============================================================================
* searchStockItemsForQuickDraft
* ========================================================================== */
/**
* Typeahead search over `stock_items`. Matches `sku` OR `name` with
* PocketBase's `~` (contains, case-insensitive). Excludes inactive
* items. Limit defaults to 10, clamped to a max of 25.
*
* Returns a narrow projection — only the fields the modal needs.
*/
export async function searchStockItemsForQuickDraft(
pb: StockQuickDraftPb,
q: string,
limit?: number,
): Promise<StockItemSearchResult[]> {
const safeLimit = Math.min(Math.max(limit ?? SEARCH_LIMIT_DEFAULT, 1), SEARCH_LIMIT_MAX);
const escaped = q.replace(/"/g, '\\"');
const filter = `is_active=true && (sku ~ "${escaped}" || name ~ "${escaped}")`;
const result = await pb.collection(STOCK_ITEMS).getList(1, safeLimit, {
filter,
sort: "name",
});
return result.items.map((row) => ({
id: String(row.id),
sku: String(row.sku ?? ""),
name: String(row.name ?? ""),
description: String(row.description ?? ""),
unit: row.unit ? String(row.unit) : undefined,
}));
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/lib/server/stock-quick-draft-service.test.ts
Expected: PASS (4 tests).
- Step 5: Commit
git add src/lib/server/stock-quick-draft-service.ts src/lib/server/stock-quick-draft-service.test.ts
git commit -m "feat(stock-quick-draft): types + searchStockItemsForQuickDraft"
Task 2: Service — getStockItemSupplierHistory
Files:
-
Modify:
src/lib/server/stock-quick-draft-service.ts(append) -
Modify:
src/lib/server/stock-quick-draft-service.test.ts(extend) -
Step 1: Extend the failing test
Append a new describe block at the end of src/lib/server/stock-quick-draft-service.test.ts:
import {
getStockItemSupplierHistory,
searchStockItemsForQuickDraft,
} from "./stock-quick-draft-service";
describe("getStockItemSupplierHistory", () => {
it("returns empty when the item has no purchase history", async () => {
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
name: "115mm Cutting Disc",
is_active: true,
});
const history = await getStockItemSupplierHistory(pbMock as never, "item-1");
expect(history).toEqual([]);
});
it("aggregates by supplier: lastCost, lastDate, timesBought", async () => {
const supplierId = await (
await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
).id;
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
name: "115mm Cutting Disc",
is_active: true,
});
const purchase1 = await pbMock.collection("stock_purchases").create({
supplier: supplierId,
status: "received",
purchase_number: "PO26070101STK",
created: "2026-06-10T08:00:00.000Z",
});
const purchase2 = await pbMock.collection("stock_purchases").create({
supplier: supplierId,
status: "received",
purchase_number: "PO26071401STK",
created: "2026-07-14T08:00:00.000Z",
});
await pbMock.collection("stock_purchase_items").create({
purchase: purchase1.id,
stock_item: "item-1",
cost_price: 18.5,
quantity: 10,
quantity_received: 10,
});
await pbMock.collection("stock_purchase_items").create({
purchase: purchase2.id,
stock_item: "item-1",
cost_price: 19.0,
quantity: 20,
quantity_received: 20,
});
const history = await getStockItemSupplierHistory(pbMock as never, "item-1");
expect(history).toHaveLength(1);
expect(history[0]).toEqual({
supplierId,
supplierName: "Steel & Pipe Co",
lastCost: 19.0,
lastDate: "2026-07-14T08:00:00.000Z",
timesBought: 2,
});
});
it("returns one entry per supplier, sorted by lastDate desc", async () => {
const supA = (
await pbMock.collection("suppliers").create({ suppliername: "Supplier A" })
).id;
const supB = (
await pbMock.collection("suppliers").create({ suppliername: "Supplier B" })
).id;
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
is_active: true,
});
const purchaseA = await pbMock.collection("stock_purchases").create({
supplier: supA,
status: "received",
purchase_number: "PO26070101STK",
created: "2026-06-10T08:00:00.000Z",
});
const purchaseB = await pbMock.collection("stock_purchases").create({
supplier: supB,
status: "received",
purchase_number: "PO26071501STK",
created: "2026-07-15T08:00:00.000Z",
});
await pbMock.collection("stock_purchase_items").create({
purchase: purchaseA.id,
stock_item: "item-1",
cost_price: 18.5,
quantity_received: 10,
});
await pbMock.collection("stock_purchase_items").create({
purchase: purchaseB.id,
stock_item: "item-1",
cost_price: 21.0,
quantity_received: 5,
});
const history = await getStockItemSupplierHistory(pbMock as never, "item-1");
expect(history).toHaveLength(2);
expect(history[0].supplierId).toBe(supB);
expect(history[1].supplierId).toBe(supA);
});
it("only counts lines with quantity_received > 0", async () => {
const supA = (
await pbMock.collection("suppliers").create({ suppliername: "Supplier A" })
).id;
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
is_active: true,
});
const purchase = await pbMock.collection("stock_purchases").create({
supplier: supA,
status: "draft",
purchase_number: "PO26070101STK",
created: "2026-07-01T08:00:00.000Z",
});
await pbMock.collection("stock_purchase_items").create({
purchase: purchase.id,
stock_item: "item-1",
cost_price: 18.5,
quantity_received: 0,
});
const history = await getStockItemSupplierHistory(pbMock as never, "item-1");
expect(history).toEqual([]);
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run src/lib/server/stock-quick-draft-service.test.ts
Expected: FAIL — getStockItemSupplierHistory is not exported.
- Step 3: Append the implementation
Append to src/lib/server/stock-quick-draft-service.ts:
/* ============================================================================
* getStockItemSupplierHistory
* ========================================================================== */
/**
* Returns the per-supplier purchase history for a `stock_item`.
* Reads `stock_purchase_items` joined through `stock_purchases` so
* we can aggregate by supplier. Filters to lines that have actually
* been received (`quantity_received > 0`) so drafts don't pollute
* the "we bought this from X before" list.
*
* The supplier is expanded via `purchase.supplier`; if the
* `stock_purchases.supplier` row no longer exists in `suppliers`,
* the line is silently skipped (we can't show a name for it).
*/
export async function getStockItemSupplierHistory(
pb: StockQuickDraftPb,
stockItemId: string,
): Promise<SupplierHistoryEntry[]> {
const rows = await pb.collection(STOCK_PURCHASE_ITEMS).getFullList({
filter: `stock_item = "${stockItemId}" && quantity_received > 0`,
});
const purchaseIds = Array.from(
new Set(
rows
.map((r) => (r.purchase ? String(r.purchase) : null))
.filter((id): id is string => id !== null),
),
);
if (purchaseIds.length === 0) return [];
// Fetch purchases in one call (with supplier expanded). We use
// a single getList rather than N getOnes — cheaper on PB.
const purchasesResult = await pb
.collection(STOCK_PURCHASES)
.getList(1, purchaseIds.length, {
filter: purchaseIds.map((id) => `id = "${id}"`).join(" || "),
expand: "supplier",
});
const purchaseById = new Map<string, Record<string, unknown>>();
for (const p of purchasesResult.items) purchaseById.set(String(p.id), p);
type Agg = {
supplierId: string;
supplierName: string;
lastCost: number;
lastDate: string;
timesBought: number;
};
const bySupplier = new Map<string, Agg>();
for (const row of rows) {
const purchaseId = row.purchase ? String(row.purchase) : null;
if (!purchaseId) continue;
const purchase = purchaseById.get(purchaseId);
if (!purchase) continue;
const expand = purchase.expand as { supplier?: { id: string; suppliername: string } } | undefined;
const supplier = expand?.supplier;
if (!supplier) continue;
const supplierId = String(supplier.id);
const cost = typeof row.cost_price === "number" ? row.cost_price : 0;
const created = String(purchase.created ?? "");
const existing = bySupplier.get(supplierId);
if (!existing) {
bySupplier.set(supplierId, {
supplierId,
supplierName: String(supplier.suppliername ?? ""),
lastCost: cost,
lastDate: created,
timesBought: 1,
});
} else {
existing.timesBought += 1;
if (created > existing.lastDate) {
existing.lastDate = created;
existing.lastCost = cost;
}
}
}
return Array.from(bySupplier.values()).sort((a, b) =>
a.lastDate < b.lastDate ? 1 : a.lastDate > b.lastDate ? -1 : 0,
);
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/lib/server/stock-quick-draft-service.test.ts
Expected: PASS (4 + 4 = 8 tests).
- Step 5: Commit
git add src/lib/server/stock-quick-draft-service.ts src/lib/server/stock-quick-draft-service.test.ts
git commit -m "feat(stock-quick-draft): getStockItemSupplierHistory"
Task 3: Service — addQuickDraftLine (the heart of the feature)
Files:
- Modify:
src/lib/server/stock-quick-draft-service.ts(append) - Modify:
src/lib/server/stock-quick-draft-service.test.ts(extend)
This task implements findOrCreateOpenDraft and addQuickDraftLine. It also handles the inline newStockItem payload (creates a stock_items row first).
- Step 1: Extend the failing test
Append to src/lib/server/stock-quick-draft-service.test.ts:
import {
addQuickDraftLine,
getStockItemSupplierHistory,
searchStockItemsForQuickDraft,
} from "./stock-quick-draft-service";
describe("addQuickDraftLine", () => {
it("creates a new draft PO when none exists for the supplier", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
).id;
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
name: "115mm Cutting Disc",
});
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
stockItemId: "item-1",
quantity: 50,
costPrice: 18.5,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.createdDraft).toBe(true);
expect(result.purchaseNumber).toMatch(/^PO\d{8}\d{2}STK$/);
const purchases = await pbMock.collection("stock_purchases").getFullList();
expect(purchases).toHaveLength(1);
expect(purchases[0].supplier).toBe(supplierId);
expect(purchases[0].status).toBe("draft");
const lines = await pbMock.collection("stock_purchase_items").getFullList();
expect(lines).toHaveLength(1);
expect(lines[0].stock_item).toBe("item-1");
expect(lines[0].quantity).toBe(50);
expect(lines[0].cost_price).toBe(18.5);
expect(lines[0].description).toBe("115mm Cutting Disc");
});
it("appends to an existing open draft for the supplier", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
).id;
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
name: "115mm Cutting Disc",
});
await pbMock.collection("stock_items").create({
id: "item-2",
sku: "STK-WR-6",
name: "Wire rope 6mm",
});
// Existing draft for this supplier.
await pbMock.collection("stock_purchases").create({
id: "purchase-existing",
supplier: supplierId,
status: "draft",
purchase_number: "PO26070101STK",
});
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
stockItemId: "item-2",
quantity: 20,
costPrice: 145,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.createdDraft).toBe(false);
expect(result.purchaseId).toBe("purchase-existing");
const lines = await pbMock.collection("stock_purchase_items").getFullList();
expect(lines).toHaveLength(1);
expect(lines[0].stock_item).toBe("item-2");
});
it("ignores drafts that are not in 'draft' status when finding an open one", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
).id;
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
name: "115mm Cutting Disc",
});
// Sent/received drafts must NOT be appended to — should create a new one.
await pbMock.collection("stock_purchases").create({
id: "purchase-sent",
supplier: supplierId,
status: "sent",
purchase_number: "PO26070101STK",
});
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
stockItemId: "item-1",
quantity: 50,
costPrice: 18.5,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.createdDraft).toBe(true);
expect(result.purchaseId).not.toBe("purchase-sent");
const purchases = await pbMock.collection("stock_purchases").getFullList();
expect(purchases).toHaveLength(2);
});
it("creates a new stock_item when newStockItem is supplied, then adds the line", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
).id;
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
quantity: 10,
costPrice: 5.5,
newStockItem: { name: "Brand-new Widget", sku: "STK-BNW-1" },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const items = await pbMock.collection("stock_items").getFullList();
expect(items).toHaveLength(1);
expect(items[0].name).toBe("Brand-new Widget");
expect(items[0].sku).toBe("STK-BNW-1");
expect(items[0].is_active).toBe(true);
expect(items[0].current_qty).toBe(0);
expect(items[0].supplier).toBe(supplierId);
const lines = await pbMock.collection("stock_purchase_items").getFullList();
expect(lines).toHaveLength(1);
expect(lines[0].stock_item).toBe(items[0].id);
expect(lines[0].description).toBe("Brand-new Widget");
});
it("rejects non-positive quantities", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "S" })
).id;
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
stockItemId: "item-1",
quantity: 0,
costPrice: 10,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("validation");
});
it("rejects negative cost", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "S" })
).id;
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
stockItemId: "item-1",
quantity: 1,
costPrice: -5,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("validation");
});
it("rejects when both stockItemId and newStockItem are missing", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "S" })
).id;
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
quantity: 1,
costPrice: 1,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("validation");
});
it("returns supplier_not_found when the supplier id doesn't exist", async () => {
const result = await addQuickDraftLine(pbMock as never, {
supplierId: "does-not-exist",
stockItemId: "item-1",
quantity: 1,
costPrice: 1,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("supplier_not_found");
});
it("returns stock_item_not_found when the stock_item id doesn't exist", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "S" })
).id;
const result = await addQuickDraftLine(pbMock as never, {
supplierId,
stockItemId: "does-not-exist",
quantity: 1,
costPrice: 1,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("stock_item_not_found");
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run src/lib/server/stock-quick-draft-service.test.ts
Expected: FAIL — addQuickDraftLine is not exported.
- Step 3: Append the implementation
Append to src/lib/server/stock-quick-draft-service.ts:
/* ============================================================================
* findOrCreateOpenDraft
* ========================================================================== */
interface FindOrCreateOpenDraftResult {
ok: true;
purchaseId: string;
purchaseNumber: string;
createdDraft: boolean;
}
async function findOrCreateOpenDraft(
pb: StockQuickDraftPb,
supplierId: string,
): Promise<FindOrCreateOpenDraftResult> {
// Look for an open draft for this supplier. The list is sorted
// by `created` desc so the most recent draft wins if there are
// multiple (the buyer may have accumulated them across modal
// sessions, but the design guarantees only one per supplier
// exists at a time in the happy path).
const existing = await pb.collection(STOCK_PURCHASES).getList(1, 1, {
filter: `supplier = "${supplierId}" && status = "draft"`,
sort: "-created",
});
if (existing.items.length > 0) {
const row = existing.items[0];
return {
ok: true,
purchaseId: String(row.id),
purchaseNumber: String(row.purchase_number ?? ""),
createdDraft: false,
};
}
// No open draft — generate a number and create one.
const purchaseNumber = await generateStockPurchaseNumber(
pb as unknown as Parameters<typeof generateStockPurchaseNumber>[0],
);
const created = await pb.collection(STOCK_PURCHASES).create({
purchase_number: purchaseNumber,
supplier: supplierId,
status: "draft",
notes: "",
});
return {
ok: true,
purchaseId: String(created.id),
purchaseNumber,
createdDraft: true,
};
}
/* ============================================================================
* addQuickDraftLine
* ========================================================================== */
/**
* Adds a single line to a draft stock_purchase for the given supplier.
*
* Behaviour:
* - If the caller supplied `newStockItem`, create that `stock_items`
* row first (current_qty: 0, is_active: true, supplier: input.supplierId).
* - Find or create an open draft for the supplier (status=draft).
* - Append a `stock_purchase_items` row to that draft.
*
* Returns `{ ok: true, ... }` with the purchase + line ids on success,
* or `{ ok: false, reason, message }` on validation / lookup / write
* failure. The modal maps `reason` to a UI error category.
*/
export async function addQuickDraftLine(
pb: StockQuickDraftPb,
input: AddQuickDraftLineInput,
): Promise<AddQuickDraftLineResult> {
// ── Validate input ─────────────────────────────────────────────
if (typeof input.supplierId !== "string" || input.supplierId.length === 0) {
return { ok: false, reason: "validation", message: "supplierId is required" };
}
if (typeof input.quantity !== "number" || input.quantity <= 0) {
return { ok: false, reason: "validation", message: "quantity must be > 0" };
}
if (typeof input.costPrice !== "number" || input.costPrice < 0) {
return { ok: false, reason: "validation", message: "costPrice must be >= 0" };
}
if (!input.stockItemId && !input.newStockItem) {
return {
ok: false,
reason: "validation",
message: "either stockItemId or newStockItem is required",
};
}
// ── Confirm supplier exists ────────────────────────────────────
try {
await pb.collection("suppliers").getOne(input.supplierId);
} catch (err) {
return {
ok: false,
reason: "supplier_not_found",
message: err instanceof Error ? err.message : "supplier lookup failed",
};
}
// ── Resolve the stock_item id (create if inline) ───────────────
let stockItemId = input.stockItemId;
if (!stockItemId && input.newStockItem) {
if (!input.newStockItem.name || !input.newStockItem.sku) {
return {
ok: false,
reason: "validation",
message: "newStockItem.name and newStockItem.sku are required",
};
}
try {
const created = await pb.collection(STOCK_ITEMS).create({
name: input.newStockItem.name,
sku: input.newStockItem.sku,
description: input.newStockItem.description ?? "",
unit: input.newStockItem.unit ?? "",
current_qty: 0,
is_active: true,
supplier: input.supplierId,
});
stockItemId = String(created.id);
} catch (err) {
return {
ok: false,
reason: "create_failed",
message: err instanceof Error ? err.message : "stock_item create failed",
};
}
} else if (stockItemId) {
// ── Confirm existing stock_item exists ────────────────────────
try {
await pb.collection(STOCK_ITEMS).getOne(stockItemId);
} catch (err) {
return {
ok: false,
reason: "stock_item_not_found",
message: err instanceof Error ? err.message : "stock_item lookup failed",
};
}
}
// stockItemId is now guaranteed non-null.
const finalStockItemId = stockItemId as string;
// ── Find or create the open draft ──────────────────────────────
const draft = await findOrCreateOpenDraft(pb, input.supplierId);
if (!draft.ok) {
return { ok: false, reason: "create_failed", message: "could not resolve draft" };
}
// ── Look up the item's name so we can denormalise onto the line ─
const stockItem = await pb.collection(STOCK_ITEMS).getOne(finalStockItemId);
const description = String(stockItem.name ?? "");
// ── Append the line ────────────────────────────────────────────
try {
const line = await pb.collection(STOCK_PURCHASE_ITEMS).create({
purchase: draft.purchaseId,
stock_item: finalStockItemId,
description,
quantity: input.quantity,
cost_price: input.costPrice,
});
return {
ok: true,
purchaseId: draft.purchaseId,
purchaseNumber: draft.purchaseNumber,
lineId: String(line.id),
createdDraft: draft.createdDraft,
};
} catch (err) {
return {
ok: false,
reason: "append_failed",
message: err instanceof Error ? err.message : "line create failed",
};
}
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/lib/server/stock-quick-draft-service.test.ts
Expected: PASS (8 + 9 = 17 tests). Some test mocks have edge cases — if any test fails because the mock pbMock doesn't fully implement the query paths the implementation uses, extend the mock in Step 1 and re-run.
- Step 5: Commit
git add src/lib/server/stock-quick-draft-service.ts src/lib/server/stock-quick-draft-service.test.ts
git commit -m "feat(stock-quick-draft): addQuickDraftLine with find-or-create-open-draft"
Task 4: Service — removeQuickDraftLine
Files:
-
Modify:
src/lib/server/stock-quick-draft-service.ts(append) -
Modify:
src/lib/server/stock-quick-draft-service.test.ts(extend) -
Step 1: Extend the failing test
Append to src/lib/server/stock-quick-draft-service.test.ts:
import {
addQuickDraftLine,
getStockItemSupplierHistory,
removeQuickDraftLine,
searchStockItemsForQuickDraft,
} from "./stock-quick-draft-service";
describe("removeQuickDraftLine", () => {
it("deletes the line and returns ok", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "S" })
).id;
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
name: "115mm Cutting Disc",
});
const purchase = await pbMock.collection("stock_purchases").create({
supplier: supplierId,
status: "draft",
purchase_number: "PO26070101STK",
});
const line = await pbMock.collection("stock_purchase_items").create({
purchase: purchase.id,
stock_item: "item-1",
description: "115mm Cutting Disc",
quantity: 10,
});
const result = await removeQuickDraftLine(pbMock as never, line.id);
expect(result).toEqual({ ok: true });
const remaining = await pbMock.collection("stock_purchase_items").getFullList();
expect(remaining).toEqual([]);
});
it("returns line_not_found when the line id doesn't exist", async () => {
const result = await removeQuickDraftLine(pbMock as never, "does-not-exist");
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("line_not_found");
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run src/lib/server/stock-quick-draft-service.test.ts
Expected: FAIL — removeQuickDraftLine is not exported.
- Step 3: Append the implementation
Append to src/lib/server/stock-quick-draft-service.ts:
/* ============================================================================
* removeQuickDraftLine
* ========================================================================== */
/**
* Deletes a `stock_purchase_items` row by id. Used by the modal's
* cards-region "Remove" button so the buyer can clean up mistakes
* without leaving the modal.
*
* Returns `{ ok: true }` on success, or `{ ok: false, reason }` on
* failure. The modal surfaces the message as a toast.
*/
export async function removeQuickDraftLine(
pb: StockQuickDraftPb,
lineId: string,
): Promise<RemoveQuickDraftLineResult> {
try {
await pb.collection(STOCK_PURCHASE_ITEMS).delete(lineId);
return { ok: true };
} catch (err) {
return {
ok: false,
reason: "line_not_found",
message: err instanceof Error ? err.message : "line delete failed",
};
}
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/lib/server/stock-quick-draft-service.test.ts
Expected: PASS (17 + 2 = 19 tests).
- Step 5: Commit
git add src/lib/server/stock-quick-draft-service.ts src/lib/server/stock-quick-draft-service.test.ts
git commit -m "feat(stock-quick-draft): removeQuickDraftLine"
Task 5: API routes — search, supplier-history, add-line, DELETE line
Files:
- Create:
src/app/api/v1/stock-quick-draft/search/route.ts - Create:
src/app/api/v1/stock-quick-draft/supplier-history/route.ts - Create:
src/app/api/v1/stock-quick-draft/add-line/route.ts - Create:
src/app/api/v1/stock-quick-draft/line/[lineId]/route.ts
These four route handlers are thin wrappers over the service. Each follows the runtime = "nodejs" + admin-PB pattern from /api/v1/orders/sprint/counts/route.ts. Errors map cleanly to HTTP status codes (400 / 404 / 409 / 500).
- Step 1: Create
src/app/api/v1/stock-quick-draft/search/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { searchStockItemsForQuickDraft } from "@/lib/server/stock-quick-draft-service";
/**
* GET /api/v1/stock-quick-draft/search?q=&limit=
*
* Typeahead search over `stock_items` for the quick-draft modal.
* Admin PB so the result isn't gated on the `listRule`.
*
* Query params:
* - q (required, min 2 chars)
* - limit (optional, default 10, max 25)
*
* Response: { ok: true, items: StockItemSearchResult[] }
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const q = (searchParams.get("q") ?? "").trim();
const limitRaw = searchParams.get("limit");
const limit = limitRaw ? Number(limitRaw) : undefined;
if (q.length < 2) {
return NextResponse.json(
{ ok: false, reason: "missing_query" },
{ status: 400 },
);
}
try {
const pb = await getAdminPocketBase();
const items = await searchStockItemsForQuickDraft(pb as never, q, limit);
return NextResponse.json({ ok: true, items });
} catch (err) {
return NextResponse.json(
{
ok: false,
reason: "search_failed",
message: err instanceof Error ? err.message : "unknown error",
},
{ status: 500 },
);
}
}
- Step 2: Create
src/app/api/v1/stock-quick-draft/supplier-history/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { getStockItemSupplierHistory } from "@/lib/server/stock-quick-draft-service";
/**
* GET /api/v1/stock-quick-draft/supplier-history?stockItemId=
*
* Returns the per-supplier purchase history for a stock_item
* (suppliers where we've bought it from before, with last cost +
* date). Admin PB so we can read across the stock_purchase_items →
* stock_purchases → suppliers chain.
*
* Response: { ok: true, history: SupplierHistoryEntry[] }
* Returns empty array when no history.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const stockItemId = searchParams.get("stockItemId") ?? "";
if (!stockItemId) {
return NextResponse.json(
{ ok: false, reason: "missing_stock_item_id" },
{ status: 400 },
);
}
try {
const pb = await getAdminPocketBase();
const history = await getStockItemSupplierHistory(pb as never, stockItemId);
return NextResponse.json({ ok: true, history });
} catch (err) {
return NextResponse.json(
{
ok: false,
reason: "history_failed",
message: err instanceof Error ? err.message : "unknown error",
},
{ status: 500 },
);
}
}
- Step 3: Create
src/app/api/v1/stock-quick-draft/add-line/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { addQuickDraftLine } from "@/lib/server/stock-quick-draft-service";
/**
* POST /api/v1/stock-quick-draft/add-line
*
* Adds a single line to a draft stock_purchase for the chosen
* supplier. The service creates the draft PO if none exists for
* the supplier. Supports inline `newStockItem` payload for the
* "+ New SKU not in stock yet" path.
*
* Request body:
* {
* supplierId: string,
* stockItemId?: string,
* quantity: number (>0),
* costPrice: number (>=0),
* newStockItem?: { name, sku, description?, unit? }
* }
*
* Response (success): { ok: true, purchaseId, purchaseNumber, lineId, createdDraft }
*
* Error mapping (reason → status):
* "validation" → 400
* "supplier_not_found" → 404
* "stock_item_not_found" → 404
* "create_failed" → 500
* "append_failed" → 500
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const STATUS_BY_REASON: Record<string, number> = {
validation: 400,
supplier_not_found: 404,
stock_item_not_found: 404,
create_failed: 500,
append_failed: 500,
};
export async function POST(req: NextRequest) {
let body: Record<string, unknown>;
try {
body = (await req.json()) as Record<string, unknown>;
} catch {
return NextResponse.json(
{ ok: false, reason: "validation", message: "invalid JSON body" },
{ status: 400 },
);
}
const supplierId = typeof body.supplierId === "string" ? body.supplierId : "";
const stockItemId =
typeof body.stockItemId === "string" ? body.stockItemId : undefined;
const quantity = typeof body.quantity === "number" ? body.quantity : NaN;
const costPrice = typeof body.costPrice === "number" ? body.costPrice : NaN;
const newStockItem =
body.newStockItem && typeof body.newStockItem === "object"
? (body.newStockItem as Record<string, unknown>)
: undefined;
try {
const pb = await getAdminPocketBase();
const result = await addQuickDraftLine(pb as never, {
supplierId,
stockItemId,
quantity,
costPrice,
newStockItem:
newStockItem &&
typeof newStockItem.name === "string" &&
typeof newStockItem.sku === "string"
? {
name: newStockItem.name,
sku: newStockItem.sku,
description:
typeof newStockItem.description === "string"
? newStockItem.description
: undefined,
unit:
typeof newStockItem.unit === "string"
? newStockItem.unit
: undefined,
}
: undefined,
});
if (!result.ok) {
const status = STATUS_BY_REASON[result.reason] ?? 500;
return NextResponse.json(result, { status });
}
return NextResponse.json(result);
} catch (err) {
return NextResponse.json(
{
ok: false,
reason: "create_failed",
message: err instanceof Error ? err.message : "unknown error",
},
{ status: 500 },
);
}
}
- Step 4: Create
src/app/api/v1/stock-quick-draft/line/[lineId]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { removeQuickDraftLine } from "@/lib/server/stock-quick-draft-service";
/**
* DELETE /api/v1/stock-quick-draft/line/[lineId]
*
* Removes a `stock_purchase_items` row the modal added. Lets the
* buyer clean up mistakes without leaving the modal.
*
* Response (success): { ok: true }
* Response (not found): 404 { ok: false, reason: "line_not_found", message }
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function DELETE(
_req: NextRequest,
ctx: { params: Promise<{ lineId: string }> },
) {
const { lineId } = await ctx.params;
if (!lineId) {
return NextResponse.json(
{ ok: false, reason: "validation", message: "lineId is required" },
{ status: 400 },
);
}
try {
const pb = await getAdminPocketBase();
const result = await removeQuickDraftLine(pb as never, lineId);
if (!result.ok) {
return NextResponse.json(result, { status: 404 });
}
return NextResponse.json(result);
} catch (err) {
return NextResponse.json(
{
ok: false,
reason: "delete_failed",
message: err instanceof Error ? err.message : "unknown error",
},
{ status: 500 },
);
}
}
- Step 5: Type-check and commit
Run: npx tsc --noEmit
Expected: 0 errors.
git add src/app/api/v1/stock-quick-draft/
git commit -m "feat(stock-quick-draft): API routes (search, history, add-line, delete-line)"
Task 6: Modal component — skeleton + header region
Files:
-
Create:
src/components/stock-orders/quick-draft-modal.tsx -
Create:
src/components/stock-orders/quick-draft-modal.test.tsx -
Step 1: Write the failing test
Create src/components/stock-orders/quick-draft-modal.test.tsx:
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QuickDraftModal } from "./quick-draft-modal";
// The modal uses ModalShell.Dialog which depends on @base-ui/react.
// 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,
},
}));
beforeEach(() => {
vi.restoreAllMocks();
});
describe("QuickDraftModal — header", () => {
it("renders nothing when closed", () => {
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={() => {}} />,
);
const input = screen.getByTestId("stock-order-reference");
expect(input).toBeInTheDocument();
// Value defaults to "Stock order entry — <MonthYear>" (e.g. "Stock order entry — Jul 2026").
expect((input as HTMLInputElement).value).toMatch(/^Stock order entry — /);
});
it("lets the buyer rename the reference", async () => {
const user = userEvent.setup();
render(
<QuickDraftModal open onOpenChange={() => {}} />,
);
const input = screen.getByTestId(
"stock-order-reference",
) as HTMLInputElement;
await user.clear(input);
await user.type(input, "Jul 2026 run");
expect(input.value).toBe("Jul 2026 run");
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx
Expected: FAIL — ./quick-draft-modal not found.
- Step 3: Write the skeleton + header
Create src/components/stock-orders/quick-draft-modal.tsx:
"use client";
import { useMemo } from "react";
import { Input } from "@/components/ui/input";
import { ModalShell } from "@/components/ui/modal-shell";
/**
* Quick-Draft Modal — item-first stock-order entry on /stock-orders.
*
* See docs/superpowers/specs/2026-07-30-stock-quick-draft-design.md
* for the full design. This file is the modal in its entirety; the
* three regions (search, cards, footer) live in sub-components
* further down so the test surface stays manageable.
*
* Data flow:
* - search & history hit /api/v1/stock-quick-draft/{search,supplier-history}
* - "Add & next" hits /api/v1/stock-quick-draft/add-line
* - cards region deletes via /api/v1/stock-quick-draft/line/[lineId]
*/
export interface QuickDraftModalProps {
open: boolean;
onOpenChange: (next: boolean) => void;
}
function formatMonthYear(date: Date): string {
return date.toLocaleDateString("en-US", { month: "short", year: "numeric" });
}
export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) {
const defaultReference = useMemo(
() => `Stock order entry — ${formatMonthYear(new Date())}`,
// intentionally only computed once per mount
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
return (
<ModalShell.Dialog open={open} onOpenChange={onOpenChange} size="xl">
<ModalShell.Header>
<div className="space-y-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Stock Order Reference
</span>
<Input
data-testid="stock-order-reference"
defaultValue={defaultReference}
className="h-9 text-base font-semibold"
/>
</div>
</ModalShell.Header>
{/* Regions wired up in later tasks */}
<ModalShell.Body>
<div data-testid="region-search" hidden />
<div data-testid="region-cards" hidden />
</ModalShell.Body>
<ModalShell.Footer>
<div data-testid="region-footer" hidden />
</ModalShell.Footer>
</ModalShell.Dialog>
);
}
- Step 4: Inspect
ModalShellexports
Run: grep -n "^export" src/components/ui/modal-shell.tsx | head -20
Expected: a list that includes Header, Body, Footer. If those sub-components are not exposed (this codebase sometimes uses ad-hoc div headers), adjust the modal to use the existing ModalShell.Dialog header / children / footer props instead. Match the existing send-modal pattern (src/app/(app)/stock-orders/[id]/page.tsx:638-764).
- Step 5: Run test to verify it passes
Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx
Expected: PASS (3 tests). If ModalShell does not export Header/Body/Footer, refactor the component to use the prop-based pattern (see step 4) and re-run.
- Step 6: Commit
git add src/components/stock-orders/quick-draft-modal.tsx src/components/stock-orders/quick-draft-modal.test.tsx
git commit -m "feat(stock-quick-draft): modal skeleton + Stock Order Reference header"
Task 7: Modal — search + supplier-history regions
Files:
-
Modify:
src/components/stock-orders/quick-draft-modal.tsx -
Modify:
src/components/stock-orders/quick-draft-modal.test.tsx -
Step 1: Extend the failing test
Append to src/components/stock-orders/quick-draft-modal.test.tsx:
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.
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();
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx
Expected: FAIL — no quick-draft-search element.
- Step 3: Add the search + history region
Replace the ModalShell.Body content in quick-draft-modal.tsx with the search + history region. Keep the existing header and footer regions untouched. The component becomes:
"use client";
import { useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { ModalShell } from "@/components/ui/modal-shell";
import { toast } from "sonner";
import type {
StockItemSearchResult,
SupplierHistoryEntry,
} from "@/lib/server/stock-quick-draft-service";
export interface QuickDraftModalProps {
open: boolean;
onOpenChange: (next: boolean) => void;
}
function formatMonthYear(date: Date): string {
return date.toLocaleDateString("en-US", { month: "short", year: "numeric" });
}
export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) {
const defaultReference = useMemo(
() => `Stock order entry — ${formatMonthYear(new Date())}`,
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
// 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} onOpenChange={onOpenChange} size="xl">
<ModalShell.Header>
<div className="space-y-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Stock Order Reference
</span>
<Input
data-testid="stock-order-reference"
defaultValue={defaultReference}
className="h-9 text-base font-semibold"
/>
</div>
</ModalShell.Header>
<ModalShell.Body>
{/* Search region */}
<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>
<div data-testid="region-cards" hidden />
</ModalShell.Body>
<ModalShell.Footer>
<div data-testid="region-footer" hidden />
</ModalShell.Footer>
</ModalShell.Dialog>
);
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx
Expected: PASS (3 + 3 = 6 tests). If ModalShell doesn't export Header/Body/Footer, refactor to use the header / footer props on ModalShell.Dialog (see the existing send-modal pattern at src/app/(app)/stock-orders/[id]/page.tsx:638-764).
- Step 5: Type-check
Run: npx tsc --noEmit
Expected: 0 errors. If StockItemSearchResult / SupplierHistoryEntry aren't exported from the service, re-export them (see Task 1 step 3 — they already are).
- Step 6: Commit
git add src/components/stock-orders/quick-draft-modal.tsx src/components/stock-orders/quick-draft-modal.test.tsx
git commit -m "feat(stock-quick-draft): modal search + supplier-history regions"
Task 8: Modal — qty/cost inputs + Add & next + new-SKU toggle
Files:
-
Modify:
src/components/stock-orders/quick-draft-modal.tsx -
Modify:
src/components/stock-orders/quick-draft-modal.test.tsx -
Step 1: Extend the failing test
Append to src/components/stock-orders/quick-draft-modal.test.tsx:
describe("QuickDraftModal — add line", () => {
it("disables Add & next until supplier, qty, and cost are all valid", async () => {
const user = userEvent.setup();
render(<QuickDraftModal open onOpenChange={() => {}} />);
// No item picked, no supplier picked → button is disabled.
const btn = screen.getByTestId("quick-draft-add");
expect(btn).toBeDisabled();
});
it("calls the add-line endpoint and shows a success toast", 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,
},
],
}),
}))
.mockImplementationOnce(async () => ({
ok: true,
json: async () => ({
ok: true,
purchaseId: "purchase-1",
purchaseNumber: "PO26073001STK",
lineId: "line-1",
createdDraft: true,
}),
}));
render(<QuickDraftModal open onOpenChange={() => {}} />);
// Type a search query and pick the result.
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/));
// Wait for history, then click the supplier.
await user.click(await screen.findByText(/Steel & Pipe Co/));
// Fill qty + cost.
await user.clear(screen.getByTestId("quick-draft-qty"));
await user.type(screen.getByTestId("quick-draft-qty"), "50");
await user.clear(screen.getByTestId("quick-draft-cost"));
await user.type(screen.getByTestId("quick-draft-cost"), "18.50");
// Submit.
await user.click(screen.getByTestId("quick-draft-add"));
// The third fetch call is add-line.
await new Promise((r) => setTimeout(r, 50));
const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
const addCall = calls.find((c) =>
String(c[0]).includes("/add-line"),
);
expect(addCall).toBeTruthy();
const body = JSON.parse(addCall[1].body);
expect(body).toMatchObject({
supplierId: "sup-1",
stockItemId: "item-1",
quantity: 50,
costPrice: 18.5,
});
});
it("flips to the new-SKU form when the toggle is clicked", async () => {
const user = userEvent.setup();
render(<QuickDraftModal open onOpenChange={() => {}} />);
await user.click(screen.getByTestId("quick-draft-new-sku-toggle"));
expect(screen.getByTestId("quick-draft-new-sku-name")).toBeInTheDocument();
expect(screen.getByTestId("quick-draft-new-sku-sku")).toBeInTheDocument();
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx
Expected: FAIL — no quick-draft-add button.
- Step 3: Wire up qty/cost inputs + add-line
Replace the inner content of the selectedItem block (the <div className="rounded-md border bg-muted/20 p-3 space-y-3">…) in quick-draft-modal.tsx with the following expanded version. Also add the new state variables and submit handler at the top of the component (above return):
Add to the state declarations:
// Pick state (per modal open).
const [pickedSupplierId, setPickedSupplierId] = useState<string | null>(null);
const [quantity, setQuantity] = useState<number>(1);
const [costPrice, setCostPrice] = useState<number>(0);
const [submitting, setSubmitting] = useState(false);
// New-SKU toggle.
const [isNewSku, setIsNewSku] = useState(false);
const [newName, setNewName] = useState("");
const [newSku, setNewSku] = useState("");
const canSubmit =
(selectedItem || (isNewSku && newName && newSku)) &&
pickedSupplierId !== null &&
quantity > 0 &&
costPrice >= 0 &&
!submitting;
const onAdd = async () => {
if (!canSubmit) return;
setSubmitting(true);
try {
const res = await fetch("/api/v1/stock-quick-draft/add-line", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
supplierId: pickedSupplierId,
stockItemId: selectedItem?.id,
quantity,
costPrice,
newStockItem: isNewSku
? { name: newName, sku: newSku }
: undefined,
}),
});
const data = (await res.json()) as {
ok: boolean;
purchaseNumber?: string;
reason?: string;
message?: string;
};
if (!data.ok) {
toast.error(data.message ?? data.reason ?? "Add failed");
return;
}
toast.success(`Added to PO ${data.purchaseNumber}`);
// Reset for the next item.
setSearchQuery("");
setSearchResults([]);
setSelectedItem(null);
setHistory([]);
setPickedSupplierId(null);
setQuantity(1);
setCostPrice(0);
setIsNewSku(false);
setNewName("");
setNewSku("");
// The cards refetch will be wired in Task 9.
} catch (err) {
toast.error(err instanceof Error ? err.message : "Add failed");
} finally {
setSubmitting(false);
}
};
Replace the existing <div className="rounded-md border bg-muted/20 p-3 space-y-3">… block in the JSX with:
{selectedItem && (
<div className="rounded-md border bg-muted/20 p-3 space-y-3">
<div className="text-sm font-medium">
{selectedItem.name}{" "}
<span className="text-xs text-success">✓ picked</span>
</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) => {
const picked = pickedSupplierId === entry.supplierId;
return (
<button
key={entry.supplierId}
type="button"
onClick={() => setPickedSupplierId(entry.supplierId)}
className={`w-full flex justify-between rounded px-2 py-1 ${
picked ? "bg-primary/10" : "hover:bg-muted/40"
}`}
>
<span>{entry.supplierName}</span>
<span className="text-muted-foreground">
last paid: <strong>R {entry.lastCost.toFixed(2)}</strong>
</span>
</button>
);
})}
</div>
)}
{/* No-history fallback: full supplier dropdown. Wired in Task 9. */}
{pickedSupplierId && (
<div className="flex gap-2 items-end">
<div className="flex-1">
<label className="text-[11px] uppercase tracking-wide text-muted-foreground">
Quantity
</label>
<Input
data-testid="quick-draft-qty"
type="number"
min={1}
value={quantity}
onChange={(e) =>
setQuantity(Math.max(0, Number(e.target.value) || 0))
}
/>
</div>
<div className="flex-1">
<label className="text-[11px] uppercase tracking-wide text-muted-foreground">
Cost / unit (ZAR)
</label>
<Input
data-testid="quick-draft-cost"
type="number"
min={0}
value={costPrice}
onChange={(e) =>
setCostPrice(Math.max(0, Number(e.target.value) || 0))
}
/>
</div>
<Button
data-testid="quick-draft-add"
onClick={onAdd}
disabled={!canSubmit}
>
{submitting ? "Adding…" : "Add & next →"}
</Button>
</div>
)}
<button
type="button"
data-testid="quick-draft-new-sku-toggle"
onClick={() => setIsNewSku((v) => !v)}
className="text-xs text-muted-foreground underline hover:text-foreground"
>
{isNewSku ? "− Hide new SKU form" : "+ New SKU not in stock yet"}
</button>
{isNewSku && (
<div className="grid grid-cols-2 gap-2">
<Input
data-testid="quick-draft-new-sku-name"
placeholder="Item name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<Input
data-testid="quick-draft-new-sku-sku"
placeholder="SKU"
value={newSku}
onChange={(e) => setNewSku(e.target.value)}
/>
</div>
)}
</div>
)}
Add the Button import at the top:
import { Button } from "@/components/ui/button";
- Step 4: Run test to verify it passes
Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx
Expected: PASS (6 + 3 = 9 tests). If the "no-history fallback" is wired up as a placeholder <div> (Task 9 wires it), the test for "no-history" will pass as long as the disabled-state assertion still holds.
- Step 5: Commit
git add src/components/stock-orders/quick-draft-modal.tsx src/components/stock-orders/quick-draft-modal.test.tsx
git commit -m "feat(stock-quick-draft): modal qty/cost inputs + Add & next + new-SKU toggle"
Task 9: Modal — cards region with remove + footer buttons + initial drafts fetch
Files:
-
Modify:
src/components/stock-orders/quick-draft-modal.tsx -
Modify:
src/components/stock-orders/quick-draft-modal.test.tsx -
Step 1: Extend the failing test
Append to src/components/stock-orders/quick-draft-modal.test.tsx:
describe("QuickDraftModal — cards region", () => {
it("fetches open drafts on open and renders one card per supplier", async () => {
global.fetch = vi.fn().mockImplementation(async (url: string) => {
if (url.includes("/stock-purchases")) {
return {
ok: true,
json: async () => ({
ok: true,
items: [
{
id: "purchase-1",
supplier: "sup-1",
status: "draft",
purchase_number: "PO26073001STK",
expand: { supplier: { suppliername: "Steel & Pipe Co" } },
},
{
id: "purchase-2",
supplier: "sup-2",
status: "draft",
purchase_number: "PO26073002STK",
expand: { supplier: { suppliername: "Fasteners Direct" } },
},
],
}),
};
}
if (url.includes("/items")) {
const id = url.split("/").pop();
return {
ok: true,
json: async () => ({
ok: true,
items: [
{
id: `line-${id}`,
purchase: id === "1" ? "purchase-1" : "purchase-2",
description: "115mm Cutting Disc",
quantity: 50,
cost_price: 18.5,
},
],
}),
};
}
return {
ok: true,
json: async () => ({ ok: true, items: [], history: [] }),
};
});
render(<QuickDraftModal open onOpenChange={() => {}} />);
expect(await screen.findByText(/Steel & Pipe Co/)).toBeInTheDocument();
expect(await screen.findByText(/Fasteners Direct/)).toBeInTheDocument();
expect(await screen.findByText(/115mm Cutting Disc/)).toBeInTheDocument();
});
it("calls the DELETE endpoint when Remove is clicked", async () => {
global.fetch = vi.fn().mockImplementation(async (url: string, opts?: RequestInit) => {
if (opts?.method === "DELETE") {
return { ok: true, json: async () => ({ ok: true }) };
}
if (url.includes("/stock-purchases")) {
return {
ok: true,
json: async () => ({
ok: true,
items: [
{
id: "purchase-1",
supplier: "sup-1",
status: "draft",
purchase_number: "PO26073001STK",
expand: { supplier: { suppliername: "Steel & Pipe Co" } },
},
],
}),
};
}
if (url.includes("/items")) {
return {
ok: true,
json: async () => ({
ok: true,
items: [
{
id: "line-1",
purchase: "purchase-1",
description: "115mm Cutting Disc",
quantity: 50,
cost_price: 18.5,
},
],
}),
};
}
return { ok: true, json: async () => ({ ok: true, items: [], history: [] }) };
});
render(<QuickDraftModal open onOpenChange={() => {}} />);
const removeBtn = await screen.findByTestId("quick-draft-remove-line-1");
// fireEvent so we don't need user-event setup here.
removeBtn.click();
await new Promise((r) => setTimeout(r, 50));
const deleteCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
(c) => c[1]?.method === "DELETE",
);
expect(deleteCall).toBeTruthy();
expect(String(deleteCall[0])).toMatch(/\/line\/line-1$/);
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx
Expected: FAIL — cards region not implemented.
- Step 3: Wire up the cards region + footer
Add the following imports at the top of quick-draft-modal.tsx:
import { useEffect, useMemo, useState, useCallback } from "react";
import { Loader2, Trash2 } from "lucide-react";
Add types and helper:
interface DraftLine {
id: string;
purchase: string;
description: string;
quantity: number;
cost_price: number;
}
interface DraftCard {
purchaseId: string;
purchaseNumber: string;
supplierId: string;
supplierName: string;
lines: DraftLine[];
}
Add state and fetch logic:
const [cards, setCards] = useState<DraftCard[]>([]);
const [cardsLoading, setCardsLoading] = useState(false);
const refetchCards = useCallback(async () => {
setCardsLoading(true);
try {
const listRes = await fetch(
"/api/v1/stock-purchases?status=draft&perPage=50",
);
const listData = (await listRes.json()) as {
ok: boolean;
items?: Array<{
id: string;
purchase_number: string;
supplier: string;
expand?: { supplier?: { suppliername: string } };
}>;
};
if (!listData.ok || !listData.items) {
setCards([]);
return;
}
const drafts = listData.items;
const draftCards: DraftCard[] = [];
for (const draft of drafts) {
const itemsRes = await fetch(
`/api/v1/stock-purchases/${draft.id}/items`,
);
const itemsData = (await itemsRes.json()) as {
ok: boolean;
items?: DraftLine[];
};
draftCards.push({
purchaseId: draft.id,
purchaseNumber: draft.purchase_number,
supplierId: draft.supplier,
supplierName: draft.expand?.supplier?.suppliername ?? "(supplier)",
lines: itemsData.items ?? [],
});
}
setCards(draftCards);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Cards fetch failed");
} finally {
setCardsLoading(false);
}
}, []);
// Refetch cards on modal open.
useEffect(() => {
if (open) void refetchCards();
}, [open, refetchCards]);
const onRemoveLine = async (lineId: string) => {
try {
const res = await fetch(
`/api/v1/stock-quick-draft/line/${encodeURIComponent(lineId)}`,
{ method: "DELETE" },
);
const data = (await res.json()) as { ok: boolean; message?: string };
if (!data.ok) {
toast.error(data.message ?? "Remove failed");
return;
}
toast.success("Line removed");
void refetchCards();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Remove failed");
}
};
Add the cards region to the JSX (replacing the placeholder <div data-testid="region-cards" hidden />):
<div className="mt-4 space-y-2">
<div className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Open drafts ({cards.length})
</div>
{cardsLoading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> Loading drafts…
</div>
)}
{!cardsLoading &&
cards.map((card) => (
<div key={card.purchaseId} className="rounded-md border">
<div className="flex items-center justify-between bg-muted/30 px-3 py-2 text-sm font-medium">
<span>{card.supplierName}</span>
<span className="text-xs text-muted-foreground">
{card.purchaseNumber} · {card.lines.length} line
{card.lines.length === 1 ? "" : "s"}
</span>
</div>
<ul className="divide-y text-sm">
{card.lines.map((line) => (
<li
key={line.id}
className="flex items-center justify-between px-3 py-2"
>
<span>
{line.description} × {line.quantity}
</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">
R {(line.cost_price ?? 0).toFixed(2)}
</span>
<button
type="button"
data-testid={`quick-draft-remove-${line.id}`}
onClick={() => onRemoveLine(line.id)}
aria-label="Remove line"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</li>
))}
</ul>
</div>
))}
</div>
Wire up the refetchCards() call after a successful add in onAdd — find // The cards refetch will be wired in Task 9. and replace with void refetchCards();.
Replace the placeholder footer <div data-testid="region-footer" hidden /> with:
<div className="flex items-center justify-between">
<div className="text-xs text-muted-foreground">
{cards.reduce((n, c) => n + c.lines.length, 0)} lines ·{" "}
{cards.length} suppliers
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
data-testid="quick-draft-save-close"
>
Save & close
</Button>
<Button
onClick={() => onOpenChange(false)}
data-testid="quick-draft-finalize"
>
Finalize
</Button>
</div>
</div>
Add the data-testid on the remove button to match the test: change data-testid={quick-draft-remove-${line.id}} to data-testid={quick-draft-remove-line-${line.id}} (the test expects quick-draft-remove-line-1).
- Step 4: Run test to verify it passes
Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx
Expected: PASS (9 + 2 = 11 tests).
- Step 5: Type-check
Run: npx tsc --noEmit
Expected: 0 errors.
- Step 6: Commit
git add src/components/stock-orders/quick-draft-modal.tsx src/components/stock-orders/quick-draft-modal.test.tsx
git commit -m "feat(stock-quick-draft): modal cards region + remove + footer"
Task 10: No-history fallback supplier dropdown
Files:
-
Modify:
src/components/stock-orders/quick-draft-modal.tsx -
Modify:
src/components/stock-orders/quick-draft-modal.test.tsx -
Step 1: Extend the failing test
Append to src/components/stock-orders/quick-draft-modal.test.tsx:
describe("QuickDraftModal — no-history fallback", () => {
it("shows the full supplier dropdown when there is no history", async () => {
const user = userEvent.setup();
global.fetch = vi
.fn()
.mockImplementationOnce(async () => ({
ok: true,
json: async () => ({
ok: true,
items: [{ id: "item-1", sku: "STK-X", name: "Brand new item" }],
}),
}))
.mockImplementationOnce(async () => ({
ok: true,
json: async () => ({ ok: true, history: [] }),
}))
.mockImplementationOnce(async () => ({
ok: true,
json: async () => ({
ok: true,
items: [
{ id: "sup-1", suppliername: "Steel & Pipe Co" },
{ id: "sup-2", suppliername: "Fasteners Direct" },
],
}),
}));
render(<QuickDraftModal open onOpenChange={() => {}} />);
await user.type(screen.getByTestId("quick-draft-search"), "new");
await new Promise((r) => setTimeout(r, 250));
await user.click(await screen.findByText(/Brand new item/));
// Fallback dropdown appears (data-testid is "quick-draft-supplier-fallback").
const dropdown = await screen.findByTestId(
"quick-draft-supplier-fallback",
);
expect(dropdown).toBeInTheDocument();
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx
Expected: FAIL — no fallback dropdown.
- Step 3: Add the fallback dropdown
In quick-draft-modal.tsx, find the {/* No-history fallback: full supplier dropdown. Wired in Task 9. */} placeholder and replace with:
{!historyLoading && history.length === 0 && (
<div className="space-y-2">
<div className="text-xs text-muted-foreground">
No prior history for this item — pick any supplier.
</div>
<SupplierSearch
data-testid="quick-draft-supplier-fallback"
value={pickedSupplierId ?? ""}
onValueChange={(id) => setPickedSupplierId(id)}
suppliers={suppliers}
placeholder="Pick a supplier…"
ariaLabel="Supplier"
/>
</div>
)}
Add state for the suppliers list and a fetch-once effect:
const [suppliers, setSuppliers] = useState<
Array<{ id: string; suppliername: string }>
>([]);
// Fetch suppliers once on mount (used by the no-history fallback).
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch("/api/v1/suppliers?perPage=200");
const data = (await res.json()) as {
ok: boolean;
items?: Array<{ id: string; suppliername: string }>;
};
if (!cancelled && data.ok && data.items) setSuppliers(data.items);
} catch {
// silent — the no-history fallback simply won't appear if suppliers fail to load
}
})();
return () => {
cancelled = true;
};
}, []);
Add the import:
import { SupplierSearch } from "@/components/ui/supplier-search";
Note: if the project's SupplierSearch doesn't pass through data-testid, wrap it in a <div data-testid="quick-draft-supplier-fallback"> instead and the test will still pass. Adjust the JSX accordingly.
- Step 4: Run test to verify it passes
Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx
Expected: PASS (11 + 1 = 12 tests).
- Step 5: Commit
git add src/components/stock-orders/quick-draft-modal.tsx src/components/stock-orders/quick-draft-modal.test.tsx
git commit -m "feat(stock-quick-draft): modal no-history supplier fallback"
Task 11: Page hook — button on /stock-orders
Files:
-
Modify:
src/app/(app)/stock-orders/page.tsx -
Step 1: Locate the existing header buttons
Open src/app/(app)/stock-orders/page.tsx and find the header buttons block (around line 154-168). Confirm the existing buttons render "Reorder from low stock" and "+ New stock order" with router.push("/stock-orders/reorder") and router.push("/stock-orders/new") respectively.
- Step 2: Add the new button + modal state
Add the import at the top of the file:
import { QuickDraftModal } from "@/components/stock-orders/quick-draft-modal";
Find the existing two <Button> elements in the header. Insert a third button between them:
<Button
variant="outline"
onClick={() => setQuickDraftOpen(true)}
data-testid="open-quick-draft"
>
+ New Stock Item
</Button>
Add the state declaration near the other useState calls in the component:
const [quickDraftOpen, setQuickDraftOpen] = useState(false);
Render the modal near the bottom of the component's JSX (just before the closing fragment / wrapper):
<QuickDraftModal open={quickDraftOpen} onOpenChange={setQuickDraftOpen} />
- Step 3: Type-check
Run: npx tsc --noEmit
Expected: 0 errors.
- Step 4: Manual smoke check (no test required)
Run: npm run dev
Navigate to http://localhost:3000/stock-orders. Confirm:
- The new "+ New Stock Item" button appears between "Reorder from low stock" and "+ New stock order".
- Clicking it opens the modal.
- The "Stock Order Reference" input is pre-filled with the current month label.
- Searching for "115" returns the seeded
STK-CD-115row. - Picking it shows supplier history (seeded data may be empty in dev — fallback dropdown shows).
- Closing the modal preserves state on next open.
If any step fails, fix and re-test.
- Step 5: Commit
git add src/app/(app)/stock-orders/page.tsx
git commit -m "feat(stock-orders): New Stock Item button + QuickDraftModal on /stock-orders"
Task 12: Final smoke + regression sweep
Files: none new
- Step 1: Run the full test suite
Run: npx vitest run
Expected: all green.
- Step 2: Lint + type-check
Run: npm run lint && npx tsc --noEmit
Expected: 0 errors. Fix any new lint warnings introduced in this branch.
- Step 3: Manual smoke (full)
With dev server running, walk the spec's smoke checklist:
- Open
/stock-orders→ click "+ New Stock Item" → modal opens. - Type "cutting" → 115mm Cutting Disc shows in results.
- Pick it → supplier history renders (or fallback dropdown if history is empty in dev).
- Add 3 items to Steel & Pipe Co, 2 to Fasteners Direct → cards region shows 2 cards with 3 + 2 lines.
- Close modal → navigate to
/stock-orders→ list shows 2 new draft POs. - Reopen modal → cards region still shows the 2 drafts.
- Click Remove on one line → line disappears, cards refetch.
- Click into one draft on
/stock-orders→ existing detail page renders the line correctly. - Click "Send to supplier" on that draft → existing send flow works unchanged.
- Type "zzz nothing" in the modal → "No stock items match" with the "+ New SKU" toggle highlighted.
- Click "+ New SKU" → fill name + SKU + supplier + qty + cost → Add & next → new line added,
stock_itemshas the new row.
- Step 4: Commit any final fixes
If any smoke step failed, fix and commit with a descriptive message. If everything passed, no commit.
Notes for the implementer
ModalShellAPI: this codebase sometimes uses prop-based header/footer onModalShell.Dialog, sometimes sub-components. The first task that touches it verifies which is in use; subsequent tasks follow whichever pattern the skeleton established.- PB mock fidelity: the
pbMockin the test scaffold supportsgetList(with simple=/!=filter clauses),getFullList,getOne,create,update,delete. If the implementation needs more sophisticated queries (e.g.ORclauses,expand), extend the mock in the same file before adding the test. - Admin PB in the route: every new route uses
await getAdminPocketBase()and casts it to the service'sStockQuickDraftPb. The cast is safe — admin PB has the same method surface the service uses, just without auth context. - Field denormalisation: the modal service copies
stock_item.nameontostock_purchase_items.descriptionon create (matching the existing/stock-orders/newpattern atsrc/app/(app)/stock-orders/new/page.tsx:478-489).