docs(stock): design spec for inline pricing edit on /stock
This commit is contained in:
parent
8ae4592108
commit
84a97ac7b2
1 changed files with 207 additions and 0 deletions
|
|
@ -0,0 +1,207 @@
|
|||
# /stock — Inline Pricing Edit Design
|
||||
|
||||
> **Date:** 2026-08-31
|
||||
> **Status:** Draft — pending user review
|
||||
|
||||
## Problem
|
||||
|
||||
`stock_items` already carries `cost_price`, `selling_price`, and `markup` fields — the POS catalogue reads them at the till — but the operator has no first-class UI to populate them on `/stock`. Today the only way to set pricing is to drive it through a supplier purchase receipt (which writes `cost_price` via the booking flow) or by editing the detail page by hand, row by row. Adding the first walk-in items, or backfilling pricing on a freshly-imported catalogue, means a slow per-item grind.
|
||||
|
||||
The POS reads `selling_price ?? cost_price`, so any item with no pricing at all shows the fallback price of zero to the cashier — a guaranteed "needs price" gap on every fresh stock take.
|
||||
|
||||
## Goal
|
||||
|
||||
Make `/stock` a price-editing surface:
|
||||
|
||||
1. Three pricing columns (Cost / Markup / Selling) hidden by default, revealed by a "Show pricing columns" toggle in the page header.
|
||||
2. A "Missing pricing only" filter that surfaces the rows where `selling_price = 0 AND cost_price = 0`, so the operator can see the exact scope of the gap.
|
||||
3. An "Edit pricing" mode toggle that converts the three pricing cells into number inputs across every visible row, with a sticky footer that bulk-saves the whole sheet on a single "Apply changes" click.
|
||||
|
||||
Editing cost must keep the audit trail honest: when `cost_price` is patched by hand, a `stock_ledger` row with `reason = "adjustment"` is written so the per-item cost history mirrors the booking flow's behaviour.
|
||||
|
||||
## User flow
|
||||
|
||||
1. **Open** `/stock`. Operator sees today's view — SKU, Name, Supplier, Current, Min, Target, Status. The three pricing columns are hidden.
|
||||
2. **Click "Missing pricing only"** (filter toggle). The list collapses to rows with `selling_price = 0 AND cost_price = 0`. The header subtitle updates to *"N items need pricing"*.
|
||||
3. **Click "Show pricing columns"** (header toggle). The table grows three right-aligned columns: **Cost (R)**, **Markup %**, **Selling (R)**. Each cell reads either the current numeric value or "—" if zero.
|
||||
4. **Click "Edit pricing"** (header toggle). The toggle label flips to *"Done editing"*. Every pricing cell across the filtered list becomes a number input. Rows the operator hasn't touched look exactly like read-only values (numbers visible, no input chrome). Every row the operator changes gets a yellow background tint.
|
||||
5. **Operator edits values.** Cost and markup inputs trigger a derived recompute of selling when both are present: `selling = round(cost × (1 + markup / 100), 2)`. The operator can also type selling directly to override the derived value.
|
||||
6. **Local draft persists.** Each keystroke serialises the staged changes into `localStorage` under the key `wfdash.stock.pricingDraft`. A page refresh after a long edit session preserves the work.
|
||||
7. **Operator clicks "Apply changes"** in the sticky footer. One POST to `/api/v1/stock/items/set-pricing` ships the delta. On success the table re-renders with the new values, the draft clears, the footer disappears.
|
||||
8. **Per-row outcome.** If a single row fails (PB validation, etc.), that row stays tinted with a red corner badge; the others persist. A toast summarises *"Saved N rows — M failed, click row to retry"*.
|
||||
|
||||
## Decisions taken
|
||||
|
||||
| Decision | Choice | Why |
|
||||
|---|---|---|
|
||||
| Fields editable from this surface | **Cost + Selling + Markup** | Operator needs to fully populate the POS for new catalogue rows. Selling-only is too restrictive; cost-only leaves the selling empty in POS. |
|
||||
| Audit on cost edit | **Write a `stock_ledger` row** with `reason = "adjustment"`, `reference_type = "manual"`, `notes = "Edited from /stock by {operator}"`, `unit_cost = new cost_price` | Cost history is the source of truth for "what did we pay for this SKU when". A hand edit must show up the same way a supplier-receipt edit does. |
|
||||
| Save semantics | **Bulk apply with one "Apply changes" button** | Stamp many rows in one pass; one PB round-trip; survives the operator stepping away mid-edit. |
|
||||
| "Missing pricing" filter scope | **`selling_price = 0 AND cost_price = 0`** | Matches the POS behaviour: only rows with no fallback at all are a hard gap. |
|
||||
| Auto-derive UX | **Cost + Markup → Selling (rounded to 2dp)**, both editable | The most common case is "I know the supplier cost and I know the margin"; the selling price should be a derived output the operator can override. |
|
||||
| Skip the `stock_items.update` whitelist for cost | **Yes — pricing goes through a new dedicated endpoint, not the existing one** | Keeps `cost_price` excluded from the generic PATCH surface so no other code path accidentally writes cost without the ledger side-effect. |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
| Component | Lives at | Responsibility |
|
||||
|---|---|---|
|
||||
| `StockListPage` | `src/app/(app)/stock/page.tsx` (existing) | Render the table, manage search / low-stock / missing-pricing toggles, manage edit-mode toggle, manage staged-state. |
|
||||
| `StockRow` | same file (existing) | Render one row; inside an `if (editMode) { <PricingCells /> } else { <ReadCells /> }` branch. |
|
||||
| `PricingReadCells` | same file (new) | The three read-only cells (Cost / Markup / Selling) shown when edit-mode is OFF but pricing columns are visible. |
|
||||
| `PricingEditCells` | same file (new) | The three controlled inputs shown in edit mode. Holds the local dirty state for one row. |
|
||||
| `PricingApplyBar` | same file (new) | Sticky footer with the *N rows with unsaved changes — Discard / Apply changes* UI. |
|
||||
| `stock-pricing-service.ts` | `src/lib/server/stock-pricing-service.ts` (new) | Pure service-layer function: takes `{ id, costPrice?, sellingPrice?, markup? }` patches, applies them, returns `{ item, ... }` per row, writes stock_ledger rows when cost changes. |
|
||||
| `POST /api/v1/stock/items/set-pricing` | `src/app/api/v1/stock/items/set-pricing/route.ts` (new) | Thin HTTP wrapper over the service. Validates body, fans out patches, returns per-row outcomes. |
|
||||
|
||||
### Data flow
|
||||
|
||||
```
|
||||
[Header "Edit pricing" toggle]
|
||||
│
|
||||
▼
|
||||
[StockListPage — editMode state]
|
||||
│
|
||||
▼
|
||||
[Each PricingEditCells — controlled inputs, dirty flag per row]
|
||||
│
|
||||
▼ (every keystroke)
|
||||
[localStorage "wfdash.stock.pricingDraft"]
|
||||
│
|
||||
▼ (Apply click)
|
||||
[POST /api/v1/stock/items/set-pricing { updates: [{id, costPrice?, sellingPrice?, markup?}, ...] }]
|
||||
│
|
||||
▼
|
||||
[stock-pricing-service.updateStockItemsPricing(pb, updates, actor)]
|
||||
│ → PB update on stock_items row
|
||||
│ → if costPrice changes: insert stock_ledger row (reason=adjustment, unit_cost=newCost, qty=0)
|
||||
▼
|
||||
[Per-row outcome { id, ok | reason }]
|
||||
│
|
||||
▼
|
||||
[Merge: kept tints on failed rows; clear draft on success]
|
||||
```
|
||||
|
||||
### Service contract — `stock-pricing-service.ts`
|
||||
|
||||
```ts
|
||||
export type StockPricingUpdate = {
|
||||
id: string; // 15-char stock_items PB id
|
||||
costPrice?: number; // undefined = don't touch
|
||||
sellingPrice?: number; // undefined = don't touch
|
||||
markup?: number; // undefined = don't touch
|
||||
};
|
||||
|
||||
export type UpdateStockItemsPricingInput = {
|
||||
updates: StockPricingUpdate[];
|
||||
actor: { id: string; name: string }; // audit stamps
|
||||
};
|
||||
|
||||
export type UpdateStockItemsPricingResult = {
|
||||
results: Array<
|
||||
| { id: string; ok: true; item: StockItem; previous: StockItemPricingSnapshot }
|
||||
| { id: string; ok: false; reason: "not_found" | "validation_error" | "pb_error"; message: string }
|
||||
>;
|
||||
};
|
||||
|
||||
export async function updateStockItemsPricing(input: UpdateStockItemsPricingInput): Promise<...>;
|
||||
```
|
||||
|
||||
Behaviours:
|
||||
|
||||
1. **Per-row independence.** Like `bulk_update_quote_items`: one row's failure never blocks the others.
|
||||
2. **Cost-edit guard.** If the row's existing `cost_price` already equals `updates.costPrice` (no actual change), skip the stock_ledger write. Only insert a ledger entry when the cost actually moves.
|
||||
3. **Idempotency on retry.** Same call twice produces the same final state (success) or same failures.
|
||||
4. **Atomicity.** `stock_items.update` and the `stock_ledger` insert for a single row happen in sequence; PB's per-row write is atomic but cross-row atomicity is NOT guaranteed — that's fine, failures are reported per-row.
|
||||
5. **Validation.** `cost_price`, `selling_price`, `markup` must each be finite numbers ≥ 0 if supplied. `cost_price === 0` is allowed (clearing the pricing), `selling_price === 0` is allowed (POS falls back to cost).
|
||||
6. **No hand-edit guard.** Unlike the quote / stock_purchase_items cost paths, this endpoint does NOT refuse to overwrite a `cost_price > 0`. That's the whole point — operator can correct a wrong cost.
|
||||
7. **Audit.** When `costPrice` is supplied and differs from existing, insert a `stock_ledger` row: `stock_item=id`, `change_qty=0`, `unit_cost=new`, `reason="adjustment"`, `reference_type="manual"`, `notes=actor.name + " edited cost from /stock"`, `created_by=actor.id` (where PB supports it).
|
||||
8. **Constraint: not for purchase-driven cost changes.** The booking flow stays the path for stock-purchase receipts. This endpoint does NOT bump `current_qty` and does NOT touch `cost_history` (cost_history is owned by the receipt path).
|
||||
|
||||
### Route — `POST /api/v1/stock/items/set-pricing`
|
||||
|
||||
```ts
|
||||
// Body
|
||||
{
|
||||
updates: [
|
||||
{ id: "abc123def456ghi", costPrice: 3.2, sellingPrice: 4.16, markup: 30 },
|
||||
{ id: "jkl789mno012pqr", sellingPrice: 0.56 },
|
||||
...
|
||||
],
|
||||
actor: { id: "userPBId15char", name: "Gary" }
|
||||
}
|
||||
|
||||
// Response 200
|
||||
{
|
||||
results: [
|
||||
{ id: "abc123def456ghi", ok: true, item: { ... }, previous: { cost_price: 0, selling_price: 0, markup: 0 } },
|
||||
{ id: "jkl789mno012pqr", ok: true, item: { ... }, previous: { cost_price: 0.45, selling_price: 0, markup: 0 } },
|
||||
{ id: "stu345vwx678yza", ok: false, reason: "not_found", message: "stock_items row stu345... not found" }
|
||||
],
|
||||
summary: { total: 3, succeeded: 2, failed: 1 }
|
||||
}
|
||||
|
||||
// Response 400 — body validation
|
||||
{ code: "forbidden_field", message: "..." }
|
||||
```
|
||||
|
||||
Reject 400 when:
|
||||
- `updates` is empty, missing, or not an array.
|
||||
- Any `update` has keys outside `{ id, costPrice, sellingPrice, markup }`.
|
||||
- Any `update` has `id` empty or not 15-char PB id.
|
||||
- Numeric fields not finite / negative.
|
||||
- `actor` missing or `actor.id` empty.
|
||||
|
||||
Idempotent: 200 on retry with the same body, same per-row outcomes.
|
||||
|
||||
### Page behaviour (`/stock/page.tsx`)
|
||||
|
||||
1. Add three states at the page level:
|
||||
- `showPricingColumns: boolean` (default `false`, persisted to `localStorage` key `wfdash.stock.showPricing`)
|
||||
- `editPricing: boolean` (session-only — closes when leaving the page)
|
||||
- `pricingDraft: Map<itemId, { costPrice?, sellingPrice?, markup? }>` (in-memory mirror of `localStorage` key `wfdash.stock.pricingDraft`).
|
||||
- `pricingApplyState: "idle" | "applying" | "applied" | "partial"` for the footer / toast.
|
||||
2. New filter toggle in the filter row: *"Missing pricing only"* — adds `(selling_price = 0 && cost_price = 0 && is_active = true)` to the PB filter. Header subtitle updates the same way the low-stock count currently does.
|
||||
3. Existing PB fetch updated to include `expand: "supplier"` (already present) and `filter` gains the missing-pricing clause when toggled.
|
||||
4. Header gets two new toggles beside the "New stock item" button: *"Show pricing columns"* (Switch), *"Edit pricing"* (Button — turns into *"Done editing"* when on).
|
||||
5. `StockRow` branches on `editPricing` for its pricing cells.
|
||||
6. New `<PricingApplyBar>` rendered sticky at the bottom of the table container when `pricingDraft.size > 0`. Shows count + Discard + Apply buttons. Reappears below the existing page padding.
|
||||
7. `useBeforeUnload` warning when `pricingDraft.size > 0` (refresh-confirmation).
|
||||
8. Realtime subscribe to `stock_items` stays unchanged; on a remote write while in edit-mode, conflicts are surfaced as a small banner *"Changes by another operator may have landed — review before apply"*.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- **Stale draft on apply.** If `localStorage.draft` has changes for a row that no longer exists server-side, the apply call returns `not_found` for that row; the rest go through; the dead row's entry clears from the draft.
|
||||
- **Cost set, markup blank.** Computed selling from `(cost, undefined)` is left blank; the operator can fill the markup next.
|
||||
- **Edit during draft → save → next edit.** Each Apply replaces the local draft entirely on success. Partial-applies leave the failed rows in the draft so retry doesn't re-send the successful ones.
|
||||
- **Closing the tab while dirty.** `useBeforeUnload` triggers the browser's confirm-leave prompt.
|
||||
- **Real-time writes from another tab.** When remote `stock_items` update arrives during edit-mode, current inputs stay; on Apply the response includes a per-row `previous` snapshot — the UI can render a small *"field X changed underneath you"* warning on rows where `previous` differs from the value the operator typed when they started editing.
|
||||
- **POS reads during edit.** POS app reads via its own REST endpoint (`/api/v1/pos/catalog`) which pulls `selling_price` from the row at request time. Brief race: an operator editing a row sees a stale POS price until they click Apply. Acceptable for v1.
|
||||
|
||||
## Testing
|
||||
|
||||
| Surface | Test type | What it covers |
|
||||
|---|---|---|
|
||||
| `stock-pricing-service.updateStockItemsPricing` | Unit (mock PB) | Happy path (cost + selling + markup), only-selling-update, only-cost-update writing a ledger row, idempotency, validation errors (negative, NaN, forbidden field), per-row independence, not_found. |
|
||||
| `stock-pricing-service` ledger side-effect | Unit (assert PB calls) | When `costPrice` changes, exactly one stock_ledger row is created with `reason="adjustment"`, `unit_cost=new`, `change_qty=0`, `reference_type="manual"`. When `costPrice` is omitted or unchanged, no ledger row. |
|
||||
| `POST /api/v1/stock/items/set-pricing` | Integration (Next route) | 200 on valid body; 400 on missing/empty updates; 400 on forbidden field; 400 on missing actor; per-row failures surfaced in `results`. |
|
||||
| `/stock` page — read-only pricing visibility | Component (`page.test.tsx`) | Three columns hidden by default; visible when `showPricingColumns=true`; rows render read cells. |
|
||||
| `/stock` page — missing-pricing filter | Component | Filter toggle adds PB filter; row count in subtitle reflects `selling=0 AND cost=0`. |
|
||||
| `/stock` page — edit mode | Component | Toggling Edit replaces cells with inputs; dirty rows tint yellow; footer count reflects staged changes; Apply is disabled while empty. |
|
||||
| `/stock` page — apply + outcomes | Component (mock fetch) | Successful apply clears draft, hides footer, merges returned items into row state; partial apply keeps failed-row tints. |
|
||||
| `/stock` page — localStorage draft | Component | Typing into inputs writes to `localStorage`; the draft rehydrates on page mount. |
|
||||
| `/stock` page — `useBeforeUnload` | Manual / e2e | Triggering leave while dirty prompts. |
|
||||
|
||||
## Migration / rollout
|
||||
|
||||
Single PR. No DB schema change. The existing page keeps its current behaviour (pricing columns hidden, no edit mode) until the operator opts in via the toggle, so the change is non-breaking.
|
||||
|
||||
## Out of scope (deliberately)
|
||||
|
||||
- Per-row undo button. Discard-all only; full per-row undo is a v2 nicety.
|
||||
- Bulk-edit page separate from /stock. Operator uses the inline toggle.
|
||||
- Bulk import of a CSV of prices. The price list import path is the right tool when the operator already has a CSV.
|
||||
- Editing `cost_history` from this surface. Cost history is append-only via the booking flow.
|
||||
- Editing `current_qty` from this surface — that's already on `/stock/[id]` via `stock_items.adjust`.
|
||||
- Real-time presence indicators (two operators editing the same row simultaneously).
|
||||
Loading…
Reference in a new issue