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 102–110) silently adds hits[0] — "tomato" can bill tomato ketchup. Every failed scan (5–15% 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, perparseEntry) and length ≥ 2, render a suggestion list directly under the scan box (absolutely positioned, part ofpos-left, never aModal). 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 (+
nameSecondarysmall, grey), code, rate (formatINR(salePricePaise),/kgwhenunit.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 1–3 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) sochawal/chaval,dhaniya/dania,maggie/magicollide.buildIndex(items: Item[]): SearchIndex(precomputes tokens + phonetic keys once; memoize inBillingScreenwithuseMemoonitems).querySearch(index, text): Item[]returning tier-ranked results.- Mirror
packages/scanning'spackage.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).Enteradds the highlighted item (sotom+ 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 ontom; Enter adds qty 3 (threadparsed.qtythrough —handleEnter's multiplier branch calls the newopenSuggestions(rest, qty)instead ofsearch).- Digits-only input never opens the dropdown (barcode/code path untouched). Wedge-classified scans short-circuit before dropdown logic — first line of
handleEnteralready does this.
Focus rules
- DOM focus never leaves the scan box. The dropdown is a rendered listbox (
role="listbox",aria-activedescendant), navigated by interceptingArrowUp/ArrowDown/Enter/Escapein the input's ownonKeyDownwithe.stopPropagation()before the window-levelonKeyhandler — otherwise arrows would move the line-grid selection (lines 226–227). 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
F2opens an edit input inside the Qty cell of the selected line;F3inside 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 282–290), wheni === editing.line, replace the cell content with<input className="wf num" autoFocus …>. - F2 qty: digits (+ decimal for
unit.decimals > 0). Commit updateslines[i].qty. Qty0converts to a Del-with-confirm (P0-5). Non-integer qty on adecimals===0unit → inline err, stay in edit. - F3 price: entered in rupees (
"270"or"270.50"), stored asunitPricePaise = Math.round(v*100); semantics identical tosalePricePaise(tax-inclusive perpriceIncludesTax). Because the server recomputes incommitBillfrom the submittedLineInputs, an editedunitPricePaiseflows through and totals agree — commit works today with zero server change. - Gates (hook now, policy rows later):
newPrice > mrpPaise→ blocked (GST-15) pending supervisor approval.- deviation from
item.salePricePaisebeyond ±PRICE_BAND_PCT→ supervisor approval. - Supervisor gate v1: inline panel replacing the notice area — supervisor picker (bootstrap
usersfiltered torole !== 'cashier',↑/↓+Enter) + 4–6 digit PIN field. Verify viaPOST /api/auth/pinthrough a new client fnverifySupervisorPin(userId, pin)inapps/pos/src/api.tsthat does not assign the module-leveltoken(do not reusepinLogin— it would hijack the cashier's session). 3 failures → panel closes, edit cancelled. Implement asasync 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— sameattempt()logic inapi.tsbut no session creation; and audit actionPIN_VERIFYinstead of a login row. Works without it today via/auth/pin(discard the token), but that pollutessessionsand audit semantics.
- Override audit (NEW SERVER, required for compliance): extend
POST /api/billsbody withoverrides?: { itemId: string; kind: 'price'|'qty'|'discount'; before: number; after: number; approvedByUserId?: string }[]; incommitBill(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. Enterexpands it into a 3-field micro-form (still inline, not aModal): Name (autofocus; pre-filled with typed text when arriving from search), Price ₹ (numeric), GST (<select>oftaxClassesfrom props, default = the modal class — compute the mode ofitems.map(i => i.taxClassCode)once viauseMemo; that is "the store slab" until settings exist).- Save →
POST /api/items(endpoint exists;createIteminrepos.tsalready acceptsstatus):{ 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 ascodeif noitems.find(i => i.code === code)collision, elseQA-+Date.now().toString(36). No server change —hsn:''is the deliberate GST-13INCOMPLETEmarker; back officeItemsPagealready rendersdraftstatus as a warn badge (the completion worklist is back-office scope). - On success: append to a new
extraItemslocal state (merged withprops.itemsviauseMemo— this merged array feedsresolveCode, the search index, and quick-keys), calladdItem(newItem, pendingQty), noticeok: "Draft item added — details to complete in Back Office". If the pending scan carried a3*multiplier it applies. - Add
createItem(...)toapps/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
keydowncapture listener active during the form that runs a secondWedgeDetector; a detected burst is swallowed (preventDefault) into aqueuedScans: string[]with a chip on the panel ("1 scan waiting"); on form close (save or skip), replay each throughresolveCode. 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/NumpadAddhandler (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) andvalue*100 >= totals.payablePaise, treat it as tendered cash: clear the input, runcommit('CASH'), and on success flash CHANGE ₹X for 4 s — rendered large in the totals panel area (statechangeFlash?: number+setTimeoutclear; 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/changePaisein the payment leg (payments: [{ mode:'CASH', amountPaise: payable, tenderedPaise, changePaise }]). ServercommitBillvalidatessum(amountPaise) === payable— unchanged; extra fields ride into the storedpayloadJSON 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 200–233) and the overlay components. One PR.
- Overlay key filtering. Today F7/F12/etc. still fire while
pay/history/dayendoverlays are open (onlysettings/customerare excluded, line 209). Rule:overlay !== 'none'⇒ only Esc plus that overlay's own keys are handled; everything else early-returns. - Pay overlay hotkeys (§1.4): while
overlay==='pay', keys1=CASH2=UPI3=CARD4=KHATA callcommit(mode). Add auseEffectkeydown inside the payModalrender or extendonKeywith anoverlay==='pay'branch. UPI/card = 3 keystrokes as budgeted (F11, digit, [confirm comes with payments integration]). - Quick-key bindings:
Ctrl+1…Ctrl+4(throughCtrl+0as the row grows) →resolveCode(QUICK_KEYS[n]),e.preventDefault()(browser tab-switch). Tiles already render thekbdhint — the binding is the missing half. - Esc clears
pendingQty(currently survives Esc — a stale3*silently multiplies the next unrelated scan; this is a live defect). Esc order: close suggestion list → clear input/notice → clearpendingQty→ close overlay. - Del inline confirm (§1.3: "inline confirm"): first
Delarms an amber strip on the selected line —Remove #3 Aashirvaad Atta 5kg? [Enter] Remove · [Esc] Keep; Enter removes (2 keys). Removed via aconfirmDel: number | undefinedstate; any other key disarms. Never a modal. - F-key
preventDefaulton all bound function keys (F6 already misses it in some branches — audit each case). - History overlay keyboard:
↑/↓selects a row in the F10 table, groundwork for P1-1'sR.
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 rawLineInput[], pre-clear) alongside the existingdayLogentry. Ctrl+B("bill again",preventDefault): if the cart is empty, re-addlastBillLinesre-resolved against the current catalog — for each line, find the item byitemIdin the merged items array and calladdItem(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,
Ron the selected row repeats that bill.GET /api/billsalready returns thepayloadcolumn (listBillsselectsb.*), andpayload.linesare engineLines which supersetLineInput— mapitemId+qtythrough 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 boxonKeyDown, when the pressed key is*and the current input is/^\d{1,4}(\.\d{1,3})?$/, immediately setpendingQty, clear the input,preventDefault. The scanner-in-left-hand flow becomes3*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
Noticerow (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-fingered10*→100*stops costing a whole void. - Decimal × unit check: decimal
pendingQtyapplied to adecimals===0item → 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: 4–5). 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 fromaddItem(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 inSettingsModallater; default on. - Notice lifecycle in
say():okauto-clears after 4 s (timer, cancelled by the next notice);err/warnpersist 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
beepErrand 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 aMap<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()toapps/pos/src/api.ts. - NEW SERVER (small, flagged): 90-day sales velocity for ranking — add
velocity90toGET /api/items(oneLEFT JOINaggregate overstock_movement WHERE reason='SALE'inlistItems,repos.ts). Until then the P0-1 tie-break (shorter name) stands. When catalogs exceed the currentLIMIT 2000inlistItems, 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
Modalinto 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_LIMITSawaiting 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 P0–P2 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 2–3 days of focused work; nothing blocks on another team except the two small server rows above.