WFDASH-WEBAPP/docs/superpowers/plans/2026-07-30-stock-quick-draft.md
SJCoetz 14badb8aee docs(plans): stock quick-draft modal implementation plan
12-task TDD plan covering the service module, four API routes,
the modal component (built incrementally across 4 tasks), and the
page-hook. Each task ends with a commit; spec gaps surfaced during
self-review already merged into task content.

Co-authored-by: Orca <help@stably.ai>
2026-07-30 21:31:59 +02:00

92 KiB
Raw Blame History

Stock Quick-Draft Modal Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add an item-first quick-draft modal on the /stock-orders page that lets a buyer type a stock item, see which suppliers we've bought it from before, pick one, set qty + cost, and add the line — accumulating items into one draft stock_purchase per supplier across multiple modal sessions.

Architecture: A new QuickDraftModal client component is opened from a new button on the /stock-orders page header. The modal hits three new /api/v1/stock-quick-draft/* route handlers, each of which delegates to a new stock-quick-draft-service.ts admin-PB service module. The service is the source of truth for "is there an open draft for this supplier?" and reuses generateStockPurchaseNumber from the existing stock-purchases-service.ts. No schema changes, no new statuses.

Tech Stack: Next.js 16 (App Router), React 19, TypeScript, PocketBase, Tailwind, lucide-react, sonner toasts, @base-ui/react/dialog (via existing ModalShell). Tests run with Vitest + @testing-library/react.


File Structure

File Responsibility
src/lib/server/stock-quick-draft-service.ts (new) Admin-PB service: search, supplier history, find-or-create open draft, append line, remove line, create stock_item.
src/lib/server/stock-quick-draft-service.test.ts (new) Unit tests for the service functions.
src/app/api/v1/stock-quick-draft/search/route.ts (new) GET ?q=&limit= typeahead for stock_items.
src/app/api/v1/stock-quick-draft/supplier-history/route.ts (new) GET ?stockItemId= supplier history for an item.
src/app/api/v1/stock-quick-draft/add-line/route.ts (new) POST add a line; supports inline newStockItem.
src/app/api/v1/stock-quick-draft/line/[lineId]/route.ts (new) DELETE remove a line the modal added.
src/components/stock-orders/quick-draft-modal.tsx (new) The modal component (search, history, qty/cost, cards, footer).
src/components/stock-orders/quick-draft-modal.test.tsx (new) Component tests: search debounce, history render, add & next, cards refetch, remove.
src/app/(app)/stock-orders/page.tsx (modify) Add the "New Stock Item" button to the header between "Reorder" and "+ New stock order"; wire the modal.

Task 1: Service types + searchStockItemsForQuickDraft

Files:

  • Create: src/lib/server/stock-quick-draft-service.ts

  • Create: src/lib/server/stock-quick-draft-service.test.ts

  • Step 1: Write the failing test

Create src/lib/server/stock-quick-draft-service.test.ts:

import { describe, it, expect, beforeEach, vi } from "vitest";

const { store, pbMock, clearStore } = vi.hoisted(() => {
  type StoreMap = Map<string, Record<string, unknown>>;
  const store: Record<string, StoreMap> = {};
  let seq = 0;
  const nextId = () => `mock-${++seq}`;

  const getOrCreateMap = (name: string): StoreMap => {
    if (!store[name]) store[name] = new Map();
    return store[name];
  };

  const where = (name: string, filter: string): Record<string, unknown>[] => {
    const items = Array.from(getOrCreateMap(name).values());
    if (!filter || !filter.trim()) return items;
    const clauses = filter.split("&&").map((c) => c.trim());
    return items.filter((item) =>
      clauses.every((clause) => {
        // Minimal parser: handles field op value. Op is `=` or `!=`.
        // Value may be quoted (string) or unquoted (number).
        const m = clause.match(/^(\w+)\s*(=|!=)\s*(.+)$/);
        if (!m) return true;
        const [, field, op, rawValue] = m;
        const value =
          rawValue.startsWith('"') && rawValue.endsWith('"')
            ? rawValue.slice(1, -1)
            : Number(rawValue);
        const actual = item[field];
        if (op === "=") return actual == value;
        if (op === "!=") return actual != value;
        return true;
      }),
    );
  };

  const pbMock = {
    collection: (name: string) => ({
      async getList(
        _page = 1,
        perPage = 25,
        opts: { filter?: string; sort?: string } = {},
      ) {
        let items = where(name, opts.filter || "");
        if (opts.sort) {
          const desc = opts.sort.startsWith("-");
          const field = desc ? opts.sort.slice(1) : opts.sort;
          items = [...items].sort((a, b) => {
            const av = a[field] as number | string | undefined;
            const bv = b[field] as number | string | undefined;
            if (av === bv) return 0;
            if (av === undefined) return 1;
            if (bv === undefined) return -1;
            const cmp = av < bv ? -1 : 1;
            return desc ? -cmp : cmp;
          });
        }
        return { items, totalItems: items.length, page: 1, perPage };
      },
      async getFullList() {
        return where(name, "");
      },
      async getOne(id: string) {
        const item = getOrCreateMap(name).get(id);
        if (!item) {
          const err = new Error("not found") as Error & { status: number };
          err.status = 404;
          throw err;
        }
        return item;
      },
      async create(data: Record<string, unknown>) {
        const item = {
          id: nextId(),
          created: new Date().toISOString(),
          updated: new Date().toISOString(),
          ...data,
        };
        getOrCreateMap(name).set(item.id, item);
        return item;
      },
      async update(id: string, data: Record<string, unknown>) {
        const map = getOrCreateMap(name);
        const existing = map.get(id);
        if (!existing) {
          const err = new Error("not found") as Error & { status: number };
          err.status = 404;
          throw err;
        }
        for (const [key, value] of Object.entries(data)) {
          existing[key] = value;
        }
        existing.updated = new Date().toISOString();
        return { ...existing };
      },
      async delete(id: string) {
        const map = getOrCreateMap(name);
        if (!map.has(id)) {
          const err = new Error("not found") as Error & { status: number };
          err.status = 404;
          throw err;
        }
        map.delete(id);
        return { id };
      },
    }),
  };

  const clearStore = () => {
    for (const key of Object.keys(store)) delete store[key];
    seq = 0;
  };

  return { store, pbMock, clearStore };
});

vi.mock("pocketbase", () => ({ default: vi.fn().mockImplementation(() => pbMock) }));
vi.mock("./pocketbase-admin", () => ({
  getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
}));

import { searchStockItemsForQuickDraft } from "./stock-quick-draft-service";

beforeEach(() => clearStore());

describe("searchStockItemsForQuickDraft", () => {
  it("returns active stock_items whose sku or name matches the query", async () => {
    await pbMock.collection("stock_items").create({
      sku: "STK-CD-115",
      name: "115mm Cutting Disc",
      is_active: true,
    });
    await pbMock.collection("stock_items").create({
      sku: "STK-WR-6",
      name: "Wire rope 6mm",
      is_active: true,
    });
    await pbMock.collection("stock_items").create({
      sku: "STK-CD-230",
      name: "230mm Cutting Disc",
      is_active: true,
    });

    const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10);

    expect(results).toHaveLength(2);
    expect(results.map((r) => r.sku).sort()).toEqual(["STK-CD-115", "STK-CD-230"]);
  });

  it("excludes inactive items", async () => {
    await pbMock.collection("stock_items").create({
      sku: "STK-CD-115",
      name: "115mm Cutting Disc",
      is_active: false,
    });

    const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10);

    expect(results).toEqual([]);
  });

  it("clamps limit to a max of 25", async () => {
    for (let i = 0; i < 30; i++) {
      await pbMock.collection("stock_items").create({
        sku: `STK-X-${i}`,
        name: `Cutting disc variant ${i}`,
        is_active: true,
      });
    }

    const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 100);

    expect(results).toHaveLength(25);
  });

  it("returns narrow projection fields", async () => {
    const created = await pbMock.collection("stock_items").create({
      sku: "STK-CD-115",
      name: "115mm Cutting Disc",
      description: "For angle grinder",
      unit: "ea",
      is_active: true,
    });

    const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10);

    expect(results[0]).toEqual({
      id: created.id,
      sku: "STK-CD-115",
      name: "115mm Cutting Disc",
      description: "For angle grinder",
      unit: "ea",
    });
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/lib/server/stock-quick-draft-service.test.ts Expected: FAIL — searchStockItemsForQuickDraft is not exported.

  • Step 3: Write minimal implementation

Create src/lib/server/stock-quick-draft-service.ts:

/**
 * Stock Quick-Draft service — backs the /stock-orders modal that lets
 * a buyer add items to draft stock_purchases in an item-first rhythm.
 *
 * Reuses `generateStockPurchaseNumber` from `stock-purchases-service.ts`
 * for the find-or-create open-draft step; no schema changes, no new
 * statuses. Admin PB only (consistent with the other service modules).
 */
import type PocketBase from "pocketbase";
import { getAdminPocketBase } from "./pocketbase-admin";
import { generateStockPurchaseNumber } from "./stock-purchases-service";

const STOCK_ITEMS = "stock_items";
const STOCK_PURCHASES = "stock_purchases";
const STOCK_PURCHASE_ITEMS = "stock_purchase_items";

const SEARCH_LIMIT_MAX = 25;
const SEARCH_LIMIT_DEFAULT = 10;

/* ============================================================================
 * Minimal PB client shape this module needs.
 *
 * The route passes in the real `getAdminPocketBase()`; the unit test
 * passes in a tiny mock. Only the methods we actually call are typed.
 * ========================================================================== */

export interface StockQuickDraftPb {
  collection: (name: string) => {
    getList: (
      page?: number,
      perPage?: number,
      opts?: { filter?: string; sort?: string; expand?: string }
    ) => Promise<{
      items: Array<Record<string, unknown>>;
      totalItems: number;
      page: number;
      perPage: number;
      totalPages: number;
    }>;
    getFullList: (opts?: { filter?: string }) => Promise<Array<Record<string, unknown>>>;
    getOne: (id: string, opts?: { expand?: string }) => Promise<Record<string, unknown>>;
    create: (data: Record<string, unknown>) => Promise<Record<string, unknown>>;
    update: (id: string, data: Record<string, unknown>) => Promise<Record<string, unknown>>;
    delete: (id: string) => Promise<Record<string, unknown>>;
  };
}

/* ============================================================================
 * Public types
 * ========================================================================== */

export interface StockItemSearchResult {
  id: string;
  sku: string;
  name: string;
  description: string;
  unit?: string;
}

export interface SupplierHistoryEntry {
  supplierId: string;
  supplierName: string;
  lastCost: number;
  lastDate: string;
  timesBought: number;
}

