From 759e05048cf17d5f7cd01caf94174f42c292af85 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 31 Aug 2026 17:12:28 +0200 Subject: [PATCH] feat(stock): edit-pricing mode + dirty rows + apply bar --- src/app/(app)/stock/page.test.tsx | 41 ++++++ src/app/(app)/stock/page.tsx | 229 ++++++++++++++++++++++++++++-- 2 files changed, 260 insertions(+), 10 deletions(-) diff --git a/src/app/(app)/stock/page.test.tsx b/src/app/(app)/stock/page.test.tsx index 8d715d8..eaf9dd0 100644 --- a/src/app/(app)/stock/page.test.tsx +++ b/src/app/(app)/stock/page.test.tsx @@ -338,6 +338,47 @@ describe("StockListPage", () => { }); }); + describe("Stock list — Edit pricing mode", () => { + let fetchSpy: ReturnType; + beforeEach(() => { + _resetState(); + _state.items = [ + { id: "aaaaaaaaaaaaaaa", 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(); + fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + results: [{ + id: "aaaaaaaaaaaaaaa", + ok: true, + item: { cost_price: 3.2, selling_price: 4.16, markup: 30 }, + previous: { cost_price: 0, selling_price: 0, markup: 0 }, + }], + summary: { total: 1, succeeded: 1, failed: 0 }, + }), + } as Response); + vi.stubGlobal("fetch", fetchSpy); + }); + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("renders inputs and an Apply bar after typing in edit mode", async () => { + render(); + // Wait for items to load before clicking — the "Edit pricing" + // button is disabled while items.length === 0. + await screen.findByText("M10 Hex Bolt"); + 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(); + }); + }); + describe("Stock list — missing-pricing-only filter", () => { beforeEach(() => { _resetState(); diff --git a/src/app/(app)/stock/page.tsx b/src/app/(app)/stock/page.tsx index 1857ca6..3c8ad60 100644 --- a/src/app/(app)/stock/page.tsx +++ b/src/app/(app)/stock/page.tsx @@ -89,6 +89,10 @@ export default function StockListPage() { const [showPricingColumns, setShowPricingColumns] = useState(false); const [missingPricingOnly, setMissingPricingOnly] = useState(false); const [createOpen, setCreateOpen] = useState(false); + 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"); const fetchItems = useCallback(async () => { setLoading(true); @@ -207,6 +211,57 @@ export default function StockListPage() { const handleSearchChange = (v: string) => setSearchInput(v); const clearSearch = () => setSearchInput(""); + 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; + } + 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"); + } + }; + return (
@@ -249,6 +304,18 @@ export default function StockListPage() { New stock item +
@@ -362,14 +429,23 @@ export default function StockListPage() { - {items.map((item) => ( - router.push(`/stock/${item.id}`)} - /> - ))} + {items.map((item) => { + const dirty = Boolean(pricingDraft[item.id]); + return ( + + setPricingDraft((d) => ({ ...d, [item.id]: { ...d[item.id], ...patch } })) + } + dirty={dirty && editPricing} + onOpen={() => router.push(`/stock/${item.id}`)} + /> + ); + })} @@ -387,6 +463,22 @@ export default function StockListPage() { router.push(`/stock/${created.id}`); }} /> + + {editPricing && Object.keys(pricingDraft).length > 0 && ( +
+ + {Object.keys(pricingDraft).length} row{Object.keys(pricingDraft).length === 1 ? "" : "s"} with unsaved pricing changes. + +
+ + +
+
+ )} ); } @@ -406,10 +498,25 @@ export default function StockListPage() { function StockRow({ item, showPricingColumns, + editPricing, + draft, + onDraftChange, + dirty, onOpen, }: { item: StockItem; showPricingColumns: boolean; + editPricing: boolean; + draft: + | { costPrice?: number; sellingPrice?: number; markup?: number; sellingLocked?: boolean } + | undefined; + onDraftChange: (patch: { + costPrice?: number; + sellingPrice?: number; + markup?: number; + sellingLocked?: boolean; + }) => void; + dirty: boolean; onOpen: () => void; }) { const status = getStockStatus(item.current_qty, item.min_qty, item.is_active); @@ -430,7 +537,10 @@ function StockRow({ onOpen(); } }} - className="cursor-pointer hover:bg-accent/40 focus-visible:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" + className={cn( + "cursor-pointer hover:bg-accent/40 focus-visible:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", + dirty && "bg-amber-50", + )} aria-label={`Open ${item.name}`} > {item.sku || "—"} @@ -463,7 +573,7 @@ function StockRow({ — )} - {showPricingColumns && ( + {showPricingColumns && !editPricing && ( <> {formatPrice(item.cost_price)} @@ -476,6 +586,13 @@ function StockRow({ )} + {showPricingColumns && editPricing && ( + + )} @@ -486,6 +603,98 @@ function StockRow({ ); } +/* ============================================================================ + * PricingEditCells — inline edit inputs for the /stock list when the + * operator has clicked "Edit pricing". + * + * Cost and markup both feed `recomputeSelling` so editing either + * keeps the (cost × (1 + markup/100)) invariant in sync. Editing + * the selling price directly flips `sellingLocked` so the next cost + * or markup keystroke no longer overwrites the operator's manual + * override (visually surfaced with `bg-amber-50` on the input). + * ========================================================================== */ +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 }); + }} + /> + + + ); +} + /* ============================================================================ * StockStatusBadge — coloured pill keyed by `StockStatus`. *