docs: design for orders review modal (amber icon + inline edit + attachment preview)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-29 13:53:36 +02:00
parent 74b7d4433f
commit 20533bcddf

View file

@ -0,0 +1,222 @@
# 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).
- **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." 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)`
```ts
export async function updateOrderReviewFields(
orderId: string,
patch: { order_number?: string; customer?: string | null },
): Promise<Order>
```
- 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)`
```ts
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:
```ts
{
order_number?: string;
customer?: string | null;
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 closes; failed items list |
| 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.