46 KiB
/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:
- Add a new state at the top of the page component (right after
lowStockOnly):
const [showPricingColumns, setShowPricingColumns] = useState(false);
- Hydrate from
localStorageafter mount (existing pattern usesuseEffect):
useEffect(() => {
if (typeof window === "undefined") return;
const v = window.localStorage.getItem("wfdash.stock.showPricing");
if (v === "true") setShowPricingColumns(true);
}, []);
- Mirror changes back:
useEffect(() => {
if (typeof window === "undefined") return;
window.localStorage.setItem("wfdash.stock.showPricing", String(showPricingColumns));
}, [showPricingColumns]);
- 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>
- Update the
<TableHeader>to add three right-aligned cells only whenshowPricingColumns:
{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>
</>
)}
- 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}`)}
/>
))}
- Update the
StockRowsignature 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:
- New state:
const [missingPricingOnly, setMissingPricingOnly] = useState(false);
- Add the PB filter clause inside
fetchItems:
if (missingPricingOnly) {
filters.push("(selling_price = 0 && cost_price = 0 && is_active = true)");
}
-
Add
missingPricingOnlyto thefetchItemsuseCallback deps. -
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>
- Update the header subtitle to surface a "needs pricing" count (mirror
lowStockCount). Add alongsidelowStockCount:
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:
- 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");
- 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>
- 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 } }))
}
/>
)}
- 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>
</>
);
}
- 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>
)}
- 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");
}
};
- Tint dirty rows — pass a
dirtyprop toStockRowand 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
PricingEditCellsrecompute +sellingLockedflag). - Service-layer cost-change-with-ledger invariant — Tasks 1–3.
POST /api/v1/stock/items/set-pricingendpoint 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
StockPricingUpdatekeys:id | costPrice | sellingPrice | markupeverywhere.updateStockItemsPricingacceptspbfield; the route injects viagetAdminPocketBase().- Service result shape (
results[]withok | not okdiscriminated byok: 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:
- Subagent-Driven (recommended) — fresh subagent per task with two-stage review between tasks.
- Inline Execution — I'll execute the plan in this session with checkpoints between tasks.