feat(stock): show pricing columns toggle on /stock
This commit is contained in:
parent
a4bfef123b
commit
f63fb7d8fc
2 changed files with 96 additions and 1 deletions
|
|
@ -302,4 +302,39 @@ describe("StockListPage", () => {
|
||||||
});
|
});
|
||||||
expect(submit).not.toBeDisabled();
|
expect(submit).not.toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides pricing columns by default", async () => {
|
||||||
|
render(<Page />);
|
||||||
|
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(<Page />);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ export default function StockListPage() {
|
||||||
/** Debounced search — what we actually query for. */
|
/** Debounced search — what we actually query for. */
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [lowStockOnly, setLowStockOnly] = useState(false);
|
const [lowStockOnly, setLowStockOnly] = useState(false);
|
||||||
|
const [showPricingColumns, setShowPricingColumns] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
const fetchItems = useCallback(async () => {
|
const fetchItems = useCallback(async () => {
|
||||||
|
|
@ -131,6 +132,22 @@ export default function StockListPage() {
|
||||||
fetchItems();
|
fetchItems();
|
||||||
}, [fetchItems]);
|
}, [fetchItems]);
|
||||||
|
|
||||||
|
// Hydrate the "Show pricing columns" toggle from localStorage on mount.
|
||||||
|
// Defaults to false when the key is missing or the value is anything
|
||||||
|
// other than the literal "true".
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const v = window.localStorage.getItem("wfdash.stock.showPricing");
|
||||||
|
if (v === "true") setShowPricingColumns(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Mirror toggle changes back to localStorage so the operator's choice
|
||||||
|
// persists across page navigations and reloads.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.localStorage.setItem("wfdash.stock.showPricing", String(showPricingColumns));
|
||||||
|
}, [showPricingColumns]);
|
||||||
|
|
||||||
// Debounce the search input → 250ms after the last keystroke. Same
|
// Debounce the search input → 250ms after the last keystroke. Same
|
||||||
// pattern as /orders and /quotes — the operator is hunting for a
|
// pattern as /orders and /quotes — the operator is hunting for a
|
||||||
// match, not paging through.
|
// match, not paging through.
|
||||||
|
|
@ -249,6 +266,16 @@ export default function StockListPage() {
|
||||||
<Label htmlFor="low-stock-toggle" className="cursor-pointer">
|
<Label htmlFor="low-stock-toggle" className="cursor-pointer">
|
||||||
Low stock only
|
Low stock only
|
||||||
</Label>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -296,6 +323,13 @@ export default function StockListPage() {
|
||||||
<TableHead className="text-right w-24">Current</TableHead>
|
<TableHead className="text-right w-24">Current</TableHead>
|
||||||
<TableHead className="text-right w-24">Min</TableHead>
|
<TableHead className="text-right w-24">Min</TableHead>
|
||||||
<TableHead className="text-right w-24">Target</TableHead>
|
<TableHead className="text-right w-24">Target</TableHead>
|
||||||
|
{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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<TableHead className="w-36">Status</TableHead>
|
<TableHead className="w-36">Status</TableHead>
|
||||||
<TableHead className="w-10" aria-label="Open" />
|
<TableHead className="w-10" aria-label="Open" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -305,6 +339,7 @@ export default function StockListPage() {
|
||||||
<StockRow
|
<StockRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
|
showPricingColumns={showPricingColumns}
|
||||||
onOpen={() => router.push(`/stock/${item.id}`)}
|
onOpen={() => router.push(`/stock/${item.id}`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -341,9 +376,21 @@ export default function StockListPage() {
|
||||||
* so the supplier name link inside can stay a real anchor without
|
* so the supplier name link inside can stay a real anchor without
|
||||||
* triggering the nested-button trap.
|
* triggering the nested-button trap.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
function StockRow({ item, onOpen }: { item: StockItem; onOpen: () => void }) {
|
function StockRow({
|
||||||
|
item,
|
||||||
|
showPricingColumns,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
item: StockItem;
|
||||||
|
showPricingColumns: boolean;
|
||||||
|
onOpen: () => void;
|
||||||
|
}) {
|
||||||
const status = getStockStatus(item.current_qty, item.min_qty, item.is_active);
|
const status = getStockStatus(item.current_qty, item.min_qty, item.is_active);
|
||||||
const supplierName = item.expand?.supplier?.suppliername || "—";
|
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 (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
|
|
@ -389,6 +436,19 @@ function StockRow({ item, onOpen }: { item: StockItem; onOpen: () => void }) {
|
||||||
<span className="italic">—</span>
|
<span className="italic">—</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
{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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<StockStatusBadge status={status} />
|
<StockStatusBadge status={status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue