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"
+ />
+