WFDASH-WEBAPP/docs/superpowers/plans/2026-07-30-stock-quick-draft.md
SJCoetz 14badb8aee docs(plans): stock quick-draft modal implementation plan
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>
2026-07-30 21:31:59 +02:00

2826 lines
No EOL
92 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`:
```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`:
```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**
```bash
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`:
```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`:
```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**
```bash
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`:
```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`:
```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**
```bash
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`:
```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`:
```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**
```bash
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`**
```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`**
```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`**
```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`**
```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.
```bash
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`:
```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`:
```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 `ModalShell` exports**
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**
```bash
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`:
```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:
```tsx
"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**
```bash
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`:
```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:
```tsx
// 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:
```tsx
{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:
```tsx
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**
```bash
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`:
```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`:
```tsx
import { useEffect, useMemo, useState, useCallback } from "react";
import { Loader2, Trash2 } from "lucide-react";
```
Add types and helper:
```tsx
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:
```tsx
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 />`):
```tsx
<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:
```tsx
<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 &amp; 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**
```bash
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`:
```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:
```tsx
{!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:
```tsx
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:
```tsx
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**
```bash
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:
```tsx
import { QuickDraftModal } from "@/components/stock-orders/quick-draft-modal";
```
Find the existing two `<Button>` elements in the header. Insert a third button between them:
```tsx
<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:
```tsx
const [quickDraftOpen, setQuickDraftOpen] = useState(false);
```
Render the modal near the bottom of the component's JSX (just before the closing fragment / wrapper):
```tsx
<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:
1. The new "+ New Stock Item" button appears between "Reorder from low stock" and "+ New stock order".
2. Clicking it opens the modal.
3. The "Stock Order Reference" input is pre-filled with the current month label.
4. Searching for "115" returns the seeded `STK-CD-115` row.
5. Picking it shows supplier history (seeded data may be empty in dev — fallback dropdown shows).
6. Closing the modal preserves state on next open.
If any step fails, fix and re-test.
- [ ] **Step 5: Commit**
```bash
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:
1. Open `/stock-orders` → click "+ New Stock Item" → modal opens.
2. Type "cutting" → 115mm Cutting Disc shows in results.
3. Pick it → supplier history renders (or fallback dropdown if history is empty in dev).
4. Add 3 items to Steel & Pipe Co, 2 to Fasteners Direct → cards region shows 2 cards with 3 + 2 lines.
5. Close modal → navigate to `/stock-orders` → list shows 2 new draft POs.
6. Reopen modal → cards region still shows the 2 drafts.
7. Click Remove on one line → line disappears, cards refetch.
8. Click into one draft on `/stock-orders` → existing detail page renders the line correctly.
9. Click "Send to supplier" on that draft → existing send flow works unchanged.
10. Type "zzz nothing" in the modal → "No stock items match" with the "+ New SKU" toggle highlighted.
11. Click "+ New SKU" → fill name + SKU + supplier + qty + cost → Add & next → new line added, `stock_items` has 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
- **`ModalShell` API**: this codebase sometimes uses prop-based header/footer on `ModalShell.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 `pbMock` in the test scaffold supports `getList` (with simple `=` / `!=` filter clauses), `getFullList`, `getOne`, `create`, `update`, `delete`. If the implementation needs more sophisticated queries (e.g. `OR` clauses, `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's `StockQuickDraftPb`. 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.name` onto `stock_purchase_items.description` on create (matching the existing `/stock-orders/new` pattern at `src/app/(app)/stock-orders/new/page.tsx:478-489`).