WFDASH-WEBAPP/docs/superpowers/plans/2026-08-31-stock-pricing-inline-edit.md

46 KiB
Raw Blame History

/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:

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:

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: <T = unknown>(id: string, opts?: Record<string, unknown>) => Promise<T>;
    update: (id: string, patch: Record<string, unknown>) => Promise<unknown>;
    create: (data: Record<string, unknown>) => Promise<unknown>;
  };
};

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<UpdateStockItemsPricingResult> {
  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<string, unknown>)[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<string, number> = {};
    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
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:

describe("updateStockItemsPricing — stock_ledger audit on cost change", () => {
  const stubPb = (before: Record<string, unknown>, updated: Record<string, unknown>) => {
    const ledgerCreates: Array<Record<string, unknown>> = [];
    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:

    // 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:

    // 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
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:

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
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:

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):

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
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": "<REPLACE_WITH_REAL_STOCK_ITEM_ID>", "sellingPrice": 3.25, "markup": 30}]
  }' | jq .

Expected: HTTP 200 with { results: [...ok...], summary: {...} }.

  • Step 4: Commit
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:

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(<StockListPage />);
    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(<StockListPage />);
    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):
  const [showPricingColumns, setShowPricingColumns] = useState(false);
  1. Hydrate from localStorage after mount (existing pattern uses useEffect):
  useEffect(() => {
    if (typeof window === "undefined") return;
    const v = window.localStorage.getItem("wfdash.stock.showPricing");
    if (v === "true") setShowPricingColumns(true);
  }, []);
  1. Mirror changes back:
  useEffect(() => {
    if (typeof window === "undefined") return;
    window.localStorage.setItem("wfdash.stock.showPricing", String(showPricingColumns));
  }, [showPricingColumns]);
  1. Add a Switch beside the existing "Low stock only" toggle in the filter row:
        <div className="flex items-center gap-2">
          <Switch
            id="low-stock-toggle"
            checked={lowStockOnly}
            onCheckedChange={(checked) => setLowStockOnly(checked === true)}
            aria-label="Low stock only"
          />
          <Label htmlFor="low-stock-toggle" className="cursor-pointer">
            Low stock only
          </Label>
          <Switch
            id="show-pricing-toggle"
            checked={showPricingColumns}
            onCheckedChange={(checked) => setShowPricingColumns(checked === true)}
            aria-label="Show pricing columns"
            data-testid="show-pricing-toggle"
          />
          <Label htmlFor="show-pricing-toggle" className="cursor-pointer">
            Show pricing columns
          </Label>
        </div>
  1. Update the <TableHeader> to add three right-aligned cells only when showPricingColumns:
              {showPricingColumns && (
                <>
                  <TableHead className="text-right w-28">Cost (R)</TableHead>
                  <TableHead className="text-right w-24">Markup %</TableHead>
                  <TableHead className="text-right w-28">Selling (R)</TableHead>
                </>
              )}
  1. Pass the boolean to <StockRow> so it can render the cells too:
              {items.map((item) => (
                <StockRow
                  key={item.id}
                  item={item}
                  showPricingColumns={showPricingColumns}
                  onOpen={() => router.push(`/stock/${item.id}`)}
                />
              ))}
  1. Update the StockRow signature and render:
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 (
    <TableRow ...>
      ...existing cells...
      {showPricingColumns && (
        <>
          <TableCell className="text-right tabular-nums text-muted-foreground">
            {formatPrice(item.cost_price)}
          </TableCell>
          <TableCell className="text-right tabular-nums text-muted-foreground">
            {formatPercent(item.markup)}
          </TableCell>
          <TableCell className="text-right tabular-nums">
            {formatPrice(item.selling_price)}
          </TableCell>
        </>
      )}
    </TableRow>
  );
}
  • Step 4: Run test to verify it passes

Run: pnpm vitest run src/app/(app)/stock/page.test.tsx Expected: PASS.

  • Step 5: Commit
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:

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(<StockListPage />);
    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:
  const [missingPricingOnly, setMissingPricingOnly] = useState(false);
  1. Add the PB filter clause inside fetchItems:
      if (missingPricingOnly) {
        filters.push("(selling_price = 0 && cost_price = 0 && is_active = true)");
      }
  1. Add missingPricingOnly to the fetchItems useCallback deps.

  2. New toggle in the filter row, beside the others:

          <Switch
            id="missing-pricing-toggle"
            checked={missingPricingOnly}
            onCheckedChange={(checked) => setMissingPricingOnly(checked === true)}
            aria-label="Missing pricing only"
          />
          <Label htmlFor="missing-pricing-toggle" className="cursor-pointer">
            Missing pricing only
          </Label>
  1. Update the header subtitle to surface a "needs pricing" count (mirror lowStockCount). Add alongside lowStockCount:
  const missingPricingCount = useMemo(
    () => items.filter((it) => (it.selling_price ?? 0) === 0 && (it.cost_price ?? 0) === 0).length,
    [items],
  );

