# 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, 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 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) 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 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**
- `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 282–290), when `i === editing.line`, replace the cell content with ``.
- **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 `LineInput`s, an edited `unitPricePaise` flows 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.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) + 4–6 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** (`