diff --git a/docs/superpowers/plans/2026-07-29-orders-review-modal.md b/docs/superpowers/plans/2026-07-29-orders-review-modal.md new file mode 100644 index 0000000..2c7c98d --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-orders-review-modal.md @@ -0,0 +1,2311 @@ +# Orders Review 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 a per-order "review against source" modal so the operator can verify AI-populated order contents against the original email and attachment in one screen, with inline edits for order number, customer, and line-item prices/quantities. + +**Architecture:** A new `OrdersReviewModal` client component is wired into the existing `OrdersPage` from a new warn-amber icon button on every order card. The modal renders the order header + editable items table on the left and an inline preview of the source attachment (PDF via iframe / image via ``) or, when no attachment exists, the email body on the right. A new `POST /api/v1/orders/[id]/review-save` route handler wraps two new service-layer functions in `orders-service.ts` (`updateOrderReviewFields`, `updateOrderItemsPrices`) — the former enforces order-number uniqueness via a typed `OrderNumberCollisionError` so the modal can render an inline error and link to the colliding order. + +**Tech Stack:** Next.js 16 (App Router), React 19, TypeScript, PocketBase, Tailwind, lucide-react, sonner toasts. Tests run with Vitest + @testing-library/react. + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `src/lib/server/orders-service.ts` (modify) | Add `OrderNumberCollisionError`, `updateOrderReviewFields`, `updateOrderItemsPrices`. | +| `src/lib/server/orders-service-review.test.ts` (new) | Unit tests for the two new service functions + the error class. | +| `src/app/api/v1/orders/[id]/review-save/route.ts` (new) | `POST` handler that parses the body, calls the service, maps typed errors to HTTP codes. | +| `src/app/api/v1/orders/[id]/review-save/route.test.ts` (new) | Tests for the route: 400 on bad body, 409 on collision, 200 on success. | +| `src/components/orders-review-modal.tsx` (new) | The modal: header strip, items table, attachment preview, save flow. | +| `src/components/orders-review-modal.test.tsx` (new) | Modal renders, dirty-state gate on Save, collision-error renders inline. | +| `src/app/(app)/orders/page.tsx` (modify) | Add the warn-amber icon button to `OrderCard`; thread `onOpenReview` prop; manage `reviewOrderId` state. | + +--- + +## Task 1: `OrderNumberCollisionError` + `updateOrderReviewFields` + +**Files:** +- Modify: `src/lib/server/orders-service.ts` (append two new exports near the bottom, after `formatOrderNumber`) +- Test: `src/lib/server/orders-service-review.test.ts` (new) + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/server/orders-service-review.test.ts`: + +```ts +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const { store, pbMock, clearStore } = vi.hoisted(() => { + type StoreMap = Map>; + const store: Record = {}; + let seq = 0; + const nextId = () => `mock-${++seq}`; + + const getOrCreateMap = (name: string): StoreMap => { + if (!store[name]) store[name] = new Map(); + return store[name]; + }; + + const where = (name: string, filter: string): Record[] => { + const items = Array.from(getOrCreateMap(name).values()); + if (!filter || !filter.trim()) return items; + const clauses = filter.split("&&").map((c) => c.trim()); + return items.filter((item) => + clauses.every((clause) => { + const m = clause.match(/^(\w+)\s*([!=]+)\s*"?([^"]+)"?$/); + if (!m) return true; + const [, field, op, raw] = m; + const value = raw.replace(/^"|"$/g, ""); + const actual = String(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 } = {}, + ) { + const items = where(name, opts.filter || ""); + return { items, totalItems: items.length, page: 1, perPage: _perPage }; + }, + async getFullList() { + return where(name, ""); + }, + async getOne(id: string) { + const item = getOrCreateMap(name).get(id); + if (!item) { + const err = new Error("not found") as Error & { status: number }; + err.status = 404; + throw err; + } + return item; + }, + async create(data: Record) { + const item = { + id: nextId(), + created: new Date().toISOString(), + updated: new Date().toISOString(), + ...data, + }; + getOrCreateMap(name).set(item.id, item); + return item; + }, + async update(id: string, data: Record) { + const map = getOrCreateMap(name); + const existing = map.get(id); + if (!existing) { + const err = new Error("not found") as Error & { status: number }; + err.status = 404; + throw err; + } + for (const [key, value] of Object.entries(data)) { + existing[key] = value; + } + existing.updated = new Date().toISOString(); + return { ...existing }; + }, + }), + }; + + 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 { + OrderNumberCollisionError, + updateOrderReviewFields, +} from "./orders-service"; + +beforeEach(() => clearStore()); + +describe("updateOrderReviewFields", () => { + it("updates order_number when unique", async () => { + const order = await pbMock.collection("orders").create({ + order_number: "ORD-2026-07-001", + customer: "cust-old", + }); + + const result = await updateOrderReviewFields(order.id, { + order_number: "ORD-2026-07-014", + }); + + expect(result.order_number).toBe("ORD-2026-07-014"); + }); + + it("updates customer", async () => { + const order = await pbMock.collection("orders").create({ + order_number: "ORD-2026-07-001", + customer: "cust-old", + }); + + const result = await updateOrderReviewFields(order.id, { + customer: "cust-new", + }); + + expect(result.customer).toBe("cust-new"); + }); + + it("updates both in a single call", async () => { + const order = await pbMock.collection("orders").create({ + order_number: "ORD-2026-07-001", + customer: "cust-old", + }); + + const result = await updateOrderReviewFields(order.id, { + order_number: "ORD-2026-07-014", + customer: "cust-new", + }); + + expect(result.order_number).toBe("ORD-2026-07-014"); + expect(result.customer).toBe("cust-new"); + }); + + it("throws OrderNumberCollisionError when another order owns the number", async () => { + await pbMock.collection("orders").create({ + order_number: "ORD-2026-07-001", + customer: "cust-1", + }); + const order = await pbMock.collection("orders").create({ + order_number: "ORD-2026-07-002", + customer: "cust-2", + }); + + let caught: unknown; + try { + await updateOrderReviewFields(order.id, { + order_number: "ORD-2026-07-001", + }); + } catch (e) { + caught = e; + } + + expect(caught).toBeInstanceOf(OrderNumberCollisionError); + expect((caught as OrderNumberCollisionError).collidingOrderId).toBeTruthy(); + expect((caught as OrderNumberCollisionError).attemptedNumber).toBe( + "ORD-2026-07-001", + ); + }); + + it("does not throw when the new number equals the order's own current number", async () => { + const order = await pbMock.collection("orders").create({ + order_number: "ORD-2026-07-001", + customer: "cust-1", + }); + + // Same number on the same order should be a no-op, not a self-collision. + const result = await updateOrderReviewFields(order.id, { + order_number: "ORD-2026-07-001", + }); + + expect(result.order_number).toBe("ORD-2026-07-001"); + }); + + it("no-ops when patch is empty", async () => { + const order = await pbMock.collection("orders").create({ + order_number: "ORD-2026-07-001", + customer: "cust-1", + }); + + const result = await updateOrderReviewFields(order.id, {}); + + expect(result.order_number).toBe("ORD-2026-07-001"); + expect(result.customer).toBe("cust-1"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/orders-service-review.test.ts` +Expected: FAIL with "Cannot find module './orders-service' exports" or "OrderNumberCollisionError is not exported". + +- [ ] **Step 3: Write minimal implementation** + +Append to `src/lib/server/orders-service.ts` (after the existing `formatOrderNumber` function, before any final newlines): + +```ts +/** + * Thrown by `updateOrderReviewFields` when the requested `order_number` + * is already in use by a different order. Carries the colliding + * order's id so the route handler can include it in the 409 response + * (the modal renders it as a link to the conflicting order). + */ +export class OrderNumberCollisionError extends Error { + readonly collidingOrderId: string; + readonly attemptedNumber: string; + constructor(collidingOrderId: string, attemptedNumber: string) { + super( + `Order number "${attemptedNumber}" is already in use by another order.`, + ); + this.name = "OrderNumberCollisionError"; + this.collidingOrderId = collidingOrderId; + this.attemptedNumber = attemptedNumber; + } +} + +/** + * Patches the order header fields editable from the review modal. + * Currently supports `order_number` and `customer`. Validates + * `order_number` uniqueness against the `orders` collection + * (excluding self) before write; throws + * `OrderNumberCollisionError` on collision. + */ +export async function updateOrderReviewFields( + orderId: string, + patch: { order_number?: string; customer?: string }, +) { + const pb = await getAdminPocketBase(); + const ORDER_COLLECTION_NAME = "orders"; + + // Validate order_number uniqueness before write — we do this as a + // separate query rather than letting PB throw on its unique index + // so we can return the colliding row's id to the UI (PB's own + // 400 doesn't carry it). + if (patch.order_number !== undefined) { + const existing = await pb + .collection(ORDER_COLLECTION_NAME) + .getOne(orderId); + if (existing.order_number !== patch.order_number) { + const conflict = await pb + .collection(ORDER_COLLECTION_NAME) + .getList<{ id: string; order_number: string }>(1, 1, { + filter: `order_number = "${patch.order_number}" && id != "${orderId}"`, + }); + if (conflict.items.length > 0) { + throw new OrderNumberCollisionError( + conflict.items[0].id, + patch.order_number, + ); + } + } + } + + // Build a clean patch object with only the defined keys — passing + // `undefined` to PB is harmless but we keep the wire payload tight. + const cleanPatch: Record = {}; + if (patch.order_number !== undefined) cleanPatch.order_number = patch.order_number; + if (patch.customer !== undefined) cleanPatch.customer = patch.customer; + + if (Object.keys(cleanPatch).length === 0) { + // No-op — just re-fetch and return. + return pb.collection(ORDER_COLLECTION_NAME).getOne(orderId); + } + + return pb.collection(ORDER_COLLECTION_NAME).update(orderId, cleanPatch); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/server/orders-service-review.test.ts` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/orders-service.ts src/lib/server/orders-service-review.test.ts +git commit -m "feat(orders): add updateOrderReviewFields + OrderNumberCollisionError + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 2: `updateOrderItemsPrices` + +**Files:** +- Modify: `src/lib/server/orders-service.ts` (append below the previous additions) +- Test: extend `src/lib/server/orders-service-review.test.ts` (add a new `describe` block) + +- [ ] **Step 1: Extend the failing test** + +Append to `src/lib/server/orders-service-review.test.ts`, after the existing imports: + +```ts +import { + OrderNumberCollisionError, + updateOrderItemsPrices, + updateOrderReviewFields, +} from "./orders-service"; +``` + +And append a new `describe` block at the end of the file: + +```ts +describe("updateOrderItemsPrices", () => { + it("updates quantity, selling_price, and cost_price for each item", async () => { + const order = await pbMock.collection("orders").create({ + order_number: "ORD-2026-07-001", + }); + await pbMock.collection("order_items").create({ + id: "item-1", + order: order.id, + description: "Drill", + quantity: 2, + selling_price: 50, + cost_price: 25, + markup: 100, + }); + await pbMock.collection("order_items").create({ + id: "item-2", + order: order.id, + description: "Bit", + quantity: 1, + selling_price: 10, + cost_price: 5, + markup: 100, + }); + + const result = await updateOrderItemsPrices(order.id, [ + { id: "item-1", quantity: 5, selling_price: 60 }, + { id: "item-2", quantity: 3 }, + ]); + + expect(result.updated).toBe(2); + expect(result.failed).toEqual([]); + + const item1 = await pbMock.collection("order_items").getOne("item-1"); + expect(item1.quantity).toBe(5); + expect(item1.selling_price).toBe(60); + // markup should have been recomputed from cost/selling: (60-25)/25*100 = 140 + expect(item1.markup).toBe(140); + + const item2 = await pbMock.collection("order_items").getOne("item-2"); + expect(item2.quantity).toBe(3); + expect(item2.selling_price).toBe(10); // unchanged + }); + + it("passes markup through when explicitly supplied", async () => { + const order = await pbMock.collection("orders").create({ + order_number: "ORD-2026-07-001", + }); + await pbMock.collection("order_items").create({ + id: "item-1", + order: order.id, + description: "Drill", + quantity: 2, + selling_price: 50, + cost_price: 25, + markup: 100, + }); + + await updateOrderItemsPrices(order.id, [ + { id: "item-1", markup: 75 }, + ]); + + const item1 = await pbMock.collection("order_items").getOne("item-1"); + expect(item1.markup).toBe(75); + }); + + it("returns partial failure when one item update throws", async () => { + const order = await pbMock.collection("orders").create({ + order_number: "ORD-2026-07-001", + }); + await pbMock.collection("order_items").create({ + id: "item-good", + order: order.id, + description: "Drill", + quantity: 2, + selling_price: 50, + cost_price: 25, + markup: 100, + }); + // item-missing does NOT exist in the store. + + const result = await updateOrderItemsPrices(order.id, [ + { id: "item-good", quantity: 5 }, + { id: "item-missing", quantity: 1 }, + ]); + + expect(result.updated).toBe(1); + expect(result.failed).toEqual([ + { id: "item-missing", error: expect.stringMatching(/not found/) }, + ]); + + const good = await pbMock.collection("order_items").getOne("item-good"); + expect(good.quantity).toBe(5); + }); + + it("returns zero updated for empty array", async () => { + const order = await pbMock.collection("orders").create({ + order_number: "ORD-2026-07-001", + }); + + const result = await updateOrderItemsPrices(order.id, []); + + expect(result.updated).toBe(0); + expect(result.failed).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/orders-service-review.test.ts` +Expected: FAIL — `updateOrderItemsPrices is not exported`. + +- [ ] **Step 3: Write minimal implementation** + +Append to `src/lib/server/orders-service.ts`: + +```ts +/** + * Patches price/qty fields on the order's line items in a non- + * transactional batch. Each item is PATCHed individually; per-row + * failures are accumulated into `failed` rather than aborting the + * batch, so the operator sees which lines couldn't save and can + * retry just those. + * + * Markup computation: if the caller supplies `selling_price` OR + * `cost_price` (but not both) and we know the other from the + * current row, we recompute `markup` from the supplied + known + * values. If the caller supplies `markup` explicitly, we pass it + * through verbatim. + */ +export async function updateOrderItemsPrices( + orderId: string, + items: Array<{ + id: string; + quantity?: number | null; + selling_price?: number | null; + cost_price?: number | null; + markup?: number | null; + }>, +): Promise<{ + updated: number; + failed: Array<{ id: string; error: string }>; +}> { + const pb = await getAdminPocketBase(); + const orderItemsCollection = getOrderItemsCollectionName(); + + if (items.length === 0) return { updated: 0, failed: [] }; + + const updated: string[] = []; + const failed: Array<{ id: string; error: string }> = []; + + for (const item of items) { + try { + // Read the current row so we can compute markup when only + // one of selling_price/cost_price is supplied. + const current = await pb.collection(orderItemsCollection).getOne(item.id); + + const patch: Record = {}; + if (item.quantity !== undefined) patch.quantity = item.quantity; + if (item.selling_price !== undefined) patch.selling_price = item.selling_price; + if (item.cost_price !== undefined) patch.cost_price = item.cost_price; + + // Markup computation: + // - explicit markup in patch → pass through + // - selling_price supplied, cost_price not → recompute from selling/current-cost + // - cost_price supplied, selling_price not → recompute from current-selling/cost + // - both supplied → recompute from the new pair + // - neither supplied, no explicit markup → leave current markup alone + if (item.markup !== undefined) { + patch.markup = item.markup; + } else if ( + item.selling_price !== undefined || + item.cost_price !== undefined + ) { + const newSelling = item.selling_price ?? current.selling_price; + const newCost = item.cost_price ?? current.cost_price; + if (Number(newCost) > 0) { + patch.markup = + Math.round(((Number(newSelling) - Number(newCost)) / Number(newCost)) * 10000) / 100; + } else { + patch.markup = 0; + } + } + + await pb.collection(orderItemsCollection).update(item.id, patch); + updated.push(item.id); + } catch (err) { + failed.push({ + id: item.id, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + // Surface unused param so tsc doesn't complain if `orderId` is + // never referenced in the future; today it's documentation of the + // contract (caller must scope the items to a single order). + void orderId; + + return { updated: updated.length, failed }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/server/orders-service-review.test.ts` +Expected: PASS (10 tests total — 6 from Task 1 + 4 from this task). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/orders-service.ts src/lib/server/orders-service-review.test.ts +git commit -m "feat(orders): add updateOrderItemsPrices with batch + per-row failure + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 3: API route — `POST /api/v1/orders/[id]/review-save` + +**Files:** +- Create: `src/app/api/v1/orders/[id]/review-save/route.ts` +- Test: `src/app/api/v1/orders/[id]/review-save/route.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/app/api/v1/orders/[id]/review-save/route.test.ts`: + +```ts +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const updateOrderReviewFields = vi.fn(); +const updateOrderItemsPrices = vi.fn(); +const collisionError: Error & { collidingOrderId?: string; attemptedNumber?: string } = + new Error("collision"); +collisionError.name = "OrderNumberCollisionError"; +collisionError.collidingOrderId = "order-collide"; +collisionError.attemptedNumber = "ORD-2026-07-014"; + +vi.mock("@/lib/server/orders-service", () => ({ + updateOrderReviewFields: (...args: unknown[]) => + updateOrderReviewFields(...args), + updateOrderItemsPrices: (...args: unknown[]) => + updateOrderItemsPrices(...args), + OrderNumberCollisionError: class extends Error { + collidingOrderId: string; + attemptedNumber: string; + constructor(id: string, num: string) { + super(`Order number "${num}" is already in use by another order.`); + this.name = "OrderNumberCollisionError"; + this.collidingOrderId = id; + this.attemptedNumber = num; + } + }, +})); + +import { POST } from "./route"; + +function postJSON(body: unknown) { + return new NextRequest( + "http://localhost/api/v1/orders/order-1/review-save", + { + method: "POST", + body: JSON.stringify(body), + }, + ); +} + +describe("POST /api/v1/orders/[id]/review-save", () => { + beforeEach(() => { + updateOrderReviewFields.mockReset(); + updateOrderItemsPrices.mockReset(); + }); + + it("400s when body is not JSON", async () => { + const req = new NextRequest( + "http://localhost/api/v1/orders/order-1/review-save", + { method: "POST", body: "not json" }, + ); + const res = await POST(req, { params: Promise.resolve({ id: "order-1" }) }); + expect(res.status).toBe(400); + }); + + it("400s when body is null", async () => { + const res = await POST(postJSON(null), { + params: Promise.resolve({ id: "order-1" }), + }); + expect(res.status).toBe(400); + }); + + it("200s with empty body (no-op)", async () => { + updateOrderReviewFields.mockResolvedValueOnce({ + id: "order-1", + order_number: "ORD-2026-07-001", + customer: "cust-1", + }); + const res = await POST(postJSON({}), { + params: Promise.resolve({ id: "order-1" }), + }); + expect(res.status).toBe(200); + expect(updateOrderReviewFields).toHaveBeenCalledWith("order-1", {}); + const body = await res.json(); + expect(body.order.id).toBe("order-1"); + expect(body.items.updated).toBe(0); + }); + + it("calls updateOrderReviewFields with order_number when supplied", async () => { + updateOrderReviewFields.mockResolvedValueOnce({ + id: "order-1", + order_number: "ORD-2026-07-014", + customer: "cust-1", + }); + updateOrderItemsPrices.mockResolvedValueOnce({ updated: 0, failed: [] }); + + const res = await POST( + postJSON({ order_number: "ORD-2026-07-014" }), + { params: Promise.resolve({ id: "order-1" }) }, + ); + + expect(res.status).toBe(200); + expect(updateOrderReviewFields).toHaveBeenCalledWith("order-1", { + order_number: "ORD-2026-07-014", + }); + }); + + it("returns 409 on OrderNumberCollisionError with collidingOrderId", async () => { + updateOrderReviewFields.mockRejectedValueOnce(collisionError); + + const res = await POST( + postJSON({ order_number: "ORD-2026-07-014" }), + { params: Promise.resolve({ id: "order-1" }) }, + ); + + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toBe("order_number_collision"); + expect(body.collidingOrderId).toBe("order-collide"); + expect(body.attemptedNumber).toBe("ORD-2026-07-014"); + }); + + it("calls updateOrderItemsPrices when items are supplied", async () => { + updateOrderReviewFields.mockResolvedValueOnce({ + id: "order-1", + order_number: "ORD-2026-07-014", + customer: "cust-1", + }); + updateOrderItemsPrices.mockResolvedValueOnce({ + updated: 2, + failed: [], + }); + + const res = await POST( + postJSON({ + order_number: "ORD-2026-07-014", + items: [ + { id: "item-1", quantity: 5 }, + { id: "item-2", selling_price: 99 }, + ], + }), + { params: Promise.resolve({ id: "order-1" }) }, + ); + + expect(res.status).toBe(200); + expect(updateOrderItemsPrices).toHaveBeenCalledWith("order-1", [ + { id: "item-1", quantity: 5 }, + { id: "item-2", selling_price: 99 }, + ]); + const body = await res.json(); + expect(body.items.updated).toBe(2); + expect(body.items.failed).toEqual([]); + }); + + it("returns 500 on unexpected service error", async () => { + updateOrderReviewFields.mockRejectedValueOnce(new Error("boom")); + const res = await POST(postJSON({ order_number: "X" }), { + params: Promise.resolve({ id: "order-1" }), + }); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toMatch(/boom/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/app/api/v1/orders/\[id\]/review-save/route.test.ts` +Expected: FAIL — `./route` not found. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/app/api/v1/orders/[id]/review-save/route.ts`: + +```ts +import { NextRequest, NextResponse } from "next/server"; +import { + OrderNumberCollisionError, + updateOrderItemsPrices, + updateOrderReviewFields, +} from "@/lib/server/orders-service"; + +/** + * POST /api/v1/orders/[id]/review-save + * + * Atomically commits the operator's review-time edits to the order + * header (order_number, customer) and any line-item price/qty + * patches. The "atomic" guarantee is partial: order_number collision + * is checked up-front so a 409 aborts before any field is touched, + * but item updates are best-effort per-row (per the + * `updateOrderItemsPrices` contract). + * + * Request body: + * { + * order_number?: string; + * customer?: string; + * items?: Array<{ + * id: string; + * quantity?: number | null; + * selling_price?: number | null; + * cost_price?: number | null; + * }>; + * } + * + * Responses: + * 200 — saved. Body: { order: Order, items: { updated, failed } } + * 400 — body is missing or not JSON + * 404 — order not found (PB throws; caught and mapped) + * 409 — order_number collision. Body: { error, collidingOrderId, attemptedNumber } + * 500 — anything else + */ +export async function POST( + req: NextRequest, + ctx: { params: Promise<{ id: string }> }, +) { + const { id: orderId } = await ctx.params; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Body must be JSON" }, { status: 400 }); + } + if (body === null || typeof body !== "object") { + return NextResponse.json({ error: "Body must be an object" }, { status: 400 }); + } + + const { order_number, customer, items } = body as { + order_number?: string; + customer?: string; + items?: Array<{ + id: string; + quantity?: number | null; + selling_price?: number | null; + cost_price?: number | null; + }>; + }; + + try { + // Header patch — order_number + customer are bundled because the + // collision check needs to fire BEFORE any items[] work runs. + const headerPatch: { order_number?: string; customer?: string } = {}; + if (typeof order_number === "string") headerPatch.order_number = order_number; + if (typeof customer === "string") headerPatch.customer = customer; + + let order: unknown; + if (Object.keys(headerPatch).length > 0) { + order = await updateOrderReviewFields(orderId, headerPatch); + } else { + // No header changes — just return the current order row. + const { getAdminPocketBase } = await import("@/lib/server/pocketbase-admin"); + const pb = await getAdminPocketBase(); + order = await pb.collection("orders").getOne(orderId); + } + + const itemResult = + Array.isArray(items) && items.length > 0 + ? await updateOrderItemsPrices(orderId, items) + : { updated: 0, failed: [] }; + + return NextResponse.json({ order, items: itemResult }); + } catch (err) { + if (err instanceof OrderNumberCollisionError) { + return NextResponse.json( + { + error: "order_number_collision", + collidingOrderId: err.collidingOrderId, + attemptedNumber: err.attemptedNumber, + }, + { status: 409 }, + ); + } + const message = err instanceof Error ? err.message : "Save failed"; + const status = + (err as Error & { status?: number }).status === 404 ? 404 : 500; + return NextResponse.json({ error: message }, { status }); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/app/api/v1/orders/\[id\]/review-save/route.test.ts` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/app/api/v1/orders/\[id\]/review-save/ +git commit -m "feat(orders): POST /api/v1/orders/[id]/review-save endpoint + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 4: Modal scaffold — header strip + items table + dirty state (no preview yet) + +**Files:** +- Create: `src/components/orders-review-modal.tsx` +- Test: `src/components/orders-review-modal.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `src/components/orders-review-modal.test.tsx`: + +```tsx +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; + +vi.mock("@/components/ui/customer-search", () => ({ + CustomerSearch: ({ + onValueChange, + value, + }: { + onValueChange: (v: string) => void; + value: string; + customers: unknown[]; + }) => ( + + ), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +import { OrdersReviewModal } from "./orders-review-modal"; +import type { Order, OrderItem, Customer, Company } from "@/lib/types"; + +const baseOrder: Order = { + id: "order-1", + created: "2026-07-28T00:00:00Z", + updated: "2026-07-28T00:00:00Z", + collectionId: "c1", + collectionName: "orders", + order_number: "ORD-2026-07-001", + company: "comp-1", + customer: "cust-1", + quote: "quote-1", + notes: "", + quote_matching_attempted: true, + quote_match_confidence: 1, + potential_quote: "", + potential_quote_confidence: 0, + is_repair_order: false, + rep: "Maria", + invoiced: false, + quote_reference_text: "", + quote_match_method: "", +}; + +const baseCustomer: Customer = { + id: "cust-1", + created: "", + updated: "", + collectionId: "", + collectionName: "", + customer: "J. Smith", + email: "", + alternative_email: "", + buyer_email: "", + number: "", + company: "comp-1", + dateadded: "", + lastvisiteddate: "", + rep: "", + description: "", +}; + +const baseCompany: Company = { + id: "comp-1", + created: "", + updated: "", + collectionId: "", + collectionName: "", + company: "Acme (Pty) Ltd", + address: "", + vendor: "", + company_email: "", + company_tel: "", +}; + +const baseItems: OrderItem[] = [ + { + id: "item-1", + created: "", + updated: "", + collectionId: "", + collectionName: "", + description: "HSS drill 6mm", + po_description: "", + quantity: 2, + order_qty: 2, + selling_price: 50, + supplier: "", + cost_price: 25, + markup: 100, + purchase_order: "", + item_status: "pending", + in_stock: false, + notes: "", + }, +]; + +const baseProps = () => ({ + open: true, + onClose: vi.fn(), + onSaved: vi.fn(), + order: baseOrder, + items: baseItems, + customers: [baseCustomer], + customer: baseCustomer, + company: baseCompany, + quoteEmail: { subject: "Re: RFQ", body: "Hi team..." }, + attachments: [], +}); + +describe("OrdersReviewModal", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("renders order number and customer in the header", () => { + render(); + expect( + screen.getByDisplayValue("ORD-2026-07-001"), + ).toBeInTheDocument(); + expect(screen.getByText("J. Smith")).toBeInTheDocument(); + expect(screen.getByText("Acme (Pty) Ltd")).toBeInTheDocument(); + }); + + it("renders one row per line item", () => { + render(); + expect(screen.getByText("HSS drill 6mm")).toBeInTheDocument(); + expect(screen.getByDisplayValue("2")).toBeInTheDocument(); + expect(screen.getByDisplayValue("50")).toBeInTheDocument(); + }); + + it("Save button is disabled until a field is changed", () => { + render(); + const save = screen.getByRole("button", { name: /save/i }); + expect(save).toBeDisabled(); + }); + + it("Save button becomes enabled after editing the order number", () => { + render(); + const numberInput = screen.getByDisplayValue("ORD-2026-07-001"); + fireEvent.change(numberInput, { + target: { value: "ORD-2026-07-014" }, + }); + const save = screen.getByRole("button", { name: /save/i }); + expect(save).not.toBeDisabled(); + }); + + it("renders inline error from the API on 409", async () => { + const fetchMock = vi + .spyOn(global, "fetch") + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: "order_number_collision", + collidingOrderId: "order-other", + attemptedNumber: "ORD-2026-07-014", + }), + { status: 409 }, + ), + ); + + render(); + const numberInput = screen.getByDisplayValue("ORD-2026-07-001"); + fireEvent.change(numberInput, { + target: { value: "ORD-2026-07-014" }, + }); + fireEvent.click(screen.getByRole("button", { name: /save/i })); + + await waitFor(() => { + expect( + screen.getByText(/ORD-2026-07-014 is already used by another order/i), + ).toBeInTheDocument(); + }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/v1/orders/order-1/review-save", + expect.objectContaining({ method: "POST" }), + ); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/components/orders-review-modal.test.tsx` +Expected: FAIL — `./orders-review-modal` not found. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/components/orders-review-modal.tsx`: + +```tsx +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + AlertTriangle, + Check, + ExternalLink, + Loader2, + Mail, + Paperclip, + X, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { CustomerSearch } from "@/components/ui/customer-search"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import type { + Company, + Customer, + Order, + OrderItem, + QuoteAttachment, +} from "@/lib/types"; + +const round2 = (n: number) => Math.round(n * 100) / 100; + +type Lock = "cost" | "selling"; + +export interface OrdersReviewModalProps { + open: boolean; + onClose: () => void; + onSaved: () => void; + order: Order; + items: OrderItem[]; + customers: Customer[]; + customer: Customer; + company: Company; + quoteEmail: { subject: string; body: string }; + attachments: QuoteAttachment[]; +} + +interface ItemDraft { + quantity: number | null; + selling_price: number | null; + cost_price: number | null; + markup: number | null; + lock: Lock; +} + +function initialItemDraft(it: OrderItem): ItemDraft { + return { + quantity: it.quantity, + selling_price: it.selling_price, + cost_price: it.cost_price, + markup: it.markup ?? null, + lock: + it.cost_price && it.cost_price > 0 ? "cost" : "selling", + }; +} + +export function OrdersReviewModal({ + open, + onClose, + onSaved, + order, + items, + customers, + customer, + company, + quoteEmail, + attachments, +}: OrdersReviewModalProps) { + const [orderNumber, setOrderNumber] = useState(order.order_number); + const [customerId, setCustomerId] = useState(order.customer); + const [itemDrafts, setItemDrafts] = useState>( + () => + Object.fromEntries( + items.map((it) => [it.id, initialItemDraft(it)]), + ), + ); + const [numberError, setNumberError] = useState(null); + const [collidingOrderId, setCollidingOrderId] = useState(null); + const [saving, setSaving] = useState(false); + + // Re-seed local state when the modal opens for a different order. + // Parent already remounts via `key={order.id}`, but defensively + // re-seed here too in case anyone forgets the key. + useEffect(() => { + if (!open) return; + setOrderNumber(order.order_number); + setCustomerId(order.customer); + setItemDrafts( + Object.fromEntries(items.map((it) => [it.id, initialItemDraft(it)])), + ); + setNumberError(null); + setCollidingOrderId(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, order.id]); + + const dirty = useMemo(() => { + if (orderNumber !== order.order_number) return true; + if (customerId !== order.customer) return true; + for (const it of items) { + const d = itemDrafts[it.id]; + if (!d) continue; + if (d.quantity !== it.quantity) return true; + if (d.selling_price !== it.selling_price) return true; + if (d.cost_price !== it.cost_price) return true; + if ((d.markup ?? null) !== (it.markup ?? null)) return true; + } + return false; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orderNumber, customerId, itemDrafts, order.id]); + + const updateItemDraft = useCallback( + (id: string, patch: Partial) => { + setItemDrafts((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } })); + }, + [], + ); + + const onCostChange = (id: string, raw: string) => { + const value = Number(raw) || 0; + const d = itemDrafts[id]; + if (!d) return; + const newSelling = round2(value * (1 + (d.markup ?? 0) / 100)); + updateItemDraft(id, { + cost_price: value, + selling_price: newSelling, + lock: "cost", + }); + }; + + const onSellingChange = (id: string, raw: string) => { + const value = Number(raw) || 0; + const d = itemDrafts[id]; + if (!d) return; + const newCost = + (d.markup ?? 0) > -100 ? round2(value / (1 + (d.markup ?? 0) / 100)) : 0; + updateItemDraft(id, { + selling_price: value, + cost_price: newCost, + lock: "selling", + }); + }; + + const onMarkupChange = (id: string, raw: string) => { + const value = Number(raw) || 0; + const d = itemDrafts[id]; + if (!d) return; + if (d.lock === "cost") { + const newSelling = round2((d.cost_price ?? 0) * (1 + value / 100)); + updateItemDraft(id, { markup: value, selling_price: newSelling }); + } else { + const newCost = + value > -100 ? round2((d.selling_price ?? 0) / (1 + value / 100)) : 0; + updateItemDraft(id, { markup: value, cost_price: newCost }); + } + }; + + const onQuantityChange = (id: string, raw: string) => { + const value = raw === "" ? null : Number(raw); + updateItemDraft(id, { quantity: value }); + }; + + const handleSave = async () => { + setSaving(true); + setNumberError(null); + setCollidingOrderId(null); + + const body: { + order_number?: string; + customer?: string; + items?: Array<{ + id: string; + quantity?: number | null; + selling_price?: number | null; + cost_price?: number | null; + }>; + } = {}; + + if (orderNumber !== order.order_number) body.order_number = orderNumber; + if (customerId !== order.customer) body.customer = customerId; + + const dirtyItems = items + .map((it) => { + const d = itemDrafts[it.id]; + if (!d) return null; + const changed: { + id: string; + quantity?: number | null; + selling_price?: number | null; + cost_price?: number | null; + } = { id: it.id }; + let isDirty = false; + if (d.quantity !== it.quantity) { + changed.quantity = d.quantity; + isDirty = true; + } + if (d.selling_price !== it.selling_price) { + changed.selling_price = d.selling_price; + isDirty = true; + } + if (d.cost_price !== it.cost_price) { + changed.cost_price = d.cost_price; + isDirty = true; + } + return isDirty ? changed : null; + }) + .filter((x): x is NonNullable => x !== null); + + if (dirtyItems.length > 0) body.items = dirtyItems; + + try { + const res = await fetch( + `/api/v1/orders/${order.id}/review-save`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + + if (res.status === 409) { + const err = await res.json(); + setNumberError( + `"${err.attemptedNumber}" is already used by another order.`, + ); + setCollidingOrderId(err.collidingOrderId); + return; + } + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Save failed (HTTP ${res.status})`); + } + + const result = await res.json(); + if (result.items?.failed?.length > 0) { + toast.error( + `Saved ${result.items.updated} items, ${result.items.failed.length} failed`, + ); + return; + } + + toast.success("Saved"); + onSaved(); + onClose(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Save failed"); + } finally { + setSaving(false); + } + }; + + // Esc closes without saving (no confirm-discard in v1; dirty work is + // small and the operator can re-open to retry). + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !saving) onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, saving, onClose]); + + if (!open) return null; + + return ( +
{ + if (e.target === e.currentTarget && !saving) onClose(); + }} + > + + +
+ + + Review order + +

