|
|
# 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 `<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 `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** (`<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 change** — `hsn:''` 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 200–233) 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 `Line`s 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: 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 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 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. |