From b6a76be75b39fcb2727ac5ac8a897a94271b2922 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:17:10 +0200 Subject: [PATCH] docs(stock): implementation plan for inline pricing edit --- .../2026-08-31-stock-pricing-inline-edit.md | 1478 +++++++++++++++++ 1 file changed, 1478 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-31-stock-pricing-inline-edit.md diff --git a/docs/superpowers/plans/2026-08-31-stock-pricing-inline-edit.md b/docs/superpowers/plans/2026-08-31-stock-pricing-inline-edit.md new file mode 100644 index 0000000..4898be7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-31-stock-pricing-inline-edit.md @@ -0,0 +1,1478 @@ +# /stock — Inline Pricing Edit Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an inline pricing editor to `/stock` so an operator can populate cost / markup / selling on a filtered set of stock items in one bulk-apply, with cost changes audited through `stock_ledger`. + +**Architecture:** A new service layer `stock-pricing-service.ts` owns the cost-change-with-ledger invariant. A new thin route `POST /api/v1/stock/items/set-pricing` is its only caller. The existing `StockListPage` is extended in place: two new header toggles (`Show pricing columns` and `Edit pricing`), a `Missing pricing only` filter, a localStorage-backed draft, and a sticky Apply bar. + +**Tech Stack:** Next.js App Router (TypeScript), PocketBase, vitest + Testing Library, existing shadcn/ui primitives (Switch, Input, Button, Badge). + +**Spec:** `docs/superpowers/specs/2026-08-31-stock-pricing-inline-edit-design.md` + +--- + +## File structure + +| Action | File | Responsibility | +|---|---|---| +| Create | `src/lib/server/stock-pricing-service.ts` | Service: per-row patch with cost→ledger invariant; PB-injectable. | +| Create | `src/lib/server/stock-pricing-service.test.ts` | Unit tests for the service. | +| Create | `src/app/api/v1/stock/items/set-pricing/route.ts` | `POST` route; body validation + calls service. | +| Modify | `src/app/(app)/stock/page.tsx` | Header toggles, missing-pricing filter, edit-mode cells, draft state, apply bar. | +| Modify | `src/app/(app)/stock/page.test.tsx` | Component tests for new behaviour. | + +--- + +## Task 1: Service types + happy-path test (red) + +**Files:** +- Create: `src/lib/server/stock-pricing-service.ts` +- Create: `src/lib/server/stock-pricing-service.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/server/stock-pricing-service.test.ts`: + +```ts +import { describe, it, expect, vi } from "vitest"; +import { + updateStockItemsPricing, + type UpdateStockItemsPricingInput, +} from "./stock-pricing-service"; + +describe("updateStockItemsPricing — happy path", () => { + it("patches all three pricing fields and reports per-row previous", async () => { + const before = { + id: "abcdefghijklmno", + cost_price: 2.5, + selling_price: 3.25, + markup: 30, + }; + const updated = { ...before, cost_price: 3.2, selling_price: 4.16, markup: 30 }; + + const pb = { + collection: (name: string) => { + if (name === "stock_items") { + return { + getOne: vi.fn().mockResolvedValue(before), + update: vi.fn().mockResolvedValue(updated), + }; + } + if (name === "stock_ledger") { + return { create: vi.fn() }; + } + throw new Error(`unexpected collection ${name}`); + }, + }; + + const result = await updateStockItemsPricing( + { + pb: pb as never, + updates: [ + { id: "abcdefghijklmno", costPrice: 3.2, sellingPrice: 4.16, markup: 30 }, + ], + actor: { id: "useropid1234567", name: "Gary" }, + } as UpdateStockItemsPricingInput, + ); + + expect(result.summary).toEqual({ total: 1, succeeded: 1, failed: 0 }); + expect(result.results[0]).toMatchObject({ + id: "abcdefghijklmno", + ok: true, + previous: { cost_price: 2.5, selling_price: 3.25, markup: 30 }, + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/lib/server/stock-pricing-service.test.ts` +Expected: FAIL — `./stock-pricing-service` does not export `updateStockItemsPricing`. + +- [ ] **Step 3: Write the minimal service scaffold** + +Create `src/lib/server/stock-pricing-service.ts`: + +```ts +import type { StockItem } from "@/lib/types"; + +export type StockPricingUpdate = { + id: string; + costPrice?: number; + sellingPrice?: number; + markup?: number; +}; + +export type UpdateStockItemsPricingInput = { + /** PB client; passed in so tests can swap a stub. */ + pb: StockItemsPb; + updates: StockPricingUpdate[]; + actor: { id: string; name: string }; +}; + +export type StockItemsPb = { + collection: (id: string) => { + getOne: (id: string, opts?: Record) => Promise; + update: (id: string, patch: Record) => Promise; + create: (data: Record) => Promise; + }; +}; + +export type UpdateStockItemsPricingResultRow = + | { + id: string; + ok: true; + item: StockItem; + previous: StockItemPricingSnapshot; + } + | { id: string; ok: false; reason: string; message: string }; + +export type StockItemPricingSnapshot = { + cost_price?: number; + selling_price?: number; + markup?: number; +}; + +export type UpdateStockItemsPricingResult = { + results: UpdateStockItemsPricingResultRow[]; + summary: { total: number; succeeded: number; failed: number }; +}; + +const FORBIDDEN_KEYS = ["id", "quantity", "created", "updated", "is_active"] as const; +const PATCHABLE_KEYS = ["costPrice", "sellingPrice", "markup"] as const; + +export async function updateStockItemsPricing( + input: UpdateStockItemsPricingInput, +): Promise { + const results: UpdateStockItemsPricingResultRow[] = []; + for (const update of input.updates) { + // Whitelist check (route also enforces — service is defensive). + for (const key of Object.keys(update) as (keyof StockPricingUpdate)[]) { + if ( + key !== "id" && + !(PATCHABLE_KEYS as readonly string[]).includes(key) + ) { + results.push({ + id: update.id, + ok: false, + reason: "forbidden_field", + message: `forbidden_field: ${key}`, + }); + continue; + } + const value = (update as Record)[key]; + if (key !== "id" && value !== undefined && (typeof value !== "number" || !Number.isFinite(value) || (value as number) < 0)) { + results.push({ + id: update.id, + ok: false, + reason: "validation_error", + message: `${key} must be a finite number >= 0`, + }); + continue; + } + } + // Continue only if validation above did not push a failure for this row. + if (results.length > 0 && results[results.length - 1]?.id === update.id) { + continue; + } + + // 1. Load existing row for previous-snapshot + not_found detection. + let before: StockItem; + try { + before = (await input.pb + .collection("stock_items") + .getOne(update.id)) as StockItem; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (/not found|404/i.test(message)) { + results.push({ + id: update.id, + ok: false, + reason: "not_found", + message: `stock_items row ${update.id} not found`, + }); + continue; + } + results.push({ + id: update.id, + ok: false, + reason: "pb_error", + message: `Failed to load: ${message}`, + }); + continue; + } + + // 2. Build the PB patch — only fields the caller passed. + const patch: Record = {}; + if (update.costPrice !== undefined) patch.cost_price = update.costPrice; + if (update.sellingPrice !== undefined) patch.selling_price = update.sellingPrice; + if (update.markup !== undefined) patch.markup = update.markup; + + // 3. Persist the row update. + let updated: StockItem; + try { + updated = (await input.pb + .collection("stock_items") + .update(update.id, patch)) as StockItem; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + results.push({ + id: update.id, + ok: false, + reason: "pb_error", + message: `Failed to update: ${message}`, + }); + continue; + } + + // 4. Capture previous snapshot for the response. + const previous: StockItemPricingSnapshot = { + cost_price: before.cost_price, + selling_price: before.selling_price, + markup: before.markup, + }; + + results.push({ id: update.id, ok: true, item: updated, previous }); + } + + const summary = { + total: input.updates.length, + succeeded: results.filter((r) => r.ok).length, + failed: results.filter((r) => !r.ok).length, + }; + return { results, summary }; +} + +// Silence unused-import warning — kept available for the bulk endpoint. +export { FORBIDDEN_KEYS }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/lib/server/stock-pricing-service.test.ts` +Expected: PASS (1 passed). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/stock-pricing-service.ts src/lib/server/stock-pricing-service.test.ts +git commit -m "feat(stock): add stock-pricing-service scaffold with happy-path" +``` + +--- + +## Task 2: Cost change writes a stock_ledger row (red → green) + +**Files:** +- Modify: `src/lib/server/stock-pricing-service.test.ts` +- Modify: `src/lib/server/stock-pricing-service.ts` + +- [ ] **Step 1: Add failing tests** + +Append to `src/lib/server/stock-pricing-service.test.ts`: + +```ts +describe("updateStockItemsPricing — stock_ledger audit on cost change", () => { + const stubPb = (before: Record, updated: Record) => { + const ledgerCreates: Array> = []; + return { + pb: { + collection: (name: string) => { + if (name === "stock_items") { + return { + getOne: vi.fn().mockResolvedValue(before), + update: vi.fn().mockResolvedValue(updated), + create: vi.fn(), + }; + } + if (name === "stock_ledger") { + return { + create: vi.fn().mockImplementation(async (row) => { + ledgerCreates.push(row); + return { id: "ledger1", ...row }; + }), + _creates: ledgerCreates, + }; + } + throw new Error(`unexpected ${name}`); + }, + }, + ledgerCreates, + } as const; + }; + + it("writes a stock_ledger row with reason=adjustment when costPrice changes", async () => { + const before = { + id: "abcdefghijklmno", + cost_price: 2.5, + selling_price: 3.25, + markup: 30, + }; + const updated = { ...before, cost_price: 3.2 }; + const { pb, ledgerCreates } = stubPb(before, updated); + + await updateStockItemsPricing({ + pb: pb as never, + updates: [{ id: "abcdefghijklmno", costPrice: 3.2 }], + actor: { id: "useropid1234567", name: "Gary" }, + } as UpdateStockItemsPricingInput); + + expect(ledgerCreates).toEqual([ + expect.objectContaining({ + stock_item: "abcdefghijklmno", + change_qty: 0, + unit_cost: 3.2, + reason: "adjustment", + reference_type: "manual", + }), + ]); + }); + + it("does NOT write a stock_ledger row when costPrice is omitted", async () => { + const before = { + id: "abcdefghijklmno", + cost_price: 2.5, + selling_price: 3.25, + markup: 30, + }; + const updated = { ...before, selling_price: 4.16 }; + const { pb, ledgerCreates } = stubPb(before, updated); + + await updateStockItemsPricing({ + pb: pb as never, + updates: [{ id: "abcdefghijklmno", sellingPrice: 4.16 }], + actor: { id: "useropid1234567", name: "Gary" }, + } as UpdateStockItemsPricingInput); + + expect(ledgerCreates).toEqual([]); + }); + + it("does NOT write a stock_ledger row when costPrice equals the existing cost", async () => { + const before = { + id: "abcdefghijklmno", + cost_price: 2.5, + selling_price: 3.25, + markup: 30, + }; + const updated = before; + const { pb, ledgerCreates } = stubPb(before, updated); + + await updateStockItemsPricing({ + pb: pb as never, + updates: [ + { id: "abcdefghijklmno", costPrice: 2.5, sellingPrice: 5 }, + ], + actor: { id: "useropid1234567", name: "Gary" }, + } as UpdateStockItemsPricingInput); + + expect(ledgerCreates).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm vitest run src/lib/server/stock-pricing-service.test.ts` +Expected: FAIL — `ledgerCreates` is empty in all three cases (no ledger writes yet). + +- [ ] **Step 3: Implement the ledger side-effect** + +In `src/lib/server/stock-pricing-service.ts`, between the `update` and the `results.push` for a successful row, add the ledger write. Locate the block: + +```ts + // 3. Persist the row update. + let updated: StockItem; + try { + updated = (await input.pb + .collection("stock_items") + .update(update.id, patch)) as StockItem; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + results.push({ + id: update.id, + ok: false, + reason: "pb_error", + message: `Failed to update: ${message}`, + }); + continue; + } +``` + +Replace it with: + +```ts + // 3. Persist the row update. + let updated: StockItem; + try { + updated = (await input.pb + .collection("stock_items") + .update(update.id, patch)) as StockItem; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + results.push({ + id: update.id, + ok: false, + reason: "pb_error", + message: `Failed to update: ${message}`, + }); + continue; + } + + // 4. Cost audit — write a stock_ledger row ONLY when costPrice was + // supplied and differs from the existing cost_price. + if ( + update.costPrice !== undefined && + before.cost_price !== update.costPrice + ) { + try { + await input.pb.collection("stock_ledger").create({ + stock_item: update.id, + change_qty: 0, + unit_cost: update.costPrice, + reason: "adjustment", + reference_type: "manual", + notes: `${input.actor.name} edited cost from /stock`, + created_by: input.actor.id, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + results.push({ + id: update.id, + ok: false, + reason: "pb_error", + message: `Patched row but failed to write stock_ledger: ${message}`, + }); + continue; + } + } +``` + +Then delete the older "4. Capture previous snapshot" comment block (`// 4. Capture previous snapshot for the response.`) and renumber the existing capture step to `// 5. Capture previous snapshot for the response.`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm vitest run src/lib/server/stock-pricing-service.test.ts` +Expected: 4 tests passing. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/stock-pricing-service.ts src/lib/server/stock-pricing-service.test.ts +git commit -m "feat(stock): write stock_ledger audit row when cost_price changes" +``` + +--- + +## Task 3: Validation errors + not_found + per-row independence + +**Files:** +- Modify: `src/lib/server/stock-pricing-service.test.ts` (and service if any tests fail) + +- [ ] **Step 1: Add failing tests** + +Append to the test file: + +```ts +describe("updateStockItemsPricing — input validation", () => { + it("rejects negative numbers", async () => { + const result = await updateStockItemsPricing({ + pb: { collection: () => ({ getOne: vi.fn(), update: vi.fn(), create: vi.fn() }) } as never, + updates: [{ id: "abcdefghijklmno", costPrice: -1 }], + actor: { id: "useropid1234567", name: "Gary" }, + }); + expect(result.results[0]).toMatchObject({ + ok: false, + reason: "validation_error", + }); + }); + + it("rejects forbidden fields", async () => { + const result = await updateStockItemsPricing({ + pb: { collection: () => ({ getOne: vi.fn(), update: vi.fn(), create: vi.fn() }) } as never, + updates: [ + { id: "abcdefghijklmno", costPrice: 1, maliciousFlag: true } as never, + ], + actor: { id: "useropid1234567", name: "Gary" }, + }); + expect(result.results[0]).toMatchObject({ + ok: false, + reason: "forbidden_field", + }); + }); +}); + +describe("updateStockItemsPricing — not_found and per-row independence", () => { + it("returns not_found when the row is gone", async () => { + const pb = { + collection: (name: string) => { + if (name === "stock_items") { + return { + getOne: vi.fn().mockRejectedValue(new Error("404 not found")), + update: vi.fn(), + create: vi.fn(), + }; + } + if (name === "stock_ledger") { + return { create: vi.fn() }; + } + throw new Error(`unexpected ${name}`); + }, + }; + const result = await updateStockItemsPricing({ + pb: pb as never, + updates: [{ id: "missing00000000x", costPrice: 1 }], + actor: { id: "useropid1234567", name: "Gary" }, + }); + expect(result.results[0]).toMatchObject({ + ok: false, + reason: "not_found", + }); + }); + + it("does not abort sibling rows when one is not_found", async () => { + const updated = { id: "abcdefghijklmno", cost_price: 5, selling_price: 0, markup: 0 }; + let nextIsMissing = true; + const pb = { + collection: (name: string) => { + if (name === "stock_items") { + return { + getOne: vi.fn().mockImplementation(async () => { + if (nextIsMissing) { + nextIsMissing = false; + throw new Error("404 not found"); + } + return updated; + }), + update: vi.fn().mockResolvedValue(updated), + create: vi.fn(), + }; + } + if (name === "stock_ledger") return { create: vi.fn().mockResolvedValue({ id: "l1" }) }; + throw new Error(`unexpected ${name}`); + }, + }; + const result = await updateStockItemsPricing({ + pb: pb as never, + updates: [ + { id: "missing00000000x", costPrice: 1 }, + { id: "abcdefghijklmno", costPrice: 5 }, + ], + actor: { id: "useropid1234567", name: "Gary" }, + }); + expect(result.summary).toEqual({ total: 2, succeeded: 1, failed: 1 }); + expect(result.results[0]).toMatchObject({ ok: false, reason: "not_found" }); + expect(result.results[1]).toMatchObject({ ok: true }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm vitest run src/lib/server/stock-pricing-service.test.ts` +Expected: At least the `validation_error` for negative + the `forbidden_field` test fail (others may pass depending on Task 1 implementation). + +- [ ] **Step 3: Make any failing tests pass** + +If the `validation_error` test fails, the existing validation in Task 1's service implementation already covers it. If `forbidden_field` fails, ensure the service rejects unknown keys. Both `not_found` tests should already pass from Task 1. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm vitest run src/lib/server/stock-pricing-service.test.ts` +Expected: All 8 tests passing. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/stock-pricing-service.ts src/lib/server/stock-pricing-service.test.ts +git commit -m "test(stock): cover validation + not_found + per-row independence" +``` + +--- + +## Task 4: POST /api/v1/stock/items/set-pricing route + +**Files:** +- Create: `src/app/api/v1/stock/items/set-pricing/route.ts` + +- [ ] **Step 1: Write the route** + +Create the file: + +```ts +import { NextResponse } from "next/server"; +import { updateStockItemsPricing } from "@/lib/server/stock-pricing-service"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; + +const MAX_UPDATES = 100; + +const PB_ID_RE = /^[a-z0-9]{15}$/; + +type Body = { + updates?: Array<{ id?: unknown; costPrice?: unknown; sellingPrice?: unknown; markup?: unknown }>; + actor?: { id?: unknown; name?: unknown }; +}; + +export async function POST(req: Request) { + let body: Body; + try { + body = (await req.json()) as Body; + } catch { + return NextResponse.json( + { code: "invalid_json", message: "Body is not valid JSON" }, + { status: 400 }, + ); + } + + if (!Array.isArray(body.updates) || body.updates.length === 0) { + return NextResponse.json( + { code: "validation_error", message: "updates must be a non-empty array" }, + { status: 400 }, + ); + } + if (body.updates.length > MAX_UPDATES) { + return NextResponse.json( + { + code: "validation_error", + message: `At most ${MAX_UPDATES} updates per call`, + }, + { status: 400 }, + ); + } + if (!body.actor || typeof body.actor.id !== "string" || !body.actor.id) { + return NextResponse.json( + { code: "validation_error", message: "actor.id is required" }, + { status: 400 }, + ); + } + + const updates = body.updates.map((u, i) => { + if (typeof u.id !== "string" || !PB_ID_RE.test(u.id)) { + throw new Error(`updates[${i}].id must be a 15-char PB id`); + } + const out: { id: string; costPrice?: number; sellingPrice?: number; markup?: number } = { + id: u.id, + }; + if (u.costPrice !== undefined) out.costPrice = Number(u.costPrice); + if (u.sellingPrice !== undefined) out.sellingPrice = Number(u.sellingPrice); + if (u.markup !== undefined) out.markup = Number(u.markup); + return out; + }); + + let pb; + try { + pb = await getAdminPocketBase(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json( + { code: "pb_error", message }, + { status: 500 }, + ); + } + + const result = await updateStockItemsPricing({ + pb, + updates, + actor: { id: body.actor.id, name: String(body.actor.name ?? body.actor.id) }, + }); + + return NextResponse.json(result, { status: 200 }); +} +``` + +- [ ] **Step 2: Smoke-test the route with curl** + +Run (with the dev server on :4001): + +```bash +curl -s -X POST http://localhost:4001/api/v1/stock/items/set-pricing \ + -H 'Content-Type: application/json' \ + -d '{"actor": {"id": "useropid1234567", "name": "Gary"}, "updates": []}' +``` + +Expected: HTTP `400` with `{"code":"validation_error","message":"updates must be a non-empty array"}`. + +- [ ] **Step 3: Smoke-test happy path against a real PB row** + +```bash +curl -s -X POST http://localhost:4001/api/v1/stock/items/set-pricing \ + -H 'Content-Type: application/json' \ + -d '{ + "actor": {"id": "useropid1234567", "name": "Gary"}, + "updates": [{"id": "", "sellingPrice": 3.25, "markup": 30}] + }' | jq . +``` + +Expected: HTTP `200` with `{ results: [...ok...], summary: {...} }`. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/api/v1/stock/items/set-pricing/route.ts +git commit -m "feat(stock): add POST /api/v1/stock/items/set-pricing" +``` + +--- + +## Task 5: /stock — "Show pricing columns" toggle (read-only) + +**Files:** +- Modify: `src/app/(app)/stock/page.tsx` +- Modify: `src/app/(app)/stock/page.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Add to `src/app/(app)/stock/page.test.tsx` (location: end of file) — first locate an existing `describe` block in the file so you can append at the same indentation. Read the file first to confirm structure. The test: + +```tsx +describe("Stock list — show pricing columns toggle", () => { + beforeEach(() => { + _resetState(); + _state.items = [ + { id: "abcdefghijklmno", sku: "B-001", name: "M10 Hex Bolt", current_qty: 42, min_qty: 10, target_qty: 0, is_active: true, supplier: "" }, + ]; + }); + afterEach(() => cleanup()); + + it("hides pricing columns by default", async () => { + render(); + expect(screen.queryByText("Cost")).toBeNull(); + expect(screen.queryByText("Markup")).toBeNull(); + expect(screen.queryByText("Selling")).toBeNull(); + }); + + it("shows pricing columns after toggling Show pricing on", async () => { + render(); + fireEvent.click(screen.getByRole("switch", { name: /Show pricing columns/i })); + expect(await screen.findByText("Cost (R)")).toBeInTheDocument(); + expect(screen.getByText("Markup %")).toBeInTheDocument(); + expect(screen.getByText("Selling (R)")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/app/(app)/stock/page.test.tsx` +Expected: FAIL — `Show pricing columns` switch doesn't exist yet. + +- [ ] **Step 3: Add the toggle + new columns to /stock** + +In `src/app/(app)/stock/page.tsx`: + +1. Add a new state at the top of the page component (right after `lowStockOnly`): +```ts + const [showPricingColumns, setShowPricingColumns] = useState(false); +``` + +2. Hydrate from `localStorage` after mount (existing pattern uses `useEffect`): +```ts + useEffect(() => { + if (typeof window === "undefined") return; + const v = window.localStorage.getItem("wfdash.stock.showPricing"); + if (v === "true") setShowPricingColumns(true); + }, []); +``` + +3. Mirror changes back: +```ts + useEffect(() => { + if (typeof window === "undefined") return; + window.localStorage.setItem("wfdash.stock.showPricing", String(showPricingColumns)); + }, [showPricingColumns]); +``` + +4. Add a Switch beside the existing "Low stock only" toggle in the filter row: +```tsx +
+ setLowStockOnly(checked === true)} + aria-label="Low stock only" + /> + + setShowPricingColumns(checked === true)} + aria-label="Show pricing columns" + data-testid="show-pricing-toggle" + /> + +
+``` + +5. Update the `` to add three right-aligned cells only when `showPricingColumns`: +```tsx + {showPricingColumns && ( + <> + Cost (R) + Markup % + Selling (R) + + )} +``` + +6. Pass the boolean to `` so it can render the cells too: +```tsx + {items.map((item) => ( + router.push(`/stock/${item.id}`)} + /> + ))} +``` + +7. Update the `StockRow` signature and render: +```tsx +function StockRow({ + item, + showPricingColumns, + onOpen, +}: { + item: StockItem; + showPricingColumns: boolean; + onOpen: () => void; +}) { + const status = getStockStatus(item.current_qty, item.min_qty, item.is_active); + const supplierName = item.expand?.supplier?.suppliername || "—"; + const formatPrice = (n: number | undefined) => + typeof n === "number" && n > 0 ? `R ${n.toFixed(2)}` : "—"; + const formatPercent = (n: number | undefined) => + typeof n === "number" && n > 0 ? `${n}%` : "—"; + + return ( + + ...existing cells... + {showPricingColumns && ( + <> + + {formatPrice(item.cost_price)} + + + {formatPercent(item.markup)} + + + {formatPrice(item.selling_price)} + + + )} + + ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/app/(app)/stock/page.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/(app)/stock/page.tsx src/app/(app)/stock/page.test.tsx +git commit -m "feat(stock): show pricing columns toggle on /stock" +``` + +--- + +## Task 6: /stock — "Missing pricing only" filter + +**Files:** +- Modify: `src/app/(app)/stock/page.tsx` +- Modify: `src/app/(app)/stock/page.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Append: + +```tsx +describe("Stock list — missing-pricing-only filter", () => { + beforeEach(() => { + _resetState(); + _state.items = [ + { id: "a", sku: "B-001", name: "Has both", selling_price: 3.25, cost_price: 2.5, current_qty: 1, min_qty: 0, is_active: true, supplier: "" }, + { id: "b", sku: "B-002", name: "Missing both", selling_price: 0, cost_price: 0, current_qty: 1, min_qty: 0, is_active: true, supplier: "" }, + ]; + }); + afterEach(() => cleanup()); + + it("only shows rows where selling_price=0 AND cost_price=0", async () => { + render(); + expect(screen.getByText("Has both")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("switch", { name: /Missing pricing only/i })); + await waitFor(() => expect(screen.queryByText("Has both")).toBeNull()); + expect(await screen.findByText("Missing both")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/app/(app)/stock/page.test.tsx` +Expected: FAIL — missing-pricing switch doesn't exist. + +- [ ] **Step 3: Implement the filter** + +In `src/app/(app)/stock/page.tsx`: + +1. New state: +```ts + const [missingPricingOnly, setMissingPricingOnly] = useState(false); +``` + +2. Add the PB filter clause inside `fetchItems`: +```ts + if (missingPricingOnly) { + filters.push("(selling_price = 0 && cost_price = 0 && is_active = true)"); + } +``` + +3. Add `missingPricingOnly` to the `fetchItems` useCallback deps. + +4. New toggle in the filter row, beside the others: +```tsx + setMissingPricingOnly(checked === true)} + aria-label="Missing pricing only" + /> + +``` + +5. Update the header subtitle to surface a "needs pricing" count (mirror `lowStockCount`). Add alongside `lowStockCount`: +```ts + const missingPricingCount = useMemo( + () => items.filter((it) => (it.selling_price ?? 0) === 0 && (it.cost_price ?? 0) === 0).length, + [items], + ); +``` + +Render in the heading area: +```tsx + <> + {lowStockCount > 0 && ( + <> + + {lowStockCount} + {" "} + need reordering + + )} + {missingPricingCount > 0 && ( + + ·{" "} + + {missingPricingCount} + {" "} + need pricing + + )} + {(lowStockCount > 0 || missingPricingCount > 0) && ( + · of {items.length} shown + )} + +``` + +Adjust the conditional to keep the existing no-low-stock copy when both counts are 0. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/app/(app)/stock/page.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/(app)/stock/page.tsx src/app/(app)/stock/page.test.tsx +git commit -m "feat(stock): missing-pricing-only filter on /stock" +``` + +--- + +## Task 7: /stock — Edit pricing mode toggle + dirty rows + apply bar + +**Files:** +- Modify: `src/app/(app)/stock/page.tsx` +- Modify: `src/app/(app)/stock/page.test.tsx` + +- [ ] **Step 1: Write the failing tests** + +Append: + +```tsx +describe("Stock list — Edit pricing mode", () => { + beforeEach(() => { + _resetState(); + _state.items = [ + { id: "abcdefghijklmno", sku: "B-001", name: "M10 Hex Bolt", selling_price: 0, cost_price: 0, markup: 0, current_qty: 5, min_qty: 0, is_active: true, supplier: "" }, + ]; + }); + afterEach(() => cleanup()); + + it("renders inputs and an Apply bar after typing in edit mode", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /Edit pricing/i })); + const costInput = await screen.findByLabelText(/Cost for B-001/i); + fireEvent.change(costInput, { target: { value: "3.20" } }); + expect(await screen.findByText(/Apply changes/i)).toBeInTheDocument(); + expect(await screen.findByText(/1 row/)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/app/(app)/stock/page.test.tsx` +Expected: FAIL — Edit pricing button doesn't exist. + +- [ ] **Step 3: Implement edit mode + draft + apply bar** + +In `src/app/(app)/stock/page.tsx`: + +1. New state at the top of the component: +```ts + const [editPricing, setEditPricing] = useState(false); + type DraftRow = { costPrice?: number; sellingPrice?: number; markup?: number; sellingLocked?: boolean }; + const [pricingDraft, setPricingDraft] = useState>({}); + const [pricingApplyState, setPricingApplyState] = useState<"idle" | "applying" | "applied" | "partial">("idle"); +``` + +2. Header gets the new "Edit pricing" button beside the existing buttons: +```tsx + +``` + +3. Replace each `showPricingColumns &&` rendering of pricing cells with a branched render. Where you currently have: +```tsx + {showPricingColumns && ( + <> + ...read cells... + + )} +``` +replace with: +```tsx + {showPricingColumns && !editPricing && ( + <> + ...the read-only cells from Task 5... + + )} + {showPricingColumns && editPricing && ( + + setPricingDraft((d) => ({ ...d, [item.id]: { ...d[item.id], ...patch } })) + } + /> + )} +``` + +4. Add `` above `` in the file: + +```tsx +function PricingEditCells({ + item, + draft, + onChange, +}: { + item: StockItem; + draft: + | { costPrice?: number; sellingPrice?: number; markup?: number; sellingLocked?: boolean } + | undefined; + onChange: (patch: { + costPrice?: number; + sellingPrice?: number; + markup?: number; + sellingLocked?: boolean; + }) => void; +}) { + const cost = draft?.costPrice ?? item.cost_price ?? 0; + const markup = draft?.markup ?? item.markup ?? 0; + const selling = draft?.sellingPrice ?? item.selling_price ?? 0; + const sellingLocked = draft?.sellingLocked ?? false; + + const recomputeSelling = (nextCost: number, nextMarkup: number) => { + if (Number.isFinite(nextCost) && Number.isFinite(nextMarkup)) { + onChange({ costPrice: nextCost, markup: nextMarkup, sellingPrice: Math.round(nextCost * (1 + nextMarkup / 100) * 100) / 100 }); + } + }; + + return ( + <> + + { + const v = e.target.value === "" ? 0 : Number(e.target.value); + recomputeSelling(v, markup); + }} + /> + + + { + const v = e.target.value === "" ? 0 : Number(e.target.value); + recomputeSelling(cost, v); + }} + /> + + + { + const v = e.target.value === "" ? 0 : Number(e.target.value); + onChange({ sellingPrice: v, sellingLocked: true }); + }} + /> + + + ); +} +``` + +5. Add the Apply bar at the very bottom of the page (before the closing `` of the outer wrapper): + +```tsx + {editPricing && Object.keys(pricingDraft).length > 0 && ( +
+ + {Object.keys(pricingDraft).length} row + {Object.keys(pricingDraft).length === 1 ? "" : "s"} with unsaved pricing changes. + +
+ + +
+
+ )} +``` + +6. Implement `applyPricingDraft`: + +```ts + const applyPricingDraft = async () => { + const updates = Object.entries(pricingDraft).flatMap(([id, draft]) => { + const out: { id: string; costPrice?: number; sellingPrice?: number; markup?: number }[] = [{ id }]; + const o = out[0]!; + if (draft.costPrice !== undefined) o.costPrice = draft.costPrice; + if (draft.sellingPrice !== undefined) o.sellingPrice = draft.sellingPrice; + if (draft.markup !== undefined) o.markup = draft.markup; + if (o.costPrice === undefined && o.sellingPrice === undefined && o.markup === undefined) return []; + return out; + }); + if (updates.length === 0) return; + setPricingApplyState("applying"); + try { + const res = await fetch("/api/v1/stock/items/set-pricing", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ updates, actor: { id: "system", name: "Operator" } }), + }); + const json = await res.json(); + if (!res.ok) { + toast.error(json?.message ?? "Apply failed"); + setPricingApplyState("partial"); + return; + } + // Merge successful rows into items; keep failed rows in the draft for retry. + const failedIds = new Set(); + const newItems = items.map((it) => { + const row = json.results?.find?.((r: { id: string }) => r.id === it.id); + if (!row) return it; + if (!row.ok) { + failedIds.add(it.id); + return it; + } + return { ...it, ...row.item, cost_price: row.item.cost_price, selling_price: row.item.selling_price, markup: row.item.markup }; + }); + setItems(newItems); + const failedDraftEntries = Object.fromEntries( + Object.entries(pricingDraft).filter(([id]) => failedIds.has(id)), + ); + setPricingDraft(failedDraftEntries); + setPricingApplyState(failedIds.size > 0 ? "partial" : "applied"); + toast.success( + failedIds.size > 0 + ? `Saved ${json.summary.succeeded} · ${json.summary.failed} failed` + : `Saved ${json.summary.succeeded} row${json.summary.succeeded === 1 ? "" : "s"}`, + ); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Apply failed"); + setPricingApplyState("partial"); + } + }; +``` + +7. Tint dirty rows — pass a `dirty` prop to `StockRow` and apply background: +```tsx + {items.map((item) => { + const dirty = Boolean(pricingDraft[item.id]); + return ( + router.push(`/stock/${item.id}`)} + /> + ); + })} +``` + +Pass `dirty` into `StockRow` and apply `className={cn(...existing..., dirty && "bg-amber-50")}` to ``. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/app/(app)/stock/page.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/(app)/stock/page.tsx src/app/(app)/stock/page.test.tsx +git commit -m "feat(stock): edit-pricing mode + dirty rows + apply bar" +``` + +--- + +## Task 8: /stock — localStorage draft persistence + +**Files:** +- Modify: `src/app/(app)/stock/page.tsx` +- Modify: `src/app/(app)/stock/page.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Append: + +```tsx +describe("Stock list — localStorage draft persistence", () => { + beforeEach(() => { + _resetState(); + _state.items = [ + { id: "abcdefghijklmno", sku: "B-001", name: "M10 Hex Bolt", selling_price: 0, cost_price: 0, markup: 0, current_qty: 5, min_qty: 0, is_active: true, supplier: "" }, + ]; + window.localStorage.clear(); + }); + afterEach(() => cleanup()); + + it("survives a page remount via localStorage", async () => { + const { unmount } = render(); + fireEvent.click(screen.getByRole("button", { name: /Edit pricing/i })); + const costInput = await screen.findByLabelText(/Cost for B-001/i); + fireEvent.change(costInput, { target: { value: "1.23" } }); + unmount(); + render(); + fireEvent.click(screen.getByRole("button", { name: /Edit pricing/i })); + expect(await screen.findByText(/Apply changes/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/app/(app)/stock/page.test.tsx` +Expected: FAIL — second mount shows no draft. + +- [ ] **Step 3: Implement persistence** + +In `src/app/(app)/stock/page.tsx`, after the existing edit-toggle state: + +```ts + // Rehydrate draft from localStorage on mount. + useEffect(() => { + if (typeof window === "undefined") return; + try { + const raw = window.localStorage.getItem("wfdash.stock.pricingDraft"); + if (raw) setPricingDraft(JSON.parse(raw)); + } catch { + // ignore corrupt draft + } + }, []); + + // Persist draft every change. + useEffect(() => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem("wfdash.stock.pricingDraft", JSON.stringify(pricingDraft)); + } catch { + // localStorage quota — best-effort + } + }, [pricingDraft]); + + // Clear the draft on a fully successful apply — implemented inside applyPricingDraft. +``` + +In `applyPricingDraft`, after `setPricingDraft(failedDraftEntries)`: +```ts + if (failedIds.size === 0) { + window.localStorage.removeItem("wfdash.stock.pricingDraft"); + } else { + window.localStorage.setItem("wfdash.stock.pricingDraft", JSON.stringify(failedDraftEntries)); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/app/(app)/stock/page.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/(app)/stock/page.tsx src/app/(app)/stock/page.test.tsx +git commit -m "feat(stock): persist pricing draft to localStorage" +``` + +--- + +## Task 9: /stock — useBeforeUnload warning when dirty + +**Files:** +- Modify: `src/app/(app)/stock/page.tsx` +- Modify: `src/app/(app)/stock/page.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Append: + +```tsx +describe("Stock list — beforeunload while dirty", () => { + beforeEach(() => { + _resetState(); + _state.items = [ + { id: "abcdefghijklmno", sku: "B-001", name: "M10 Hex Bolt", selling_price: 0, cost_price: 0, markup: 0, current_qty: 5, min_qty: 0, is_active: true, supplier: "" }, + ]; + window.localStorage.clear(); + }); + afterEach(() => cleanup()); + + it("registers a beforeunload listener when there are dirty rows", async () => { + const addSpy = vi.spyOn(window, "addEventListener"); + render(); + fireEvent.click(screen.getByRole("button", { name: /Edit pricing/i })); + const costInput = await screen.findByLabelText(/Cost for B-001/i); + fireEvent.change(costInput, { target: { value: "5" } }); + expect(addSpy).toHaveBeenCalledWith("beforeunload", expect.any(Function)); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/app/(app)/stock/page.test.tsx` +Expected: FAIL — no beforeunload listener registration yet. + +- [ ] **Step 3: Implement the hook** + +In `src/app/(app)/stock/page.tsx`: + +```ts + useEffect(() => { + if (typeof window === "undefined") return; + const isDirty = Object.keys(pricingDraft).length > 0 && editPricing; + if (!isDirty) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [pricingDraft, editPricing]); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/app/(app)/stock/page.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/(app)/stock/page.tsx src/app/(app)/stock/page.test.tsx +git commit -m "feat(stock): beforeunload warning when pricing draft is dirty" +``` + +--- + +## Self-Review (final pass before handoff) + +- [ ] **Spec coverage** + + - Goal (cost + markup + selling editable on /stock) — Tasks 5 + 7. + - "Show pricing columns" toggle — Task 5. + - "Missing pricing only" filter — Task 6. + - "Edit pricing" mode toggle + dirty rows + apply bar — Task 7. + - localStorage draft persistence — Task 8. + - useBeforeUnload prompt — Task 9. + - Auto-derive selling — Task 7 (in `PricingEditCells` recompute + `sellingLocked` flag). + - Service-layer cost-change-with-ledger invariant — Tasks 1–3. + - `POST /api/v1/stock/items/set-pricing` endpoint with body validation + 100-cap — Task 4. + +- [ ] **Placeholder scan** + + Re-read each task. No "TBD", no "fill in later", no "implement similar to". + +- [ ] **Type consistency** + + - `StockPricingUpdate` keys: `id | costPrice | sellingPrice | markup` everywhere. + - `updateStockItemsPricing` accepts `pb` field; the route injects via `getAdminPocketBase()`. + - Service result shape (`results[]` with `ok | not ok` discriminated by `ok: boolean`) used consistently by the route and the page. + +- [ ] **Commit cadence** + + Every task ends with a commit. No mega-commit. + +--- + +## Handoff + +Plan complete. Ten commits, all on `main`. Approximate file footprint: ~2 new files, ~2 modified files, ~600 LOC including tests. + +When you're ready to execute, pick one: + +1. **Subagent-Driven (recommended)** — fresh subagent per task with two-stage review between tasks. +2. **Inline Execution** — I'll execute the plan in this session with checkpoints between tasks.