You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/docs/13-SPEC-BILLING-POLISH.md

27 KiB

SiMS Next — POS Billing Screen Polish Spec (v1)

Scope: apps/pos/src/BillingScreen.tsx and the packages it consumes. Everything below is implementable against the current architecture (React + @sims/* + store-server REST). Items needing server work are marked NEW SERVER. Keystroke convention per 09-UX §1: scan = 0 keys, every keypress = 1.

Constants introduced below (PRICE_BAND_PCT=10, QTY_WARN=50, DISC_WARN_PCT=10, TENDER_MAX_DIGITS=6) are module consts today, DB setting rows later — name them once in a POS_LIMITS object at the top of BillingScreen.tsx so the migration to config is one import swap.


P0 — ship first

P0-1 · Live search suggestions dropdown (replaces blind first-match add)

Why first: search() in BillingScreen.tsx (lines 102110) silently adds hits[0] — "tomato" can bill tomato ketchup. Every failed scan (515% of items, POS-4) funnels here; this is both the biggest speed and the biggest correctness gap.

Behavior

  • When the scan box content parses as kind:'search' (letters present, per parseEntry) and length ≥ 2, render a suggestion list directly under the scan box (absolutely positioned, part of pos-left, never a Modal). Recompute synchronously on every keystroke — no debounce (09-UX §5.1: debounce = felt lag). In-memory filter over the items cache (≤ 2000 rows today) is comfortably < 5 ms; budget assert < 150 ms.
  • Max 8 rows. Each row: name (+ nameSecondary small, grey), code, rate (formatINR(salePricePaise), /kg when unit.code==='KG'), MRP struck-through when rate < MRP, and a stock chip (ok/low/out — see P1-5; render until stock is loaded). Row 1 pre-highlighted.
  • Match tiers (short-circuit, per 09-UX §5.1): 1 whole-string prefix on name → 2 token prefix on any word of name/nameSecondary → 3 phonetic-key match → 4 fuzzy (edit distance ≤ 2, only when tiers 13 yield < 8). Tie-break: shorter name first (velocity ranking is P1-5).
  • Phonetic-friendly: create the package the blueprint names — packages/search-core (@sims/search-core), pure TS, no deps:
    • normalize(s): lowercase, strip punctuation, collapse spaces.
    • phoneticKey(s): the Indic folding table from §5.1 (aa→a, ee→i, oo→u, w→v, ph→f, bh→b, chh→ch, sh→s, th→t, dh→d, z→j, drop doubled letters) so chawal/chaval, dhaniya/dania, maggie/magi collide.
    • buildIndex(items: Item[]): SearchIndex (precomputes tokens + phonetic keys once; memoize in BillingScreen with useMemo on items).
    • querySearch(index, text): Item[] returning tier-ranked results.
    • Mirror packages/scanning's package.json/tsconfig; unit-test the folding table (this same package later backs the back-office Ctrl+K — one engine, two profiles).

Exact keystrokes

  • Type letters → list appears. / move highlight (wraps). Enter adds the highlighted item (so tom + Enter is unchanged in key count from today, but now visible-before-add). Esc (first press) closes the list and clears the input; second Esc is the normal no-op.
  • 3*tom → dropdown filters on tom; Enter adds qty 3 (thread parsed.qty through — handleEnter's multiplier branch calls the new openSuggestions(rest, qty) instead of search).
  • Digits-only input never opens the dropdown (barcode/code path untouched). Wedge-classified scans short-circuit before dropdown logic — first line of handleEnter already does this.

Focus rules

  • DOM focus never leaves the scan box. The dropdown is a rendered listbox (role="listbox", aria-activedescendant), navigated by intercepting ArrowUp/ArrowDown/Enter/Escape in the input's own onKeyDown with e.stopPropagation() before the window-level onKey handler — otherwise arrows would move the line-grid selection (lines 226227). Rule: dropdown open ⇒ arrows own the dropdown; dropdown closed ⇒ arrows own the grid.

Edge cases

  • 0 hits → inline row: No item matches "xyz" (E-1102) · [Enter] Quick-add · [Esc] Clear — Enter jumps to P0-3's mini-form with Name pre-filled with the typed text.
  • Exactly 1 hit → still show the list (predictability beats cleverness); Enter adds it.
  • A scan arriving mid-typing corrupts the buffer today — accepted for P0, fixed properly in P2-1.
  • Item list updated by quick-add (P0-3) → index memo recomputes (depends on merged items array).

Touches: NEW packages/search-core/src/index.ts (+ tests); BillingScreen.tsx — delete search(), add suggest state {q, qty?, hits, highlight} | undefined, extend input onKeyDown, render block after the scan box; pos.css dropdown styles. No server change.


P0-2 · F2 qty edit + F3 price override — inline cell editing with supervisor-gate hook

Behavior

  • F2 opens an edit input inside the Qty cell of the selected line; F3 inside the Rate cell. Never a modal (POS-2). The cell input is pre-filled with the current value, text selected.
  • Add state editing?: { line: number; field: 'qty' | 'price'; value: string }. Render: in the lines table (lines 282290), when i === editing.line, replace the cell content with <input className="wf num" autoFocus …>.
  • F2 qty: digits (+ decimal for unit.decimals > 0). Commit updates lines[i].qty. Qty 0 converts to a Del-with-confirm (P0-5). Non-integer qty on a decimals===0 unit → inline err, stay in edit.
  • F3 price: entered in rupees ("270" or "270.50"), stored as unitPricePaise = Math.round(v*100); semantics identical to salePricePaise (tax-inclusive per priceIncludesTax). Because the server recomputes in commitBill from the submitted LineInputs, an edited unitPricePaise flows through and totals agree — commit works today with zero server change.
  • Gates (hook now, policy rows later):
    • newPrice > mrpPaiseblocked (GST-15) pending supervisor approval.
    • deviation from item.salePricePaise beyond ±PRICE_BAND_PCT → supervisor approval.
    • Supervisor gate v1: inline panel replacing the notice area — supervisor picker (bootstrap users filtered to role !== 'cashier', ↑/↓+Enter) + 46 digit PIN field. Verify via POST /api/auth/pin through a new client fn verifySupervisorPin(userId, pin) in apps/pos/src/api.ts that does not assign the module-level token (do not reuse pinLogin — it would hijack the cashier's session). 3 failures → panel closes, edit cancelled. Implement as async function supervisorGate(desc: string): Promise<{ approvedBy: string } | undefined> so F4 discounts (P1-3) and future Del/void reuse it.
    • NEW SERVER (recommended, small): POST /api/auth/verify-pin — same attempt() logic in api.ts but no session creation; and audit action PIN_VERIFY instead of a login row. Works without it today via /auth/pin (discard the token), but that pollutes sessions and audit semantics.
  • Override audit (NEW SERVER, required for compliance): extend POST /api/bills body with overrides?: { itemId: string; kind: 'price'|'qty'|'discount'; before: number; after: number; approvedByUserId?: string }[]; in commitBill (repos.ts), inside the existing transaction, writeAudit(db, …, 'PRICE_OVERRIDE'|'QTY_OVERRIDE', 'bill', id, {before}, {after, approvedBy}) per entry. Client accumulates the array in state and clears it on commit/cart-clear. ~15 lines server-side.

Exact keystrokes: F2 2 Enter = 3 (matches scoreboard). F3 2 6 5 Enter = 5, + PIN out of band. Esc in the cell cancels the edit, restores the value.

Focus rules: cell input takes focus on open; on commit or Esc, inputRef.current?.focus() — the sacred line always gets focus back. While editing is set, the window onKey handler must early-return for everything except nothing (guard at top: if (editingRef.current) return) so F12 etc. cannot fire mid-edit. Same guard for the supervisor panel.

Edge cases: F2/F3 with empty cart → quiet no-op. F2 on a scale-weight line → allowed, but amber notice "weighed line — reweigh is safer". Selected-line index after edits stays; selected already tracked. Qty edit beyond QTY_WARN → amber inline confirm (one extra Enter, POS-7 sanity guard, never a modal).

Touches: BillingScreen.tsx (onKey cases F2/F3, table cell render, supervisorGate), apps/pos/src/api.ts (verifySupervisorPin, overrides in postBill body type), apps/store-server/src/api.ts + repos.ts (NEW: overrides audit; optional verify-pin route).


P0-3 · Unknown-barcode quick-add (inline mini-form → draft item → immediately billable)

Why: today resolveCode() (line 99) dead-ends with "add it in Back Office" — the cashier either abandons the sale or keys a substitute item. Blueprint §1.4: 3 keystrokes + typing.

Behavior

  • On lookup miss in resolveCode (and 0-hit Enter from P0-1): render the inline panel under the scan box (replaces the notice slot): Unknown code 8901…4357 (E-1102) · [Enter] Quick-add · [Esc] Skip.
  • Enter expands it into a 3-field micro-form (still inline, not a Modal): Name (autofocus; pre-filled with typed text when arriving from search), Price ₹ (numeric), GST (<select> of taxClasses from props, default = the modal class — compute the mode of items.map(i => i.taxClassCode) once via useMemo; that is "the store slab" until settings exist).
  • Save → POST /api/items (endpoint exists; createItem in repos.ts already accepts status): { code, name, hsn: '', taxClassCode, unitCode: 'PCS', unitDecimals: 0, salePricePaise: Math.round(price*100), barcode: scannedCode, status: 'draft' }. Code generation client-side: use the barcode itself as code if no items.find(i => i.code === code) collision, else QA- + Date.now().toString(36). No server changehsn:'' is the deliberate GST-13 INCOMPLETE marker; back office ItemsPage already renders draft status as a warn badge (the completion worklist is back-office scope).
  • On success: append to a new extraItems local state (merged with props.items via useMemo — this merged array feeds resolveCode, the search index, and quick-keys), call addItem(newItem, pendingQty), notice ok: "Draft item added — details to complete in Back Office". If the pending scan carried a 3* multiplier it applies.
  • Add createItem(...) to apps/pos/src/api.ts (mirror the back-office client fn).

Exact keystrokes: Enter (open) → type name → Tab → type price → Enter (save; GST select only touched when non-default: Tab ↑/↓). = 3 keys + typing, matching the scoreboard. Esc at any point = skip, 1 key.

Focus rules: form fields hold focus while open; Esc from any field closes and refocuses the scan box; save success refocuses the scan box. Window onKey suspended while the form is open (same editingRef-style guard).

Edge cases

  • Scans while the form is open must not corrupt the Name field (§1.4: "further scans queue behind it"): keep a window-level keydown capture listener active during the form that runs a second WedgeDetector; a detected burst is swallowed (preventDefault) into a queuedScans: string[] with a chip on the panel ("1 scan waiting"); on form close (save or skip), replay each through resolveCode. If the same unknown code is scanned again while its own form is open, ignore it.
  • Price empty/zero → block save with inline err (a ₹0 draft item is a margin leak).
  • Server failure/offline → form stays populated, inline err with the E-code from call(); Esc still exits (sale continues without the line).
  • Weighing-scale PLU miss (Scale PLU … not in catalog) does not offer quick-add (a draft PCS item can't represent a scale PLU correctly) — keep the plain error.

Touches: BillingScreen.tsx (resolveCode miss path, quickAdd state + panel render, extraItems), apps/pos/src/api.ts (add createItem). No server change.


P0-4 · Cash with change — tendered amount before F12

Why: blueprint §1.4's 5-keystroke "cash with change" path doesn't exist; cashiers do mental arithmetic on every non-exact cash bill.

Behavior

  • In the F12/NumpadAdd handler (and the Cash button): if the scan box currently holds /^\d{1,6}$/ (≤ TENDER_MAX_DIGITS — a barcode is ≥ 8 digits and can never be read as tender) and value*100 >= totals.payablePaise, treat it as tendered cash: clear the input, run commit('CASH'), and on success flash CHANGE ₹X for 4 s — rendered large in the totals panel area (state changeFlash?: number + setTimeout clear; it is a render, not a toast, and steals nothing).
  • If the input is non-empty and does not qualify (short digits < payable, or letters): do not guess — inline warn "Finish the line first (Enter) or clear (Esc)", no commit. Never silently discard cashier input on the close key.
  • Record it: include tenderedPaise/changePaise in the payment leg (payments: [{ mode:'CASH', amountPaise: payable, tenderedPaise, changePaise }]). Server commitBill validates sum(amountPaise) === payable — unchanged; extra fields ride into the stored payload JSON automatically. No server change (reference field already exists on the type; add the two optional numbers to the client body type only).

Exact keystrokes: 2 0 0 0 F12 = 5. Exact cash stays F12 = 1.

Focus rules: none change — scan box focused throughout; change flash is passive.

Edge cases: tendered while pendingQty set → the digits are in the input, not in pendingQty; pendingQty also cleared on commit. Double-F12 while busy → ignored silently (guard exists, line 135 — change the say('warn') to a no-op when busy to avoid noise). Change flash also mirrors to the customer display when that surface lands (out of scope now).

Touches: BillingScreen.tsx (onKey F12/NumpadAdd branch, commit() signature gains optional tenderedPaise, totals panel render). No server change.


P0-5 · Keyboard & overlay discipline batch (small fixes, §1 gaps)

All in BillingScreen.tsx's onKey effect (lines 200233) and the overlay components. One PR.

  1. Overlay key filtering. Today F7/F12/etc. still fire while pay/history/dayend overlays are open (only settings/customer are excluded, line 209). Rule: overlay !== 'none' ⇒ only Esc plus that overlay's own keys are handled; everything else early-returns.
  2. Pay overlay hotkeys (§1.4): while overlay==='pay', keys 1=CASH 2=UPI 3=CARD 4=KHATA call commit(mode). Add a useEffect keydown inside the pay Modal render or extend onKey with an overlay==='pay' branch. UPI/card = 3 keystrokes as budgeted (F11, digit, [confirm comes with payments integration]).
  3. Quick-key bindings: Ctrl+1…Ctrl+4 (through Ctrl+0 as the row grows) → resolveCode(QUICK_KEYS[n]), e.preventDefault() (browser tab-switch). Tiles already render the kbd hint — the binding is the missing half.
  4. Esc clears pendingQty (currently survives Esc — a stale 3* silently multiplies the next unrelated scan; this is a live defect). Esc order: close suggestion list → clear input/notice → clear pendingQty → close overlay.
  5. Del inline confirm (§1.3: "inline confirm"): first Del arms an amber strip on the selected line — Remove #3 Aashirvaad Atta 5kg? [Enter] Remove · [Esc] Keep; Enter removes (2 keys). Removed via a confirmDel: number | undefined state; any other key disarms. Never a modal.
  6. F-key preventDefault on all bound function keys (F6 already misses it in some branches — audit each case).
  7. History overlay keyboard: ↑/↓ selects a row in the F10 table, groundwork for P1-1's R.

Focus rules: unchanged — the onBlur refocus sentinel (line 264) stays; extend its exclusion list to every state that legitimately holds focus: overlay !== 'none' || editing || quickAdd || supervisorPanel.

Touches: BillingScreen.tsx only. No server change.


P1 — next

P1-1 · Repeat last bill + repeat from history

Behavior

  • On commit success, snapshot lastBillLines = lines (the raw LineInput[], pre-clear) alongside the existing dayLog entry.
  • Ctrl+B ("bill again", preventDefault): if the cart is empty, re-add lastBillLines re-resolved against the current catalog — for each line, find the item by itemId in the merged items array and call addItem(item, line.qty) so current price/tax apply (never resurrect old prices; the old bill is immutable, the new cart is fresh). Missing/inactive items are skipped with one notice: "Repeated last bill — 2 items no longer available". Non-empty cart → warn "Hold or close the current bill first".
  • From history: in the F10 overlay, R on the selected row repeats that bill. GET /api/bills already returns the payload column (listBills selects b.*), and payload.lines are engine Lines which superset LineInput — map itemId+qty through the same re-resolve path. No server change.
  • Regular-customer ergonomics for free: attach customer (F6), Ctrl+B, F12 — the daily-milk-and-bread bill is 3 keys + phone.

Keystrokes: Ctrl+B = 1. History repeat: F10 ↓… R = 3+. Focus: scan box keeps focus; F10 path closes the overlay on R and refocuses. Edge cases: last bill was a held-then-resumed cart — snapshot is whatever committed; scale-weight lines repeat at the same weight (amber hint "check weighed items"); repeat when lastBillLines empty (fresh login) → quiet warn.

Touches: BillingScreen.tsx (commit success path, onKey Ctrl+B, history overlay R).

P1-2 · Multiplier ergonomics

Behavior

  • Instant *: in the scan box onKeyDown, when the pressed key is * and the current input is /^\d{1,4}(\.\d{1,3})?$/, immediately set pendingQty, clear the input, preventDefault. The scanner-in-left-hand flow becomes 3 * scan = 2 keystrokes with zero Enter (matches the scoreboard) and the chip is visible before the scan.
  • Chip, not Notice: move the pending-qty indicator from the Notice row (line 268) to a chip rendered inside the scan-box row's right edge (3 × amber) — closer to the eye line, doesn't shift layout.
  • Sanity guard (POS-7): pendingQty > QTY_WARN (50) → the chip renders amber with "press Enter to confirm ×120"; the next add requires that one confirming Enter. Fat-fingered 10*100* stops costing a whole void.
  • Decimal × unit check: decimal pendingQty applied to a decimals===0 item → err notice "Whole quantities only for PCS", multiplier kept so the cashier can rescan the right item.
  • Esc-clears already added in P0-5.

Touches: BillingScreen.tsx (input onKeyDown, addItem validation, chip render). parseEntry's 3*code inline form keeps working unchanged.

P1-3 · F4 line discount (engine already supports it)

Behavior: F4 opens the same inline cell editor (P0-2 machinery) on a new Disc column of the selected line. Grammar per §1.3: raw 5 = ₹5 off (discount: {kind:'amount', paise: 500}), 5% = percent ({kind:'percentBp', value: 500}). LineInput.discount exists in @sims/billing-engine (compute.ts line 14) and the server recomputes — commits work today. 0 or empty clears the discount. Gate: discount > DISC_WARN_PCT of line value → supervisorGate() from P0-2; audit rides the same NEW SERVER overrides array (kind:'discount'). F5 bill-level discount is deferred until the engine grows a bill-level discount input — do not fake it by spreading across lines. Keystrokes: F4 5 Enter = 3; F4 5 % Enter = 4 (scoreboard: 45). Esc cancels. Focus/edge: identical to P0-2. Show Disc column only when any line has one (keeps the grid clean for the 95% case). Touches: BillingScreen.tsx; server overrides from P0-2.

P1-4 · Audio + notice refinements (§5.6: sound is the primary feedback channel)

  • NEW apps/pos/src/audio.ts: WebAudio oscillator beeps, zero assets — beepOk() (880 Hz, 60 ms), beepErr() (220 Hz, 2 × 120 ms), beepWarn() (440 Hz, 100 ms). Called from addItem (ok — this is the app-side confirmation beep distinct from the scanner's own, POS-1), say('err'…) (err), sanity guards (warn). Counter-scope mute flag in SettingsModal later; default on.
  • Notice lifecycle in say(): ok auto-clears after 4 s (timer, cancelled by the next notice); err/warn persist until Esc or the next successful action. Today an old error lingers until something overwrites it, and successes never leave.
  • Every actionable notice carries its key hints as text chips ([Enter] Quick-add · [Esc] Skip) — the P0-3 panel sets the pattern; apply to E-1102/E-1103 and commit-failure messages.
  • Commit failure message (line 167) already says "cart kept" — add beepErr and keep the cart-untouched guarantee prominent.

Touches: new audio.ts; BillingScreen.tsx say()/addItem. No server change.

P1-5 · Stock (and later velocity) enrichment for the dropdown

  • After login, fetch GET /api/stock (exists — stockView) into a Map<code, onHand>; refresh opportunistically (after each commit, decrement billed quantities locally; hard refresh on F10 open). Feeds the P0-1 stock chip and an amber, non-blocking "out of stock" line hint on add (SY-7: warn, never block; the shelf is the arbiter).
  • Add fetchStock() to apps/pos/src/api.ts.
  • NEW SERVER (small, flagged): 90-day sales velocity for ranking — add velocity90 to GET /api/items (one LEFT JOIN aggregate over stock_movement WHERE reason='SALE' in listItems, repos.ts). Until then the P0-1 tie-break (shorter name) stands. When catalogs exceed the current LIMIT 2000 in listItems, the same PR should raise the cache limit or add delta refresh — flagging now so 100k-SKU pilots don't hit a silent truncation.

P2 — when P0/P1 are telemetry-green

P2-1 · Wedge burst splitting + double-read discard (POS-3)

Upgrade packages/scanning/src/wedge.ts: record per-char timestamps; terminate() gains a third result { type:'mixed', typed: string, code: string } when a trailing ≥ 6-char burst with gaps ≤ 35 ms follows slower typed chars. handleEnter then resolves the code and restores the typed fragment to the input (with the suggestion dropdown reopening). Also: identical code within 150 ms of the last resolved scan with no intervening key = hardware double-read → discard + beepWarn. Pure-TS change with injected timestamps — extend the existing tests. Touches wedge.ts, BillingScreen.handleEnter.

P2-2 · Held-bill tray + persistence

F8 with > 1 held bill opens an inline tray (not a modal — render in the lines area): token, line count, value, age; ↑/↓+Enter resumes, Esc closes; single held bill keeps today's instant resume (2 keys, scoreboard-compliant). Persist held + tokenSeq to localStorage keyed by counter so held carts survive a refresh (blueprint requires journaling — true server journaling is NEW SERVER POST /api/holds, deferred; localStorage closes 90% of the power-cut gap on the web build today). Touches BillingScreen.tsx (hold/resume, tray render, useEffect persistence).

P2-3 · Ctrl+P reprint last (§1.3, 1 keystroke)

Snapshot { computedLines, totals, docNo, mode } at commit success; Ctrl+P re-renders via renderReceipt with a new duplicate: true option that prints a DUPLICATE line (touches packages/printing — add the flag to the receipt options and template) and kickDrawer: false. No supervisor gate at copy 1; count copies per bill locally. Touches BillingScreen.tsx, packages/printing.

P2-4 · F1 keymap cheat-sheet

Static overlay listing the live bindings with one-line descriptions; user-initiated (F1), Esc closes and refocuses. Content from a const array so the future DB keymap re-uses it. Trivial; high training value (§5.6 — the F-row is the UI for a non-reader).

P2-5 · Customer modal keyboard completion

CustomerModal (line 411): results are click-only. Add ↑/↓+Enter to attach; when phone ≥ 10 digits and no match, plain Enter creates phone-only with the default name (blueprint §1.4: "No match → Enter again creates the customer") — the name field becomes optional polish, keeping attach ≤ 12 keys. Touches CustomerModal only.


What NOT to add (guard the one-screen rule)

  • No modals on the billing path. Everything in this spec is inline or a keystroke-initiated overlay that Esc exits to the scan box. Adopt the POS-1 lint rule now: importing Modal into a new billing-path module fails CI (existing F10/F11/day-end overlays are grandfathered user-initiated overlays; do not add more).
  • No "print? Y/N", no printer picker, no mandatory phone/customer field, no close confirmation. Each would tax 100% of bills (POS-8). Exact-cash close stays exactly 1 key.
  • No network in the scan→line path. Suggestions, stock chips, quick-add defaults all read local state; only quick-add's save and the commit itself hit the server, and both fail soft with the cart intact.
  • No debounce, no async spinner on search. Synchronous in-memory query or nothing.
  • No second screen/route, no tabs, no drawer panels. Returns mode (F9), payment integrations, and the customer display are separate roadmap items — do not stub their UI here.
  • No stock-based sale blocking, no batch/expiry UI yet — warn-never-block (SY-7) until batch tracking lands.
  • No toast/snackbar library, ever, on this screen. say() + the notice slot + audio is the entire feedback vocabulary.
  • No settings/config UI growth mid-bill — new constants go in POS_LIMITS awaiting DB settings, not new Settings panels.

NEW SERVER work — consolidated

Item Endpoint / function Size Needed by
Override audit rows POST /api/bills accepts overrides[]; commitBill writes audit in-txn (repos.ts) ~15 lines P0-2, P1-3 (required for compliance; UI works without it)
PIN verify without session POST /api/auth/verify-pin (api.ts, reuse attempt) ~10 lines P0-2 (workaround exists via /auth/pin)
Sales velocity for ranking velocity90 in GET /api/items (listItems) ~10 lines P1-5 (optional)
Held-cart journal POST/GET /api/holds new table + routes P2-2 (localStorage interim)

Everything else in P0P2 ships against the API exactly as it exists today (POST /api/items with status:'draft', GET /api/stock, GET /api/bills payload column, POST /api/bills server-side recompute absorbing qty/price/discount edits).

Suggested build order: P0-5 (discipline base) → P0-1 (search-core + dropdown) → P0-3 (quick-add, reuses the dropdown's zero-hit path) → P0-2 (inline edit + gate) → P0-4 (tender) → P1 in listed order. P0 total is roughly 23 days of focused work; nothing blocks on another team except the two small server rows above.