+ Verify AI-populated fields against the source email & + attachments. +

+
+ +
+ + + {/* Header strip */} +
+
+ + { + setOrderNumber(e.target.value); + setNumberError(null); + setCollidingOrderId(null); + }} + className={cn( + "h-9 font-mono text-sm", + numberError && "border-destructive focus-visible:ring-destructive/40", + )} + data-testid="review-order-number" + /> + {numberError && ( +

+ + {numberError} + {collidingOrderId && ( + + open existing + + )} +

+ )} +
+ +
+ + +
+ +
+ +

+ {company.company} +

+
+ +
+ +

+ {order.rep || "—"} +

+
+
+ + {/* Items table (left side) + preview placeholder (right side). + The preview region is filled in by Task 5. */} +
+
+

+ Order items +

+ {items.length === 0 ? ( +

+ No line items on this order. +

+ ) : ( +
    + {items.map((it) => { + const d = itemDrafts[it.id]; + if (!d) return null; + return ( +
  • +

    + {it.description || "(no description)"} +

    +
    +
    + + + onQuantityChange(it.id, e.target.value) + } + className="h-8 text-xs tabular-nums" + /> +
    +
    + + onCostChange(it.id, e.target.value)} + className={cn( + "h-8 text-xs tabular-nums", + d.lock === "cost" && "border-warn/40 bg-warn/10", + )} + /> +
    +
    + + + onMarkupChange(it.id, e.target.value) + } + className="h-8 text-xs tabular-nums" + /> +
    +
    + + + onSellingChange(it.id, e.target.value) + } + className={cn( + "h-8 text-xs tabular-nums", + d.lock === "selling" && "border-warn/40 bg-warn/10", + )} + /> +
    +
    +
  • + ); + })} +