Render in the heading area:

              <>
                {lowStockCount > 0 && (
                  <>
                    <span className="font-semibold text-amber-700 dark:text-amber-400">
                      {lowStockCount}
                    </span>{" "}
                    need reordering
                  </>
                )}
                {missingPricingCount > 0 && (
                  <span className="ml-2">
                    ·{" "}
                    <span className="font-semibold text-rose-700 dark:text-rose-400">
                      {missingPricingCount}
                    </span>{" "}
                    need pricing
                  </span>
                )}
                {(lowStockCount > 0 || missingPricingCount > 0) && (
                  <span className="text-muted-foreground"> · of {items.length} shown</span>
                )}
              </>

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
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:

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(<StockListPage />);
    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:
  const [editPricing, setEditPricing] = useState(false);
  type DraftRow = { costPrice?: number; sellingPrice?: number; markup?: number; sellingLocked?: boolean };
  const [pricingDraft, setPricingDraft] = useState<Record<string, DraftRow>>({});
  const [pricingApplyState, setPricingApplyState] = useState<"idle" | "applying" | "applied" | "partial">("idle");
  1. Header gets the new "Edit pricing" button beside the existing buttons:
          <Button
            variant={editPricing ? "default" : "outline"}
            onClick={() => setEditPricing((v) => !v)}
            disabled={items.length === 0}
            data-testid="edit-pricing-toggle"
          >
            {editPricing ? "Done editing" : "Edit pricing"}
          </Button>
  1. Replace each showPricingColumns && rendering of pricing cells with a branched render. Where you currently have:
      {showPricingColumns && (
        <>
          ...read cells...
        </>
      )}

replace with:

      {showPricingColumns && !editPricing && (
        <>
          ...the read-only cells from Task 5...
        </>
      )}
      {showPricingColumns && editPricing && (
        <PricingEditCells
          item={item}
          draft={pricingDraft[item.id]}
          onChange={(patch) =>
            setPricingDraft((d) => ({ ...d, [item.id]: { ...d[item.id], ...patch } }))
          }
        />
      )}
  1. Add <PricingEditCells> above <StockStatusBadge> in the file:
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 (
    <>
      <TableCell className="text-right">
        <Input
          aria-label={`Cost for ${item.sku ?? item.name}`}
          type="number"
          inputMode="decimal"
          step="0.01"
          min="0"
          className="h-8 text-right tabular-nums"
          value={cost || ""}
          onChange={(e) => {
            const v = e.target.value === "" ? 0 : Number(e.target.value);
            recomputeSelling(v, markup);
          }}
        />
      </TableCell>
      <TableCell className="text-right">
        <Input
          aria-label={`Markup for ${item.sku ?? item.name}`}
          type="number"
          inputMode="numeric"
          step="1"
          min="0"
          className="h-8 text-right tabular-nums"
          value={markup || ""}
          onChange={(e) => {
            const v = e.target.value === "" ? 0 : Number(e.target.value);
            recomputeSelling(cost, v);
          }}
        />
      </TableCell>
      <TableCell className="text-right">
        <Input
          aria-label={`Selling for ${item.sku ?? item.name}`}
          type="number"
          inputMode="decimal"
          step="0.01"
          min="0"
          className={`h-8 text-right tabular-nums ${sellingLocked ? "bg-amber-50" : ""}`}
          value={selling || ""}
          onChange={(e) => {
            const v = e.target.value === "" ? 0 : Number(e.target.value);
            onChange({ sellingPrice: v, sellingLocked: true });
          }}
        />
      </TableCell>
    </>
  );
}
  1. Add the Apply bar at the very bottom of the page (before the closing </div> of the outer wrapper):
      {editPricing && Object.keys(pricingDraft).length > 0 && (
        <div className="sticky bottom-0 left-0 right-0 z-10 -mx-4 sm:-mx-6 px-4 sm:px-6 py-3 bg-amber-50 border-t border-amber-300 shadow-md flex items-center justify-between gap-3">
          <span className="text-sm font-medium">
            {Object.keys(pricingDraft).length} row
            {Object.keys(pricingDraft).length === 1 ? "" : "s"} with unsaved pricing changes.
          </span>
          <div className="flex items-center gap-2">
            <Button variant="outline" onClick={() => setPricingDraft({})} data-testid="discard-pricing">
              Discard
            </Button>
            <Button onClick={applyPricingDraft} data-testid="apply-pricing">
              Apply changes
            </Button>
          </div>
        </div>
      )}
  1. Implement applyPricingDraft:
  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<string>();
      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");
    }
  };
  1. Tint dirty rows — pass a dirty prop to StockRow and apply background:
              {items.map((item) => {
                const dirty = Boolean(pricingDraft[item.id]);
                return (
                  <StockRow
                    key={item.id}
                    item={item}
                    showPricingColumns={showPricingColumns}
                    dirty={dirty && editPricing}
                    onOpen={() => router.push(`/stock/${item.id}`)}
                  />
                );
              })}

Pass dirty into StockRow and apply className={cn(...existing..., dirty && "bg-amber-50")} to <TableRow>.

  • Step 4: Run test to verify it passes

Run: pnpm vitest run src/app/(app)/stock/page.test.tsx Expected: PASS.

  • Step 5: Commit
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:

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(<StockListPage />);
    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(<StockListPage />);
    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:

  // 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):

      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
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:

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(<StockListPage />);
    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:

  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
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.