← All tests

POS multi-order tabs rework LOCAL

Narong's feedback: "I select a 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, hence the gap I mentioned." Ideal UX: orders as tabs at the top so you can switch between order-list and product-grid, create a new order while another is on-going, switch back and forth, and see status (pending/on-going, paid, refunded). Both tiers should share the experience.

6/6 local checks passed. Pro lockable + Pro in-app + Starter lockable + Starter in-app all route through one new <PosWorkspace> component that owns the tab strip + multi-tab state + History tab. No migration; cafe.draft_orders already had everything we needed.

The Pro bug, traced in the diff

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

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

After:
  <PosWorkspace> (NEW)
    state: { tabs: Map<id, {lines, customer, ...}>, activeTabId, mode }
    auto-save: debounced 500ms per active tab to cafe.draft_orders
    <PosTabStrip /> (always visible)
    if (mode === "history") <OrdersHistoryView />
    else <RegisterShell key={activeTabId} initialCart={tabs[activeTabId].lines} />
                       ^^^^^^^^^^^^^^^^^^
                       remount per tab, but state lives in workspace

  Fix flow:
    1. Add items → onCartChange propagates to workspace.tabs[active].lines
    2. Click "+ New" → workspace mints new tab id, activeTabId = new
    3. RegisterShell key changes → remounts with empty cart
    4. The old tab's lines are STILL in workspace.tabs[old.id]
    5. Click "Order 1" tab → activeTabId = old → remount with [A, B, C]
    6. No data loss across tab switches.

The Starter gap, closed

Before (starter-register-mount.tsx):
  const STARTER_CAPABILITIES = { canParkDrafts: false, ... };
  // No saveDraft handler — gated by canParkDrafts.
  // Starter top-bar Orders button → router.push("/cafe/orders") (LEAVES POS)
  // Starter lockable Orders button → window.open("/cafe/orders") (NEW TAB)

After:
  const STARTER_CAPABILITIES = { canParkDrafts: true, ... };
  // saveDraft + deleteDraft handlers added (identical to Pro — cafe.draft_orders
  //   is tier-agnostic, no Odoo dependency).
  // Starter no longer needs a route-away Orders button — tabs + History live
  //   inside the workspace.

Result: one POS UX. Whether the cashier opens the in-app /cafe/pos or the
lockable /cafe/pos/register/N, on Starter or Pro, they see the same tab
strip with the same multi-order semantics. The tier difference is now
strictly about the data backend (cafe.orders vs Odoo pos.order), not the
cart UX.

What ships

NEW:
  app/(authed)/pos/_components/pos-workspace.tsx        (~330 LOC)
    Tab orchestrator. Owns tabs[], activeTabId, mode (tabs|history).
    Auto-save with debounce + flush-on-switch. Renders tab strip + active
    cart or history view.

  app/(authed)/pos/_components/pos-tab-strip.tsx        (~110 LOC)
    Horizontal tab buttons. Each shows label + amber dot + line/total
    count. "+ New" mints empty tab. "History" toggles mode.

  app/(authed)/pos/_components/orders-history-view.tsx  (~480 LOC)
    Extracted from the old orders-view.tsx, draft/active filter dropped.
    Paid/refunded/cancelled/partial list with per-line refund + receipt
    print.

MODIFIED:
  app/(authed)/pos/_components/register-shell.tsx
    Now controlled: cart + customer come in via props; onCartChange /
    onCustomerChange report up. Removed drafts state, drafts-strip,
    park/resume/discard handlers, nix-cafe:* event listeners.

  app/(authed)/pos/_components/pro-register-mount.tsx
  app/(authed)/pos/_components/starter-register-mount.tsx
    Wrap RegisterShell with <PosWorkspace>. Both accept initialDrafts +
    sessionOrders props from the page-level fetcher.

  app/(authed)/pos/_adapters/starter-handlers.ts
    + saveDraft / deleteDraft (identical to Pro). + normalizeStarterDrafts.

  app/(authed)/pos/_components/starter-register-mount.tsx
    capabilities.canParkDrafts: false → true.

  app/(authed)/pos/_components/starter-top-bar.tsx
  app/(authed)/pos/_components/starter-lockable-shell.tsx
    Dropped the Orders button that routed away from POS. History now lives
    inside the tab strip.

  app/(pos-fullscreen)/pos/register/[configId]/lockable-shell.tsx
    Dropped the view-state toggle + Orders TopBar button. PosWorkspace
    handles in-shell history. The unmount-on-switch bug is gone.

  app/(authed)/pos/page.tsx
  app/(authed)/pos/starter-register-page.tsx
  app/(pos-fullscreen)/pos/register/[configId]/page.tsx
    Server-side fetch of initialDrafts (listDraftsForSession) +
    sessionOrders (listOrdersForSession) for all four POS surfaces.

  app/(authed)/pos/_components/cart.tsx
    Dropped the draftsStrip slot (replaced by top tab strip).