+ )} +
+ +
+

+ Source of truth +

+ {/* Task 5 fills this in. */} + +
+
+
+ +
+ + +
+
+
+ ); +} + +// --- Source-of-truth panel (filled out in Task 5) ------------------------- +function SourceOfTruth({ + quoteEmail, + attachments, +}: { + quoteEmail: { subject: string; body: string }; + attachments: QuoteAttachment[]; +}) { + if (attachments.length === 0) { + return ( +
+
+ + Email +
+

{quoteEmail.subject || "(no subject)"}

+
+          {quoteEmail.body || "(no body)"}
+        
+
+ ); + } + return ( +
+
+ + Attachments ({attachments.length}) — preview added in Task 5 +
+
    + {attachments.map((a) => ( +
  • + {a.filename} +
  • + ))} +
+
+ ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/components/orders-review-modal.test.tsx` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/components/orders-review-modal.tsx src/components/orders-review-modal.test.tsx +git commit -m "feat(orders): review modal scaffold (header + items + dirty state) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 5: Inline attachment preview chain (PDF / image / email fallback) + +**Files:** +- Modify: `src/components/orders-review-modal.tsx` (replace the `SourceOfTruth` component with a real preview chain) + +- [ ] **Step 1: Extend the failing test** + +Append to `src/components/orders-review-modal.test.tsx`, inside the existing `describe("OrdersReviewModal")` block: + +```tsx + it("renders PDF preview via iframe when first attachment is a PDF", () => { + render( + , + ); + const iframe = document.querySelector("iframe"); + expect(iframe).toBeInTheDocument(); + expect(iframe?.getAttribute("src")).toBe( + "/api/v1/files/coll-1/att-1/acme-token", + ); + }); + + it("renders image preview via when first attachment is an image", () => { + render( + , + ); + const img = document.querySelector("img"); + expect(img).toBeInTheDocument(); + expect(img?.getAttribute("src")).toBe( + "/api/v1/files/coll-1/att-1/photo-token", + ); + }); + + it("renders 'no preview' list when attachments are not PDF/image", () => { + render( + , + ); + expect(screen.getByText(/no preview/i)).toBeInTheDocument(); + expect(screen.getByText("doc.docx")).toBeInTheDocument(); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/components/orders-review-modal.test.tsx` +Expected: FAIL — iframe / img / "no preview" not found. + +- [ ] **Step 3: Implement the preview chain** + +Replace the `SourceOfTruth` function in `src/components/orders-review-modal.tsx` with: + +```tsx +// --- Source-of-truth panel ----------------------------------------------- +// Inline preview of the source attachment so the operator can scan +// the actual PDF/image against the order items in one screen. +// Falls back to the email body when no attachments exist. +function SourceOfTruth({ + quoteEmail, + attachments, +}: { + quoteEmail: { subject: string; body: string }; + attachments: QuoteAttachment[]; +}) { + if (attachments.length === 0) { + return ; + } + + // Pick the first PDF if any, else first image, else the first + // entry of any other kind (which gets the "no preview" treatment). + const firstPdf = attachments.find((a) => a.kind === "pdf"); + const firstImage = attachments.find((a) => a.kind === "image"); + const firstOther = attachments.find( + (a) => a.kind !== "pdf" && a.kind !== "image", + ); + + const previewTarget = firstPdf ?? firstImage ?? null; + const othersAreUnpreviewable = attachments.some( + (a) => a.kind !== "pdf" && a.kind !== "image", + ); + + return ( +
+
+ + Attachments ({attachments.length}) +
+ + {/* Tab strip — one button per attachment, primary tab = previewTarget */} + {attachments.length > 1 && ( +
+ {attachments.map((a) => { + const isActive = previewTarget && a.id === previewTarget.id; + const previewable = a.kind === "pdf" || a.kind === "image"; + return ( + + {previewable ? "●" : "○"} {a.filename} + + ); + })} +
+ )} + + {previewTarget && previewTarget.kind === "pdf" && ( +