Skip to content

review-v2: multi-tab Excel workbooks

Status: design — not implemented Owner: David Boone Jr Date: 2026-05-14 (rev 3 after /plan-ceo-review — descoped to workaround approach) Branch this was authored on: claude/priceless-mestorf-531bbf Implementation branch: TBD, base staging Trigger: Cleveland Kitchen POC kickoff (giant-eagle-ck-test.xlsx, PO #406206)

Rev 2 changes (2026-05-14): Scope expanded after eng review surfaced inbox / post-submit redirect coupling. The original “wire up sheet pills” framing was incomplete — the same bug surfaces in two more places (the inbox row marks complete after first submission, and post-submit auto-redirects to the next document).

Rev 3 changes (2026-05-14): CEO review descoped from “modify work_items completion semantic” to a per-document workaround flag. Reason: work_items infrastructure is freshly built per docs/designs/inbox-is-the-product.md (ACTIVE, 2026-03-21). Changing its completion model under POC deadline pressure is a stage-2-to-stage-3 quote-to-cash decision that deserves a deliberate design exercise, not a side-effect of a UI bug fix. This PR ships an additive pdf_documents.multi_order_pending flag that gates inbox completion and the post-submit redirect — reversible, small blast radius, doesn’t touch work_items. The “real” multi-order-aware inbox semantic is filed as a follow-up that belongs in the next iteration of inbox-is-the-product.md.


David at Cleveland Kitchen kickoff:

“We lost the ability to submit separate orders per tab in Excel. I click the next tab and nothing changes.”

giant-eagle-ck-test.xlsx has four sheets. Each is a logically separate PO with its own ship-to / store / line items:

SheetRole
PittPO for Pittsburgh store
RiserPO for a different store
PO info - Del ChicagoPO for Del Chicago
Sheet3metadata / info-only

In the old single-pane review surface, clicking a sheet tab swapped the validation surface to that tab’s rows; the user submitted each as a separate ERP order. In review-v2, sheet pills appear in the right pane (document viewer) but clicking them is visual-only — the left items list and middle focused-review pane stay locked to the first sheet’s order.

Net for Cleveland Kitchen today: only ~1/3 of the POs reach WhereFour. Tabs 2 and 3 are silently un-submittable through the UI.


2. Reality check: where the bug actually lives

Section titled “2. Reality check: where the bug actually lives”

Investigation found this is not a data-model problem. The pipeline already does the right thing; only the UI is wrong.

What the backend already does (works correctly)

Section titled “What the backend already does (works correctly)”

apps/temporal-worker/activities/multiformat.py:1514-1645:

  1. get_spreadsheet_sheet_names() (packages/pdf-shared/pdf_shared/file_processing.py:239) lists every sheet via openpyxl.
  2. _is_data_sheet() (multiformat.py:1171) classifies each sheet as data_sheets (≥3 columns + header-term matches) or info_only_sheets.
  3. For each data sheet, _extract_tabular_for_sheet() is called independently; the LLM (Gemini, per-sheet CSV) sees one sheet at a time.
  4. Each data sheet produces one row in extracted_orders, tagged with:
    • documentOrderPosition (1, 2, 3, …) — schema column at packages/db/src/schema/pdf-documents.ts:268
    • source_sheet_name — stored on the order JSON and aggregated in pdfDocuments.originalData.sheet_order_mapping

For giant-eagle-ck-test.xlsx today, the DB contains 3 separate extracted_orders rows (Pitt, Riser, Del Chicago) and 1 entry in info_only_sheets (Sheet3). This is the correct data shape.

  1. The review page loader fetches all orders for the document:

    // apps/webapp/src/pages/orders/pdf/[documentId]/review.astro:105
    const fetchedOrders = await db.query.extractedOrders.findMany({
    where: eq(extractedOrders.pdfDocumentId, documentId),
    with: { items: true, orderRecord: { ... } },
    });

    All N orders are passed to the hydrator (ReviewV2Hydrator.tsx:38: setExtractedOrders(extractedOrders)).

  2. The store auto-selects only the first order:

    // apps/webapp/src/stores/pdfOrder/order.ts:153-156
    if (typedOrders.length > 0) {
    selectedOrderId.set(typedOrders[0].id);
    }

    No UI ever lets the user switch selectedOrderId. Orders 2 and 3 are loaded into memory but invisible.

  3. The right-pane sheet pills are visual-only:

    // apps/webapp/src/components/orders/SpreadsheetFullView.tsx:301-326
    <button
    onClick={() => {
    setInternalActiveSheet(idx);
    onSheetChange?.(idx, s.name); // ← parent never wires this
    }}
    >
    {s.name}
    </button>

    ReviewDocViewer.tsx:77 passes onSheetChange={() => {}} — a literal no-op. The click only updates local internalActiveSheet inside SpreadsheetFullView, which swaps the rendered grid but nothing else.

  4. Submit always posts a single order:

    // apps/webapp/src/components/orders/review-v2/ReviewSubmitFlow.tsx:337-356
    await fetch(`/api/erp/orders/${targetOrderId}/submit`, { method: 'POST', ... });

    Even if you could switch orders, there’s no “submit all 3” affordance.

  5. Post-submit auto-redirects off the workbook (rev-2 discovery). After a successful submit, the page navigates to the next document in the queue:

    // apps/webapp/src/components/orders/review-v2/ReviewSubmitFlow.tsx:64-72
    if (next?.nextDocId) {
    window.location.href = `/orders/pdf/${next.nextDocId}/review?${params}`;
    }

    For a multi-order workbook this means: user submits order 1 → page redirects to a different document → orders 2 and 3 are never reached. Even if (1)-(4) are fixed in isolation, this redirect alone reproduces David’s exact complaint.

  6. The inbox completion model is keyed on pdfDocumentId, not extractedOrderId (rev-2 discovery). The work_items table has a unique constraint on pdf_document_id:

    // packages/db/src/schema/work-items.ts:130
    unique('work_items_pdf_document_unique').on(table.pdfDocumentId),

    Combined with a CHECK constraint that requires exactly one of (emailEventId, pdfDocumentId, clonedFromWorkItemId) (:132), there is structurally only one inbox row per workbook. Whatever logic marks that row “complete” today will fire after the first submission, hiding the remaining 2 orders from the inbox view too.

ScenarioToday’s behavior
4-tab workbook (3 POs + 1 info sheet)All 3 POs extracted to DB. UI shows only the first → user can only submit 1 of 3 ERP orders.
Sheet classifier misclassifies a real PO as info_onlyReal data loss — that sheet never reaches extracted_orders. No UI affordance to override.
Single PO that spans 2 sheets (header on Sheet1, items on Sheet2)Two malformed extracted_orders rows; each has half a PO. Wrong for this shape, but not Cleveland Kitchen’s case.
Submit-then-redirect (rev 2)User submits order 1, page redirects to next workbook in queue, orders 2-3 abandoned in memory.
Inbox marks workbook complete after first submission (rev 2)Operator scanning the inbox sees the workbook gone from the queue; assumes done. Riser + Del Chi never submitted.

The classifier mis-categorization is a real silent-failure mode for future customers. Cleveland Kitchen specifically appears fine (all three are clean data sheets) — but the design below adds an “all sheets” affordance so we can recover.


“Click a sheet tab, review that sheet’s PO, submit it. Repeat. The UI is honest about how many orders are in the file.”

Cleveland Kitchen’s mental model is: one workbook = N POs, one per tab. Anything that hides the N is the bug. The fix is the UI telling the truth about what the DB already contains.


Three options, scored on completeness/risk/effort. Effort is CC-driven — a working session, not human-weeks.

Section titled “Option A — Order-switcher driven by sheet pills (recommended)”

Promote the right-pane sheet pills from cosmetic to the navigator. Clicking a pill switches selectedOrderId to the order whose source_sheet_name matches. Left list and middle pane re-render against that order’s items. Submit becomes per-order (one button, “Submit this order to WhereFour”); a secondary “Submit all 3” affordance batches the N submissions.

┌─────────────────┬──────────────────────────┬─────────────────────────────┐
│ LEFT: items │ MIDDLE: focused review │ RIGHT: doc viewer │
│ (for order 1) │ (focused item from │ │
│ │ order 1) │ ┌─────────────────────────┐ │
│ ▸ item 1.1 │ │ │[Pitt●][Riser][Del Chi] │ │ ← active=Pitt
│ ▸ item 1.2 │ │ │[Sheet3 ⓘ info only] │ │
│ ▸ item 1.3 │ │ └─────────────────────────┘ │
│ │ │ │
│ ━━━━━━━━━━━━━━ │ ━━━━━━━━━━━━━━━━━━━━━━ │ │
│ Other orders: │ │ <sheet grid> │
│ • Riser (5) │ │ │
│ • Del Chi (12) │ │ │
└─────────────────┴──────────────────────────┴─────────────────────────────┘
click "Riser" pill
selectedOrderId ─→ order for source_sheet_name="Riser"
left list re-renders with order 2's items
middle clears focused item, picks first item of order 2
right grid swaps to Riser sheet

State diagram:

[loaded N orders] ─── auto-select order[0] ─── [reviewing order 0]
│ click pill for sheet S
find order where
source_sheet_name=S
┌───────────── found? ──┤
│ │ no
▼ ▼
[reviewing order N] pill shows "ⓘ no order
│ (info-only sheet)"
Submit ↓ │
▼ │
POST /api/erp/orders/{N}/submit │
│ │
▼ │
pill turns green ←───────────┘
"✓ submitted to WhereFour"

Pros

  • Zero schema change. All data already exists.
  • Mental model maps 1:1 to user expectation (sheet = PO).
  • Failure mode for misclassified sheets is visible: the info-only pills are still rendered (greyed), so the user can spot “wait, Sheet3 should be a PO” and we can add a reclassify action in a follow-up.
  • Same affordance scales to PDFs with N orders (we already number them by documentOrderPosition; we’d just label pills “PO 1 / PO 2 / PO 3” when there’s no sheet name).

Cons

  • Submit becomes per-order, which means N clicks for N POs unless we add “Submit all”. This is honest but slightly more keystrokes than the old flow.
  • Loss of cross-order context — the user can’t see “all items across all 3 POs” at once. Acceptable: Cleveland Kitchen’s QA loop is per-PO anyway (each goes to a different store).

Completeness: 9/10 (the missing 1 point is recovery UX for misclassified sheets, which is a follow-up.) Effort (CC): one session — ~3-5 hours of editing across 4-6 files.

Option B — One review surface, sectioned by sheet

Section titled “Option B — One review surface, sectioned by sheet”

Keep selectedOrderId always pointing at the first order, but introduce a virtual “merged view”: the left list shows all items across all N orders, grouped under section headers (”— Pitt — / — Riser — / — Del Chicago —”). Submit produces N WhereFour orders in a single batch via a new /api/erp/orders/batch-submit endpoint that takes a list of extracted_order ids.

┌─────────────────┐
│ LEFT: items │
│ — Pitt — │
│ ▸ item 1.1 │
│ ▸ item 1.2 │
│ — Riser — │
│ ▸ item 2.1 │
│ ▸ item 2.2 │
│ — Del Chicago — │
│ ▸ item 3.1 │
│ │
│ [Submit all 3] │
└─────────────────┘

Pros

  • Single keystroke to submit. Closer to the “one workflow” feel of the old single-pane UI.
  • Header/totals validation could be visualized in one place per sheet without page-swap.

Cons

  • We’d need to refactor every store derivation that assumes “one selectedOrderId at a time” (status counters, validation rollups, submit flow, ERP search modal target — ERPSearchModal.tsx:6 explicitly anchors on extractedOrders store). Half-day at minimum.
  • New batch-submit endpoint = transactional question: if Riser submits and Del Chicago fails, what’s the rollback story? Today’s single-submit endpoint can stay in place.
  • The progress / validation chips at the top of the rail ($itemsByStatus) become ambiguous: 25/30 for which order?

Completeness: 7/10 (loses per-order header validation clarity). Effort (CC): two sessions — the store refactor is the long pole.

Option C — Force-merge into one extracted_order

Section titled “Option C — Force-merge into one extracted_order”

Change the LLM prompt to merge all sheets into a single PO at extraction time. UI unchanged.

Why this is wrong for this case: Cleveland Kitchen’s tabs have different ship-to addresses and store codes. A merged order has no way to represent “these 12 items go to Pittsburgh and these 5 go to Del Chicago” without inventing a per-line-item ship-to field. WhereFour doesn’t accept that shape.

Completeness: 2/10 — loses real information. Listed only to reject it.


Take Option A. Wire the right-pane sheet pills to switch selectedOrderId. Add a small “Other orders in this workbook” section at the bottom of the left rail so users with the viewer collapsed still see the multi-order reality. Default Submit stays single-order; add a secondary “Submit all unsubmitted” button when N>1.

  1. N keystrokes for N submits (one Submit per PO, plus the batch shortcut). Acceptable — three POs per workbook is a typical case, not a thousand. The batch shortcut covers the lazy path.
  2. Sheet classifier misclassification is still possible. We add the cue (info-only pill rendered greyed-out so user can see “huh, that should be a PO”) but the actual “reclassify and re-extract” action is a follow-up ticket. For the Cleveland Kitchen demo, all three POs classify cleanly.
  3. No cross-order analytics. “How many items across the whole workbook are still pending validation?” requires aggregating across selectedOrderId swaps. We accept this; add a small “Workbook: 2 of 3 orders complete” counter at the rail header.
  4. Re-extraction on user-driven sheet reclassification is out of scope. If the user disagrees with the info-only classification, this design surfaces it but doesn’t yet let them act on it. Filed as future work in §9.

Rev 1 framed this as ~6 files. Rev 2 expanded to ~12 after eng review surfaced the post-submit redirect + inbox completion coupling. Rev 3 settles at ~7-8 by replacing the “modify work_items completion semantic” plan with a per-document multi_order_pending flag that the inbox query and post-submit redirect both consult.

The flag is additive, reversible, and doesn’t touch work_items — it lives on pdf_documents and represents “this document still has unsubmitted orders.” The inbox row stays visible while the flag is true; post-submit redirect honors nextDocId only when the flag flips false.

A. Data layer (Lane B — independent, ~20 min CC, ship first)

Section titled “A. Data layer (Lane B — independent, ~20 min CC, ship first)”
FileChangeOrigin
packages/db/src/schema/pdf-documents.tsAdd sourceSheetName text column to extracted_orders. Add multiOrderPending boolean default false column to pdf_documents. Drizzle migration via pnpm drizzle:generate. Backfill script: (1) reads pdf_documents.original_data.sheet_order_mapping and populates source_sheet_name for existing rows; (2) sets multi_order_pending = true for any document where COUNT(extracted_orders.erp_verification_status IS NULL) > 1 and the document has >1 data sheet.A2 + rev-3
apps/temporal-worker/activities/database.py (save_extracted_order near :288-317)Write source_sheet_name to the new typed column AND keep writing to original_data.sheet_order_mapping for backward compat for one release. When N>1 data sheets and saving the first extracted_order for a document, also set multi_order_pending = true on the parent pdf_documents row.A2 + rev-3
FileChangeRev-2 origin
apps/webapp/src/stores/pdfOrder/order.tsExport setSelectedOrderId(orderId) action. In the existing selectedOrderId.listen (:64), add side-effect: clear $focusedItemId, re-pick first unvalidated item of the new order, and history.replaceState to push ?order=<id> into the URL.new + CQ2
apps/webapp/src/stores/pdfOrder/review-v2.tsAdd derived store $activeSheetName computed from selectedOrder.sourceSheetName. Add $ordersForCurrentDocument derived (already-loaded extractedOrders filtered by current document). Add $workbookCompletionStatus derived: (submittedCount, totalCount) from orderRecord.erpVerificationStatus across all loaded orders.new
apps/webapp/src/components/orders/review-v2/ReviewV2Hydrator.tsxOn hydrate, read ?order= from Astro.url.searchParams. If present and matches a loaded order, override setExtractedOrders’s auto-select-first behavior with setSelectedOrderId(paramOrderId).CQ2
FileChangeRev-2 origin
apps/webapp/src/components/orders/review-v2/ReviewDocViewer.tsx:77Replace onSheetChange={() => {}} with a handler that maps pill index → orders[i] (sorted by documentOrderPosition) → setSelectedOrderId(orders[i].id). For info-only sheets (no matching order), update local viewer to preview the sheet’s grid contents read-only but do NOT mutate selectedOrderId.A1, CQ1
apps/webapp/src/components/orders/SpreadsheetFullView.tsx:300-326Render pills in documentOrderPosition order. Add badges per pill driven by orderRecord.erpVerificationStatus: green ✓ = verified, amber ⚠ = mismatch | pending, red ✕ = verification_failed, neutral = null. Info-only sheets render greyed with ⓘ. Drive activeSheet from $activeSheetName prop, falling back to internal state for non-review-v2 callsites.A1, B4
apps/webapp/src/components/orders/review-v2/ReviewLeftRail.tsx (or equivalent)Add “Other orders in this workbook” section listing N-1 non-selected orders for this document with their pill labels + 5-state badges. This is the mobile-primary navigator, not optional polish — on mobile the right pane is collapsed by default. Clicking dispatches setSelectedOrderId.A3, B4
apps/webapp/src/components/orders/review-v2/ReviewSubmitFlow.tsx:60-78Post-submit handler rewrite. After a single-order submit succeeds, server returns refreshed pdfDocument.multiOrderPending in the response (or the client re-fetches it). If multiOrderPending === true: stay on the page, advance selectedOrderId to the next unsubmitted order in $ordersForCurrentDocument, show toast “Order submitted. N more in this workbook.” If multiOrderPending === false (or column is null on legacy docs): honor the existing nextDocId redirect.B1 (rev-3 flag-driven)
apps/webapp/src/components/orders/review-v2/ReviewSubmitFlow.tsx:333-356Add “Submit remaining N orders” button when N > 1 and >1 unsubmitted. Capture the order-id list upfront before the loop starts (do NOT read editingOrder.get()?.id inside the loop — store moves under it). Use Promise.allSettled with explicit runSubmit(orderId) per iteration.new + B2
apps/webapp/src/components/orders/review-v2/ReviewRailHeader.tsx (or wherever the title sits)Replace single counter “Doc M of N” with two counters when workbook has >1 order: “Workbook M of N · Order P of Q (sheet name) · X submitted”.B7

D. Inbox / completion (Lane C — flag-based workaround, ~30 min CC)

Section titled “D. Inbox / completion (Lane C — flag-based workaround, ~30 min CC)”

This lane does not modify the work_items completion semantic. It only adds a single filter that respects the new flag.

FileChangeOrigin
apps/webapp/src/pages/api/erp/orders/[id]/submit.ts (or the Temporal workflow that follows it)After successful WhereFour submission, run a small SQL update: if all sibling extracted_orders for the same pdf_document_id now have non-null erpVerificationStatus, set pdf_documents.multi_order_pending = false. Idempotent and safe to run on every submit.rev-3
Inbox query (TBD — likely apps/webapp/src/pages/api/inbox/ or the loader feeding useInboxItems.ts)One-line filter change: keep documents in the active queue if multi_order_pending = true even when the work_items row would otherwise be marked done. No change to work_items semantic. Single-order documents (legacy + new) get multi_order_pending = false by default → no behavior change for them.rev-3
Inbox row component (TBD — likely InboxRow.tsx)When multi_order_pending = true, render a small “N of M submitted” badge derived from extracted_orders count. Single-order docs render unchanged.rev-3

One migration, two columns:

  • Add extracted_orders.source_sheet_name text. Backfill from pdf_documents.original_data.sheet_order_mapping (one-time script in apps/webapp/scripts/ or apps/dagster/scripts/). Dual-write JSONB for one release, then drop in a follow-up.
  • Add pdf_documents.multi_order_pending boolean default false. Backfill: set true where the document has more than one extracted_orders row with erp_verification_status IS NULL. Default false for legacy + single-order documents (no behavior change).

Both columns are nullable-defaultable and additive — zero-downtime, backward-compat.

None required for Cleveland Kitchen. The current per-sheet extraction is the right shape — each sheet is sent independently and produces one order.

Future-work prompt change (defer): when a workbook has both data_sheets and info_only_sheets, pass a brief context note to each per-sheet call (“This is sheet 2 of 3 data sheets in workbook foo.xlsx; ignore any references to other sheets in this CSV”). This is preventative against a future case where one sheet has cross-references to another; not observed in the Cleveland Kitchen file.

After A2 lands, the extractedOrders query already returns sourceSheetName as a typed column — no JSONB traversal needed on the client. pdf_documents.originalData.info_only_sheets still flows through originalData for the info-only pill rendering.


Desktop (left pane visible, right pane visible)

Section titled “Desktop (left pane visible, right pane visible)”
ActionLeft paneMiddle paneRight pane
Initial load (N=3 orders)Renders order 1’s items + “Other orders in this workbook” section listing Riser, Del Chicago with 5-state badgesFocuses order 1’s first unvalidated itemRenders order 1’s sheet (“Pitt”) as active pill
Click pill “Riser”Re-renders with order 2’s items; URL becomes ?order=<riserId>Clears focused item, re-picks order 2’s first unvalidated”Riser” becomes active pill, grid swaps
Click pill “Sheet3” (info-only)No changeNo changePill stays greyed/inactive. Grid swaps to Sheet3 contents read-only. selectedOrderId unchanged.
Confirm last item in order 1All items green; rail shows “Order 1 ready to submit”; “Submit remaining 2 orders” button enabled if other orders also cleanEmpty / “Ready” state”Pitt” pill keeps active; remains neutral until submitted
Click “Submit this order” (single) — partial workbookOrder 1’s pill flips to amber (pending) while Temporal workflow runs. Page stays on the workbook.Toast: “Order submitted. Continuing with Riser.” selectedOrderId advances to next unsubmitted order.”Pitt” pill goes amber, then green ✓ once erpVerificationStatus → verified (visible on reload or via polling). “Riser” becomes active pill.
Click “Submit this order” (single) — last unsubmitted in workbookAll pills show submitted-state badgesToast: “Workbook complete.”Honors existing nextDocId redirect — moves to next workbook in queue.
Click “Submit remaining” (batch)Each order’s pill flips to amber as its workflow starts. Promise.allSettled over the captured-upfront order-id list.Aggregated toast: “Pitt submitted. Riser blocked (2 items invalid). Del Chicago submitted.”Pills reflect per-order outcomes.
Pill 5-state colorsgreen ✓ = verified, amber ⚠ = mismatch or pending, red ✕ = verification_failed, neutral = null (never submitted).

Mobile (right pane collapsed by default — $reviewMobilePane)

Section titled “Mobile (right pane collapsed by default — $reviewMobilePane)”
ActionLeft paneMiddle paneRight pane
Initial loadOrder 1’s items + “Other orders in this workbook” section visible (this is the mobile-primary navigator)Order 1’s first unvalidated item focusedCollapsed
Tap “Other orders → Riser” in left railRe-renders with order 2’s items; URL becomes ?order=<riserId>Re-focuses to order 2’s first unvalidated itemStill collapsed; if user expands later, shows Riser sheet active
Tap right-pane togglePane shifts leftExpands; sheet pills become tappable (same behavior as desktop)

Post-submit logic flowchart (rev-3 flag-driven)

Section titled “Post-submit logic flowchart (rev-3 flag-driven)”
On single-submit success:
├── Server side: after WhereFour submit completes
│ │
│ ├── UPDATE pdf_documents SET multi_order_pending = false
│ │ WHERE id = :docId
│ │ AND NOT EXISTS (
│ │ SELECT 1 FROM extracted_orders
│ │ WHERE pdf_document_id = :docId
│ │ AND erp_verification_status IS NULL)
│ │
│ └── Return refreshed pdfDocument.multiOrderPending in response
├── Client side:
│ │
│ ├── multiOrderPending === true?
│ │ │
│ │ ├── YES → setSelectedOrderId(nextUnsubmitted.id)
│ │ │ toast: "Submitted. {N} more in this workbook."
│ │ │ do NOT navigate to nextDocId
│ │ │
│ │ └── NO → honor existing `next.nextDocId` redirect
On batch-submit (Submit remaining) start:
├── capture orderIds = [...$ordersForCurrentDocument.filter(unsubmitted)]
│ (BEFORE any state mutation — fixes B2)
├── for orderId in orderIds:
│ await runSubmit(orderId) # explicit param, no store read inside loop
│ update pill 5-state badge
├── after last: server flips multi_order_pending = false automatically
└── aggregate toast at end

Manual repro: giant-eagle-ck-test.xlsx (the actual file Cleveland Kitchen sent), uploaded against the Cleveland Kitchen ERP connection. PO #406206.

  1. Tab navigation switches everything. Upload the file. Land on review-v2. Click “Riser” pill. Assert: left items list now shows Riser’s items (not Pitt’s); middle pane shows a Riser item focused; right grid shows Riser sheet. URL gains ?order=<riserOrderId> so it’s linkable.
  2. Info-only sheet click previews read-only, doesn’t switch state. Click “Sheet3”. Assert: pill greys/highlights as info-only-active, right grid swaps to Sheet3 contents read-only, left/middle do NOT change, selectedOrderId unchanged. URL unchanged.
  3. Per-order submit posts only that order, stays on workbook if more exist. Confirm all items in order 1 (Pitt). Click “Submit this order”. Assert: POST /api/erp/orders/{pitt-order-id}/submit exactly once. Page does NOT navigate to nextDocId. Selected order advances to Riser. Toast: “Pitt submitted. 2 more in this workbook.” (B1 acceptance)
  4. Last-in-workbook submit honors the queue redirect. Validate + submit Pitt, then Riser, then Del Chicago in sequence. Assert: after Del Chicago succeeds, page navigates to next.nextDocId (existing behavior). (B1 acceptance)
  5. Batch submit handles partial failure with upfront capture. Validate orders 1 and 3, leave order 2 with one invalid item. Click “Submit remaining”. Assert: two API calls (orders 1 and 3, in any order). Order 2 not posted. Toast shows “2 submitted, 1 blocked (Riser: 1 invalid item)”. Specifically: re-run the test with selectedOrderId swapping mid-batch (simulated) and assert each iteration submitted the right order id — proves B2 capture-upfront fix works.
  6. Reload preserves order selection via URL. Click into Riser, reload. Assert: page lands on Riser (read from ?order=<id> param), not Pitt. (CQ2 acceptance)
  7. PDF compatibility (regression). Open an existing single-order PDF (any historical doc with one extracted_order). Assert: behavior identical to before — no pill row rendered, single Submit button, no “Other orders” rail section, post-submit redirect to nextDocId works as today.
  8. Empty / no-sheet workbook. Upload an Excel file where every sheet is classified info-only. Assert: empty-state message, no crash.
  9. Pre-submitted order rendering (T1). Setup: insert a pdf_documents row with 3 extracted_orders where one has orderRecord.erpVerificationStatus='verified'. Open the review page. Assert: that order’s pill is green ✓, its Submit button shows “Already submitted” state and is disabled; remaining orders behave normally.
  10. Mobile navigation (T2). Resize viewport to ≤768px. Right pane collapses by default. Assert: “Other orders in this workbook” section visible in left rail. Tap Riser. Assert: left/middle re-render to Riser, URL updates, right pane stays collapsed.
  11. 5-state pill color rendering (B4). Force-set the 4 non-null erpVerificationStatus values on three different orders and reload. Assert: green ✓ for verified, amber ⚠ for mismatch and pending, red ✕ for verification_failed. Neutral pill for null.
  12. Inbox multi-order completion via flag (rev-3). Open the inbox while a workbook has 1 of 3 orders submitted. Assert: workbook row visible in active queue with badge “1 of 3 submitted to WhereFour” because pdf_documents.multi_order_pending = true. Submit the remaining two. Assert: row only leaves active queue when the post-submit SQL flips multi_order_pending = false. Verify by checking the DB column directly.
  13. Single-order document inbox regression (rev-3). Open the inbox with a normal single-order document. Assert: no “N of M” badge, multi_order_pending = false in DB, row leaves the queue on submit exactly as before. Proves no regression for the 95% common path.
  • apps/webapp/src/stores/pdfOrder/review-ux-state.test.ts — add cases for switching selectedOrderId, clearing focusedItemId, URL sync, capture-upfront in batch submit.
  • New: integration test for ReviewDocViewer that mounts with a 3-order document and asserts pill clicks dispatch the right store action with correct order id (B2 regression).
  • New: integration test for ReviewSubmitFlow post-submit branching — partial-workbook stays, complete-workbook redirects (B1 regression).
  • New: integration test for inbox row completion semantics (B1 regression).

  • User-driven sheet reclassification: “this info-only sheet is actually a PO, re-extract it.” Requires a re-extraction trigger UX and a way to mark the original info-only classification as user-overridden.
  • Cross-sheet PO detection: when a workbook IS one PO across multiple sheets (header on sheet 1, items on sheet 2), today we extract two malformed orders. Needs an LLM pass that first asks “are these sheets one PO or N POs” before extracting. Not Cleveland Kitchen’s case.
  • Inbox grouping: show multi-order workbooks as one expandable row with N children, mirroring the review UI’s mental model (this design fixes the completion model but keeps the row count at 1; expandable child rows are a v2 polish).
  • LLM context note for multi-sheet workbooks (“This is sheet 2 of 3 data sheets in workbook foo.xlsx; ignore any references to other sheets”).

10. Known limitations (accepted in this design)

Section titled “10. Known limitations (accepted in this design)”

These are real gaps the design chooses to live with — surface them in CK comms so expectations match reality.

  • B3 — Temporal/WhereFour terminal-state polling. Submit returns 202 after Temporal starts the workflow; the actual WhereFour POST happens ~30s later inside the workflow. The UI optimistically marks the pill pending on 202, then it transitions to verified|mismatch|verification_failed only after the user reloads (or after we ship server-sent events later). This is a pre-existing gap, not introduced by this design. Acceptable because: the 5-state pill colors (B4) honestly distinguish pending from verified, so the user can see “submitted but not yet confirmed by WhereFour” rather than a false green ✓.
  • Classifier false negatives. _is_data_sheet is heuristic. A real PO sheet that fails the ≥3-column-and-header-term test gets dropped as info-only. This design surfaces info-only sheets (greyed pills) but doesn’t yet let the user say “no, this IS a PO, re-extract it.” Filed in §9.
  • No retry-from-UI on failed Temporal workflows. If a workflow errors (network, WhereFour 5xx), the pill goes red ✕ but the user has no Submit-again affordance in this PR. They reload + try again.

11. Pre-implementation checklist (do these BEFORE writing code)

Section titled “11. Pre-implementation checklist (do these BEFORE writing code)”

These are dry-runs and external verifications. Cheap, prevent demo-day surprises.

  • B5 — Classifier dry-run on CK history. Run _is_data_sheet against every multi-tab workbook CK has emailed in since the POC started. Confirm zero false-positives (no info-only sheet getting extracted as a PO) and zero false-negatives (no real PO sheet getting dropped). If any misclassifications, commit those workbooks as test fixtures in apps/temporal-worker/tests/fixtures/multitab/. Estimated 30 min.
  • B6 — WhereFour duplicate-PO# behavior. All 4 tabs in giant-eagle-ck-test.xlsx carry PO #406206. Test against the CK WhereFour sandbox: do 3 sequential POST /orders with the same external PO# but different ship-to / line items get accepted, deduplicated, or rejected? If rejected, the design needs a disambiguator step (406206-Pitt, 406206-Riser, etc.) — extend the extracted_order’s poNumber write in multiformat.py to suffix with sheet name when N>1 sheets share a PO#. Estimated 30 min.
  • Coordinate with the inbox-is-the-product.md author before merging the inbox query change. The flag-based approach is intentionally minimal — it filters the existing inbox query rather than modifying work_items completion logic. Confirm with whoever owns the inbox v2 design that adding a multi_order_pending filter is acceptable as a temporary workaround. Document the coordination in the PR description. The “real” multi-order-aware completion semantic belongs in the next inbox-is-the-product revision; this PR is explicitly scoped not to make that decision.

12. One-paragraph summary for the implementation session

Section titled “12. One-paragraph summary for the implementation session”

The bug surfaces in three coupled places. (1) The backend already produces N extracted_orders rows for an N-tab Excel workbook, but review-v2 only renders the first because setExtractedOrders auto-selects orders[0] and the sheet pills are wired to a no-op. (2) The post-submit handler at ReviewSubmitFlow.tsx:64-72 redirects to nextDocId after the first success, abandoning the remaining orders. (3) The inbox row is unique per pdfDocumentId and gets marked complete after the first submission, hiding the rest. The fix is to wire the sheet pills to switch selectedOrderId (joined by documentOrderPosition, not sheet name), add a pdf_documents.multi_order_pending boolean flag set at extraction time and cleared server-side when all sibling orders are submitted, make the post-submit handler read that flag to decide stay-on-page vs honor-nextDocId, and add a one-line filter to the inbox query to keep flagged documents visible in the active queue. The work_items completion semantic is intentionally untouched — that’s a stage-2-to-stage-3 quote-to-cash decision that belongs in the next iteration of inbox-is-the-product.md, filed as a follow-up. Add a typed sourceSheetName column to extracted_orders so the UI doesn’t traverse JSONB on every render. Add 5-state pill colors driven by erpVerificationStatus so operators see pending/mismatch/verification_failed honestly instead of false green ✓. Capture the order-id list upfront in batch submit. Targeted at ~7-8 files across apps/webapp/ + one Drizzle migration adding two columns. Two pre-implementation dry-runs gate go: confirm the classifier works on CK history, confirm WhereFour accepts (or de-dupes intentionally) the duplicate PO# across sibling orders. Acceptance test is giant-eagle-ck-test.xlsx producing three separate WhereFour orders via three Submit clicks staying on the same page, with the inbox showing “X of 3 submitted” until multi_order_pending flips false.