# 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>; const store: Record = {}; 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[] => { 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) { 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) { 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>; totalItems: number; page: number; perPage: number; totalPages: number; }>; getFullList: (opts?: { filter?: string }) => Promise>>; getOne: (id: string, opts?: { expand?: string }) => Promise>; create: (data: Record) => Promise>; update: (id: string, data: Record) => Promise>; delete: (id: string) => Promise>; }; } /* ============================================================================ * 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 { 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 { 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 { 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>(); 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(); 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 { // 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[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 { // ── 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 { 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 = { 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; try { body = (await req.json()) as Record; } 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) : 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 ?
{children}
: null, }, })); beforeEach(() => { vi.restoreAllMocks(); }); describe("QuickDraftModal — header", () => { it("renders nothing when closed", () => { render( {}} />, ); expect(screen.queryByTestId("modal")).toBeNull(); }); it("renders the Stock Order Reference input with a default month label", () => { render( {}} />, ); const input = screen.getByTestId("stock-order-reference"); expect(input).toBeInTheDocument(); // Value defaults to "Stock order entry — " (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( {}} />, ); 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 (
Stock Order Reference
{/* Regions wired up in later tasks */}