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.
1. Problem
Section titled “1. Problem”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:
| Sheet | Role |
|---|---|
Pitt | PO for Pittsburgh store |
Riser | PO for a different store |
PO info - Del Chicago | PO for Del Chicago |
Sheet3 | metadata / 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:
get_spreadsheet_sheet_names()(packages/pdf-shared/pdf_shared/file_processing.py:239) lists every sheet viaopenpyxl._is_data_sheet()(multiformat.py:1171) classifies each sheet asdata_sheets(≥3 columns + header-term matches) orinfo_only_sheets.- For each data sheet,
_extract_tabular_for_sheet()is called independently; the LLM (Gemini, per-sheet CSV) sees one sheet at a time. - Each data sheet produces one row in
extracted_orders, tagged with:documentOrderPosition(1, 2, 3, …) — schema column atpackages/db/src/schema/pdf-documents.ts:268source_sheet_name— stored on the order JSON and aggregated inpdfDocuments.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.
What the UI does wrong
Section titled “What the UI does wrong”-
The review page loader fetches all orders for the document:
// apps/webapp/src/pages/orders/pdf/[documentId]/review.astro:105const 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)). -
The store auto-selects only the first order:
// apps/webapp/src/stores/pdfOrder/order.ts:153-156if (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. -
The right-pane sheet pills are visual-only:
// apps/webapp/src/components/orders/SpreadsheetFullView.tsx:301-326<buttononClick={() => {setInternalActiveSheet(idx);onSheetChange?.(idx, s.name); // ← parent never wires this}}>{s.name}</button>ReviewDocViewer.tsx:77passesonSheetChange={() => {}}— a literal no-op. The click only updates localinternalActiveSheetinsideSpreadsheetFullView, which swaps the rendered grid but nothing else. -
Submit always posts a single order:
// apps/webapp/src/components/orders/review-v2/ReviewSubmitFlow.tsx:337-356await fetch(`/api/erp/orders/${targetOrderId}/submit`, { method: 'POST', ... });Even if you could switch orders, there’s no “submit all 3” affordance.
-
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-72if (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.
-
The inbox completion model is keyed on
pdfDocumentId, notextractedOrderId(rev-2 discovery). Thework_itemstable has a unique constraint onpdf_document_id:// packages/db/src/schema/work-items.ts:130unique('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.
Data-loss risk today
Section titled “Data-loss risk today”| Scenario | Today’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_only | Real 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.
3. The memorable thing
Section titled “3. The memorable thing”“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.
4. Design options
Section titled “4. Design options”Three options, scored on completeness/risk/effort. Effort is CC-driven — a working session, not human-weeks.
Option A — Order-switcher driven by sheet pills (recommended)
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 sheetState 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:6explicitly 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.
5. Recommendation: Option A
Section titled “5. Recommendation: Option A”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.
Failure modes this accepts
Section titled “Failure modes this accepts”- 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.
- 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.
- No cross-order analytics. “How many items across the whole workbook are still pending validation?” requires aggregating across
selectedOrderIdswaps. We accept this; add a small “Workbook: 2 of 3 orders complete” counter at the rail header. - 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.
6. Implementation skeleton
Section titled “6. Implementation skeleton”Scope settled after CEO review
Section titled “Scope settled after CEO review”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.
Files to touch
Section titled “Files to touch”A. Data layer (Lane B — independent, ~20 min CC, ship first)
Section titled “A. Data layer (Lane B — independent, ~20 min CC, ship first)”| File | Change | Origin |
|---|---|---|
packages/db/src/schema/pdf-documents.ts | Add 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 |
B. Store layer (Lane A core)
Section titled “B. Store layer (Lane A core)”| File | Change | Rev-2 origin |
|---|---|---|
apps/webapp/src/stores/pdfOrder/order.ts | Export 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.ts | Add 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.tsx | On 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 |
C. UI components (Lane A core)
Section titled “C. UI components (Lane A core)”| File | Change | Rev-2 origin |
|---|---|---|
apps/webapp/src/components/orders/review-v2/ReviewDocViewer.tsx:77 | Replace 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-326 | Render 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-78 | Post-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-356 | Add “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.
| File | Change | Origin |
|---|---|---|
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 |
Schema migrations
Section titled “Schema migrations”One migration, two columns:
- Add
extracted_orders.source_sheet_name text. Backfill frompdf_documents.original_data.sheet_order_mapping(one-time script inapps/webapp/scripts/orapps/dagster/scripts/). Dual-write JSONB for one release, then drop in a follow-up. - Add
pdf_documents.multi_order_pending boolean default false. Backfill: settruewhere the document has more than oneextracted_ordersrow witherp_verification_status IS NULL. Defaultfalsefor legacy + single-order documents (no behavior change).
Both columns are nullable-defaultable and additive — zero-downtime, backward-compat.
LLM extractor prompt changes
Section titled “LLM extractor prompt changes”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.
Hydration plumbing
Section titled “Hydration plumbing”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.
7. Three-pane interaction spec
Section titled “7. Three-pane interaction spec”Desktop (left pane visible, right pane visible)
Section titled “Desktop (left pane visible, right pane visible)”| Action | Left pane | Middle pane | Right pane |
|---|---|---|---|
| Initial load (N=3 orders) | Renders order 1’s items + “Other orders in this workbook” section listing Riser, Del Chicago with 5-state badges | Focuses order 1’s first unvalidated item | Renders 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 change | No change | Pill stays greyed/inactive. Grid swaps to Sheet3 contents read-only. selectedOrderId unchanged. |
| Confirm last item in order 1 | All items green; rail shows “Order 1 ready to submit”; “Submit remaining 2 orders” button enabled if other orders also clean | Empty / “Ready” state | ”Pitt” pill keeps active; remains neutral until submitted |
| Click “Submit this order” (single) — partial workbook | Order 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 workbook | All pills show submitted-state badges | Toast: “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 colors | — | — | green ✓ = 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)”| Action | Left pane | Middle pane | Right pane |
|---|---|---|---|
| Initial load | Order 1’s items + “Other orders in this workbook” section visible (this is the mobile-primary navigator) | Order 1’s first unvalidated item focused | Collapsed |
| Tap “Other orders → Riser” in left rail | Re-renders with order 2’s items; URL becomes ?order=<riserId> | Re-focuses to order 2’s first unvalidated item | Still collapsed; if user expands later, shows Riser sheet active |
| Tap right-pane toggle | Pane shifts left | — | Expands; 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 end8. Test plan
Section titled “8. Test plan”Manual repro: giant-eagle-ck-test.xlsx (the actual file Cleveland Kitchen sent), uploaded against the Cleveland Kitchen ERP connection. PO #406206.
Acceptance tests
Section titled “Acceptance tests”- 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. - 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,
selectedOrderIdunchanged. URL unchanged. - 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}/submitexactly once. Page does NOT navigate tonextDocId. Selected order advances to Riser. Toast: “Pitt submitted. 2 more in this workbook.” (B1 acceptance) - 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) - 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
selectedOrderIdswapping mid-batch (simulated) and assert each iteration submitted the right order id — proves B2 capture-upfront fix works. - Reload preserves order selection via URL. Click into Riser, reload. Assert: page lands on Riser (read from
?order=<id>param), not Pitt. (CQ2 acceptance) - 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 tonextDocIdworks as today. - Empty / no-sheet workbook. Upload an Excel file where every sheet is classified info-only. Assert: empty-state message, no crash.
- Pre-submitted order rendering (T1). Setup: insert a
pdf_documentsrow with 3extracted_orderswhere one hasorderRecord.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. - 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.
- 5-state pill color rendering (B4). Force-set the 4 non-null
erpVerificationStatusvalues on three different orders and reload. Assert: green ✓ forverified, amber ⚠ formismatchandpending, red ✕ forverification_failed. Neutral pill fornull. - 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 flipsmulti_order_pending = false. Verify by checking the DB column directly. - 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 = falsein DB, row leaves the queue on submit exactly as before. Proves no regression for the 95% common path.
Existing test files to update
Section titled “Existing test files to update”apps/webapp/src/stores/pdfOrder/review-ux-state.test.ts— add cases for switchingselectedOrderId, clearingfocusedItemId, URL sync, capture-upfront in batch submit.- New: integration test for
ReviewDocViewerthat 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
ReviewSubmitFlowpost-submit branching — partial-workbook stays, complete-workbook redirects (B1 regression). - New: integration test for inbox row completion semantics (B1 regression).
Cross-reference
Section titled “Cross-reference”- docs/designs/inbox-is-the-product.md — the inbox completion model change in this design intersects with that doc’s scope. Coordinate with inbox author before merge.
9. Future work (out of scope here)
Section titled “9. Future work (out of scope here)”- 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
pendingon 202, then it transitions toverified|mismatch|verification_failedonly 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 distinguishpendingfromverified, so the user can see “submitted but not yet confirmed by WhereFour” rather than a false green ✓. - Classifier false negatives.
_is_data_sheetis 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_sheetagainst 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 inapps/temporal-worker/tests/fixtures/multitab/. Estimated 30 min. - B6 — WhereFour duplicate-PO# behavior. All 4 tabs in
giant-eagle-ck-test.xlsxcarry PO #406206. Test against the CK WhereFour sandbox: do 3 sequentialPOST /orderswith 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’spoNumberwrite inmultiformat.pyto 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_itemscompletion logic. Confirm with whoever owns the inbox v2 design that adding amulti_order_pendingfilter 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.