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