DELETED:
  app/(authed)/pos/_components/drafts-strip.tsx
  app/(pos-fullscreen)/pos/register/[configId]/orders-view.tsx

NO MIGRATION. cafe.draft_orders + cafe.orders already carry every column
the workspace needs (R9.3 reserved order_number + sequence_no on drafts;
state enum on orders covers paid/refunded/partial_refund/cancelled).

Test strategy

The Gate 1 local test (test-pos-tabs-local.mjs):
  01  /cafe/login returns 200 (auth surface).
  02  /cafe/pos returns 307 (unauthed → login, not 5xx).
  03  /cafe/pos/register/1 returns 307 (lockable route compiles).
  04  /cafe/pos/register/9999 also 307 (auth gate runs before configId
      lookup — no 500 on missing config).
  05  Login page renders end-to-end with the new component bundle (a
      Webpack failure on any of the new files would surface here).
  06  Schema smoke — cafe.draft_orders columns present (id, tenant_id,
      session_id, label, payload, order_number, sequence_no, updated_at,
      created_at) + cafe.orders.state + cafe.orders.order_number.

The full multi-tab UX flow (add tab, switch + verify cart preserved,
auto-save, History tab) needs a logged-in session, which needs the full
Commerce SSO local stack. We took the same shape that the mobile-
responsive session used (workflow precedent in
project_session_2026_05_12_perf_and_mobile.md): typecheck + route smoke
on local, full Playwright at Gate 2 on prod. Cleaner — prod has real
Odoo + real sessions + the Cloudflare Workers runtime where this code
actually deploys.

Gate 2 plan

1. Single cafe commit: feat(pos): multi-order tabs + history view on both tiers.
2. Push origin karouna-dev. Cloudflare auto-rebuilds Cafe Worker (~60-90s).
3. Wait for deploy (ScheduleWakeup ~90s).
4. Prod test (test-pos-tabs-prod.mjs against get-coffee for Pro + flip to
   Starter via cafePlanController helper):
   01  Schema check — no migration; just assert columns present
   02  Pre-flight — config 7 has no open session
   03  SSO login as get-coffee owner
   04  Create test cashier, capture PIN
   05  Open popup terminal at /cafe/pos/register/7
   06  PIN unlock → PreShift
   07  Open shift with $0 beginning cash
   08  Verify tab strip visible: 1 active tab "Order 1", "+", "History"
   09  Add an item → tab auto-saves; tab badge shows line count
   10  Click "+" → new tab "Order 2" mints; tab strip shows both
   11  Switch back to "Order 1" → cart restored; switch to "Order 2" →
       cart restored (the Pro bug fix verified)
   12  Pay from "Order 1" → SuccessModal renders; close; tab removed
   13  Click "History" → paid order appears with green Paid chip
   14  Refund one line from History → state flips to partial_refund chip
   15  Flip tenant to starter via planCtrl.flipToStarter() and re-run
       9-11 to prove Starter has the same tabs (the closed gap)
   16  planCtrl.restore() (always-on, in process.on('exit'))
   17  Cleanup — force-close session, deactivate cashier, remove drafts
5. Regression sweep (sequential per workflow R8.2):
   phase1 + phase2-sso-outdoor + phase2-cafe-multishop + m1 + r7 + r8
   + r9 + r10 = ~95 checks. r9 covers the old orders-view code path
   which we replaced — must still pass.
6. Publish prod gallery, stop for approval.