export interface AddQuickDraftLineNewStockItem {
  name: string;
  sku: string;
  description?: string;
  unit?: string;
}

export interface AddQuickDraftLineInput {
  supplierId: string;
  stockItemId?: string;
  quantity: number;
  costPrice: number;
  newStockItem?: AddQuickDraftLineNewStockItem;
}

export type AddQuickDraftLineResult =
  | { ok: true; purchaseId: string; purchaseNumber: string; lineId: string; createdDraft: boolean }
  | { ok: false; reason: string; message: string };

export type RemoveQuickDraftLineResult =
  | { ok: true }
  | { ok: false; reason: string; message: string };

/* ============================================================================
 * Helpers
 * ========================================================================== */

async function resolvePb(pb?: StockQuickDraftPb): Promise<StockQuickDraftPb> {
  if (pb) return pb;
  return (await getAdminPocketBase()) as unknown as StockQuickDraftPb;
}

/* ============================================================================
 * searchStockItemsForQuickDraft
 * ========================================================================== */

/**
 * Typeahead search over `stock_items`. Matches `sku` OR `name` with
 * PocketBase's `~` (contains, case-insensitive). Excludes inactive
 * items. Limit defaults to 10, clamped to a max of 25.
 *
 * Returns a narrow projection — only the fields the modal needs.
 */
