diff --git a/docs/superpowers/plans/2026-07-30-stock-quick-draft.md b/docs/superpowers/plans/2026-07-30-stock-quick-draft.md new file mode 100644 index 0000000..ccc2a41 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-stock-quick-draft.md @@ -0,0 +1,2826 @@ +# 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 */} + +