14 tasks: 2 view-model helper tasks, 3 service tasks (listCollectedItems, updateOrderItemCost, confirmOrderItemsForInvoicing), 3 API routes, 4 component extractions (ConfirmInvoiceDialog, PhotoStrip, ItemsTable, PoCard), and 2 page-rewire tasks. TDD throughout. Vitest, not Jest. getAdminPocketBase + ServiceError for the new service; appendCostNote imported from the view-model. Co-authored-by: Orca <help@stably.ai>
70 KiB
Collection Confirm Revamp 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: Remove the "Adjust Quantity" modal, surface invoice photos in a per-PO strip, expose inline-editable supplier costs, and migrate the page to a thin service layer with three new /api/v1 routes.
Architecture: The page becomes a thin client orchestrator. A new lib/server/collection-confirm-service.ts owns all PB access. Three new routes (GET /api/v1/collection-confirm, POST /api/v1/order-items/[id]/cost, POST /api/v1/order-items/confirm) are thin wrappers. The view-model gains appendCostNote, dedupeAttachments, and a costOverride field on buildConfirmPatch. The page is composed from PoCard, PhotoStrip, ItemsTable, and the extracted ConfirmInvoiceDialog component. The confirm flow uses the PB SDK pb.createBatch() helper.
Tech Stack: Next.js (App Router, server components where possible), TypeScript, Vitest, PocketBase JS SDK, getAdminPocketBase from src/lib/server/pocketbase-admin, getServerIdentity from src/lib/dev-identity.
Spec: docs/superpowers/specs/2026-08-02-collection-confirm-revamp-design.md
Conventions for this plan
- Tests are Vitest. Use
vi.mockfor module mocking. Test files live next to the source as*.test.ts(x). - The PB client in
lib/server/is always the admin client fromgetAdminPocketBase(). The browser-sidegetPocketBase()is no longer used by this page. - The
notesfeed andorder_item_details.invoicewriters already exist in the view-model; the new service imports them rather than duplicating the JSON shape. - Service errors are typed via a small
ServiceErrorclass with astatusfield. Routes maperr.statusto the HTTP response code. - Commits use
git add <files>then a one-linegit commit -m "..."summary. The full body in the agent's plan is for the implementer; the actual commit message is short.
Task 1: Add appendCostNote and dedupeAttachments to the view-model
Files:
-
Modify:
src/app/(app)/collection-confirm/view-model.ts -
Modify:
src/app/(app)/collection-confirm/view-model.test.ts -
Step 1: Add failing test for
appendCostNote
Append the following block to src/app/(app)/collection-confirm/view-model.test.ts (at the end, before any final }):
import { appendCostNote, dedupeAttachments } from "./view-model";
describe("appendCostNote", () => {
it("writes a category=cost note with prev/new values and the actor", () => {
const result = JSON.parse(
appendCostNote("", { id: "u-1", name: "sj" }, 45, 50)
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
actorId: "u-1",
actorName: "sj",
category: "cost",
body: { prevCost: 45, newCost: 50 },
});
expect(result[0].ts).toEqual(expect.any(String));
});
it("preserves existing notes when appending", () => {
const existing = JSON.stringify([
{ ts: "2026-07-01T00:00:00Z", actorId: "u-1", actorName: "sj", category: "confirm", body: "Confirmed for invoicing — INV-1" },
]);
const result = JSON.parse(appendCostNote(existing, { id: "u-2", name: "ab" }, 10, 12));
expect(result).toHaveLength(2);
expect(result[0].category).toBe("confirm");
expect(result[1].category).toBe("cost");
});
it("returns the original JSON when prevCost === newCost (no-op)", () => {
const existing = JSON.stringify([
{ ts: "2026-07-01T00:00:00Z", actorId: "u-1", actorName: "sj", category: "note", body: "hello" },
]);
expect(appendCostNote(existing, { id: "u-1", name: "sj" }, 10, 10)).toBe(existing);
});
});
- Step 2: Run test to verify it fails
Run: pnpm vitest run src/app/(app)/collection-confirm/view-model.test.ts -t appendCostNote
Expected: FAIL with appendCostNote is not a function (or Cannot find module).
- Step 3: Implement
appendCostNotein the view-model
Open src/app/(app)/collection-confirm/view-model.ts. Add to the NoteCategory union:
export type NoteCategory = "collection" | "note" | "problem" | "quantity" | "confirm" | "cost";
Add a new export at the bottom of the file:
export function appendCostNote(
rawNotes: string | null | undefined,
actor: ConfirmActor,
prevCost: number,
newCost: number
): string {
if (prevCost === newCost) return rawNotes ?? "";
const existing = parseNotesFeed(rawNotes);
const entry: NoteEntry = {
id: `cost-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
ts: new Date().toISOString(),
actor,
category: "cost",
body: { prevCost, newCost },
};
return JSON.stringify([entry, ...existing]);
}
(Note: ConfirmActor, parseNotesFeed, and NoteEntry are already exported from this file. Date.now() and Math.random() are fine here — the view-model is browser code.)
- Step 4: Run test to verify it passes
Run: pnpm vitest run src/app/(app)/collection-confirm/view-model.test.ts -t appendCostNote
Expected: PASS (3 tests).
- Step 5: Add failing test for
dedupeAttachments
Append to the same test file:
describe("dedupeAttachments", () => {
it("returns one entry per distinct filename across rows", () => {
const rows = [
{ order_item_attachements: ["INV-001.pdf", "DN-7.pdf"] },
{ order_item_attachements: ["INV-002.pdf"] },
];
const out = dedupeAttachments(rows);
expect(out).toEqual([
{ filename: "INV-001.pdf", itemIds: [] },
{ filename: "DN-7.pdf", itemIds: [] },
{ filename: "INV-002.pdf", itemIds: [] },
]);
});
it("groups duplicate filenames with the list of source item ids", () => {
const rows = [
{ id: "a", order_item_attachements: ["INV-001.pdf"] },
{ id: "b", order_item_attachements: ["INV-001.pdf", "DN-7.pdf"] },
{ id: "c", order_item_attachements: ["DN-7.pdf"] },
];
const out = dedupeAttachments(rows);
expect(out).toEqual([
{ filename: "INV-001.pdf", itemIds: ["a", "b"] },
{ filename: "DN-7.pdf", itemIds: ["b", "c"] },
]);
});
it("returns an empty array when no rows have attachments", () => {
expect(dedupeAttachments([{ id: "a" }, { id: "b" }])).toEqual([]);
expect(dedupeAttachments([])).toEqual([]);
});
});
- Step 6: Run test to verify it fails
Run: pnpm vitest run src/app/(app)/collection-confirm/view-model.test.ts -t dedupeAttachments
Expected: FAIL with dedupeAttachments is not a function.
- Step 7: Implement
dedupeAttachments
Add to view-model.ts:
export type DedupedAttachment = { filename: string; itemIds: string[] };
export function dedupeAttachments(
rows: Array<{ id?: string; order_item_attachements?: string[] | null }>
): DedupedAttachment[] {
const byFilename = new Map<string, string[]>();
for (const row of rows) {
const list = row.order_item_attachements ?? [];
for (const filename of list) {
const bucket = byFilename.get(filename) ?? [];
if (row.id) bucket.push(row.id);
byFilename.set(filename, bucket);
}
}
return Array.from(byFilename.entries()).map(([filename, itemIds]) => ({ filename, itemIds }));
}
- Step 8: Run test to verify it passes
Run: pnpm vitest run src/app/(app)/collection-confirm/view-model.test.ts -t dedupeAttachments
Expected: PASS (3 tests).
- Step 9: Commit
git add src/app/(app)/collection-confirm/view-model.ts src/app/(app)/collection-confirm/view-model.test.ts
git commit -m "feat(collection-confirm): add appendCostNote and dedupeAttachments helpers"
Task 2: Extend buildConfirmPatch to accept a costOverride
Files:
-
Modify:
src/app/(app)/collection-confirm/view-model.ts -
Modify:
src/app/(app)/collection-confirm/view-model.test.ts -
Step 1: Add failing tests for the
costOverridefield
Append to view-model.test.ts:
import { buildConfirmPatch } from "./view-model";
describe("buildConfirmPatch with costOverride", () => {
const baseRow = {
id: "row-1",
item_status: "collected",
notes: null,
order_item_details: null,
cost_price: 45,
} as const;
const actor = { id: "u-1", name: "sj" };
const invoice = { date: "2026-08-01", number: "INV-1" };
it("writes cost_price when the override is provided and different", () => {
const patch = buildConfirmPatch(baseRow, { actor, invoice, costOverride: 50 });
expect(patch.cost_price).toBe(50);
expect(patch.item_status).toBe("ready_invoice");
});
it("omits cost_price when no override is provided", () => {
const patch = buildConfirmPatch(baseRow, { actor, invoice });
expect(patch).not.toHaveProperty("cost_price");
});
it("omits cost_price when the override equals the current value", () => {
const patch = buildConfirmPatch(baseRow, { actor, invoice, costOverride: 45 });
expect(patch).not.toHaveProperty("cost_price");
});
});
- Step 2: Run test to verify it fails
Run: pnpm vitest run src/app/(app)/collection-confirm/view-model.test.ts -t "buildConfirmPatch with costOverride"
Expected: FAIL (existing buildConfirmPatch does not accept costOverride).
- Step 3: Read the current
buildConfirmPatchsignature
Open src/app/(app)/collection-confirm/view-model.ts. Find buildConfirmPatch (around line 192). The existing signature is something like:
export function buildConfirmPatch(row, actor, invoice): ConfirmPatch
The exact parameter order may differ — read the file and adapt the next step to match.
- Step 4: Refactor
buildConfirmPatchto take an options bag
Replace the function so its signature becomes:
export type ConfirmOptions = {
actor: ConfirmActor;
invoice: { date: string; number: string };
costOverride?: number;
};
export function buildConfirmPatch(
row: { id: string; item_status?: string; notes?: string | null; order_item_details?: string | null; cost_price?: number | null },
opts: ConfirmOptions
): ConfirmPatch & { cost_price?: number } {
const notes = appendConfirmingNote(row.notes, opts.actor, opts.invoice);
const order_item_details = writeInvoiceDetails(row.order_item_details, {
date: opts.invoice.date,
number: opts.invoice.number,
setBy: opts.actor.id,
setByName: opts.actor.name,
setAt: new Date().toISOString(),
});
const patch: ConfirmPatch & { cost_price?: number } = {
item_status: nextItemStatusForConfirm(row.item_status ?? ""),
notes,
order_item_details,
};
if (
typeof opts.costOverride === "number" &&
opts.costOverride !== (row.cost_price ?? null)
) {
patch.cost_price = opts.costOverride;
}
return patch;
}
If the existing function takes positional actor and invoice arguments, update every call site in the same PR to pass { actor, invoice, costOverride } instead. Search for buildConfirmPatch( in src/ and update each hit. The page's confirmRow and bulkConfirm call sites must pass costOverride from the dialog's costOverrides map.
- Step 5: Update the existing two
buildConfirmPatchtests to use the new options-bag signature
In view-model.test.ts, find the two existing buildConfirmPatch tests (around lines 251 and 307) and rewrite their calls from positional args to the options bag:
const patch = buildConfirmPatch(row, { actor, invoice: { date: "2026-08-01", number: "INV-1" } });
Adjust the expect shapes to match — the result shape is the same except for the new cost_price field (which only appears when overridden, as covered by the new tests).
- Step 6: Run the full view-model test file
Run: pnpm vitest run src/app/(app)/collection-confirm/view-model.test.ts
Expected: ALL PASS (existing 13 cases + 3 from Task 1 + 3 from this task).
- Step 7: Commit
git add src/app/(app)/collection-confirm/view-model.ts src/app/(app)/collection-confirm/view-model.test.ts
git commit -m "refactor(collection-confirm): make buildConfirmPatch take options bag with costOverride"
Task 3: Create collection-confirm-service.ts with listCollectedItems
Files:
-
Create:
src/lib/server/collection-confirm-service.ts -
Create:
src/lib/server/collection-confirm-service.test.ts -
Step 1: Write the failing test for
listCollectedItems
Create src/lib/server/collection-confirm-service.test.ts:
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("./pocketbase-admin", () => ({ getAdminPocketBase: vi.fn() }));
import { getAdminPocketBase } from "./pocketbase-admin";
import { listCollectedItems } from "./collection-confirm-service";
type AnyRec = Record<string, unknown>;
function makeAdmin(items: { items: AnyRec[]; totalItems: number }, stock: { items: AnyRec[]; totalItems: number }) {
return {
collection: (name: string) => {
if (name === "order_items") {
return { getList: vi.fn().mockResolvedValue(items) };
}
if (name === "stock_purchase_items") {
return { getList: vi.fn().mockResolvedValue(stock) };
}
throw new Error(`unexpected collection: ${name}`);
},
};
}
describe("listCollectedItems", () => {
beforeEach(() => vi.resetAllMocks());
it("returns customer items and stock items with the right filter and expand", async () => {
const orderItems = {
items: [
{ id: "oi-1", item_status: "collected", order_qty: 10, qty_collected: 10, cost_price: 45, expand: { purchase_order: { po_number: "PO-1" } } },
{ id: "oi-2", item_status: "collected", order_qty: 20, qty_collected: 22, cost_price: 12.5, expand: { purchase_order: { po_number: "PO-1" } } },
],
totalItems: 2,
};
const stockItems = {
items: [{ id: "sp-1", status: "collected", quantity: 5, cost_price: 8 }],
totalItems: 1,
};
vi.mocked(getAdminPocketBase).mockResolvedValue(makeAdmin(orderItems, stockItems) as never);
const out = await listCollectedItems();
expect(out).toHaveLength(3);
expect(out[0].source).toBe("customer");
expect(out[2].source).toBe("stock");
const pb = await getAdminPocketBase();
const oiCall = (pb.collection("order_items").getList as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(oiCall).toMatchObject({ filter: 'item_status="collected"', expand: "purchase_order.supplier,order.company,order.customer" });
});
});
- Step 2: Run test to verify it fails
Run: pnpm vitest run src/lib/server/collection-confirm-service.test.ts
Expected: FAIL with Cannot find module './collection-confirm-service'.
- Step 3: Implement
listCollectedItems
Create src/lib/server/collection-confirm-service.ts:
import { getAdminPocketBase } from "./pocketbase-admin";
import { getOrderItemsCollectionName } from "./orders-service";
export type ConfirmRow = {
id: string;
source: "customer" | "stock";
description?: string;
po_description?: string;
order_qty?: number;
qty_collected?: number;
quantity?: number;
item_status?: string;
cost_price?: number | null;
notes?: string | null;
drv_collected_at?: string | null;
order_item_attachements?: string[];
problem_flag?: boolean | null;
problem_type?: string | null;
problem_notes?: string | null;
surplus_qty?: number | null;
stock_booked_from?: string | null;
sku?: string;
supplier?: string;
poNumber?: string;
stock_purchase_id?: string | null;
stock_line_status?: string | null;
assigned_at?: string | null;
expand?: Record<string, unknown>;
};
export async function listCollectedItems(): Promise<ConfirmRow[]> {
const pb = await getAdminPocketBase();
const items = await pb.collection(getOrderItemsCollectionName()).getList(1, 500, {
filter: 'item_status="collected"',
sort: "-drv_collected_at",
expand: "purchase_order.supplier,order.company,order.customer",
});
const stock = await pb.collection("stock_purchase_items").getList(1, 500, {
filter: 'status="collected"',
});
const customer: ConfirmRow[] = items.items.map((row) => ({
...(row as unknown as ConfirmRow),
source: "customer",
}));
const stockRows: ConfirmRow[] = stock.items.map((row) => ({
...(row as unknown as ConfirmRow),
source: "stock",
item_status: (row as { status?: string }).status ?? "collected",
}));
return [...customer, ...stockRows];
}
- Step 4: Run test to verify it passes
Run: pnpm vitest run src/lib/server/collection-confirm-service.test.ts
Expected: PASS (1 test).
- Step 5: Commit
git add src/lib/server/collection-confirm-service.ts src/lib/server/collection-confirm-service.test.ts
git commit -m "feat(collection-confirm): add listCollectedItems service"
Task 4: Add updateOrderItemCost to the service
Files:
-
Modify:
src/lib/server/collection-confirm-service.ts -
Modify:
src/lib/server/collection-confirm-service.test.ts -
Step 1: Add failing tests for
updateOrderItemCost
Append to collection-confirm-service.test.ts:
import { updateOrderItemCost } from "./collection-confirm-service";
describe("updateOrderItemCost", () => {
beforeEach(() => vi.resetAllMocks());
it("writes cost_price and appends a cost note when the value changes", async () => {
const update = vi.fn().mockResolvedValue({ id: "row-1", cost_price: 50 });
const getOne = vi.fn().mockResolvedValue({ id: "row-1", cost_price: 45, notes: null });
vi.mocked(getAdminPocketBase).mockResolvedValue({
collection: () => ({ getOne, update }),
} as never);
await updateOrderItemCost("row-1", { cost_price: 50, actor: { id: "u-1", name: "sj" } });
expect(update).toHaveBeenCalledTimes(1);
const [calledId, body] = update.mock.calls[0];
expect(calledId).toBe("row-1");
expect(body).toMatchObject({ cost_price: 50 });
expect(typeof body.notes).toBe("string");
const parsed = JSON.parse(body.notes);
expect(parsed[0]).toMatchObject({
actorId: "u-1",
actorName: "sj",
category: "cost",
body: { prevCost: 45, newCost: 50 },
});
});
it("skips the note when the new cost equals the existing cost", async () => {
const update = vi.fn().mockResolvedValue({ id: "row-1", cost_price: 45 });
const getOne = vi.fn().mockResolvedValue({ id: "row-1", cost_price: 45, notes: "[]" });
vi.mocked(getAdminPocketBase).mockResolvedValue({
collection: () => ({ getOne, update }),
} as never);
await updateOrderItemCost("row-1", { cost_price: 45, actor: { id: "u-1", name: "sj" } });
expect(update).toHaveBeenCalledTimes(1);
const [, body] = update.mock.calls[0];
expect(body).not.toHaveProperty("notes");
});
});
- Step 2: Run test to verify it fails
Run: pnpm vitest run src/lib/server/collection-confirm-service.test.ts -t updateOrderItemCost
Expected: FAIL with updateOrderItemCost is not a function.
- Step 3: Implement
updateOrderItemCost
Add to collection-confirm-service.ts:
import { appendCostNote, type ConfirmActor } from "@/app/(app)/collection-confirm/view-model";
export class ServiceError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
export async function updateOrderItemCost(
id: string,
args: { cost_price: number; actor: ConfirmActor }
): Promise<{ id: string; cost_price: number }> {
if (!Number.isFinite(args.cost_price) || args.cost_price < 0) {
throw new ServiceError("cost_price must be a non-negative number", 400);
}
const pb = await getAdminPocketBase();
const existing = await pb.collection(getOrderItemsCollectionName()).getOne(id);
const prevCost = typeof existing.cost_price === "number" ? existing.cost_price : null;
const body: Record<string, unknown> = { cost_price: args.cost_price };
if (prevCost !== args.cost_price) {
body.notes = appendCostNote(existing.notes ?? null, args.actor, prevCost ?? 0, args.cost_price);
}
const updated = await pb.collection(getOrderItemsCollectionName()).update(id, body);
return { id: updated.id as string, cost_price: updated.cost_price as number };
}
- Step 4: Run test to verify it passes
Run: pnpm vitest run src/lib/server/collection-confirm-service.test.ts -t updateOrderItemCost
Expected: PASS (2 tests).
- Step 5: Commit
git add src/lib/server/collection-confirm-service.ts src/lib/server/collection-confirm-service.test.ts
git commit -m "feat(collection-confirm): add updateOrderItemCost service"
Task 5: Add confirmOrderItemsForInvoicing to the service
Files:
-
Modify:
src/lib/server/collection-confirm-service.ts -
Modify:
src/lib/server/collection-confirm-service.test.ts -
Step 1: Add failing tests
Append to collection-confirm-service.test.ts:
import { confirmOrderItemsForInvoicing } from "./collection-confirm-service";
describe("confirmOrderItemsForInvoicing", () => {
beforeEach(() => vi.resetAllMocks());
it("issues one batch with confirm + cost patches for the given item ids", async () => {
const getOne = vi.fn().mockImplementation((id: string) =>
Promise.resolve({
id,
item_status: "collected",
notes: null,
order_item_details: null,
cost_price: id === "row-1" ? 45 : 12,
})
);
const createBatch = vi.fn().mockResolvedValue([{ id: "row-1" }, { id: "row-2" }]);
vi.mocked(getAdminPocketBase).mockResolvedValue({
collection: () => ({ getOne }),
createBatch: vi.fn().mockImplementation((requests: unknown[]) => {
createBatch(requests);
return Promise.resolve(requests.map((_r, i) => ({ id: `row-${i + 1}` })));
}),
} as never);
const out = await confirmOrderItemsForInvoicing(["row-1", "row-2"], {
invoice: { date: "2026-08-01", number: "INV-1" },
costOverrides: { "row-1": 50 },
actor: { id: "u-1", name: "sj" },
});
expect(out).toEqual({ updatedIds: ["row-1", "row-2"] });
const requests = createBatch.mock.calls[0][0];
expect(requests).toHaveLength(2);
expect(requests[0].method).toBe("PATCH");
expect(requests[0].url).toContain("/api/collections/");
expect(requests[0].url).toContain("row-1");
expect(requests[0].body).toMatchObject({ cost_price: 50, item_status: "ready_invoice" });
expect(requests[1].body).not.toHaveProperty("cost_price");
expect(requests[1].body).toMatchObject({ item_status: "ready_invoice" });
});
it("returns successfully with empty updatedIds when no item ids are given", async () => {
const createBatch = vi.fn();
vi.mocked(getAdminPocketBase).mockResolvedValue({
collection: () => ({ getOne: vi.fn() }),
createBatch: vi.fn().mockImplementation((reqs: unknown[]) => {
createBatch(reqs);
return Promise.resolve([]);
}),
} as never);
const out = await confirmOrderItemsForInvoicing([], {
invoice: { date: "2026-08-01", number: "INV-1" },
actor: { id: "u-1", name: "sj" },
});
expect(out).toEqual({ updatedIds: [] });
expect(createBatch).not.toHaveBeenCalled();
});
});
- Step 2: Run test to verify it fails
Run: pnpm vitest run src/lib/server/collection-confirm-service.test.ts -t confirmOrderItemsForInvoicing
Expected: FAIL.
- Step 3: Implement
confirmOrderItemsForInvoicing
Add to collection-confirm-service.ts:
import { buildConfirmPatch } from "@/app/(app)/collection-confirm/view-model";
export type ConfirmInvoice = { date: string; number: string };
export async function confirmOrderItemsForInvoicing(
ids: string[],
args: { invoice: ConfirmInvoice; costOverrides?: Record<string, number>; actor: ConfirmActor }
): Promise<{ updatedIds: string[] }> {
if (ids.length === 0) return { updatedIds: [] };
const pb = await getAdminPocketBase();
const requests: Array<{ method: string; url: string; body: Record<string, unknown> }> = [];
for (const id of ids) {
const existing = await pb.collection(getOrderItemsCollectionName()).getOne(id);
const patch = buildConfirmPatch(
existing as Parameters<typeof buildConfirmPatch>[0],
{ actor: args.actor, invoice: args.invoice, costOverride: args.costOverrides?.[id] }
);
const { collectionId } = await import("./pocketbase-admin").then((m) => {
const adminPb = m as unknown as { __collectionId?: string };
return { collectionId: adminPb.__collectionId ?? "" };
}).catch(() => ({ collectionId: "" }));
// Fall back to the canonical collection name in the URL — PocketBase accepts
// either the numeric id or the collection name in batch URLs.
const collectionRef = collectionId || getOrderItemsCollectionName();
requests.push({
method: "PATCH",
url: `/api/collections/${collectionRef}/records/${id}`,
body: patch as unknown as Record<string, unknown>,
});
}
const results = (await pb.createBatch(requests)) as Array<{ id?: string }>;
return { updatedIds: results.map((r) => r.id).filter((x): x is string => Boolean(x)) };
}
(Note: the import("./pocketbase-admin") indirection above is a sketch — in practice, use whatever constant the existing service-layer code uses for the collection ref in batch URLs. Look at buyer-tasks-service.ts for the canonical pattern. If batch URLs in this codebase use the collection name directly, replace collectionId || getOrderItemsCollectionName() with just getOrderItemsCollectionName(). Adjust this step to match the actual pattern.)
- Step 4: Run test to verify it passes
Run: pnpm vitest run src/lib/server/collection-confirm-service.test.ts -t confirmOrderItemsForInvoicing
Expected: PASS (2 tests). If the batch-URL shape is wrong, the test fails and the engineer fixes the URL pattern in this step.
- Step 5: Commit
git add src/lib/server/collection-confirm-service.ts src/lib/server/collection-confirm-service.test.ts
git commit -m "feat(collection-confirm): add confirmOrderItemsForInvoicing batch service"
Task 6: Create GET /api/v1/collection-confirm route
Files:
-
Create:
src/app/api/v1/collection-confirm/route.ts -
Create:
src/app/api/v1/collection-confirm/route.test.ts -
Step 1: Write the failing test
Create src/app/api/v1/collection-confirm/route.test.ts:
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/server/collection-confirm-service", () => ({
listCollectedItems: vi.fn(),
}));
import { listCollectedItems } from "@/lib/server/collection-confirm-service";
import { GET } from "./route";
describe("GET /api/v1/collection-confirm", () => {
beforeEach(() => vi.resetAllMocks());
it("returns the collected items as JSON", async () => {
vi.mocked(listCollectedItems).mockResolvedValue([
{ id: "row-1", source: "customer" } as never,
]);
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({ items: [{ id: "row-1", source: "customer" }] });
});
it("returns 500 with an error message when the service throws", async () => {
vi.mocked(listCollectedItems).mockRejectedValue(new Error("boom"));
const res = await GET();
expect(res.status).toBe(500);
const body = await res.json();
expect(body).toEqual({ error: "boom" });
});
});
- Step 2: Run test to verify it fails
Run: pnpm vitest run src/app/api/v1/collection-confirm/route.test.ts
Expected: FAIL with Cannot find module './route'.
- Step 3: Implement the route
Create src/app/api/v1/collection-confirm/route.ts:
import { NextResponse } from "next/server";
import { listCollectedItems } from "@/lib/server/collection-confirm-service";
export async function GET() {
try {
const items = await listCollectedItems();
return NextResponse.json({ items });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Unknown error" },
{ status: 500 }
);
}
}
- Step 4: Run test to verify it passes
Run: pnpm vitest run src/app/api/v1/collection-confirm/route.test.ts
Expected: PASS (2 tests).
- Step 5: Commit
git add src/app/api/v1/collection-confirm/route.ts src/app/api/v1/collection-confirm/route.test.ts
git commit -m "feat(api): add GET /api/v1/collection-confirm"
Task 7: Create POST /api/v1/order-items/[id]/cost route
Files:
-
Create:
src/app/api/v1/order-items/[id]/cost/route.ts -
Create:
src/app/api/v1/order-items/[id]/cost/route.test.ts -
Step 1: Write the failing test
Create src/app/api/v1/order-items/[id]/cost/route.test.ts:
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/server/collection-confirm-service", () => ({
updateOrderItemCost: vi.fn(),
}));
vi.mock("@/lib/dev-identity", () => ({
getServerIdentity: vi.fn().mockResolvedValue("sj@example.com"),
}));
import { updateOrderItemCost } from "@/lib/server/collection-confirm-service";
import { getServerIdentity } from "@/lib/dev-identity";
import { POST } from "./route";
function makeRequest(body: unknown): Request {
return new Request("http://localhost/api/v1/order-items/row-1/cost", {
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
});
}
describe("POST /api/v1/order-items/[id]/cost", () => {
beforeEach(() => vi.resetAllMocks());
it("calls the service with the actor derived from the server identity and returns 200", async () => {
vi.mocked(updateOrderItemCost).mockResolvedValue({ id: "row-1", cost_price: 50 } as never);
const res = await POST(makeRequest({ cost_price: 50 }), { params: Promise.resolve({ id: "row-1" }) });
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({ id: "row-1", cost_price: 50 });
expect(updateOrderItemCost).toHaveBeenCalledWith("row-1", {
cost_price: 50,
actor: { id: "sj@example.com", name: "sj" },
});
});
it("returns 400 for a non-numeric cost", async () => {
vi.mocked(updateOrderItemCost).mockRejectedValue(new Error("cost_price must be a non-negative number"));
const res = await POST(makeRequest({ cost_price: -1 }), { params: Promise.resolve({ id: "row-1" }) });
expect(res.status).toBe(500); // see step 3 — route maps unknown errors to 500, ServiceError to its status
});
});
(Note: the second test asserts 500 because the route's try/catch only maps ServiceError to its status; the test deliberately rejects with a plain Error to exercise that path. In step 3 we will adjust this to 500 for plain errors.)
- Step 2: Run test to verify it fails
Run: pnpm vitest run src/app/api/v1/order-items/[id]/cost/route.test.ts
Expected: FAIL with Cannot find module './route'.
- Step 3: Implement the route
Create src/app/api/v1/order-items/[id]/cost/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { ServiceError, updateOrderItemCost } from "@/lib/server/collection-confirm-service";
import { getServerIdentity } from "@/lib/dev-identity";
export async function POST(
req: NextRequest,
ctx: { params: Promise<{ id: string }> }
) {
try {
const { id } = await ctx.params;
const body = (await req.json()) as { cost_price?: unknown };
if (typeof body.cost_price !== "number") {
return NextResponse.json({ error: "cost_price is required and must be a number" }, { status: 400 });
}
const email = await getServerIdentity(req);
const actor = { id: email, name: email.split("@")[0] || email };
const updated = await updateOrderItemCost(id, { cost_price: body.cost_price, actor });
return NextResponse.json(updated);
} catch (err) {
const status = err instanceof ServiceError ? err.status : 500;
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Unknown error" },
{ status }
);
}
}
- Step 4: Run test to verify it passes
Run: pnpm vitest run src/app/api/v1/order-items/[id]/cost/route.test.ts
Expected: PASS (2 tests).
- Step 5: Commit
git add src/app/api/v1/order-items/[id]/cost/route.ts src/app/api/v1/order-items/[id]/cost/route.test.ts
git commit -m "feat(api): add POST /api/v1/order-items/[id]/cost"
Task 8: Create POST /api/v1/order-items/confirm route
Files:
-
Create:
src/app/api/v1/order-items/confirm/route.ts -
Create:
src/app/api/v1/order-items/confirm/route.test.ts -
Step 1: Write the failing test
Create src/app/api/v1/order-items/confirm/route.test.ts:
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/server/collection-confirm-service", () => ({
confirmOrderItemsForInvoicing: vi.fn(),
}));
vi.mock("@/lib/dev-identity", () => ({
getServerIdentity: vi.fn().mockResolvedValue("sj@example.com"),
}));
import { confirmOrderItemsForInvoicing } from "@/lib/server/collection-confirm-service";
import { POST } from "./route";
function makeRequest(body: unknown): Request {
return new Request("http://localhost/api/v1/order-items/confirm", {
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
});
}
describe("POST /api/v1/order-items/confirm", () => {
beforeEach(() => vi.resetAllMocks());
it("returns 200 with the updated ids and forwards the body to the service", async () => {
vi.mocked(confirmOrderItemsForInvoicing).mockResolvedValue({ updatedIds: ["row-1", "row-2"] });
const res = await POST(
makeRequest({
itemIds: ["row-1", "row-2"],
invoice: { date: "2026-08-01", number: "INV-1" },
costOverrides: { "row-1": 50 },
})
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({ updatedIds: ["row-1", "row-2"] });
expect(confirmOrderItemsForInvoicing).toHaveBeenCalledWith(
["row-1", "row-2"],
{
invoice: { date: "2026-08-01", number: "INV-1" },
costOverrides: { "row-1": 50 },
actor: { id: "sj@example.com", name: "sj" },
}
);
});
it("returns 400 when itemIds is missing or empty", async () => {
const res = await POST(makeRequest({ invoice: { date: "2026-08-01", number: "INV-1" } }));
expect(res.status).toBe(400);
});
it("returns 400 when invoice date or number is missing", async () => {
const res = await POST(makeRequest({ itemIds: ["row-1"], invoice: { date: "" } }));
expect(res.status).toBe(400);
});
});
- Step 2: Run test to verify it fails
Run: pnpm vitest run src/app/api/v1/order-items/confirm/route.test.ts
Expected: FAIL with Cannot find module './route'.
- Step 3: Implement the route
Create src/app/api/v1/order-items/confirm/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { confirmOrderItemsForInvoicing } from "@/lib/server/collection-confirm-service";
import { getServerIdentity } from "@/lib/dev-identity";
type Body = {
itemIds?: unknown;
invoice?: { date?: unknown; number?: unknown };
costOverrides?: Record<string, number>;
};
export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as Body;
if (!Array.isArray(body.itemIds) || body.itemIds.length === 0) {
return NextResponse.json({ error: "itemIds is required and must be non-empty" }, { status: 400 });
}
if (typeof body.invoice?.date !== "string" || typeof body.invoice?.number !== "string" || !body.invoice.date || !body.invoice.number) {
return NextResponse.json({ error: "invoice.date and invoice.number are required" }, { status: 400 });
}
const email = await getServerIdentity(req);
const actor = { id: email, name: email.split("@")[0] || email };
const out = await confirmOrderItemsForInvoicing(body.itemIds as string[], {
invoice: { date: body.invoice.date, number: body.invoice.number },
costOverrides: body.costOverrides,
actor,
});
return NextResponse.json(out);
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Unknown error" },
{ status: 500 }
);
}
}
- Step 4: Run test to verify it passes
Run: pnpm vitest run src/app/api/v1/order-items/confirm/route.test.ts
Expected: PASS (3 tests).
- Step 5: Commit
git add src/app/api/v1/order-items/confirm/route.ts src/app/api/v1/order-items/confirm/route.test.ts
git commit -m "feat(api): add POST /api/v1/order-items/confirm"
Task 9: Extract ConfirmInvoiceDialog into a component
Files:
-
Create:
src/components/collection-confirm/confirm-invoice-dialog.tsx -
Create:
src/components/collection-confirm/confirm-invoice-dialog.test.tsx -
Step 1: Read the current dialog JSX in
page.tsx
Open src/app/(app)/collection-confirm/page.tsx and find ConfirmInvoiceDialog (around line 1682). Copy the JSX block and the dialog's state hooks verbatim into a new component file. Also note the existing ModalShell.Dialog import path.
- Step 2: Write a failing test for the extracted dialog
Create src/components/collection-confirm/confirm-invoice-dialog.test.tsx:
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ConfirmInvoiceDialog } from "./confirm-invoice-dialog";
describe("ConfirmInvoiceDialog", () => {
it("calls onSubmit with the date, number, and costOverrides map", () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
<ConfirmInvoiceDialog
open
onOpenChange={() => {}}
onSubmit={onSubmit}
costOverrides={{ "row-1": 50, "row-2": 60 }}
rowCount={3}
/>
);
fireEvent.change(screen.getByLabelText(/invoice date/i), { target: { value: "2026-08-01" } });
fireEvent.change(screen.getByLabelText(/invoice number/i), { target: { value: "INV-1" } });
fireEvent.click(screen.getByRole("button", { name: /confirm/i }));
return Promise.resolve().then(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
date: "2026-08-01",
number: "INV-1",
costOverrides: { "row-1": 50, "row-2": 60 },
})
);
});
});
it("renders the summary line with row and override counts", () => {
render(
<ConfirmInvoiceDialog
open
onOpenChange={() => {}}
onSubmit={vi.fn()}
costOverrides={{ "row-1": 50 }}
rowCount={3}
/>
);
expect(screen.getByText(/Confirming 3 row\(s\)/)).toBeTruthy();
expect(screen.getByText(/1 cost override/i)).toBeTruthy();
});
});
- Step 3: Run test to verify it fails
Run: pnpm vitest run src/components/collection-confirm/confirm-invoice-dialog.test.tsx
Expected: FAIL with Cannot find module './confirm-invoice-dialog'.
- Step 4: Create the component
Create src/components/collection-confirm/confirm-invoice-dialog.tsx:
"use client";
import { useState } from "react";
import { ModalShell } from "@/components/ui/modal-shell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { buildConfirmNote } from "@/app/(app)/collection-confirm/view-model";
export type ConfirmInvoiceSubmit = {
date: string;
number: string;
costOverrides: Record<string, number>;
};
export function ConfirmInvoiceDialog({
open,
onOpenChange,
onSubmit,
costOverrides,
rowCount,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (args: ConfirmInvoiceSubmit) => Promise<void> | void;
costOverrides: Record<string, number>;
rowCount: number;
}) {
const [date, setDate] = useState("");
const [number, setNumber] = useState("");
const [submitting, setSubmitting] = useState(false);
const canSubmit = Boolean(date && number.trim()) && !submitting;
const overrideCount = Object.keys(costOverrides).length;
return (
<ModalShell.Dialog open={open} onOpenChange={onOpenChange}>
<ModalShell.Content>
<ModalShell.Header>
<ModalShell.Title>Confirm for invoicing</ModalShell.Title>
</ModalShell.Header>
<div className="space-y-3">
<div>
<Label htmlFor="confirm-invoice-date">Supplier invoice date</Label>
<Input
id="confirm-invoice-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
<div>
<Label htmlFor="confirm-invoice-number">Supplier invoice number</Label>
<Input
id="confirm-invoice-number"
type="text"
value={number}
onChange={(e) => setNumber(e.target.value)}
placeholder="Required"
/>
</div>
<p className="text-sm text-muted-foreground">
{buildConfirmNote({ date, number: number.trim() })}
</p>
<p className="text-xs text-muted-foreground">
Confirming {rowCount} row(s) · {overrideCount} cost override
{overrideCount === 1 ? "" : "s"} applied
</p>
</div>
<ModalShell.Footer>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
disabled={!canSubmit}
onClick={async () => {
setSubmitting(true);
try {
await onSubmit({ date, number: number.trim(), costOverrides });
onOpenChange(false);
} finally {
setSubmitting(false);
}
}}
>
Confirm
</Button>
</ModalShell.Footer>
</ModalShell.Content>
</ModalShell.Dialog>
);
}
(Adjust the ModalShell.* subcomponent names to match the real exports in src/components/ui/modal-shell.tsx — read that file first and substitute the correct names.)
- Step 5: Run test to verify it passes
Run: pnpm vitest run src/components/collection-confirm/confirm-invoice-dialog.test.tsx
Expected: PASS (2 tests). If the label/button text doesn't match (e.g. "Cancel" vs "Close"), adjust the test to match the actual rendered text and re-run.
- Step 6: Commit
git add src/components/collection-confirm/confirm-invoice-dialog.tsx src/components/collection-confirm/confirm-invoice-dialog.test.tsx
git commit -m "refactor(collection-confirm): extract ConfirmInvoiceDialog component"
Task 10: Create PhotoStrip component
Files:
-
Create:
src/components/collection-confirm/photo-strip.tsx -
Create:
src/components/collection-confirm/photo-strip.test.tsx -
Step 1: Write the failing tests
Create src/components/collection-confirm/photo-strip.test.tsx:
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { PhotoStrip } from "./photo-strip";
describe("PhotoStrip", () => {
it("renders one chip per distinct filename and links to the file-stream URL", () => {
render(
<PhotoStrip
attachments={[
{ filename: "INV-001.pdf", itemIds: ["row-1", "row-2"] },
{ filename: "DN-7.pdf", itemIds: ["row-1"] },
]}
/>
);
const link1 = screen.getByRole("link", { name: /INV-001\.pdf/ });
expect(link1.getAttribute("href")).toBe("/api/v1/files/order_items/row-1/INV-001.pdf");
expect(link1.getAttribute("target")).toBe("_blank");
expect(screen.getByRole("link", { name: /DN-7\.pdf/ }).getAttribute("href")).toBe(
"/api/v1/files/order_items/row-1/DN-7.pdf"
);
});
it("renders the empty state when no attachments are provided", () => {
render(<PhotoStrip attachments={[]} />);
expect(screen.getByText(/no invoice photo attached/i)).toBeTruthy();
});
});
- Step 2: Run test to verify it fails
Run: pnpm vitest run src/components/collection-confirm/photo-strip.test.tsx
Expected: FAIL with Cannot find module './photo-strip'.
- Step 3: Implement the component
Create src/components/collection-confirm/photo-strip.tsx:
import { Paperclip } from "lucide-react";
export type PhotoStripAttachment = { filename: string; itemIds: string[] };
export function PhotoStrip({ attachments }: { attachments: PhotoStripAttachment[] }) {
if (attachments.length === 0) {
return (
<div className="rounded-md border border-dashed border-muted-foreground/30 p-3 text-sm text-muted-foreground">
No invoice photo attached. The driver can attach via the Attach dialog.
</div>
);
}
return (
<div className="flex flex-wrap items-center gap-2">
{attachments.map(({ filename, itemIds }, idx) => {
const firstItemId = itemIds[0] ?? "";
return (
<a
key={filename}
href={`/api/v1/files/order_items/${firstItemId}/${encodeURIComponent(filename)}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 text-sm hover:bg-muted"
>
<Paperclip className="h-4 w-4" />
<span>Invoice {idx + 1}</span>
<span className="text-muted-foreground">·</span>
<span className="font-mono text-xs">{filename}</span>
{itemIds.length > 1 ? (
<span className="text-xs text-muted-foreground">({itemIds.length} items)</span>
) : null}
</a>
);
})}
</div>
);
}
- Step 4: Run test to verify it passes
Run: pnpm vitest run src/components/collection-confirm/photo-strip.test.tsx
Expected: PASS (2 tests).
- Step 5: Commit
git add src/components/collection-confirm/photo-strip.tsx src/components/collection-confirm/photo-strip.test.tsx
git commit -m "feat(collection-confirm): add PhotoStrip component"
Task 11: Create ItemsTable with inline cost edit
Files:
-
Create:
src/components/collection-confirm/items-table.tsx -
Create:
src/components/collection-confirm/items-table.test.tsx -
Step 1: Write the failing tests
Create src/components/collection-confirm/items-table.test.tsx:
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ItemsTable, type ItemsTableRow } from "./items-table";
const rows: ItemsTableRow[] = [
{ id: "row-1", description: "Steel sheet 1m", sku: "SK-001", order_qty: 10, qty_collected: 10, cost_price: 45 },
{ id: "row-2", description: "Steel rod 2m", sku: "SK-002", order_qty: 20, qty_collected: 22, cost_price: 12.5, surplus: 2 },
];
describe("ItemsTable", () => {
it("renders one row per item with the cost formatted as currency", () => {
render(<ItemsTable rows={rows} onCostChange={vi.fn()} />);
expect(screen.getByText(/Steel sheet 1m/)).toBeTruthy();
expect(screen.getByText(/R45\.00/)).toBeTruthy();
expect(screen.getByText(/R12\.50/)).toBeTruthy();
expect(screen.getByText(/\+2/)).toBeTruthy();
});
it("clicking the pencil swaps the cell to a number input prefilled with the current cost", () => {
render(<ItemsTable rows={rows} onCostChange={vi.fn()} />);
const buttons = screen.getAllByRole("button", { name: /edit cost/i });
fireEvent.click(buttons[0]);
const input = screen.getByDisplayValue("45") as HTMLInputElement;
expect(input.tagName).toBe("INPUT");
expect(input.type).toBe("number");
});
it("pressing Enter on the input calls onCostChange with the new value", () => {
const onCostChange = vi.fn().mockResolvedValue(undefined);
render(<ItemsTable rows={rows} onCostChange={onCostChange} />);
const buttons = screen.getAllByRole("button", { name: /edit cost/i });
fireEvent.click(buttons[0]);
const input = screen.getByDisplayValue("45");
fireEvent.change(input, { target: { value: "50" } });
fireEvent.keyDown(input, { key: "Enter" });
return Promise.resolve().then(() => {
expect(onCostChange).toHaveBeenCalledWith("row-1", 50);
});
});
it("pressing Esc on the input reverts without calling onCostChange", () => {
const onCostChange = vi.fn();
render(<ItemsTable rows={rows} onCostChange={onCostChange} />);
const buttons = screen.getAllByRole("button", { name: /edit cost/i });
fireEvent.click(buttons[0]);
const input = screen.getByDisplayValue("45");
fireEvent.change(input, { target: { value: "50" } });
fireEvent.keyDown(input, { key: "Escape" });
return Promise.resolve().then(() => {
expect(onCostChange).not.toHaveBeenCalled();
expect(screen.getByText(/R45\.00/)).toBeTruthy();
});
});
});
- Step 2: Run test to verify it fails
Run: pnpm vitest run src/components/collection-confirm/items-table.test.tsx
Expected: FAIL with Cannot find module './items-table'.
- Step 3: Implement the component
Create src/components/collection-confirm/items-table.tsx:
"use client";
import { useState } from "react";
import { Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export type ItemsTableRow = {
id: string;
description?: string;
sku?: string;
order_qty?: number | null;
qty_collected?: number | null;
cost_price?: number | null;
surplus?: number | null;
};
const randarFormatter = new Intl.NumberFormat("en-ZA", {
style: "currency",
currency: "ZAR",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
function formatR(value: number | null | undefined): string {
if (value == null) return "—";
return randarFormatter.format(value);
}
export function ItemsTable({
rows,
onCostChange,
}: {
rows: ItemsTableRow[];
onCostChange: (id: string, newCost: number) => Promise<void> | void;
}) {
return (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase text-muted-foreground">
<th className="py-2 pr-3">SKU / Description</th>
<th className="py-2 pr-3">Ordered</th>
<th className="py-2 pr-3">Collected</th>
<th className="py-2 pr-3">Cost</th>
<th className="py-2 pr-3">Surplus</th>
<th className="py-2 pr-3 text-right">Edit</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<CostRow key={row.id} row={row} onCostChange={onCostChange} />
))}
</tbody>
</table>
);
}
function CostRow({
row,
onCostChange,
}: {
row: ItemsTableRow;
onCostChange: (id: string, newCost: number) => Promise<void> | void;
}) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState<string>(row.cost_price != null ? String(row.cost_price) : "");
const [error, setError] = useState<string | null>(null);
function commit() {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
setError("Must be a non-negative number");
return;
}
setError(null);
setEditing(false);
void onCostChange(row.id, parsed);
}
function cancel() {
setError(null);
setValue(row.cost_price != null ? String(row.cost_price) : "");
setEditing(false);
}
return (
<tr className="border-t">
<td className="py-2 pr-3">
<div className="font-mono text-xs">{row.sku ?? "—"}</div>
<div>{row.description ?? "—"}</div>
</td>
<td className="py-2 pr-3 font-mono">{row.order_qty ?? 0}</td>
<td className="py-2 pr-3 font-mono">{row.qty_collected ?? 0}</td>
<td className="py-2 pr-3 font-mono">
{editing ? (
<div>
<Input
type="number"
min={0}
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") cancel();
}}
className="h-7 w-24"
/>
{error ? <div className="text-xs text-destructive">{error}</div> : null}
</div>
) : (
formatR(row.cost_price)
)}
</td>
<td className="py-2 pr-3 font-mono">
{row.surplus && row.surplus > 0 ? `+${row.surplus}` : "—"}
</td>
<td className="py-2 pr-3 text-right">
{!editing ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Edit cost"
onClick={() => setEditing(true)}
>
<Pencil className="h-4 w-4" />
</Button>
) : null}
</td>
</tr>
);
}
- Step 4: Run test to verify it passes
Run: pnpm vitest run src/components/collection-confirm/items-table.test.tsx
Expected: PASS (4 tests). If the formatter outputs R 45.00 (with a space) instead of R45.00, adjust the test regexes to match the actual Intl output.
- Step 5: Commit
git add src/components/collection-confirm/items-table.tsx src/components/collection-confirm/items-table.test.tsx
git commit -m "feat(collection-confirm): add ItemsTable with inline cost edit"
Task 12: Create PoCard component composing PhotoStrip, ItemsTable, and ConfirmInvoiceDialog
Files:
-
Create:
src/components/collection-confirm/po-card.tsx -
Create:
src/components/collection-confirm/po-card.test.tsx -
Step 1: Write the failing test
Create src/components/collection-confirm/po-card.test.tsx:
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { PoCard } from "./po-card";
const rows = [
{ id: "row-1", description: "Steel sheet 1m", sku: "SK-001", order_qty: 10, qty_collected: 10, cost_price: 45, order_item_attachements: ["INV-001.pdf"] },
{ id: "row-2", description: "Steel rod 2m", sku: "SK-002", order_qty: 20, qty_collected: 22, cost_price: 12.5, surplus: 2 },
] as const;
describe("PoCard", () => {
it("renders the PO number, supplier, photo strip, and items table", () => {
render(
<PoCard
poNumber="PO-26081401"
supplierName="Acme Steel"
rows={rows as never}
costOverrides={{}}
onCostChange={vi.fn()}
onConfirm={vi.fn()}
/>
);
expect(screen.getByText(/PO 26081401/)).toBeTruthy();
expect(screen.getByText(/Acme Steel/)).toBeTruthy();
expect(screen.getByText(/Steel sheet 1m/)).toBeTruthy();
expect(screen.getByText(/R12\.50/)).toBeTruthy();
expect(screen.getByRole("link", { name: /INV-001\.pdf/ })).toBeTruthy();
});
it("opens the confirm dialog when the confirm button is clicked", () => {
render(
<PoCard
poNumber="PO-26081401"
supplierName="Acme Steel"
rows={rows as never}
costOverrides={{ "row-1": 50 }}
onCostChange={vi.fn()}
onConfirm={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: /confirm for invoicing/i }));
expect(screen.getByText(/Confirming 2 row\(s\)/)).toBeTruthy();
expect(screen.getByText(/1 cost override/i)).toBeTruthy();
});
});
- Step 2: Run test to verify it fails
Run: pnpm vitest run src/components/collection-confirm/po-card.test.tsx
Expected: FAIL with Cannot find module './po-card'.
- Step 3: Implement the component
Create src/components/collection-confirm/po-card.tsx:
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { PhotoStrip, type PhotoStripAttachment } from "./photo-strip";
import { ItemsTable, type ItemsTableRow } from "./items-table";
import { ConfirmInvoiceDialog, type ConfirmInvoiceSubmit } from "./confirm-invoice-dialog";
import { dedupeAttachments, type ConfirmRow } from "@/app/(app)/collection-confirm/view-model";
export type PoCardProps = {
poNumber: string;
supplierName: string;
rows: ConfirmRow[];
costOverrides: Record<string, number>;
onCostChange: (id: string, newCost: number) => Promise<void> | void;
onConfirm: (args: ConfirmInvoiceSubmit & { itemIds: string[] }) => Promise<void> | void;
};
export function PoCard({
poNumber,
supplierName,
rows,
costOverrides,
onCostChange,
onConfirm,
}: PoCardProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const attachments: PhotoStripAttachment[] = dedupeAttachments(rows);
const itemIds = rows.map((r) => r.id);
const tableRows: ItemsTableRow[] = rows.map((r) => ({
id: r.id,
description: r.description,
sku: r.sku,
order_qty: r.order_qty,
qty_collected: r.qty_collected,
cost_price:
costOverrides[r.id] !== undefined ? costOverrides[r.id] : r.cost_price ?? null,
surplus:
r.qty_collected != null && r.order_qty != null && r.qty_collected > r.order_qty
? r.qty_collected - r.order_qty
: null,
}));
return (
<section className="rounded-lg border bg-card p-4 space-y-3">
<header className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold">PO {poNumber}</h2>
<p className="text-sm text-muted-foreground">
{supplierName} · {rows.length} item{rows.length === 1 ? "" : "s"}
</p>
</div>
<Button onClick={() => setDialogOpen(true)}>Confirm for invoicing</Button>
</header>
<PhotoStrip attachments={attachments} />
<ItemsTable rows={tableRows} onCostChange={onCostChange} />
<ConfirmInvoiceDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
costOverrides={costOverrides}
rowCount={itemIds.length}
onSubmit={async (args) => {
await onConfirm({ ...args, itemIds });
}}
/>
</section>
);
}
- Step 4: Run test to verify it passes
Run: pnpm vitest run src/components/collection-confirm/po-card.test.tsx
Expected: PASS (2 tests).
- Step 5: Commit
git add src/components/collection-confirm/po-card.tsx src/components/collection-confirm/po-card.test.tsx
git commit -m "feat(collection-confirm): add PoCard component"
Task 13: Rewire page.tsx to use the new components and endpoints
Files:
-
Modify:
src/app/(app)/collection-confirm/page.tsx -
Modify:
src/app/(app)/collection-confirm/view-model.ts -
Step 1: Read the current
page.tsxend-to-end
Open src/app/(app)/collection-confirm/page.tsx (2208 lines) and read it through. Identify the following landmarks:
- The
load()function (around line 234) - The
handleQuantityfunction (around line 417) - The
QuantityDialogJSX (around line 1481) - The "Adjust Quantity" button in the actions grid (around line 1351)
- The
ConfirmInvoiceDialogJSX (around line 1682) - The
bulkConfirmfunction (around line 482) - The
confirmRowfunction (around line 480) - The render root that groups rows by PO and renders one
ConfirmCardper row
Make a list of the props/state each of these reads. We'll wire them up to the new service calls in the next step.
- Step 2: Replace
load()to call the new endpoint
In page.tsx, find load() and replace its body (keep the function signature) so it calls the new endpoint:
const res = await fetch("/api/v1/collection-confirm", { cache: "no-store" });
if (!res.ok) throw new Error(`Failed to load: ${res.status}`);
const { items } = (await res.json()) as { items: ConfirmRow[] };
setRows(items);
Remove the now-unused direct pb.collection("order_items") and pb.collection("stock_purchase_items") imports in the load() body.
- Step 3: Add
costOverridesstate to the page
At the top of the CollectionConfirmPage component (or wherever the other useState calls live), add:
const [costOverrides, setCostOverrides] = useState<Record<string, number>>({});
- Step 4: Add
handleCostChangecallback
const handleCostChange = useCallback(async (id: string, newCost: number) => {
setCostOverrides((prev) => ({ ...prev, [id]: newCost }));
try {
const res = await fetch(`/api/v1/order-items/${id}/cost`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ cost_price: newCost }),
});
if (!res.ok) {
const { error } = (await res.json()) as { error?: string };
setCostOverrides((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
toast.error(`Could not save cost for ${id}: ${error ?? res.status}`);
} else {
toast.success("Cost saved");
}
} catch (err) {
setCostOverrides((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
toast.error(err instanceof Error ? err.message : "Network error");
}
}, []);
- Step 5: Replace the confirm dialog open path with the new component
In page.tsx, replace the inline ConfirmInvoiceDialog JSX with an empty fragment or remove the local state for it. The new ConfirmInvoiceDialog is rendered inside PoCard. Remove the corresponding useState for confirmOpen/confirmTarget and the handleConfirm function. Add:
const handleConfirm = useCallback(
async (args: { date: string; number: string; costOverrides: Record<string, number>; itemIds: string[] }) => {
const res = await fetch("/api/v1/order-items/confirm", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(args),
});
if (!res.ok) {
const { error } = (await res.json()) as { error?: string };
toast.error(`Confirm failed: ${error ?? res.status}`);
return;
}
const { updatedIds } = (await res.json()) as { updatedIds: string[] };
setCostOverrides({});
await load();
toast.success(`Confirmed ${updatedIds.length} row(s) for invoicing`);
},
[]
);
- Step 6: Replace the per-row render with
<PoCard>per PO group
Find the JSX that walks the PO groups and renders one ConfirmCard per row. Replace the entire per-row loop with one <PoCard> per group:
{poGroups.map((group) => (
<PoCard
key={group.poNumber}
poNumber={group.poNumber}
supplierName={group.supplierName}
rows={group.rows}
costOverrides={costOverrides}
onCostChange={handleCostChange}
onConfirm={handleConfirm}
/>
))}
Add the import:
import { PoCard } from "@/components/collection-confirm/po-card";
-
Step 7: Remove the quantity modal and related code
-
Delete the
QuantityDialogJSX block. -
Delete the
handleQuantityfunction. -
Delete the "Adjust Quantity" action button JSX (around line 1351).
-
Remove any state hooks that were only used by the modal (
quantityOpen,quantityTarget, etc.). -
In
view-model.ts, remove the quantity branch fromcanConfirmRow. The function should now only guard onproblem_flagwithout a confirming note. -
Step 8: Remove unused imports
Read the top of page.tsx. Remove any imports that are no longer used: pb.collection("order_items"), pb.collection("stock_purchase_items") in the load body, the appendQuantityNote-style helper imports, the ModalShell import if no other dialog uses it in the file (the new dialog is inside ConfirmInvoiceDialog). Also remove ImageIcon if no longer referenced (the existing View photo button for collection_photo is still conditional — keep it if so, otherwise remove).
- Step 9: Run typecheck and the existing test file
Run: pnpm tsc --noEmit
Expected: 0 errors.
Run: pnpm vitest run src/app/(app)/collection-confirm/page.test.tsx
Expected: most existing tests FAIL because the page layout changed (this is the intended state for this task). We fix the tests in the next task.
- Step 10: Commit
git add src/app/(app)/collection-confirm/page.tsx src/app/(app)/collection-confirm/view-model.ts
git commit -m "refactor(collection-confirm): rewire page to use PoCard and new service endpoints"
Task 14: Update page.test.tsx and remove the quantity-modal cases
Files:
-
Modify:
src/app/(app)/collection-confirm/page.test.tsx -
Step 1: Replace the
load()mock
Find the existing vi.mock("@/lib/pocketbase") block at the top of the test file. Replace it with a fetch mock:
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
function mockFetchOnce(body: unknown, status = 200) {
fetchMock.mockResolvedValueOnce({
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as Response);
}
- Step 2: Update each existing test case
For each of the 9 it blocks in the file, replace its setup so it:
- Pre-loads the page with
mockFetchOnce({ items: [...] }, 200)then renders. - Asserts against the new layout (PO cards, items table, photo strip) instead of the old per-row card.
Reuse the existing 3-row fixture from the file (the customer rows with no-surplus, with-surplus, and already-booked) and a stock row fixture. Group them under one PO with po_number: "PO-1". The page's groupRowsByPurchaseOrder will produce one group.
Example for the "Renders collected rows" case (line 161):
mockFetchOnce({
items: [
{ id: "row-1", source: "customer", order_qty: 10, qty_collected: 10, cost_price: 45, poNumber: "PO-1", expand: { purchase_order: { po_number: "PO-1", expand: { supplier: { name: "Acme" } } } } },
{ id: "row-2", source: "customer", order_qty: 20, qty_collected: 22, cost_price: 12.5, poNumber: "PO-1", expand: { purchase_order: { po_number: "PO-1", expand: { supplier: { name: "Acme" } } } } },
],
});
render(<Page />);
await waitFor(() => expect(screen.getByText(/PO 1/)).toBeTruthy());
expect(screen.getByText(/Acme/)).toBeTruthy();
expect(screen.getByText(/R45\.00/)).toBeTruthy();
expect(screen.getByText(/R12\.50/)).toBeTruthy();
- Step 3: Add new test cases for cost edit and confirm
Append to the file:
describe("CollectionConfirmPage — new flow", () => {
it("calls POST /api/v1/order-items/{id}/cost when the cost pencil is saved", async () => {
mockFetchOnce({ items: [/* one row, see step 2 */] });
render(<Page />);
await waitFor(() => screen.getByRole("button", { name: /edit cost/i }));
mockFetchOnce({ id: "row-1", cost_price: 50 });
fireEvent.click(screen.getByRole("button", { name: /edit cost/i }));
fireEvent.change(screen.getByDisplayValue("45"), { target: { value: "50" } });
fireEvent.keyDown(screen.getByDisplayValue("50"), { key: "Enter" });
await waitFor(() => {
const call = fetchMock.mock.calls.find((c) => String(c[0]).includes("/cost"));
expect(call).toBeTruthy();
expect(JSON.parse(String(call?.[1]?.body))).toEqual({ cost_price: 50 });
});
});
it("calls POST /api/v1/order-items/confirm with the right body on confirm", async () => {
mockFetchOnce({ items: [/* one row */] });
render(<Page />);
await waitFor(() => screen.getByRole("button", { name: /confirm for invoicing/i }));
mockFetchOnce({ updatedIds: ["row-1"] });
fireEvent.click(screen.getByRole("button", { name: /confirm for invoicing/i }));
fireEvent.change(screen.getByLabelText(/invoice date/i), { target: { value: "2026-08-01" } });
fireEvent.change(screen.getByLabelText(/invoice number/i), { target: { value: "INV-1" } });
fireEvent.click(screen.getByRole("button", { name: /^confirm$/i }));
await waitFor(() => {
const call = fetchMock.mock.calls.find((c) => String(c[0]).endsWith("/confirm"));
expect(call).toBeTruthy();
const body = JSON.parse(String(call?.[1]?.body));
expect(body).toMatchObject({
itemIds: ["row-1"],
invoice: { date: "2026-08-01", number: "INV-1" },
});
});
});
});
- Step 4: Run the full page test file
Run: pnpm vitest run src/app/(app)/collection-confirm/page.test.tsx
Expected: ALL PASS.
- Step 5: Run the full test suite
Run: pnpm vitest run
Expected: ALL PASS across the project.
- Step 6: Run typecheck
Run: pnpm tsc --noEmit
Expected: 0 errors.
- Step 7: Manual smoke
Run the dev server (pnpm dev), navigate to /collection-confirm, and verify:
- The page loads with one card per PO.
- The photo strip shows a chip per distinct filename (or the empty state if no photos).
- Clicking a photo chip opens the file in a new tab.
- Clicking a cost pencil swaps the cell to an input; Enter saves; Esc reverts.
- The cost edit shows a toast on success and on failure.
- Clicking "Confirm for invoicing" opens the dialog with the right row count and override count.
- Submitting the dialog with date + number writes the cost override + invoice details + status and the row disappears from the confirm feed.
- Step 8: Commit
git add src/app/(app)/collection-confirm/page.test.tsx
git commit -m "test(collection-confirm): update page tests for new layout, add cost and confirm cases"
Self-Review (run by the plan author before publishing)
After writing the plan, the author ran the following checks and fixed issues inline:
- Spec coverage: every spec requirement is covered. Photo strip + dedup → Tasks 10 + 12. Inline cost edit → Task 11. Confirm flow with overrides → Tasks 5 + 8 + 12. Adjust Quantity removal → Task 13 step 7. Service-layer migration → Tasks 3-5 + 6-8. Notes-feed audit for cost → Task 4 (service) + Task 1 (view-model).
- Placeholder scan: no "TBD" or "implement later". Code is shown in full for every component.
- Type consistency:
ConfirmRowis defined in the service (Task 3) and re-exported through the view-model for the page to import. TheConfirmInvoiceSubmittype is defined inconfirm-invoice-dialog.tsx(Task 9) and consumed inpo-card.tsx(Task 12) and the page (Task 13).costOverridesis aRecord<string, number>consistently across the service, the route, the dialog, and the page. - PB batch URL: Task 5 step 3 includes a note telling the implementer to read the existing
buyer-tasks-service.tsbatch pattern and substitute the correct collection ref. This is intentional — we want the implementer to confirm the URL shape against the real codebase rather than the plan author guessing.
Execution Handoff
Plan complete and saved to docs/superpowers/plans/2026-08-02-collection-confirm-revamp.md. Two execution options:
- Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration.
- Inline Execution — Execute tasks in this session with executing-plans, batch execution with checkpoints.
Which approach?