export async function searchStockItemsForQuickDraft(
  pb: StockQuickDraftPb,
  q: string,
  limit?: number,
): Promise<StockItemSearchResult[]> {
  const safeLimit = Math.min(Math.max(limit ?? SEARCH_LIMIT_DEFAULT, 1), SEARCH_LIMIT_MAX);
  const escaped = q.replace(/"/g, '\\"');
  const filter = `is_active=true && (sku ~ "${escaped}" || name ~ "${escaped}")`;

  const result = await pb.collection(STOCK_ITEMS).getList(1, safeLimit, {
    filter,
    sort: "name",
  });

  return result.items.map((row) => ({
    id: String(row.id),
    sku: String(row.sku ?? ""),
    name: String(row.name ?? ""),
    description: String(row.description ?? ""),
    unit: row.unit ? String(row.unit) : undefined,
  }));
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/lib/server/stock-quick-draft-service.test.ts Expected: PASS (4 tests).

  • Step 5: Commit
git add src/lib/server/stock-quick-draft-service.ts src/lib/server/stock-quick-draft-service.test.ts
git commit -m "feat(stock-quick-draft): types + searchStockItemsForQuickDraft"

Task 2: Service — getStockItemSupplierHistory

Files:

  • Modify: src/lib/server/stock-quick-draft-service.ts (append)

  • Modify: src/lib/server/stock-quick-draft-service.test.ts (extend)

  • Step 1: Extend the failing test

Append a new describe block at the end of src/lib/server/stock-quick-draft-service.test.ts:

import {
  getStockItemSupplierHistory,
  searchStockItemsForQuickDraft,
} from "./stock-quick-draft-service";

describe("getStockItemSupplierHistory", () => {
  it("returns empty when the item has no purchase history", async () => {
    await pbMock.collection("stock_items").create({
      id: "item-1",
      sku: "STK-CD-115",
      name: "115mm Cutting Disc",
      is_active: true,
    });

    const history = await getStockItemSupplierHistory(pbMock as never, "item-1");

    expect(history).toEqual([]);
  });

  it("aggregates by supplier: lastCost, lastDate, timesBought", async () => {
    const supplierId = await (
      await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
    ).id;
    await pbMock.collection("stock_items").create({
      id: "item-1",
      sku: "STK-CD-115",
      name: "115mm Cutting Disc",
      is_active: true,
    });
    const purchase1 = await pbMock.collection("stock_purchases").create({
      supplier: supplierId,
      status: "received",
      purchase_number: "PO26070101STK",
      created: "2026-06-10T08:00:00.000Z",
    });
    const purchase2 = await pbMock.collection("stock_purchases").create({
      supplier: supplierId,
      status: "received",
      purchase_number: "PO26071401STK",
      created: "2026-07-14T08:00:00.000Z",
    });
    await pbMock.collection("stock_purchase_items").create({
      purchase: purchase1.id,
      stock_item: "item-1",
      cost_price: 18.5,
      quantity: 10,
      quantity_received: 10,
    });
    await pbMock.collection("stock_purchase_items").create({
      purchase: purchase2.id,
      stock_item: "item-1",
      cost_price: 19.0,
      quantity: 20,
      quantity_received: 20,
    });

    const history = await getStockItemSupplierHistory(pbMock as never, "item-1");

    expect(history).toHaveLength(1);
    expect(history[0]).toEqual({
      supplierId,
      supplierName: "Steel & Pipe Co",
      lastCost: 19.0,
      lastDate: "2026-07-14T08:00:00.000Z",
      timesBought: 2,
    });
  });

  it("returns one entry per supplier, sorted by lastDate desc", async () => {
    const supA = (
      await pbMock.collection("suppliers").create({ suppliername: "Supplier A" })
    ).id;
    const supB = (
      await pbMock.collection("suppliers").create({ suppliername: "Supplier B" })
    ).id;
    await pbMock.collection("stock_items").create({
      id: "item-1",
      sku: "STK-CD-115",
      is_active: true,
    });
    const purchaseA = await pbMock.collection("stock_purchases").create({
      supplier: supA,
      status: "received",
      purchase_number: "PO26070101STK",
      created: "2026-06-10T08:00:00.000Z",
    });
    const purchaseB = await pbMock.collection("stock_purchases").create({
      supplier: supB,
      status: "received",
      purchase_number: "PO26071501STK",
      created: "2026-07-15T08:00:00.000Z",
    });
    await pbMock.collection("stock_purchase_items").create({
      purchase: purchaseA.id,
      stock_item: "item-1",
      cost_price: 18.5,
      quantity_received: 10,
    });
    await pbMock.collection("stock_purchase_items").create({
      purchase: purchaseB.id,
      stock_item: "item-1",
      cost_price: 21.0,
      quantity_received: 5,
    });

    const history = await getStockItemSupplierHistory(pbMock as never, "item-1");

    expect(history).toHaveLength(2);
    expect(history[0].supplierId).toBe(supB);
    expect(history[1].supplierId).toBe(supA);
  });

  it("only counts lines with quantity_received > 0", async () => {
    const supA = (
      await pbMock.collection("suppliers").create({ suppliername: "Supplier A" })
    ).id;
    await pbMock.collection("stock_items").create({
      id: "item-1",
      sku: "STK-CD-115",
      is_active: true,
    });
    const purchase = await pbMock.collection("stock_purchases").create({
      supplier: supA,
      status: "draft",
      purchase_number: "PO26070101STK",
      created: "2026-07-01T08:00:00.000Z",
    });
    await pbMock.collection("stock_purchase_items").create({
      purchase: purchase.id,
      stock_item: "item-1",
      cost_price: 18.5,
      quantity_received: 0,
    });

    const history = await getStockItemSupplierHistory(pbMock as never, "item-1");

    expect(history).toEqual([]);
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/lib/server/stock-quick-draft-service.test.ts Expected: FAIL — getStockItemSupplierHistory is not exported.

  • Step 3: Append the implementation

Append to src/lib/server/stock-quick-draft-service.ts:

/* ============================================================================
 * getStockItemSupplierHistory
 * ========================================================================== */

/**
 * Returns the per-supplier purchase history for a `stock_item`.
 * Reads `stock_purchase_items` joined through `stock_purchases` so
 * we can aggregate by supplier. Filters to lines that have actually
 * been received (`quantity_received > 0`) so drafts don't pollute
 * the "we bought this from X before" list.
 *
 * The supplier is expanded via `purchase.supplier`; if the
 * `stock_purchases.supplier` row no longer exists in `suppliers`,
 * the line is silently skipped (we can't show a name for it).
 */
export async function getStockItemSupplierHistory(
  pb: StockQuickDraftPb,
  stockItemId: string,
): Promise<SupplierHistoryEntry[]> {
  const rows = await pb.collection(STOCK_PURCHASE_ITEMS).getFullList({
    filter: `stock_item = "${stockItemId}" && quantity_received > 0`,
  });

  const purchaseIds = Array.from(
    new Set(
      rows
        .map((r) => (r.purchase ? String(r.purchase) : null))
        .filter((id): id is string => id !== null),
    ),
  );
  if (purchaseIds.length === 0) return [];

  // Fetch purchases in one call (with supplier expanded). We use
  // a single getList rather than N getOnes — cheaper on PB.
  const purchasesResult = await pb
    .collection(STOCK_PURCHASES)
    .getList(1, purchaseIds.length, {
      filter: purchaseIds.map((id) => `id = "${id}"`).join(" || "),
      expand: "supplier",
    });

  const purchaseById = new Map<string, Record<string, unknown>>();
  for (const p of purchasesResult.items) purchaseById.set(String(p.id), p);

  type Agg = {
    supplierId: string;
    supplierName: string;
    lastCost: number;
    lastDate: string;
    timesBought: number;
  };
  const bySupplier = new Map<string, Agg>();

  for (const row of rows) {
    const purchaseId = row.purchase ? String(row.purchase) : null;
    if (!purchaseId) continue;
    const purchase = purchaseById.get(purchaseId);
    if (!purchase) continue;

    const expand = purchase.expand as { supplier?: { id: string; suppliername: string } } | undefined;
    const supplier = expand?.supplier;
    if (!supplier) continue;

    const supplierId = String(supplier.id);
    const cost = typeof row.cost_price === "number" ? row.cost_price : 0;
    const created = String(purchase.created ?? "");

    const existing = bySupplier.get(supplierId);
    if (!existing) {
      bySupplier.set(supplierId, {
        supplierId,
        supplierName: String(supplier.suppliername ?? ""),
        lastCost: cost,
        lastDate: created,
        timesBought: 1,
      });
    } else {
      existing.timesBought += 1;
      if (created > existing.lastDate) {
        existing.lastDate = created;
        existing.lastCost = cost;
      }
    }
  }

  return Array.from(bySupplier.values()).sort((a, b) =>
    a.lastDate < b.lastDate ? 1 : a.lastDate > b.lastDate ? -1 : 0,
  );
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/lib/server/stock-quick-draft-service.test.ts Expected: PASS (4 + 4 = 8 tests).

  • Step 5: Commit
git add src/lib/server/stock-quick-draft-service.ts src/lib/server/stock-quick-draft-service.test.ts
git commit -m "feat(stock-quick-draft): getStockItemSupplierHistory"

Task 3: Service — addQuickDraftLine (the heart of the feature)

Files:

  • Modify: src/lib/server/stock-quick-draft-service.ts (append)
  • Modify: src/lib/server/stock-quick-draft-service.test.ts (extend)

This task implements findOrCreateOpenDraft and addQuickDraftLine. It also handles the inline newStockItem payload (creates a stock_items row first).

  • Step 1: Extend the failing test

Append to src/lib/server/stock-quick-draft-service.test.ts:

import {
  addQuickDraftLine,
  getStockItemSupplierHistory,
  searchStockItemsForQuickDraft,
} from "./stock-quick-draft-service";

describe("addQuickDraftLine", () => {
  it("creates a new draft PO when none exists for the supplier", async () => {
    const supplierId = (
      await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
    ).id;
    await pbMock.collection("stock_items").create({
      id: "item-1",
      sku: "STK-CD-115",
      name: "115mm Cutting Disc",
    });

    const result = await addQuickDraftLine(pbMock as never, {
      supplierId,
      stockItemId: "item-1",
      quantity: 50,
      costPrice: 18.5,
    });

    expect(result.ok).toBe(true);
    if (!result.ok) return;
    expect(result.createdDraft).toBe(true);
    expect(result.purchaseNumber).toMatch(/^PO\d{8}\d{2}STK$/);

    const purchases = await pbMock.collection("stock_purchases").getFullList();
    expect(purchases).toHaveLength(1);
    expect(purchases[0].supplier).toBe(supplierId);
    expect(purchases[0].status).toBe("draft");

    const lines = await pbMock.collection("stock_purchase_items").getFullList();
    expect(lines).toHaveLength(1);
    expect(lines[0].stock_item).toBe("item-1");
    expect(lines[0].quantity).toBe(50);
    expect(lines[0].cost_price).toBe(18.5);
    expect(lines[0].description).toBe("115mm Cutting Disc");
  });

  it("appends to an existing open draft for the supplier", async () => {
    const supplierId = (
      await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
    ).id;
    await pbMock.collection("stock_items").create({
      id: "item-1",
      sku: "STK-CD-115",
      name: "115mm Cutting Disc",
    });
    await pbMock.collection("stock_items").create({
      id: "item-2",
      sku: "STK-WR-6",
      name: "Wire rope 6mm",
    });
    // Existing draft for this supplier.
    await pbMock.collection("stock_purchases").create({
      id: "purchase-existing",
      supplier: supplierId,
      status: "draft",
      purchase_number: "PO26070101STK",
    });

    const result = await addQuickDraftLine(pbMock as never, {
      supplierId,
      stockItemId: "item-2",
      quantity: 20,
      costPrice: 145,
    });

    expect(result.ok).toBe(true);
    if (!result.ok) return;
    expect(result.createdDraft).toBe(false);
    expect(result.purchaseId).toBe("purchase-existing");

    const lines = await pbMock.collection("stock_purchase_items").getFullList();
    expect(lines).toHaveLength(1);
    expect(lines[0].stock_item).toBe("item-2");
  });

  it("ignores drafts that are not in 'draft' status when finding an open one", async () => {
    const supplierId = (
      await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
    ).id;
    await pbMock.collection("stock_items").create({
      id: "item-1",
      sku: "STK-CD-115",
      name: "115mm Cutting Disc",
    });
    // Sent/received drafts must NOT be appended to — should create a new one.
    await pbMock.collection("stock_purchases").create({
      id: "purchase-sent",
      supplier: supplierId,
      status: "sent",
      purchase_number: "PO26070101STK",
    });

    const result = await addQuickDraftLine(pbMock as never, {
      supplierId,
      stockItemId: "item-1",
      quantity: 50,
      costPrice: 18.5,
    });

    expect(result.ok).toBe(true);
    if (!result.ok) return;
    expect(result.createdDraft).toBe(true);
    expect(result.purchaseId).not.toBe("purchase-sent");

    const purchases = await pbMock.collection("stock_purchases").getFullList();
    expect(purchases).toHaveLength(2);
  });

  it("creates a new stock_item when newStockItem is supplied, then adds the line", async () => {
    const supplierId = (
      await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
    ).id;

    const result = await addQuickDraftLine(pbMock as never, {
      supplierId,
      quantity: 10,
      costPrice: 5.5,
      newStockItem: { name: "Brand-new Widget", sku: "STK-BNW-1" },
    });

    expect(result.ok).toBe(true);
    if (!result.ok) return;

    const items = await pbMock.collection("stock_items").getFullList();
    expect(items).toHaveLength(1);
    expect(items[0].name).toBe("Brand-new Widget");
    expect(items[0].sku).toBe("STK-BNW-1");
    expect(items[0].is_active).toBe(true);
    expect(items[0].current_qty).toBe(0);
    expect(items[0].supplier).toBe(supplierId);

    const lines = await pbMock.collection("stock_purchase_items").getFullList();
    expect(lines).toHaveLength(1);
    expect(lines[0].stock_item).toBe(items[0].id);
    expect(lines[0].description).toBe("Brand-new Widget");
  });

  it("rejects non-positive quantities", async () => {
    const supplierId = (
      await pbMock.collection("suppliers").create({ suppliername: "S" })
    ).id;
    const result = await addQuickDraftLine(pbMock as never, {
      supplierId,
      stockItemId: "item-1",
      quantity: 0,
      costPrice: 10,
    });
    expect(result.ok).toBe(false);
    if (result.ok) return;
    expect(result.reason).toBe("validation");
  });

  it("rejects negative cost", async () => {
    const supplierId = (
      await pbMock.collection("suppliers").create({ suppliername: "S" })
    ).id;
    const result = await addQuickDraftLine(pbMock as never, {
      supplierId,
      stockItemId: "item-1",
      quantity: 1,
      costPrice: -5,
    });
    expect(result.ok).toBe(false);
    if (result.ok) return;
    expect(result.reason).toBe("validation");
  });

  it("rejects when both stockItemId and newStockItem are missing", async () => {
    const supplierId = (
      await pbMock.collection("suppliers").create({ suppliername: "S" })
    ).id;
    const result = await addQuickDraftLine(pbMock as never, {
      supplierId,
      quantity: 1,
      costPrice: 1,
    });
    expect(result.ok).toBe(false);
    if (result.ok) return;
    expect(result.reason).toBe("validation");
  });

  it("returns supplier_not_found when the supplier id doesn't exist", async () => {
    const result = await addQuickDraftLine(pbMock as never, {
      supplierId: "does-not-exist",
      stockItemId: "item-1",
      quantity: 1,
      costPrice: 1,
    });
    expect(result.ok).toBe(false);
    if (result.ok) return;
    expect(result.reason).toBe("supplier_not_found");
  });

  it("returns stock_item_not_found when the stock_item id doesn't exist", async () => {
    const supplierId = (
      await pbMock.collection("suppliers").create({ suppliername: "S" })
    ).id;
    const result = await addQuickDraftLine(pbMock as never, {
      supplierId,
      stockItemId: "does-not-exist",
      quantity: 1,
      costPrice: 1,
    });
    expect(result.ok).toBe(false);
    if (result.ok) return;
    expect(result.reason).toBe("stock_item_not_found");
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/lib/server/stock-quick-draft-service.test.ts Expected: FAIL — addQuickDraftLine is not exported.

  • Step 3: Append the implementation

Append to src/lib/server/stock-quick-draft-service.ts:

/* ============================================================================
 * findOrCreateOpenDraft
 * ========================================================================== */

interface FindOrCreateOpenDraftResult {
  ok: true;
  purchaseId: string;
  purchaseNumber: string;
  createdDraft: boolean;
}

async function findOrCreateOpenDraft(
  pb: StockQuickDraftPb,
  supplierId: string,
): Promise<FindOrCreateOpenDraftResult> {
  // Look for an open draft for this supplier. The list is sorted
  // by `created` desc so the most recent draft wins if there are
  // multiple (the buyer may have accumulated them across modal
  // sessions, but the design guarantees only one per supplier
  // exists at a time in the happy path).
  const existing = await pb.collection(STOCK_PURCHASES).getList(1, 1, {
    filter: `supplier = "${supplierId}" && status = "draft"`,
    sort: "-created",
  });

  if (existing.items.length > 0) {
    const row = existing.items[0];
    return {
      ok: true,
      purchaseId: String(row.id),
      purchaseNumber: String(row.purchase_number ?? ""),
      createdDraft: false,
    };
  }

  // No open draft — generate a number and create one.
  const purchaseNumber = await generateStockPurchaseNumber(
    pb as unknown as Parameters<typeof generateStockPurchaseNumber>[0],
  );

  const created = await pb.collection(STOCK_PURCHASES).create({
    purchase_number: purchaseNumber,
    supplier: supplierId,
    status: "draft",
    notes: "",
  });

  return {
    ok: true,
    purchaseId: String(created.id),
    purchaseNumber,
    createdDraft: true,
  };
}

/* ============================================================================
 * addQuickDraftLine
 * ========================================================================== */

/**
 * Adds a single line to a draft stock_purchase for the given supplier.
 *
 * Behaviour:
 *   - If the caller supplied `newStockItem`, create that `stock_items`
 *     row first (current_qty: 0, is_active: true, supplier: input.supplierId).
 *   - Find or create an open draft for the supplier (status=draft).
 *   - Append a `stock_purchase_items` row to that draft.
 *
 * Returns `{ ok: true, ... }` with the purchase + line ids on success,
 * or `{ ok: false, reason, message }` on validation / lookup / write
 * failure. The modal maps `reason` to a UI error category.
 */
export async function addQuickDraftLine(
  pb: StockQuickDraftPb,
  input: AddQuickDraftLineInput,
): Promise<AddQuickDraftLineResult> {
  // ── Validate input ─────────────────────────────────────────────
  if (typeof input.supplierId !== "string" || input.supplierId.length === 0) {
    return { ok: false, reason: "validation", message: "supplierId is required" };
  }
  if (typeof input.quantity !== "number" || input.quantity <= 0) {
    return { ok: false, reason: "validation", message: "quantity must be > 0" };
  }
  if (typeof input.costPrice !== "number" || input.costPrice < 0) {
    return { ok: false, reason: "validation", message: "costPrice must be >= 0" };
  }
  if (!input.stockItemId && !input.newStockItem) {
    return {
      ok: false,
      reason: "validation",
      message: "either stockItemId or newStockItem is required",
    };
  }

  // ── Confirm supplier exists ────────────────────────────────────
  try {
    await pb.collection("suppliers").getOne(input.supplierId);
  } catch (err) {
    return {
      ok: false,
      reason: "supplier_not_found",
      message: err instanceof Error ? err.message : "supplier lookup failed",
    };
  }

  // ── Resolve the stock_item id (create if inline) ───────────────
  let stockItemId = input.stockItemId;
  if (!stockItemId && input.newStockItem) {
    if (!input.newStockItem.name || !input.newStockItem.sku) {
      return {
        ok: false,
        reason: "validation",
        message: "newStockItem.name and newStockItem.sku are required",
      };
    }
    try {
      const created = await pb.collection(STOCK_ITEMS).create({
        name: input.newStockItem.name,
        sku: input.newStockItem.sku,
        description: input.newStockItem.description ?? "",
        unit: input.newStockItem.unit ?? "",
        current_qty: 0,
        is_active: true,
        supplier: input.supplierId,
      });
      stockItemId = String(created.id);
    } catch (err) {
      return {
        ok: false,
        reason: "create_failed",
        message: err instanceof Error ? err.message : "stock_item create failed",
      };
    }
  } else if (stockItemId) {
    // ── Confirm existing stock_item exists ────────────────────────
    try {
      await pb.collection(STOCK_ITEMS).getOne(stockItemId);
    } catch (err) {
      return {
        ok: false,
        reason: "stock_item_not_found",
        message: err instanceof Error ? err.message : "stock_item lookup failed",
      };
    }
  }

  // stockItemId is now guaranteed non-null.
  const finalStockItemId = stockItemId as string;

  // ── Find or create the open draft ──────────────────────────────
  const draft = await findOrCreateOpenDraft(pb, input.supplierId);
  if (!draft.ok) {
    return { ok: false, reason: "create_failed", message: "could not resolve draft" };
  }

  // ── Look up the item's name so we can denormalise onto the line ─
  const stockItem = await pb.collection(STOCK_ITEMS).getOne(finalStockItemId);
  const description = String(stockItem.name ?? "");

  // ── Append the line ────────────────────────────────────────────
  try {
    const line = await pb.collection(STOCK_PURCHASE_ITEMS).create({
      purchase: draft.purchaseId,
      stock_item: finalStockItemId,
      description,
      quantity: input.quantity,
      cost_price: input.costPrice,
    });
    return {
      ok: true,
      purchaseId: draft.purchaseId,
      purchaseNumber: draft.purchaseNumber,
      lineId: String(line.id),
      createdDraft: draft.createdDraft,
    };
  } catch (err) {
    return {
      ok: false,
      reason: "append_failed",
      message: err instanceof Error ? err.message : "line create failed",
    };
  }
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/lib/server/stock-quick-draft-service.test.ts Expected: PASS (8 + 9 = 17 tests). Some test mocks have edge cases — if any test fails because the mock pbMock doesn't fully implement the query paths the implementation uses, extend the mock in Step 1 and re-run.

  • Step 5: Commit
git add src/lib/server/stock-quick-draft-service.ts src/lib/server/stock-quick-draft-service.test.ts
git commit -m "feat(stock-quick-draft): addQuickDraftLine with find-or-create-open-draft"

Task 4: Service — removeQuickDraftLine

Files:

  • Modify: src/lib/server/stock-quick-draft-service.ts (append)

  • Modify: src/lib/server/stock-quick-draft-service.test.ts (extend)

  • Step 1: Extend the failing test

Append to src/lib/server/stock-quick-draft-service.test.ts:

import {
  addQuickDraftLine,
  getStockItemSupplierHistory,
  removeQuickDraftLine,
  searchStockItemsForQuickDraft,
} from "./stock-quick-draft-service";

describe("removeQuickDraftLine", () => {
  it("deletes the line and returns ok", async () => {
    const supplierId = (
      await pbMock.collection("suppliers").create({ suppliername: "S" })
    ).id;
    await pbMock.collection("stock_items").create({
      id: "item-1",
      sku: "STK-CD-115",
      name: "115mm Cutting Disc",
    });
    const purchase = await pbMock.collection("stock_purchases").create({
      supplier: supplierId,
      status: "draft",
      purchase_number: "PO26070101STK",
    });
    const line = await pbMock.collection("stock_purchase_items").create({
      purchase: purchase.id,
      stock_item: "item-1",
      description: "115mm Cutting Disc",
      quantity: 10,
    });

    const result = await removeQuickDraftLine(pbMock as never, line.id);

    expect(result).toEqual({ ok: true });
    const remaining = await pbMock.collection("stock_purchase_items").getFullList();
    expect(remaining).toEqual([]);
  });

  it("returns line_not_found when the line id doesn't exist", async () => {
    const result = await removeQuickDraftLine(pbMock as never, "does-not-exist");
    expect(result.ok).toBe(false);
    if (result.ok) return;
    expect(result.reason).toBe("line_not_found");
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/lib/server/stock-quick-draft-service.test.ts Expected: FAIL — removeQuickDraftLine is not exported.

  • Step 3: Append the implementation

Append to src/lib/server/stock-quick-draft-service.ts:

/* ============================================================================
 * removeQuickDraftLine
 * ========================================================================== */

/**
 * Deletes a `stock_purchase_items` row by id. Used by the modal's
 * cards-region "Remove" button so the buyer can clean up mistakes
 * without leaving the modal.
 *
 * Returns `{ ok: true }` on success, or `{ ok: false, reason }` on
 * failure. The modal surfaces the message as a toast.
 */
export async function removeQuickDraftLine(
  pb: StockQuickDraftPb,
  lineId: string,
): Promise<RemoveQuickDraftLineResult> {
  try {
    await pb.collection(STOCK_PURCHASE_ITEMS).delete(lineId);
    return { ok: true };
  } catch (err) {
    return {
      ok: false,
      reason: "line_not_found",
      message: err instanceof Error ? err.message : "line delete failed",
    };
  }
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/lib/server/stock-quick-draft-service.test.ts Expected: PASS (17 + 2 = 19 tests).

  • Step 5: Commit
git add src/lib/server/stock-quick-draft-service.ts src/lib/server/stock-quick-draft-service.test.ts
git commit -m "feat(stock-quick-draft): removeQuickDraftLine"

Task 5: API routes — search, supplier-history, add-line, DELETE line

Files:

  • Create: src/app/api/v1/stock-quick-draft/search/route.ts
  • Create: src/app/api/v1/stock-quick-draft/supplier-history/route.ts
  • Create: src/app/api/v1/stock-quick-draft/add-line/route.ts
  • Create: src/app/api/v1/stock-quick-draft/line/[lineId]/route.ts

These four route handlers are thin wrappers over the service. Each follows the runtime = "nodejs" + admin-PB pattern from /api/v1/orders/sprint/counts/route.ts. Errors map cleanly to HTTP status codes (400 / 404 / 409 / 500).

  • Step 1: Create src/app/api/v1/stock-quick-draft/search/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { searchStockItemsForQuickDraft } from "@/lib/server/stock-quick-draft-service";

/**
 * GET /api/v1/stock-quick-draft/search?q=&limit=
 *
 * Typeahead search over `stock_items` for the quick-draft modal.
 * Admin PB so the result isn't gated on the `listRule`.
 *
 * Query params:
 *   - q (required, min 2 chars)
 *   - limit (optional, default 10, max 25)
 *
 * Response: { ok: true, items: StockItemSearchResult[] }
 */
export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const q = (searchParams.get("q") ?? "").trim();
  const limitRaw = searchParams.get("limit");
  const limit = limitRaw ? Number(limitRaw) : undefined;

  if (q.length < 2) {
    return NextResponse.json(
      { ok: false, reason: "missing_query" },
      { status: 400 },
    );
  }

  try {
    const pb = await getAdminPocketBase();
    const items = await searchStockItemsForQuickDraft(pb as never, q, limit);
    return NextResponse.json({ ok: true, items });
  } catch (err) {
    return NextResponse.json(
      {
        ok: false,
        reason: "search_failed",
        message: err instanceof Error ? err.message : "unknown error",
      },
      { status: 500 },
    );
  }
}
  • Step 2: Create src/app/api/v1/stock-quick-draft/supplier-history/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { getStockItemSupplierHistory } from "@/lib/server/stock-quick-draft-service";

/**
 * GET /api/v1/stock-quick-draft/supplier-history?stockItemId=
 *
 * Returns the per-supplier purchase history for a stock_item
 * (suppliers where we've bought it from before, with last cost +
 * date). Admin PB so we can read across the stock_purchase_items →
 * stock_purchases → suppliers chain.
 *
 * Response: { ok: true, history: SupplierHistoryEntry[] }
 *   Returns empty array when no history.
 */
export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const stockItemId = searchParams.get("stockItemId") ?? "";

  if (!stockItemId) {
    return NextResponse.json(
      { ok: false, reason: "missing_stock_item_id" },
      { status: 400 },
    );
  }

  try {
    const pb = await getAdminPocketBase();
    const history = await getStockItemSupplierHistory(pb as never, stockItemId);
    return NextResponse.json({ ok: true, history });
  } catch (err) {
    return NextResponse.json(
      {
        ok: false,
        reason: "history_failed",
        message: err instanceof Error ? err.message : "unknown error",
      },
      { status: 500 },
    );
  }
}
  • Step 3: Create src/app/api/v1/stock-quick-draft/add-line/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { addQuickDraftLine } from "@/lib/server/stock-quick-draft-service";

/**
 * POST /api/v1/stock-quick-draft/add-line
 *
 * Adds a single line to a draft stock_purchase for the chosen
 * supplier. The service creates the draft PO if none exists for
 * the supplier. Supports inline `newStockItem` payload for the
 * "+ New SKU not in stock yet" path.
 *
 * Request body:
 *   {
 *     supplierId: string,
 *     stockItemId?: string,
 *     quantity: number (>0),
 *     costPrice: number (>=0),
 *     newStockItem?: { name, sku, description?, unit? }
 *   }
 *
 * Response (success): { ok: true, purchaseId, purchaseNumber, lineId, createdDraft }
 *
 * Error mapping (reason → status):
 *   "validation"        → 400
 *   "supplier_not_found" → 404
 *   "stock_item_not_found" → 404
 *   "create_failed"     → 500
 *   "append_failed"     → 500
 */
export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const STATUS_BY_REASON: Record<string, number> = {
  validation: 400,
  supplier_not_found: 404,
  stock_item_not_found: 404,
  create_failed: 500,
  append_failed: 500,
};

export async function POST(req: NextRequest) {
  let body: Record<string, unknown>;
  try {
    body = (await req.json()) as Record<string, unknown>;
  } catch {
    return NextResponse.json(
      { ok: false, reason: "validation", message: "invalid JSON body" },
      { status: 400 },
    );
  }

  const supplierId = typeof body.supplierId === "string" ? body.supplierId : "";
  const stockItemId =
    typeof body.stockItemId === "string" ? body.stockItemId : undefined;
  const quantity = typeof body.quantity === "number" ? body.quantity : NaN;
  const costPrice = typeof body.costPrice === "number" ? body.costPrice : NaN;
  const newStockItem =
    body.newStockItem && typeof body.newStockItem === "object"
      ? (body.newStockItem as Record<string, unknown>)
      : undefined;

  try {
    const pb = await getAdminPocketBase();
    const result = await addQuickDraftLine(pb as never, {
      supplierId,
      stockItemId,
      quantity,
      costPrice,
      newStockItem:
        newStockItem &&
        typeof newStockItem.name === "string" &&
        typeof newStockItem.sku === "string"
          ? {
              name: newStockItem.name,
              sku: newStockItem.sku,
              description:
                typeof newStockItem.description === "string"
                  ? newStockItem.description
                  : undefined,
              unit:
                typeof newStockItem.unit === "string"
                  ? newStockItem.unit
                  : undefined,
            }
          : undefined,
    });

    if (!result.ok) {
      const status = STATUS_BY_REASON[result.reason] ?? 500;
      return NextResponse.json(result, { status });
    }
    return NextResponse.json(result);
  } catch (err) {
    return NextResponse.json(
      {
        ok: false,
        reason: "create_failed",
        message: err instanceof Error ? err.message : "unknown error",
      },
      { status: 500 },
    );
  }
}
  • Step 4: Create src/app/api/v1/stock-quick-draft/line/[lineId]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { removeQuickDraftLine } from "@/lib/server/stock-quick-draft-service";

/**
 * DELETE /api/v1/stock-quick-draft/line/[lineId]
 *
 * Removes a `stock_purchase_items` row the modal added. Lets the
 * buyer clean up mistakes without leaving the modal.
 *
 * Response (success): { ok: true }
 * Response (not found): 404 { ok: false, reason: "line_not_found", message }
 */
export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function DELETE(
  _req: NextRequest,
  ctx: { params: Promise<{ lineId: string }> },
) {
  const { lineId } = await ctx.params;
  if (!lineId) {
    return NextResponse.json(
      { ok: false, reason: "validation", message: "lineId is required" },
      { status: 400 },
    );
  }

  try {
    const pb = await getAdminPocketBase();
    const result = await removeQuickDraftLine(pb as never, lineId);
    if (!result.ok) {
      return NextResponse.json(result, { status: 404 });
    }
    return NextResponse.json(result);
  } catch (err) {
    return NextResponse.json(
      {
        ok: false,
        reason: "delete_failed",
        message: err instanceof Error ? err.message : "unknown error",
      },
      { status: 500 },
    );
  }
}
  • Step 5: Type-check and commit

Run: npx tsc --noEmit Expected: 0 errors.

git add src/app/api/v1/stock-quick-draft/
git commit -m "feat(stock-quick-draft): API routes (search, history, add-line, delete-line)"

Task 6: Modal component — skeleton + header region

Files:

  • Create: src/components/stock-orders/quick-draft-modal.tsx

  • Create: src/components/stock-orders/quick-draft-modal.test.tsx

  • Step 1: Write the failing test

Create src/components/stock-orders/quick-draft-modal.test.tsx:

import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QuickDraftModal } from "./quick-draft-modal";

// The modal uses ModalShell.Dialog which depends on @base-ui/react.
// The component test environment stubs it via a passthrough wrapper.
vi.mock("@/components/ui/modal-shell", () => ({
  ModalShell: {
    Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
      open ? <div data-testid="modal">{children}</div> : null,
  },
}));

beforeEach(() => {
  vi.restoreAllMocks();
});

describe("QuickDraftModal — header", () => {
  it("renders nothing when closed", () => {
    render(
      <QuickDraftModal
        open={false}
        onOpenChange={() => {}}
      />,
    );
    expect(screen.queryByTestId("modal")).toBeNull();
  });

  it("renders the Stock Order Reference input with a default month label", () => {
    render(
      <QuickDraftModal open onOpenChange={() => {}} />,
    );
    const input = screen.getByTestId("stock-order-reference");
    expect(input).toBeInTheDocument();
    // Value defaults to "Stock order entry — <MonthYear>" (e.g. "Stock order entry — Jul 2026").
    expect((input as HTMLInputElement).value).toMatch(/^Stock order entry — /);
  });

  it("lets the buyer rename the reference", async () => {
    const user = userEvent.setup();
    render(
      <QuickDraftModal open onOpenChange={() => {}} />,
    );
    const input = screen.getByTestId(
      "stock-order-reference",
    ) as HTMLInputElement;
    await user.clear(input);
    await user.type(input, "Jul 2026 run");
    expect(input.value).toBe("Jul 2026 run");
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx Expected: FAIL — ./quick-draft-modal not found.

  • Step 3: Write the skeleton + header

Create src/components/stock-orders/quick-draft-modal.tsx:

"use client";

import { useMemo } from "react";
import { Input } from "@/components/ui/input";
import { ModalShell } from "@/components/ui/modal-shell";

/**
 * Quick-Draft Modal — item-first stock-order entry on /stock-orders.
 *
 * See docs/superpowers/specs/2026-07-30-stock-quick-draft-design.md
 * for the full design. This file is the modal in its entirety; the
 * three regions (search, cards, footer) live in sub-components
 * further down so the test surface stays manageable.
 *
 * Data flow:
 *   - search & history hit /api/v1/stock-quick-draft/{search,supplier-history}
 *   - "Add & next" hits /api/v1/stock-quick-draft/add-line
 *   - cards region deletes via /api/v1/stock-quick-draft/line/[lineId]
 */

export interface QuickDraftModalProps {
  open: boolean;
  onOpenChange: (next: boolean) => void;
}

function formatMonthYear(date: Date): string {
  return date.toLocaleDateString("en-US", { month: "short", year: "numeric" });
}

export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) {
  const defaultReference = useMemo(
    () => `Stock order entry — ${formatMonthYear(new Date())}`,
    // intentionally only computed once per mount
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [],
  );

  return (
    <ModalShell.Dialog open={open} onOpenChange={onOpenChange} size="xl">
      <ModalShell.Header>
        <div className="space-y-1.5">
          <span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
            Stock Order Reference
          </span>
          <Input
            data-testid="stock-order-reference"
            defaultValue={defaultReference}
            className="h-9 text-base font-semibold"
          />
        </div>
      </ModalShell.Header>

      {/* Regions wired up in later tasks */}
      <ModalShell.Body>
        <div data-testid="region-search" hidden />
        <div data-testid="region-cards" hidden />
      </ModalShell.Body>

      <ModalShell.Footer>
        <div data-testid="region-footer" hidden />
      </ModalShell.Footer>
    </ModalShell.Dialog>
  );
}
  • Step 4: Inspect ModalShell exports

Run: grep -n "^export" src/components/ui/modal-shell.tsx | head -20 Expected: a list that includes Header, Body, Footer. If those sub-components are not exposed (this codebase sometimes uses ad-hoc div headers), adjust the modal to use the existing ModalShell.Dialog header / children / footer props instead. Match the existing send-modal pattern (src/app/(app)/stock-orders/[id]/page.tsx:638-764).

  • Step 5: Run test to verify it passes

Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx Expected: PASS (3 tests). If ModalShell does not export Header/Body/Footer, refactor the component to use the prop-based pattern (see step 4) and re-run.

  • Step 6: Commit
git add src/components/stock-orders/quick-draft-modal.tsx src/components/stock-orders/quick-draft-modal.test.tsx
git commit -m "feat(stock-quick-draft): modal skeleton + Stock Order Reference header"

Task 7: Modal — search + supplier-history regions

Files:

  • Modify: src/components/stock-orders/quick-draft-modal.tsx

  • Modify: src/components/stock-orders/quick-draft-modal.test.tsx

  • Step 1: Extend the failing test

Append to src/components/stock-orders/quick-draft-modal.test.tsx:

describe("QuickDraftModal — search", () => {
  beforeEach(() => {
    global.fetch = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({
        ok: true,
        items: [
          {
            id: "item-1",
            sku: "STK-CD-115",
            name: "115mm Cutting Disc",
            description: "For angle grinder",
            unit: "ea",
          },
        ],
      }),
    });
  });

  it("debounces search and calls the search endpoint", async () => {
    const user = userEvent.setup();
    render(<QuickDraftModal open onOpenChange={() => {}} />);
    const input = screen.getByTestId("quick-draft-search");
    await user.type(input, "cutting");
    // Wait for debounce.
    await new Promise((r) => setTimeout(r, 250));
    expect(global.fetch).toHaveBeenCalledWith(
      expect.stringContaining("/api/v1/stock-quick-draft/search"),
      expect.objectContaining({}),
    );
  });

  it("renders a search result", async () => {
    const user = userEvent.setup();
    render(<QuickDraftModal open onOpenChange={() => {}} />);
    await user.type(screen.getByTestId("quick-draft-search"), "cutting");
    await new Promise((r) => setTimeout(r, 250));
    expect(await screen.findByText(/115mm Cutting Disc/)).toBeInTheDocument();
  });

  it("fetches supplier history when a result is clicked", async () => {
    const user = userEvent.setup();
    global.fetch = vi
      .fn()
      .mockImplementationOnce(async () => ({
        ok: true,
        json: async () => ({
          ok: true,
          items: [
            { id: "item-1", sku: "STK-CD-115", name: "115mm Cutting Disc" },
          ],
        }),
      }))
      .mockImplementationOnce(async () => ({
        ok: true,
        json: async () => ({
          ok: true,
          history: [
            {
              supplierId: "sup-1",
              supplierName: "Steel & Pipe Co",
              lastCost: 18.5,
              lastDate: "2026-07-14T08:00:00.000Z",
              timesBought: 2,
            },
          ],
        }),
      }));

    render(<QuickDraftModal open onOpenChange={() => {}} />);
    await user.type(screen.getByTestId("quick-draft-search"), "cutting");
    await new Promise((r) => setTimeout(r, 250));
    await user.click(await screen.findByText(/115mm Cutting Disc/));

    expect(global.fetch).toHaveBeenCalledTimes(2);
    const secondUrl = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[1][0];
    expect(secondUrl).toContain("/supplier-history");
    expect(await screen.findByText(/Steel & Pipe Co/)).toBeInTheDocument();
    expect(await screen.findByText(/R 18\.50/)).toBeInTheDocument();
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx Expected: FAIL — no quick-draft-search element.

  • Step 3: Add the search + history region

Replace the ModalShell.Body content in quick-draft-modal.tsx with the search + history region. Keep the existing header and footer regions untouched. The component becomes:

"use client";

import { useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { ModalShell } from "@/components/ui/modal-shell";
import { toast } from "sonner";
import type {
  StockItemSearchResult,
  SupplierHistoryEntry,
} from "@/lib/server/stock-quick-draft-service";

export interface QuickDraftModalProps {
  open: boolean;
  onOpenChange: (next: boolean) => void;
}

function formatMonthYear(date: Date): string {
  return date.toLocaleDateString("en-US", { month: "short", year: "numeric" });
}

export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) {
  const defaultReference = useMemo(
    () => `Stock order entry — ${formatMonthYear(new Date())}`,
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [],
  );

  // Search state.
  const [searchQuery, setSearchQuery] = useState("");
  const [searchResults, setSearchResults] = useState<StockItemSearchResult[]>(
    [],
  );
  const [selectedItem, setSelectedItem] = useState<StockItemSearchResult | null>(
    null,
  );
  const [history, setHistory] = useState<SupplierHistoryEntry[]>([]);
  const [historyLoading, setHistoryLoading] = useState(false);

  // Debounced search.
  useEffect(() => {
    if (searchQuery.trim().length < 2) {
      setSearchResults([]);
      return;
    }
    const handle = setTimeout(async () => {
      try {
        const res = await fetch(
          `/api/v1/stock-quick-draft/search?q=${encodeURIComponent(
            searchQuery.trim(),
          )}&limit=10`,
        );
        const data = (await res.json()) as { ok: boolean; items?: StockItemSearchResult[] };
        if (data.ok && data.items) setSearchResults(data.items);
      } catch (err) {
        toast.error(err instanceof Error ? err.message : "Search failed");
      }
    }, 200);
    return () => clearTimeout(handle);
  }, [searchQuery]);

  const onPickItem = async (item: StockItemSearchResult) => {
    setSelectedItem(item);
    setHistoryLoading(true);
    setHistory([]);
    try {
      const res = await fetch(
        `/api/v1/stock-quick-draft/supplier-history?stockItemId=${encodeURIComponent(item.id)}`,
      );
      const data = (await res.json()) as { ok: boolean; history?: SupplierHistoryEntry[] };
      if (data.ok && data.history) setHistory(data.history);
    } catch (err) {
      toast.error(err instanceof Error ? err.message : "History fetch failed");
    } finally {
      setHistoryLoading(false);
    }
  };

  return (
    <ModalShell.Dialog open={open} onOpenChange={onOpenChange} size="xl">
      <ModalShell.Header>
        <div className="space-y-1.5">
          <span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
            Stock Order Reference
          </span>
          <Input
            data-testid="stock-order-reference"
            defaultValue={defaultReference}
            className="h-9 text-base font-semibold"
          />
        </div>
      </ModalShell.Header>

      <ModalShell.Body>
        {/* Search region */}
        <div className="space-y-3">
          <Input
            data-testid="quick-draft-search"
            placeholder="Search stock item (e.g. '115 cutting disc')"
            value={searchQuery}
            onChange={(e) => setSearchQuery(e.target.value)}
          />
          {searchResults.length > 0 && (
            <div className="rounded-md border divide-y">
              {searchResults.map((item) => (
                <button
                  key={item.id}
                  type="button"
                  className="w-full px-3 py-2 text-left text-sm hover:bg-muted/40"
                  onClick={() => onPickItem(item)}
                >
                  <div className="font-medium">{item.name}</div>
                  <div className="text-xs text-muted-foreground">
                    SKU: {item.sku}
                  </div>
                </button>
              ))}
            </div>
          )}

          {selectedItem && (
            <div className="rounded-md border bg-muted/20 p-3 space-y-3">
              <div className="text-sm font-medium">{selectedItem.name}</div>
              {historyLoading && (
                <div className="text-xs text-muted-foreground">
                  Loading supplier history…
                </div>
              )}
              {!historyLoading && history.length === 0 && (
                <div className="text-xs text-muted-foreground">
                  No prior history for this item.
                </div>
              )}
              {!historyLoading && history.length > 0 && (
                <div className="text-xs space-y-1">
                  {history.map((entry) => (
                    <div
                      key={entry.supplierId}
                      className="flex justify-between"
                    >
                      <span>{entry.supplierName}</span>
                      <span className="text-muted-foreground">
                        last paid: <strong>R {entry.lastCost.toFixed(2)}</strong>
                      </span>
                    </div>
                  ))}
                </div>
              )}
            </div>
          )}
        </div>

        <div data-testid="region-cards" hidden />
      </ModalShell.Body>

      <ModalShell.Footer>
        <div data-testid="region-footer" hidden />
      </ModalShell.Footer>
    </ModalShell.Dialog>
  );
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx Expected: PASS (3 + 3 = 6 tests). If ModalShell doesn't export Header/Body/Footer, refactor to use the header / footer props on ModalShell.Dialog (see the existing send-modal pattern at src/app/(app)/stock-orders/[id]/page.tsx:638-764).

  • Step 5: Type-check

Run: npx tsc --noEmit Expected: 0 errors. If StockItemSearchResult / SupplierHistoryEntry aren't exported from the service, re-export them (see Task 1 step 3 — they already are).

  • Step 6: Commit
git add src/components/stock-orders/quick-draft-modal.tsx src/components/stock-orders/quick-draft-modal.test.tsx
git commit -m "feat(stock-quick-draft): modal search + supplier-history regions"

Task 8: Modal — qty/cost inputs + Add & next + new-SKU toggle

Files:

  • Modify: src/components/stock-orders/quick-draft-modal.tsx

  • Modify: src/components/stock-orders/quick-draft-modal.test.tsx

  • Step 1: Extend the failing test

Append to src/components/stock-orders/quick-draft-modal.test.tsx:

describe("QuickDraftModal — add line", () => {
  it("disables Add & next until supplier, qty, and cost are all valid", async () => {
    const user = userEvent.setup();
    render(<QuickDraftModal open onOpenChange={() => {}} />);
    // No item picked, no supplier picked → button is disabled.
    const btn = screen.getByTestId("quick-draft-add");
    expect(btn).toBeDisabled();
  });

  it("calls the add-line endpoint and shows a success toast", async () => {
    const user = userEvent.setup();
    global.fetch = vi
      .fn()
      .mockImplementationOnce(async () => ({
        ok: true,
        json: async () => ({
          ok: true,
          items: [{ id: "item-1", sku: "STK-CD-115", name: "115mm Cutting Disc" }],
        }),
      }))
      .mockImplementationOnce(async () => ({
        ok: true,
        json: async () => ({
          ok: true,
          history: [
            {
              supplierId: "sup-1",
              supplierName: "Steel & Pipe Co",
              lastCost: 18.5,
              lastDate: "2026-07-14T08:00:00.000Z",
              timesBought: 2,
            },
          ],
        }),
      }))
      .mockImplementationOnce(async () => ({
        ok: true,
        json: async () => ({
          ok: true,
          purchaseId: "purchase-1",
          purchaseNumber: "PO26073001STK",
          lineId: "line-1",
          createdDraft: true,
        }),
      }));

    render(<QuickDraftModal open onOpenChange={() => {}} />);

    // Type a search query and pick the result.
    await user.type(screen.getByTestId("quick-draft-search"), "cutting");
    await new Promise((r) => setTimeout(r, 250));
    await user.click(await screen.findByText(/115mm Cutting Disc/));

    // Wait for history, then click the supplier.
    await user.click(await screen.findByText(/Steel & Pipe Co/));

    // Fill qty + cost.
    await user.clear(screen.getByTestId("quick-draft-qty"));
    await user.type(screen.getByTestId("quick-draft-qty"), "50");
    await user.clear(screen.getByTestId("quick-draft-cost"));
    await user.type(screen.getByTestId("quick-draft-cost"), "18.50");

    // Submit.
    await user.click(screen.getByTestId("quick-draft-add"));

    // The third fetch call is add-line.
    await new Promise((r) => setTimeout(r, 50));
    const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
    const addCall = calls.find((c) =>
      String(c[0]).includes("/add-line"),
    );
    expect(addCall).toBeTruthy();
    const body = JSON.parse(addCall[1].body);
    expect(body).toMatchObject({
      supplierId: "sup-1",
      stockItemId: "item-1",
      quantity: 50,
      costPrice: 18.5,
    });
  });

  it("flips to the new-SKU form when the toggle is clicked", async () => {
    const user = userEvent.setup();
    render(<QuickDraftModal open onOpenChange={() => {}} />);
    await user.click(screen.getByTestId("quick-draft-new-sku-toggle"));
    expect(screen.getByTestId("quick-draft-new-sku-name")).toBeInTheDocument();
    expect(screen.getByTestId("quick-draft-new-sku-sku")).toBeInTheDocument();
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx Expected: FAIL — no quick-draft-add button.

  • Step 3: Wire up qty/cost inputs + add-line

Replace the inner content of the selectedItem block (the <div className="rounded-md border bg-muted/20 p-3 space-y-3">…) in quick-draft-modal.tsx with the following expanded version. Also add the new state variables and submit handler at the top of the component (above return):

Add to the state declarations:

  // Pick state (per modal open).
  const [pickedSupplierId, setPickedSupplierId] = useState<string | null>(null);
  const [quantity, setQuantity] = useState<number>(1);
  const [costPrice, setCostPrice] = useState<number>(0);
  const [submitting, setSubmitting] = useState(false);

  // New-SKU toggle.
  const [isNewSku, setIsNewSku] = useState(false);
  const [newName, setNewName] = useState("");
  const [newSku, setNewSku] = useState("");

  const canSubmit =
    (selectedItem || (isNewSku && newName && newSku)) &&
    pickedSupplierId !== null &&
    quantity > 0 &&
    costPrice >= 0 &&
    !submitting;

  const onAdd = async () => {
    if (!canSubmit) return;
    setSubmitting(true);
    try {
      const res = await fetch("/api/v1/stock-quick-draft/add-line", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          supplierId: pickedSupplierId,
          stockItemId: selectedItem?.id,
          quantity,
          costPrice,
          newStockItem: isNewSku
            ? { name: newName, sku: newSku }
            : undefined,
        }),
      });
      const data = (await res.json()) as {
        ok: boolean;
        purchaseNumber?: string;
        reason?: string;
        message?: string;
      };
      if (!data.ok) {
        toast.error(data.message ?? data.reason ?? "Add failed");
        return;
      }
      toast.success(`Added to PO ${data.purchaseNumber}`);
      // Reset for the next item.
      setSearchQuery("");
      setSearchResults([]);
      setSelectedItem(null);
      setHistory([]);
      setPickedSupplierId(null);
      setQuantity(1);
      setCostPrice(0);
      setIsNewSku(false);
      setNewName("");
      setNewSku("");
      // The cards refetch will be wired in Task 9.
    } catch (err) {
      toast.error(err instanceof Error ? err.message : "Add failed");
    } finally {
      setSubmitting(false);
    }
  };

Replace the existing <div className="rounded-md border bg-muted/20 p-3 space-y-3">… block in the JSX with:

          {selectedItem && (
            <div className="rounded-md border bg-muted/20 p-3 space-y-3">
              <div className="text-sm font-medium">
                {selectedItem.name}{" "}
                <span className="text-xs text-success">✓ picked</span>
              </div>

              {historyLoading && (
                <div className="text-xs text-muted-foreground">
                  Loading supplier history…
                </div>
              )}
              {!historyLoading && history.length === 0 && (
                <div className="text-xs text-muted-foreground">
                  No prior history for this item.
                </div>
              )}
              {!historyLoading && history.length > 0 && (
                <div className="text-xs space-y-1">
                  {history.map((entry) => {
                    const picked = pickedSupplierId === entry.supplierId;
                    return (
                      <button
                        key={entry.supplierId}
                        type="button"
                        onClick={() => setPickedSupplierId(entry.supplierId)}
                        className={`w-full flex justify-between rounded px-2 py-1 ${
                          picked ? "bg-primary/10" : "hover:bg-muted/40"
                        }`}
                      >
                        <span>{entry.supplierName}</span>
                        <span className="text-muted-foreground">
                          last paid: <strong>R {entry.lastCost.toFixed(2)}</strong>
                        </span>
                      </button>
                    );
                  })}
                </div>
              )}

              {/* No-history fallback: full supplier dropdown. Wired in Task 9. */}

              {pickedSupplierId && (
                <div className="flex gap-2 items-end">
                  <div className="flex-1">
                    <label className="text-[11px] uppercase tracking-wide text-muted-foreground">
                      Quantity
                    </label>
                    <Input
                      data-testid="quick-draft-qty"
                      type="number"
                      min={1}
                      value={quantity}
                      onChange={(e) =>
                        setQuantity(Math.max(0, Number(e.target.value) || 0))
                      }
                    />
                  </div>
                  <div className="flex-1">
                    <label className="text-[11px] uppercase tracking-wide text-muted-foreground">
                      Cost / unit (ZAR)
                    </label>
                    <Input
                      data-testid="quick-draft-cost"
                      type="number"
                      min={0}
                      value={costPrice}
                      onChange={(e) =>
                        setCostPrice(Math.max(0, Number(e.target.value) || 0))
                      }
                    />
                  </div>
                  <Button
                    data-testid="quick-draft-add"
                    onClick={onAdd}
                    disabled={!canSubmit}
                  >
                    {submitting ? "Adding…" : "Add & next →"}
                  </Button>
                </div>
              )}

              <button
                type="button"
                data-testid="quick-draft-new-sku-toggle"
                onClick={() => setIsNewSku((v) => !v)}
                className="text-xs text-muted-foreground underline hover:text-foreground"
              >
                {isNewSku ? "− Hide new SKU form" : "+ New SKU not in stock yet"}
              </button>

              {isNewSku && (
                <div className="grid grid-cols-2 gap-2">
                  <Input
                    data-testid="quick-draft-new-sku-name"
                    placeholder="Item name"
                    value={newName}
                    onChange={(e) => setNewName(e.target.value)}
                  />
                  <Input
                    data-testid="quick-draft-new-sku-sku"
                    placeholder="SKU"
                    value={newSku}
                    onChange={(e) => setNewSku(e.target.value)}
                  />
                </div>
              )}
            </div>
          )}

Add the Button import at the top:

import { Button } from "@/components/ui/button";
  • Step 4: Run test to verify it passes

Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx Expected: PASS (6 + 3 = 9 tests). If the "no-history fallback" is wired up as a placeholder <div> (Task 9 wires it), the test for "no-history" will pass as long as the disabled-state assertion still holds.

  • Step 5: Commit
git add src/components/stock-orders/quick-draft-modal.tsx src/components/stock-orders/quick-draft-modal.test.tsx
git commit -m "feat(stock-quick-draft): modal qty/cost inputs + Add & next + new-SKU toggle"

Files:

  • Modify: src/components/stock-orders/quick-draft-modal.tsx

  • Modify: src/components/stock-orders/quick-draft-modal.test.tsx

  • Step 1: Extend the failing test

Append to src/components/stock-orders/quick-draft-modal.test.tsx:

describe("QuickDraftModal — cards region", () => {
  it("fetches open drafts on open and renders one card per supplier", async () => {
    global.fetch = vi.fn().mockImplementation(async (url: string) => {
      if (url.includes("/stock-purchases")) {
        return {
          ok: true,
          json: async () => ({
            ok: true,
            items: [
              {
                id: "purchase-1",
                supplier: "sup-1",
                status: "draft",
                purchase_number: "PO26073001STK",
                expand: { supplier: { suppliername: "Steel & Pipe Co" } },
              },
              {
                id: "purchase-2",
                supplier: "sup-2",
                status: "draft",
                purchase_number: "PO26073002STK",
                expand: { supplier: { suppliername: "Fasteners Direct" } },
              },
            ],
          }),
        };
      }
      if (url.includes("/items")) {
        const id = url.split("/").pop();
        return {
          ok: true,
          json: async () => ({
            ok: true,
            items: [
              {
                id: `line-${id}`,
                purchase: id === "1" ? "purchase-1" : "purchase-2",
                description: "115mm Cutting Disc",
                quantity: 50,
                cost_price: 18.5,
              },
            ],
          }),
        };
      }
      return {
        ok: true,
        json: async () => ({ ok: true, items: [], history: [] }),
      };
    });

    render(<QuickDraftModal open onOpenChange={() => {}} />);

    expect(await screen.findByText(/Steel & Pipe Co/)).toBeInTheDocument();
    expect(await screen.findByText(/Fasteners Direct/)).toBeInTheDocument();
    expect(await screen.findByText(/115mm Cutting Disc/)).toBeInTheDocument();
  });

  it("calls the DELETE endpoint when Remove is clicked", async () => {
    global.fetch = vi.fn().mockImplementation(async (url: string, opts?: RequestInit) => {
      if (opts?.method === "DELETE") {
        return { ok: true, json: async () => ({ ok: true }) };
      }
      if (url.includes("/stock-purchases")) {
        return {
          ok: true,
          json: async () => ({
            ok: true,
            items: [
              {
                id: "purchase-1",
                supplier: "sup-1",
                status: "draft",
                purchase_number: "PO26073001STK",
                expand: { supplier: { suppliername: "Steel & Pipe Co" } },
              },
            ],
          }),
        };
      }
      if (url.includes("/items")) {
        return {
          ok: true,
          json: async () => ({
            ok: true,
            items: [
              {
                id: "line-1",
                purchase: "purchase-1",
                description: "115mm Cutting Disc",
                quantity: 50,
                cost_price: 18.5,
              },
            ],
          }),
        };
      }
      return { ok: true, json: async () => ({ ok: true, items: [], history: [] }) };
    });

    render(<QuickDraftModal open onOpenChange={() => {}} />);
    const removeBtn = await screen.findByTestId("quick-draft-remove-line-1");
    // fireEvent so we don't need user-event setup here.
    removeBtn.click();
    await new Promise((r) => setTimeout(r, 50));
    const deleteCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
      (c) => c[1]?.method === "DELETE",
    );
    expect(deleteCall).toBeTruthy();
    expect(String(deleteCall[0])).toMatch(/\/line\/line-1$/);
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx Expected: FAIL — cards region not implemented.

  • Step 3: Wire up the cards region + footer

Add the following imports at the top of quick-draft-modal.tsx:

import { useEffect, useMemo, useState, useCallback } from "react";
import { Loader2, Trash2 } from "lucide-react";

Add types and helper:

interface DraftLine {
  id: string;
  purchase: string;
  description: string;
  quantity: number;
  cost_price: number;
}

interface DraftCard {
  purchaseId: string;
  purchaseNumber: string;
  supplierId: string;
  supplierName: string;
  lines: DraftLine[];
}

Add state and fetch logic:

  const [cards, setCards] = useState<DraftCard[]>([]);
  const [cardsLoading, setCardsLoading] = useState(false);

  const refetchCards = useCallback(async () => {
    setCardsLoading(true);
    try {
      const listRes = await fetch(
        "/api/v1/stock-purchases?status=draft&perPage=50",
      );
      const listData = (await listRes.json()) as {
        ok: boolean;
        items?: Array<{
          id: string;
          purchase_number: string;
          supplier: string;
          expand?: { supplier?: { suppliername: string } };
        }>;
      };
      if (!listData.ok || !listData.items) {
        setCards([]);
        return;
      }
      const drafts = listData.items;
      const draftCards: DraftCard[] = [];
      for (const draft of drafts) {
        const itemsRes = await fetch(
          `/api/v1/stock-purchases/${draft.id}/items`,
        );
        const itemsData = (await itemsRes.json()) as {
          ok: boolean;
          items?: DraftLine[];
        };
        draftCards.push({
          purchaseId: draft.id,
          purchaseNumber: draft.purchase_number,
          supplierId: draft.supplier,
          supplierName: draft.expand?.supplier?.suppliername ?? "(supplier)",
          lines: itemsData.items ?? [],
        });
      }
      setCards(draftCards);
    } catch (err) {
      toast.error(err instanceof Error ? err.message : "Cards fetch failed");
    } finally {
      setCardsLoading(false);
    }
  }, []);

  // Refetch cards on modal open.
  useEffect(() => {
    if (open) void refetchCards();
  }, [open, refetchCards]);

  const onRemoveLine = async (lineId: string) => {
    try {
      const res = await fetch(
        `/api/v1/stock-quick-draft/line/${encodeURIComponent(lineId)}`,
        { method: "DELETE" },
      );
      const data = (await res.json()) as { ok: boolean; message?: string };
      if (!data.ok) {
        toast.error(data.message ?? "Remove failed");
        return;
      }
      toast.success("Line removed");
      void refetchCards();
    } catch (err) {
      toast.error(err instanceof Error ? err.message : "Remove failed");
    }
  };

Add the cards region to the JSX (replacing the placeholder <div data-testid="region-cards" hidden />):

        <div className="mt-4 space-y-2">
          <div className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
            Open drafts ({cards.length})
          </div>
          {cardsLoading && (
            <div className="flex items-center gap-2 text-xs text-muted-foreground">
              <Loader2 className="h-3 w-3 animate-spin" /> Loading drafts…
            </div>
          )}
          {!cardsLoading &&
            cards.map((card) => (
              <div key={card.purchaseId} className="rounded-md border">
                <div className="flex items-center justify-between bg-muted/30 px-3 py-2 text-sm font-medium">
                  <span>{card.supplierName}</span>
                  <span className="text-xs text-muted-foreground">
                    {card.purchaseNumber} · {card.lines.length} line
                    {card.lines.length === 1 ? "" : "s"}
                  </span>
                </div>
                <ul className="divide-y text-sm">
                  {card.lines.map((line) => (
                    <li
                      key={line.id}
                      className="flex items-center justify-between px-3 py-2"
                    >
                      <span>
                        {line.description} × {line.quantity}
                      </span>
                      <div className="flex items-center gap-2">
                        <span className="text-muted-foreground">
                          R {(line.cost_price ?? 0).toFixed(2)}
                        </span>
                        <button
                          type="button"
                          data-testid={`quick-draft-remove-${line.id}`}
                          onClick={() => onRemoveLine(line.id)}
                          aria-label="Remove line"
                          className="text-muted-foreground hover:text-destructive"
                        >
                          <Trash2 className="h-3.5 w-3.5" />
                        </button>
                      </div>
                    </li>
                  ))}
                </ul>
              </div>
            ))}
        </div>

Wire up the refetchCards() call after a successful add in onAdd — find // The cards refetch will be wired in Task 9. and replace with void refetchCards();.

Replace the placeholder footer <div data-testid="region-footer" hidden /> with:

        <div className="flex items-center justify-between">
          <div className="text-xs text-muted-foreground">
            {cards.reduce((n, c) => n + c.lines.length, 0)} lines ·{" "}
            {cards.length} suppliers
          </div>
          <div className="flex gap-2">
            <Button
              variant="outline"
              onClick={() => onOpenChange(false)}
              data-testid="quick-draft-save-close"
            >
              Save &amp; close
            </Button>
            <Button
              onClick={() => onOpenChange(false)}
              data-testid="quick-draft-finalize"
            >
              Finalize
            </Button>
          </div>
        </div>

Add the data-testid on the remove button to match the test: change data-testid={quick-draft-remove-${line.id}} to data-testid={quick-draft-remove-line-${line.id}} (the test expects quick-draft-remove-line-1).

  • Step 4: Run test to verify it passes

Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx Expected: PASS (9 + 2 = 11 tests).

  • Step 5: Type-check

Run: npx tsc --noEmit Expected: 0 errors.

  • Step 6: Commit
git add src/components/stock-orders/quick-draft-modal.tsx src/components/stock-orders/quick-draft-modal.test.tsx
git commit -m "feat(stock-quick-draft): modal cards region + remove + footer"

Task 10: No-history fallback supplier dropdown

Files:

  • Modify: src/components/stock-orders/quick-draft-modal.tsx

  • Modify: src/components/stock-orders/quick-draft-modal.test.tsx

  • Step 1: Extend the failing test

Append to src/components/stock-orders/quick-draft-modal.test.tsx:

describe("QuickDraftModal — no-history fallback", () => {
  it("shows the full supplier dropdown when there is no history", async () => {
    const user = userEvent.setup();
    global.fetch = vi
      .fn()
      .mockImplementationOnce(async () => ({
        ok: true,
        json: async () => ({
          ok: true,
          items: [{ id: "item-1", sku: "STK-X", name: "Brand new item" }],
        }),
      }))
      .mockImplementationOnce(async () => ({
        ok: true,
        json: async () => ({ ok: true, history: [] }),
      }))
      .mockImplementationOnce(async () => ({
        ok: true,
        json: async () => ({
          ok: true,
          items: [
            { id: "sup-1", suppliername: "Steel & Pipe Co" },
            { id: "sup-2", suppliername: "Fasteners Direct" },
          ],
        }),
      }));

    render(<QuickDraftModal open onOpenChange={() => {}} />);
    await user.type(screen.getByTestId("quick-draft-search"), "new");
    await new Promise((r) => setTimeout(r, 250));
    await user.click(await screen.findByText(/Brand new item/));

    // Fallback dropdown appears (data-testid is "quick-draft-supplier-fallback").
    const dropdown = await screen.findByTestId(
      "quick-draft-supplier-fallback",
    );
    expect(dropdown).toBeInTheDocument();
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx Expected: FAIL — no fallback dropdown.

  • Step 3: Add the fallback dropdown

In quick-draft-modal.tsx, find the {/* No-history fallback: full supplier dropdown. Wired in Task 9. */} placeholder and replace with:

              {!historyLoading && history.length === 0 && (
                <div className="space-y-2">
                  <div className="text-xs text-muted-foreground">
                    No prior history for this item — pick any supplier.
                  </div>
                  <SupplierSearch
                    data-testid="quick-draft-supplier-fallback"
                    value={pickedSupplierId ?? ""}
                    onValueChange={(id) => setPickedSupplierId(id)}
                    suppliers={suppliers}
                    placeholder="Pick a supplier…"
                    ariaLabel="Supplier"
                  />
                </div>
              )}

Add state for the suppliers list and a fetch-once effect:

  const [suppliers, setSuppliers] = useState<
    Array<{ id: string; suppliername: string }>
  >([]);

  // Fetch suppliers once on mount (used by the no-history fallback).
  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const res = await fetch("/api/v1/suppliers?perPage=200");
        const data = (await res.json()) as {
          ok: boolean;
          items?: Array<{ id: string; suppliername: string }>;
        };
        if (!cancelled && data.ok && data.items) setSuppliers(data.items);
      } catch {
        // silent — the no-history fallback simply won't appear if suppliers fail to load
      }
    })();
    return () => {
      cancelled = true;
    };
  }, []);

Add the import:

import { SupplierSearch } from "@/components/ui/supplier-search";

Note: if the project's SupplierSearch doesn't pass through data-testid, wrap it in a <div data-testid="quick-draft-supplier-fallback"> instead and the test will still pass. Adjust the JSX accordingly.

  • Step 4: Run test to verify it passes

Run: npx vitest run src/components/stock-orders/quick-draft-modal.test.tsx Expected: PASS (11 + 1 = 12 tests).

  • Step 5: Commit
git add src/components/stock-orders/quick-draft-modal.tsx src/components/stock-orders/quick-draft-modal.test.tsx
git commit -m "feat(stock-quick-draft): modal no-history supplier fallback"

Task 11: Page hook — button on /stock-orders

Files:

  • Modify: src/app/(app)/stock-orders/page.tsx

  • Step 1: Locate the existing header buttons

Open src/app/(app)/stock-orders/page.tsx and find the header buttons block (around line 154-168). Confirm the existing buttons render "Reorder from low stock" and "+ New stock order" with router.push("/stock-orders/reorder") and router.push("/stock-orders/new") respectively.

  • Step 2: Add the new button + modal state

Add the import at the top of the file:

import { QuickDraftModal } from "@/components/stock-orders/quick-draft-modal";

Find the existing two <Button> elements in the header. Insert a third button between them:

<Button
  variant="outline"
  onClick={() => setQuickDraftOpen(true)}
  data-testid="open-quick-draft"
>
  + New Stock Item
</Button>

Add the state declaration near the other useState calls in the component:

const [quickDraftOpen, setQuickDraftOpen] = useState(false);

Render the modal near the bottom of the component's JSX (just before the closing fragment / wrapper):

<QuickDraftModal open={quickDraftOpen} onOpenChange={setQuickDraftOpen} />
  • Step 3: Type-check

Run: npx tsc --noEmit Expected: 0 errors.

  • Step 4: Manual smoke check (no test required)

Run: npm run dev Navigate to http://localhost:3000/stock-orders. Confirm:

  1. The new "+ New Stock Item" button appears between "Reorder from low stock" and "+ New stock order".
  2. Clicking it opens the modal.
  3. The "Stock Order Reference" input is pre-filled with the current month label.
  4. Searching for "115" returns the seeded STK-CD-115 row.
  5. Picking it shows supplier history (seeded data may be empty in dev — fallback dropdown shows).
  6. Closing the modal preserves state on next open.

If any step fails, fix and re-test.

  • Step 5: Commit
git add src/app/(app)/stock-orders/page.tsx
git commit -m "feat(stock-orders): New Stock Item button + QuickDraftModal on /stock-orders"

Task 12: Final smoke + regression sweep

Files: none new

  • Step 1: Run the full test suite

Run: npx vitest run Expected: all green.

  • Step 2: Lint + type-check

Run: npm run lint && npx tsc --noEmit Expected: 0 errors. Fix any new lint warnings introduced in this branch.

  • Step 3: Manual smoke (full)

With dev server running, walk the spec's smoke checklist:

  1. Open /stock-orders → click "+ New Stock Item" → modal opens.
  2. Type "cutting" → 115mm Cutting Disc shows in results.
  3. Pick it → supplier history renders (or fallback dropdown if history is empty in dev).
  4. Add 3 items to Steel & Pipe Co, 2 to Fasteners Direct → cards region shows 2 cards with 3 + 2 lines.
  5. Close modal → navigate to /stock-orders → list shows 2 new draft POs.
  6. Reopen modal → cards region still shows the 2 drafts.
  7. Click Remove on one line → line disappears, cards refetch.
  8. Click into one draft on /stock-orders → existing detail page renders the line correctly.
  9. Click "Send to supplier" on that draft → existing send flow works unchanged.
  10. Type "zzz nothing" in the modal → "No stock items match" with the "+ New SKU" toggle highlighted.
  11. Click "+ New SKU" → fill name + SKU + supplier + qty + cost → Add & next → new line added, stock_items has the new row.
  • Step 4: Commit any final fixes

If any smoke step failed, fix and commit with a descriptive message. If everything passed, no commit.


Notes for the implementer

  • ModalShell API: this codebase sometimes uses prop-based header/footer on ModalShell.Dialog, sometimes sub-components. The first task that touches it verifies which is in use; subsequent tasks follow whichever pattern the skeleton established.
  • PB mock fidelity: the pbMock in the test scaffold supports getList (with simple = / != filter clauses), getFullList, getOne, create, update, delete. If the implementation needs more sophisticated queries (e.g. OR clauses, expand), extend the mock in the same file before adding the test.
  • Admin PB in the route: every new route uses await getAdminPocketBase() and casts it to the service's StockQuickDraftPb. The cast is safe — admin PB has the same method surface the service uses, just without auth context.
  • Field denormalisation: the modal service copies stock_item.name onto stock_purchase_items.description on create (matching the existing /stock-orders/new pattern at src/app/(app)/stock-orders/new/page.tsx:478-489).