From 104cf4cc6c11e3415bfa19883c9b7fe9a9b74a5f Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 31 Aug 2026 17:08:26 +0200 Subject: [PATCH] feat(stock): missing-pricing-only filter on /stock --- src/app/(app)/stock/page.test.tsx | 26 ++++++++++++++++++++++++++ src/app/(app)/stock/page.tsx | 29 ++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/app/(app)/stock/page.test.tsx b/src/app/(app)/stock/page.test.tsx index c5aa036..8d715d8 100644 --- a/src/app/(app)/stock/page.test.tsx +++ b/src/app/(app)/stock/page.test.tsx @@ -337,4 +337,30 @@ describe("StockListPage", () => { expect(screen.getByText("Selling (R)")).toBeInTheDocument(); }); }); + + describe("Stock list — missing-pricing-only filter", () => { + beforeEach(() => { + _resetState(); + _state.items = [ + { id: "aaaaaaaaaaaaaaa", 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: "bbbbbbbbbbbbbbb", sku: "B-002", name: "Missing both", selling_price: 0, cost_price: 0, current_qty: 1, min_qty: 0, is_active: true, supplier: "" }, + ]; + }); + afterEach(() => cleanup()); + + it("only shows rows where selling_price=0 AND cost_price=0", async () => { + render(); + expect(await screen.findByText("Has both")).toBeInTheDocument(); + + // Simulate the PB server filtering by the missing-pricing clause: + // only "Missing both" comes back from getList when the toggle is on. + mockListResponse([ + { id: "bbbbbbbbbbbbbbb", sku: "B-002", name: "Missing both", selling_price: 0, cost_price: 0, current_qty: 1, min_qty: 0, is_active: true, supplier: "" }, + ]); + + 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(); + }); + }); }); diff --git a/src/app/(app)/stock/page.tsx b/src/app/(app)/stock/page.tsx index b3f3cc8..1857ca6 100644 --- a/src/app/(app)/stock/page.tsx +++ b/src/app/(app)/stock/page.tsx @@ -87,6 +87,7 @@ export default function StockListPage() { const [search, setSearch] = useState(""); const [lowStockOnly, setLowStockOnly] = useState(false); const [showPricingColumns, setShowPricingColumns] = useState(false); + const [missingPricingOnly, setMissingPricingOnly] = useState(false); const [createOpen, setCreateOpen] = useState(false); const fetchItems = useCallback(async () => { @@ -104,6 +105,9 @@ export default function StockListPage() { if (lowStockOnly) { filters.push("(current_qty <= min_qty && is_active = true)"); } + if (missingPricingOnly) { + filters.push("(selling_price = 0 && cost_price = 0 && is_active = true)"); + } const result = await pb.collection("stock_items").getList( 1, @@ -125,7 +129,7 @@ export default function StockListPage() { } finally { setLoading(false); } - }, [search, lowStockOnly]); + }, [search, lowStockOnly, missingPricingOnly]); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- data fetch on dep change @@ -195,6 +199,11 @@ export default function StockListPage() { [items], ); + const missingPricingCount = useMemo( + () => items.filter((it) => (it.selling_price ?? 0) === 0 && (it.cost_price ?? 0) === 0).length, + [items], + ); + const handleSearchChange = (v: string) => setSearchInput(v); const clearSearch = () => setSearchInput(""); @@ -215,6 +224,15 @@ export default function StockListPage() { ) : ( "Inventory levels across all stock items." )} + {missingPricingCount > 0 && ( + + ·{" "} + + {missingPricingCount} + {" "} + need pricing + + )}

@@ -276,6 +294,15 @@ export default function StockListPage() { + setMissingPricingOnly(checked === true)} + aria-label="Missing pricing only" + /> +