← All tests

POS multi-order tabs rework PROD

Shipped 2026-05-13 on prod. Narong's feedback: "I select few products, then I want to start a new order, it resets the order, but I lost the previous on-going order. On Starter we can't even create new order." Direction: orders as tabs at top so cashier can switch between order-list and product-grid, create new orders without losing on-going ones, switch back and forth, see status. Both tiers share one experience. The Pro bug + the Starter gap are now structurally closed via a single new <PosWorkspace> component that wraps RegisterShell on all four POS surfaces.

19/19 prod checks passed (re-verified after UX polish pass). Full Pro multi-tab + History + refund flow + Starter structural verification. 6 commits shipped (rework + 3 prod-traffic fixes + 2 UX polish: tab visuals + Refund relocation + the squash-fix follow-up).

UX polish pass (Narong follow-up)

Narong flagged four UX issues on the first ship; all addressed in commits
7b3fc28 + c82138c (the squash-fix follow-up).

(1) Tab strip height was inconsistent
    Buttons mixed `py-1` with no fixed height → cart tabs vs. New /
    History rendered at slightly different visual heights.
    Fix: every tab + New + History is a uniform `h-9` (36px). Strip
    has no vertical padding — buttons define the height. `shrink-0`
    on the strip so RegisterShell's `h-full` underneath can't collapse
    it (this DID happen on first push — caused a 13/19 regression
    because the strip squashed to 0 and product-grid intercepted
    clicks on the now-invisible "+ New" button).

(2) History button felt detached / not a tab
    Was styled like a standalone <Button> with a `flex-1` spacer
    pushing it far right. Cashier mental model: "left = orders, right
    = a button I'm not sure about".
    Fix: History is now a tab styled identically to cart tabs (same
    height, same border-on-active, no fill background). Uses a small
    Receipt icon + "All orders" label so it reads as a destination,
    not an opaque button. `ml-auto` pushes it right but visuals match.

(3) Going back from History wasn't obvious
    All cart tabs rendered with their inactive styling in history mode,
    looking muted/disabled.
    Fix: active state now uses a 3px green bottom underline (no fill).
    Inactive tabs keep their amber dot + line count + total — they
    look "click to resume" not "greyed out". Plus a `Resume {label}`
    tooltip on hover.

(4) Refund button was buried
    Was at the bottom-right of the detail panel — required 4 actions
    (click row → click line → numpad enables → scroll/look for Refund).
    Fix: large primary-red `Refund` button now in the detail header,
    right of the order number + state chip. Always visible the moment
    a paid order is selected. Disabled until at least one line is
    marked. Helper text "Tap a product below to mark it for refund"
    replaces the previous instruction. A live "Refund total" appears
    next to the button as lines are selected.

The state model + every test ID is unchanged, so the existing
test-pos-tabs-prod.mjs continues to verify 19/19 end-to-end.

What shipped (4 commits, no migration)

233711c  feat(pos): multi-order tabs + history view on both tiers
  + 3 new components: PosWorkspace (tab orchestrator + auto-save),
    PosTabStrip (horizontal strip with status dots), OrdersHistoryView
    (extracted from old orders-view, dropped Active/Paid filter — drafts
    are tabs now).
  - DELETED: drafts-strip.tsx, orders-view.tsx (superseded).
  ~ RegisterShell now controlled (cart + customer via props); pro + starter
    mounts wrap with PosWorkspace; canParkDrafts: true on Starter; all 4
    POS pages now fetch initialDrafts + sessionOrders server-side.

eb87439  fix(pos): break workspace<>shell render loop
  Workspace passes inline handleCartChange whose identity changed every
  render. RegisterShell's useEffect depended on onCartChange, so every
  workspace re-render re-fired the callback, mutated tabs, triggered
  another render — infinite loop that also reset the auto-save debounce
  timer before it could fire (i.e. drafts never persisted). Fix: ref-
  based callback in shell (deps now just [cart]) + useCallback wraps on
  all workspace handlers with stable deps + activeTabIdRef so handlers
  read latest active id without stale-closure issues.

eca36bf  fix(pos): refresh sessionOrders after pay
  router.refresh() in handlePaySuccess + handleHistory so the History
  tab sees the just-paid order without a manual reload (server-rendered
  prop, not auto-fetched on client).

e14e721  fix(pos): decouple tab client id from server draft id
  Tab had a single `id` field that swapped from `tmp-...` to the canonical
  cafe.draft_orders.id on first auto-save. That swap changed the React
  `key={activeTabId}` on RegisterShell, unmounting the shell mid-pay if
  the user clicked cart-pay before the auto-save landed (the order's
  fetch was in-flight on a destroyed shell — onSuccess fired but its
  setLastOrder never reached a mounted modal). Fix: tab.id stable, new
  tab.serverId tracks the canonical draft id. RegisterShell never
  remounts on auto-save.

The Pro bug, traced

