diff --git a/src/app/(app)/stock/page.test.tsx b/src/app/(app)/stock/page.test.tsx index 0e74b17..a71ec5c 100644 --- a/src/app/(app)/stock/page.test.tsx +++ b/src/app/(app)/stock/page.test.tsx @@ -428,4 +428,25 @@ describe("StockListPage", () => { expect(await screen.findByText("Missing both")).toBeInTheDocument(); }); }); + + describe("Stock list — beforeunload while dirty", () => { + 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(); + }); + afterEach(() => cleanup()); + + it("registers a beforeunload listener when there are dirty rows", async () => { + const addSpy = vi.spyOn(window, "addEventListener"); + render(); + await screen.findByText("M10 Hex Bolt"); // wait for items + 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)); + }); + }); }); diff --git a/src/app/(app)/stock/page.tsx b/src/app/(app)/stock/page.tsx index 6debc83..fafe284 100644 --- a/src/app/(app)/stock/page.tsx +++ b/src/app/(app)/stock/page.tsx @@ -177,6 +177,23 @@ export default function StockListPage() { } }, [pricingDraft]); + // Warn the operator before they navigate away with unsaved pricing + // changes. Only registered when edit mode is on AND there's actually + // something in the draft — the bar already gates the apply button + // on the same condition, so a Cmd-W / tab close here would silently + // lose the work otherwise. + 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]); + // Debounce the search input → 250ms after the last keystroke. Same // pattern as /orders and /quotes — the operator is hunting for a // match, not paging through.