Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Orca <help@stably.ai>
16 KiB
Orders Review Modal Design
Date: 2026-07-29
Area: src/app/(app)/orders/page.tsx, src/components/orders-review-modal.tsx (new), src/lib/server/orders-service.ts, src/app/api/v1/orders/[id]/review-save/route.ts (new)
Status: Drafted for review
Goal
Add a per-order "review against source" workflow so the operator can verify AI-populated order contents (line items, prices, customer) against the original email and attachments in one screen, and correct any AI mistakes inline — without leaving the orders list.
User Need
Today the AI populates orders from inbound emails via the n8n pipeline. When the AI makes a mistake (wrong order number, wrong customer, mis-extracted qty/price, missed line item), the operator has no way to see the AI's output next to the source-of-truth RFQ email/attachment. They have to remember what the email said, open the order detail page, and fix things one field at a time.
The operator needs:
- A visible affordance on every order card that opens a review surface.
- The order's line items side-by-side with the source email and attachment(s).
- Inline editing of order_number, customer, and line-item prices/quantities.
- A preview of the actual attachment (PDF/image) inline — not a separate download — so they only need to spot-check the PDF/picture against the system, not flip windows.
Approved Direction
Add a new amber-tinted icon button on every order card that opens a review modal. The modal lays out the order's editable fields on the left and an inline preview of the source attachment (PDF/image) — or, if no attachment, the email body — on the right. Edits save atomically via a new /api/v1/orders/[id]/review-save endpoint; the card refreshes in place via the existing PB realtime subscription.
Scope
In scope:
- A new
OrdersReviewModalcomponent rendered fromOrdersPagewhenever a card's review button is clicked. - A new warn-amber icon button on each
OrderCard, placed to the LEFT of the existingExternalLink"open" button. - A new
updateOrderReviewFields+updateOrderItemsPricespair insrc/lib/server/orders-service.ts. - A new
POST /api/v1/orders/[id]/review-saveroute handler. - Inline preview of the first matching attachment (PDF via iframe, image via
<img>, both via the existing/api/v1/files/{collectionId}/{recordId}/{token}proxy); tab strip when there are multiple. - Fallback to the email body (subject + body) when the order has zero source attachments.
- Free-text
order_numberoverride with uniqueness validation against theorderscollection (excluding self) on save. CustomerSearchcombobox for thecustomerfield.
Out of scope:
- Migrating the existing
getOrdersWithItems/searchOrdersWithItemscalls inOrdersPagefrompb-helpers.tsto the service layer (one resource at a time, no big-bang rewrites per AGENTS.md). - Renaming the order-number generator or changing the
order_counterscollection. The override is a free-text PATCH againstorders.order_number; the counter is untouched. - New PocketBase collections or schema changes.
- A dedicated
/orders/[id]/reviewroute. - Anything beyond header fields + line-item prices/qty in the editable region (description edits, supplier changes, status transitions, etc. stay on the order detail page).
Data Source
Reads from existing collections:
orders(id, order_number, company, customer, rep, quote, ai_insights)order_items(order, description, po_description, quantity, selling_price, cost_price, markup)quotes(via expand onorders.quote) — supplies the source email:email_subject,email_body, andemail_message_idfor fallbackquote_attachments(viaquoterelation) — supplies the source attachment(s)
Writes back to existing collections:
orders.order_number,orders.customerorder_items.quantity,order_items.selling_price,order_items.cost_price,order_items.markup
The modal reads via the existing pb.collection("orders").getOne(orderId, { expand: "quote,customer,company" }) pattern (mirrors the OrderDetailPage fetch) plus a separate quote_attachments query keyed on quote_attachments.quote = <orders.quote>. The proxy URL pattern /api/v1/files/{collectionId}/{recordId}/{token} is already used by SprintItemPricingModal for quote attachments.
UX
Card affordance
┌──────────────────────────────────────────────────────────────────────────┐
│ ┊ ▸ 🛒 ORD-2026-07-014 [↗ Q-2026-07-022] ✓ Complete · Acme Ltd … │
│ ┊ 12 items · 5 new · 3 processing · 2 delivered · 2 cancelled │
│ ┊ ⚠ ◯ │
└──────────────────────────────────────────────────────────────────────────┘
▲ ▲
│ │
│ └─ ExternalLink (existing) "open"
└───── NEW warn-amber icon "review"
Same size + ghost-icon styling as ExternalLink. Tinted warn-amber. Tooltip: "Review order against email & attachments." Click opens the modal.
Modal layout
┌──────────────────────────────────────────────────────────────────────────────┐
│ ⚠ Review order [Cancel] [ Save ] │
│ Verify AI-populated fields against the source email & attachments │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ Order number [ ORD-2026-07-014 ] │
│ Company Acme (Pty) Ltd (read-only) │
│ Customer [ 🏢 J. Smith — Acme ▾ ] (CustomerSearch) │
│ Rep Maria (read-only) │
│ │
├──────────────────────────────┬───────────────────────────────────────────────┤
│ ORDER ITEMS (left) │ SOURCE OF TRUTH (right) │
│ │ │
│ Description Qty Price │ ── Attachment ────────────────────────────── │
│ ──────────── ──── ──────── │ ┌─[Acme-RFQ.pdf] [site-photo.jpg]──────┐ │
│ HSS drill 6mm [2 ] [42.50]│ │ │ │
│ HSS drill 8mm [1 ] [38.00]│ │ [ PDF rendered via iframe / │ │
│ HSS drill 10mm[3 ] [44.00]│ │ image rendered via <img> ] │ │
│ HSS drill 12mm[4 ] [50.00]│ │ │ │
│ │ └───────────────────────────────────────┘ │
│ + markup auto-recomputes │ │
│ cost ↔ selling via lock │ ── (only if 0 attachments) ──────────────── │
│ │ Subject: Re: RFQ — Drill bits │
│ │ Hi team, please quote on the following: │
│ │ • 2× 6mm HSS drills │
│ │ • 1× 8mm HSS drills │
│ │ … │
└──────────────────────────────┴───────────────────────────────────────────────┘
Behavior details
- Editability — every numeric cell in the order items table is an
Input. Theselling_price↔cost_pricepair uses the lock-pattern fromSprintItemPricingModal(type into one, the other recomputes viamarkup). Quantity is a plain integer input.descriptionandpo_descriptionare read-only here (edits live on the order detail page). The modal shows all line items for the order regardless of which tab the operator came from — the review is always whole-order, never tab-filtered. - Dirty state — the modal tracks a
dirty: Set<"number" | "customer" | "items">so Save is disabled until at least one field changes. Cancel and Esc discard changes without confirmation if nothing's dirty; if dirty, show a confirm-discard dialog. - Save flow — POST to
/api/v1/orders/[id]/review-savewith{ order_number?, customer?, items?: [{ id, quantity?, selling_price?, cost_price? }] }. The route returns 200 on success, 409 on number collision (with the colliding order id), or 4xx with a message on validation failure. - Number collision error — rendered inline beneath the order-number input:
"ORD-2026-07-014 is already used by another order."The colliding order id is rendered as a link to/orders/<id>so the operator can jump straight to the existing row and decide what to do (merge, re-rename the new one, etc.). Modal stays open, no fields lost. - Realtime collision — if PB fires an
ordersororder_itemswrite while the modal is open, the modal keeps its own dirty state and refetches on close. The card behind the modal updates via the existing subscription; reopening the modal re-fetches fresh data. - No source email (order not from a quote) — right pane shows "No source email — this order wasn't created from a quote." Edits still work; nothing to preview.
Attachment preview rules
The preview region resolves attachments in this priority:
- If
quote_attachmentshas ≥1 entry withkind = "pdf", prefer the first PDF. Render via<iframe src={proxyUrl}>— the browser's native PDF viewer takes over. - Else if
quote_attachmentshas ≥1 entry withkind = "image", render the first image via<img src={proxyUrl}>. - Else if there are ≥1 attachments of any other kind, render a small list with "no preview — [open ↗]" links for each.
- Else (0 attachments total), render the email body in a
<pre>block, prefixed with the subject as a heading. - If there are multiple attachments, show a horizontal tab strip above the preview region so the operator can switch between them. Default tab is the first PDF if any, else first image, else first entry.
Service Layer
Two new functions in src/lib/server/orders-service.ts (the existing data-layer target per AGENTS.md):
updateOrderReviewFields(orderId, patch)
export async function updateOrderReviewFields(
orderId: string,
patch: { order_number?: string; customer?: string },
): Promise<Order>
customer is non-nullable here on purpose: the orders.customer field is a required relation, and the operator is choosing among real PB customer rows in the CustomerSearch combobox. Sending null would be a 400 from PB; the route handler enforces this so the modal can never accidentally clear the relation.
- If
order_numberis set and differs from the current value, querypb.collection("orders").getList(1, 1, { filter: \order_number = "${newNumber}" && id != "${orderId}"` }). If any row matches, throwOrderNumberCollisionError` carrying the colliding order id. - Else PATCH the order with the supplied fields via
pb.collection("orders").update(orderId, patch). - Return the updated
Order. - Add a typed error class (
OrderNumberCollisionError extends Error) so the route handler can map it to a 409 response.
updateOrderItemsPrices(orderId, items)
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 }> }>
- For each item, compute the patch (
quantity,selling_price,cost_price,markup— recomputemarkupfrom the other two when only one ofselling_price/cost_priceis supplied; otherwise passmarkupthrough). - PATCH each row via
pb.collection("order_items").update(id, patch). Catch per-row errors, accumulate intofailed. - Return
{ updated, failed }so the route can report partial success.
API Route
New file src/app/api/v1/orders/[id]/review-save/route.ts. Single endpoint, single verb:
POST /api/v1/orders/[id]/review-save
Request body:
{
order_number?: string;
customer?: string;
items?: Array<{
id: string;
quantity?: number | null;
selling_price?: number | null;
cost_price?: number | null;
}>;
}
Behavior:
- Parse + validate the body (zod or hand-rolled — match the existing route patterns).
- If
order_numberis present, callupdateOrderReviewFields(orderId, { order_number }). CatchOrderNumberCollisionError→ return409 { error: "order_number_collision", collidingOrderId }. - If
customeris present, callupdateOrderReviewFields(orderId, { customer }). - If
itemsis non-empty, callupdateOrderItemsPrices(orderId, items). - Return
200 { order: Order, items: { updated, failed } }.
Use getAdminPocketBase() for writes (mirrors the existing orders-service.ts pattern). The endpoint is server-only; no client-side PB write goes through.
Files Touched
- New:
src/components/orders-review-modal.tsx— the modal component. - New:
src/app/api/v1/orders/[id]/review-save/route.ts— the save endpoint. - Modified:
src/app/(app)/orders/page.tsx— add the warn-amber icon button toOrderCard; threadonOpenReviewprop; manage thereviewOrderIdstate at the page level. - Modified:
src/lib/server/orders-service.ts— addupdateOrderReviewFields,updateOrderItemsPrices, andOrderNumberCollisionError. - Tests (new):
src/components/orders-review-modal.test.tsx— at minimum: modal renders, dirty-state gate on Save, error renders on 409. - Tests (new):
src/lib/server/orders-service.test.ts(or extend existing) — order_number collision path, happy path, item price updates.
Error Handling
| Failure | Where caught | What the user sees |
|---|---|---|
| Number already used | Route handler | Inline error under order_number input; modal stays open |
| PB validation error on PATCH | Service layer | Toast with the PB error message; modal stays open |
| Partial item PATCH failure | Service layer | Toast: "Saved N items, M failed"; modal stays open; failed item ids shown beneath the items table so the operator can retry |
| Network failure on Save click | Client (try/catch) | Toast: "Save failed"; modal stays open; no fields lost |
| Realtime update during modal | Client (refetch) | Modal keeps dirty state; underlying card updates; reopen re-fetches |
Open Questions
None at draft time. The collision-validation UX, the attachment preview fallback chain, and the inline-edit scope are all settled.