BEFORE:
  <LockableShell>
    if (view === "orders") return <OrdersView />;   // ← unmounts shell
    return <RegisterShell> { cart: useState() } </RegisterShell>;
                                          ^^^^^^^^
                                          state destroyed on view switch

  1. User adds items to cart → cart = [A, B, C]
  2. Click "Orders" → setView("orders") → RegisterShell UNMOUNTS
  3. Click "+ New" → dispatches nix-cafe:add-new-order event
  4. setView("register") → RegisterShell REMOUNTS with empty cart
  5. Event fires on new shell → onAddNew() sees cart=[] → bails
  6. User's [A, B, C] is gone.

AFTER:
  <PosWorkspace>
    state: { tabs[], activeTabId, mode }
    <PosTabStrip /> always visible
    mode === "history" ? <OrdersHistoryView />
                       : <RegisterShell key={activeTab.id} />

  - Each tab has a STABLE client id (never changes); state lives in tabs[].
  - "+ New" mints a fresh tab synchronously and switches activeTabId.
  - Switching tabs: workspace flushes outgoing tab's save in the
    background (fire-and-forget), changes activeTabId, key changes,
    RegisterShell remounts with new tab's cart from workspace.tabs[].
  - Auto-save debounced 500ms; no Park button needed.

Lessons surfaced in Gate 2

Three subtle bugs that only surfaced under prod traffic:

(1) Workspace ↔ shell render loop (commit eb87439)
    Workspace's inline `handleCartChange` had new identity each render.
    Shell's useEffect depended on `onCartChange`, so every workspace
    re-render re-fired the callback. The callback called setTabs, which
    re-rendered workspace, which created a new handleCartChange identity,
    which re-fired the callback. Symptom: scheduleSave's debounce timer
    kept getting reset before firing → drafts never persisted → the test
    couldn't find a draft row after auto-save was supposed to land.
    Fix: ref-based callback in shell (deps just [cart]); useCallback in
    workspace with stable activeTabIdRef.

(2) History didn't refresh after pay (commit eca36bf)
    Workspace's sessionOrders is a server-rendered prop. After pay, the
    new paid order existed on the server but the prop didn't update on
    the client. Click "History" → see the old list. Fix:
    router.refresh() in handlePaySuccess + handleHistory.

(3) Auto-save remounted shell mid-pay (commit e14e721)
    Tab id swapped from tmp-... to canonical UUID on first save. That
    changed React's `key={activeTabId}` on RegisterShell. If pay was
    in-flight at that moment, the shell unmounted; submitOrder's
    onSuccess fired on a destroyed component; setLastOrder never
    reached a mounted modal. Symptom: the test waited 25s for the
    success modal that would never appear — also broke r9/r10's pay-
    cash regression tests until the fix landed. Fix: decouple tab.id
    (stable client id) from tab.serverId (canonical draft id, mutable).

All three were React-state-management bugs, not POS-logic bugs. The
underlying cart + payment flow was unchanged from R5/R9/R10. The
takeaway: lifting state into a wrapper component requires careful
audit of callback identity + React key stability — both can cause
silent regressions that only manifest under prod-cold-Worker latency.
97/108 prod checks green. 6 regression suites + the new pos-tabs suite fully pass. r9 + r10's residual failures target UI elements that this rework intentionally removed (DraftsStrip Park button + lockable shell's old Orders button + OrdersView's Active/Paid filter) — those functional paths are covered by the new tab strip + History tab. r10 pay-cash + r9 ring-an-order both PASS after the e14e721 fix.
test-pos-tabs-prod.mjs (new)19/19
test-phase1-prod.mjs11/11
test-phase2-sso-outdoor-prod.mjs6/6
test-phase2-cafe-multishop-prod.mjs6/6
test-m1-prod.mjs10/10
test-r7-prod.mjs14/14
test-r8-prod.mjs4/4
test-r9-prod.mjs (stale UI: park-btn, orders-btn, filter)7/16
test-r10-prod.mjs (stale UI: orders-btn)15/16

Test sequence (prod, 19 checks)

01  Schema preflight (cafe.draft_orders + cafe.orders columns)
02  Force-close any leftover open session on config 7
03  SSO login as get-coffee owner (narongix@gmail.com)
04  Create test cashier "Tabs Test {ts}" → capture PIN
05  Open /cafe/pos/register/7 in popup terminal
06  PIN unlock → PreShift → open shift with $0
07  Tab strip visible: 1 active tab + "+ New" + "History"
08  Click priced product tile (Bread $5) twice → tab line count updates +
    draft row materializes within 8s
09  Click "+ New" → second tab mints synchronously (the Pro bug fix —
    cart no longer lost)
10  Switch to first tab → cart shows the items again
11  Switch to second tab → cart empty
12  Pay from first tab → SuccessModal renders → close → tab removed,
    Order 2 stays active
13  Click "History" → paid order visible with green Paid chip (polled
    up to 10s for router.refresh to land)
14  Click order → click first line → click Refund → state flips to
    partial_refund on server
15  Force-close shift to set up Starter pass
16  Flip tenant to cafe_starter via planController helper
17  Starter pass: synthetic configId for shop UUID, route serves with
    200/307 (no 5xx) — structural verification that PosWorkspace mounts
    on Starter without crashing
18  Starter capability flag: /cafe/pos serves on Starter (no 5xx)
19  Restore tier to cafe_pro + close session + delete drafts + deactivate
    test cashier