WFDASH-WEBAPP/docs/superpowers/specs/2026-07-29-orders-review-modal-design.md
SJCoetz 03ca2c8d29 docs: clarify customer non-nullable, full-order scope, collision link, partial-fail UX
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
2026-07-29 13:54:21 +02:00

16 KiB
Raw Permalink Blame History

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 OrdersReviewModal component rendered from OrdersPage whenever a card's review button is clicked.
  • A new warn-amber icon button on each OrderCard, placed to the LEFT of the existing ExternalLink "open" button.
  • A new updateOrderReviewFields + updateOrderItemsPrices pair in src/lib/server/orders-service.ts.
  • A new POST /api/v1/orders/[id]/review-save route 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_number override with uniqueness validation against the orders collection (excluding self) on save.
  • CustomerSearch combobox for the customer field.

Out of scope:

  • Migrating the existing getOrdersWithItems / searchOrdersWithItems calls in OrdersPage from pb-helpers.ts to the service layer (one resource at a time, no big-bang rewrites per AGENTS.md).
  • Renaming the order-number generator or changing the order_counters collection. The override is a free-text PATCH against orders.order_number; the counter is untouched.
  • New PocketBase collections or schema changes.
  • A dedicated /orders/[id]/review route.
  • 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 on orders.quote) — supplies the source email: email_subject, email_body, and email_message_id for fallback
  • quote_attachments (via quote relation) — supplies the source attachment(s)

Writes back to existing collections:

  • orders.order_number, orders.customer
  • order_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. The selling_price ↔ cost_price pair uses the lock-pattern from SprintItemPricingModal (type into one, the other recomputes via markup). Quantity is a plain integer input. description and po_description are 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-save with { 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 orders or order_items write 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:

  1. If quote_attachments has ≥1 entry with kind = "pdf", prefer the first PDF. Render via <iframe src={proxyUrl}> — the browser's native PDF viewer takes over.
  2. Else if quote_attachments has ≥1 entry with kind = "image", render the first image via <img src={proxyUrl}>.
  3. Else if there are ≥1 attachments of any other kind, render a small list with "no preview — [open ↗]" links for each.
  4. Else (0 attachments total), render the email body in a <pre> block, prefixed with the subject as a heading.
  5. 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_number is set and differs from the current value, query pb.collection("orders").getList(1, 1, { filter: \order_number = "${newNumber}" && id != "${orderId}"` }). If any row matches, throw OrderNumberCollisionError` 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 — recompute markup from the other two when only one of selling_price / cost_price is supplied; otherwise pass markup through).
  • PATCH each row via pb.collection("order_items").update(id, patch). Catch per-row errors, accumulate into failed.
  • 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:

  1. Parse + validate the body (zod or hand-rolled — match the existing route patterns).
  2. If order_number is present, call updateOrderReviewFields(orderId, { order_number }). Catch OrderNumberCollisionError → return 409 { error: "order_number_collision", collidingOrderId }.
  3. If customer is present, call updateOrderReviewFields(orderId, { customer }).
  4. If items is non-empty, call updateOrderItemsPrices(orderId, items).
  5. 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 to OrderCard; thread onOpenReview prop; manage the reviewOrderId state at the page level.
  • Modified: src/lib/server/orders-service.ts — add updateOrderReviewFields, updateOrderItemsPrices, and OrderNumberCollisionError.
  • 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.