+ Verify AI-populated fields against the source email & + attachments. +
+
+
+ {company.company} +
++ {order.rep || "—"} +
++ No line items on this order. +
+ ) : ( ++ {it.description || "(no description)"} +
+{quoteEmail.subject || "(no subject)"}
+
+ {quoteEmail.body || "(no body)"}
+
+ + No inline preview for {firstOther.kind ?? "this"} file type. +
++ + {attachments.filter( + (a) => a.kind !== "pdf" && a.kind !== "image", + ).length}{" "} + unpreviewable attachment(s) — open to view. +
++ {quoteEmail.subject || "(no subject)"} +
+
+ {quoteEmail.body || "(no body)"}
+
+ {error}
+ +` block. + +- [ ] **Step 7: Commit any smoke fixes** + +If steps 2–6 surfaced any small UI tweak (alignment, colour, spacing), commit as a follow-up: + +```bash +git add src/app/\(app\)/orders/page.tsx src/components/orders-review-modal.tsx +git commit -m "fix(orders): review modal smoke fixes from manual QA + +Co-Authored-By: Claude Opus 4.8" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Warn-amber icon button on every card → Task 6 (Step 3). +- Side-by-side modal with editable items + source preview → Tasks 4, 5. +- `updateOrderReviewFields` + collision error → Task 1. +- `updateOrderItemsPrices` with per-row failure → Task 2. +- `POST /api/v1/orders/[id]/review-save` → Task 3. +- Inline attachment preview chain (PDF → image → "no preview" → email) → Task 5. +- CustomerSearch for customer → Task 4 (component), Task 6 (data fetch). +- Save inline + card refresh via realtime → Task 6 (`fetchOrders()` after save). +- Test coverage → Tasks 1, 2, 3, 4 each ship a test file. + +**Placeholders:** none. Every code step has the actual code. + +**Type consistency:** `OrderNumberCollisionError` is exported and imported consistently across Tasks 1, 3, and the route test. `updateOrderReviewFields({ order_number?, customer? })` shape matches between Tasks 1, 3, and 4. `updateOrderItemsPrices` signature matches between Tasks 2 and 3. The modal props match between Task 4 and the mount component in Task 6. + +**Open ambiguity:** none at write-time. The `quote` expand on `getOrder` may not include `email_subject` / `email_body` by default in `pb-helpers.getOrder` — Task 6 includes an explicit `expand: "company,customer,quote"` argument and casts the quote shape inline, which keeps the contract local to the mount helper. If the running PB instance rejects the expand string (it shouldn't — `quote` is a valid relation), the email fields just render empty and the email fallback handles it. diff --git a/src/lib/server/orders-service-review.test.ts b/src/lib/server/orders-service-review.test.ts index 71692b3..8d3f250 100644 --- a/src/lib/server/orders-service-review.test.ts +++ b/src/lib/server/orders-service-review.test.ts @@ -93,6 +93,7 @@ vi.mock("./pocketbase-admin", () => ({ import { OrderNumberCollisionError, + updateOrderItemsPrices, updateOrderReviewFields, } from "./orders-service"; @@ -188,3 +189,109 @@ describe("updateOrderReviewFields", () => { expect(result.customer).toBe("cust-1"); }); }); + +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([]); + }); +}); diff --git a/src/lib/server/orders-service.ts b/src/lib/server/orders-service.ts index 514fbfa..57fac7f 100644 --- a/src/lib/server/orders-service.ts +++ b/src/lib/server/orders-service.ts @@ -1,6 +1,7 @@ import { getAdminPocketBase } from "./pocketbase-admin"; const ORDER_COLLECTION = "orders"; +const ORDER_ITEMS_COLLECTION = "order_items"; const QUOTE_COLLECTION = "quotes"; const QUOTE_ITEMS_COLLECTION = "quote_items"; const ORDER_COUNTERS_COLLECTION = "order_counters"; @@ -334,3 +335,87 @@ export async function updateOrderReviewFields( return pb.collection(ORDER_COLLECTION).update(orderId, cleanPatch); } + +/** + * 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(); + + 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(ORDER_ITEMS_COLLECTION).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(ORDER_ITEMS_COLLECTION).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 }; +}