Two spec-vs-implementation drifts caught by the final review: - Move 'per-row validation → 200 with reason in results[]' out of the 'Reject 400' list. The route enforces body shape (json / empty / too-many / bad id / missing actor) as 400; the service enforces forbidden_field / non-finite / not_found / pb_error per row so one bad sibling never aborts the rest. - Move the 'realtime-conflict banner' from Page-behaviour to Out of scope (the implementation deferred it; the spec still listed it).
16 KiB
/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:
- Three pricing columns (Cost / Markup / Selling) hidden by default, revealed by a "Show pricing columns" toggle in the page header.
- 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. - 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
- Open
/stock. Operator sees today's view — SKU, Name, Supplier, Current, Min, Target, Status. The three pricing columns are hidden. - 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". - 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.
- 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.
- Operator edits values. When the operator types into Cost or Markup, Selling auto-derives to
round(cost × (1 + markup / 100), 2)— but only if the operator has NOT manually edited Selling for that row in the current edit-pass. The "Selling manually edited" flag is per-row, reset on row-blur or Apply. If the operator clicks into Selling and types, Selling locks to their value and stops deriving until the row is re-focused or Apply is run. - Local draft persists. Each keystroke serialises the staged changes into
localStorageunder the keywfdash.stock.pricingDraft. A page refresh after a long edit session preserves the work. - Operator clicks "Apply changes" in the sticky footer. One POST to
/api/v1/stock/items/set-pricingships the delta. On success the table re-renders with the new values, the draft clears, the footer disappears. - 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
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:
- Per-row independence. Like
bulk_update_quote_items: one row's failure never blocks the others. - Cost-edit guard. If the row's existing
cost_pricealready equalsupdates.costPrice(no actual change), skip the stock_ledger write. Only insert a ledger entry when the cost actually moves. - Idempotency on retry. Same call twice produces the same final state (success) or same failures.
- Atomicity.
stock_items.updateand thestock_ledgerinsert 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. - Validation.
cost_price,selling_price,markupmust each be finite numbers ≥ 0 if supplied.cost_price === 0is allowed (clearing the pricing),selling_price === 0is allowed (POS falls back to cost). - 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. - Audit. When
costPriceis supplied and differs from existing, insert astock_ledgerrow: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). - Constraint: not for purchase-driven cost changes. The booking flow stays the path for stock-purchase receipts. This endpoint does NOT bump
current_qtyand does NOT touchcost_history(cost_history is owned by the receipt path).
Route — POST /api/v1/stock/items/set-pricing
// 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:
- Request body is not valid JSON.
updatesis empty, missing, or not an array.updates.length > 100(matchesbulk_update_quote_itemscap; the page slices if more).- Any
updatehasidempty or not 15-char PB id. actormissing oractor.idempty.
Per-row rejections surface inside the 200 response, not as 400s: forbidden-field / non-finite / non-numeric / not_found / pb_error failures each land in results[] with { ok: false, reason, message } so one bad row never aborts the siblings. The route relies on the service for those checks (per-row independence). Clients should inspect summary.failed and walk results[] for the per-row failure reasons.
Idempotent: 200 on retry with the same body, same per-row outcomes.
Page behaviour (/stock/page.tsx)
- Add three states at the page level:
showPricingColumns: boolean(defaultfalse, persisted tolocalStoragekeywfdash.stock.showPricing)editPricing: boolean(session-only — closes when leaving the page)pricingDraft: Map<itemId, { costPrice?, sellingPrice?, markup? }>(in-memory mirror oflocalStoragekeywfdash.stock.pricingDraft).pricingApplyState: "idle" | "applying" | "applied" | "partial"for the footer / toast.
- 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. - Existing PB fetch updated to include
expand: "supplier"(already present) andfiltergains the missing-pricing clause when toggled. - Header gets two new toggles beside the "New stock item" button: "Show pricing columns" (Switch), "Edit pricing" (Button — turns into "Done editing" when on).
StockRowbranches oneditPricingfor its pricing cells.- New
<PricingApplyBar>rendered sticky at the bottom of the table container whenpricingDraft.size > 0. Shows count + Discard + Apply buttons. Reappears below the existing page padding. useBeforeUnloadwarning whenpricingDraft.size > 0(refresh-confirmation).- Realtime subscribe to
stock_itemsstays unchanged. Realtime-edit-conflict detection (a banner warning when another operator's writes land while you're editing) is deferred — seeOut of scopebelow.
Edge cases
- Stale draft on apply. If
localStorage.drafthas changes for a row that no longer exists server-side, the apply call returnsnot_foundfor 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.
useBeforeUnloadtriggers the browser's confirm-leave prompt. - Real-time writes from another tab. When remote
stock_itemsupdate arrives during edit-mode, current inputs stay; on Apply the response includes a per-rowprevioussnapshot — the UI can render a small "field X changed underneath you" warning on rows wherepreviousdiffers 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 pullsselling_pricefrom 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 malformed JSON / empty updates / >100 updates / malformed PB id / missing actor.id. Per-row forbidden-field / non-finite / not_found / pb_error failures surface inside results[].reason — never as route-level 400s (see route contract note above). |
/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_historyfrom this surface. Cost history is append-only via the booking flow. - Editing
current_qtyfrom this surface — that's already on/stock/[id]viastock_items.adjust. - Real-time presence indicators (two operators editing the same row simultaneously).