feat(stock): edit-pricing mode + dirty rows + apply bar

This commit is contained in:
Dave 2026-08-31 17:12:28 +02:00
parent 104cf4cc6c
commit 759e05048c
2 changed files with 260 additions and 10 deletions

View file

@ -338,6 +338,47 @@ describe("StockListPage", () => {
});
});
describe("Stock list — Edit pricing mode", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
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(<Page />);
// 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();

View file

@ -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<Record<string, DraftRow>>({});
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<string>();
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 (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
@ -249,6 +304,18 @@ export default function StockListPage() {
<Plus className="mr-2 h-4 w-4" />
New stock item
</Button>
<Button
variant={editPricing ? "default" : "outline"}
onClick={() => {
const next = !editPricing;
setEditPricing(next);
if (next) setShowPricingColumns(true);
}}
disabled={items.length === 0}
data-testid="edit-pricing-toggle"
>
{editPricing ? "Done editing" : "Edit pricing"}
</Button>
</div>
</div>
@ -362,14 +429,23 @@ export default function StockListPage() {
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<StockRow
key={item.id}
item={item}
showPricingColumns={showPricingColumns}
onOpen={() => router.push(`/stock/${item.id}`)}
/>
))}
{items.map((item) => {
const dirty = Boolean(pricingDraft[item.id]);
return (
<StockRow
key={item.id}
item={item}
showPricingColumns={showPricingColumns}
editPricing={editPricing}
draft={pricingDraft[item.id]}
onDraftChange={(patch) =>
setPricingDraft((d) => ({ ...d, [item.id]: { ...d[item.id], ...patch } }))
}
dirty={dirty && editPricing}
onOpen={() => router.push(`/stock/${item.id}`)}
/>
);
})}
</TableBody>
</Table>
</div>
@ -387,6 +463,22 @@ export default function StockListPage() {
router.push(`/stock/${created.id}`);
}}
/>
{editPricing && Object.keys(pricingDraft).length > 0 && (
<div className="sticky bottom-0 left-0 right-0 z-10 -mx-4 sm:-mx-6 px-4 sm:px-6 py-3 bg-amber-50 border-t border-amber-300 shadow-md flex items-center justify-between gap-3">
<span className="text-sm font-medium">
{Object.keys(pricingDraft).length} row{Object.keys(pricingDraft).length === 1 ? "" : "s"} with unsaved pricing changes.
</span>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setPricingDraft({})} data-testid="discard-pricing">
Discard
</Button>
<Button onClick={applyPricingDraft} data-testid="apply-pricing">
Apply changes
</Button>
</div>
</div>
)}
</div>
);
}
@ -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}`}
>
<TableCell className="font-mono text-xs">{item.sku || "—"}</TableCell>
@ -463,7 +573,7 @@ function StockRow({
<span className="italic">—</span>
)}
</TableCell>
{showPricingColumns && (
{showPricingColumns && !editPricing && (
<>
<TableCell className="text-right tabular-nums text-muted-foreground">
{formatPrice(item.cost_price)}
@ -476,6 +586,13 @@ function StockRow({
</TableCell>
</>
)}
{showPricingColumns && editPricing && (
<PricingEditCells
item={item}
draft={draft}
onChange={onDraftChange}
/>
)}
<TableCell>
<StockStatusBadge status={status} />
</TableCell>
@ -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 (
<>
<TableCell className="text-right">
<Input
aria-label={`Cost for ${item.sku ?? item.name}`}
type="number"
inputMode="decimal"
step="0.01"
min="0"
className="h-8 text-right tabular-nums"
value={cost || ""}
onChange={(e) => {
const v = e.target.value === "" ? 0 : Number(e.target.value);
recomputeSelling(v, markup);
}}
/>
</TableCell>
<TableCell className="text-right">
<Input
aria-label={`Markup for ${item.sku ?? item.name}`}
type="number"
inputMode="numeric"
step="1"
min="0"
className="h-8 text-right tabular-nums"
value={markup || ""}
onChange={(e) => {
const v = e.target.value === "" ? 0 : Number(e.target.value);
recomputeSelling(cost, v);
}}
/>
</TableCell>
<TableCell className="text-right">
<Input
aria-label={`Selling for ${item.sku ?? item.name}`}
type="number"
inputMode="decimal"
step="0.01"
min="0"
className={`h-8 text-right tabular-nums ${sellingLocked ? "bg-amber-50" : ""}`}
value={selling || ""}
onChange={(e) => {
const v = e.target.value === "" ? 0 : Number(e.target.value);
onChange({ sellingPrice: v, sellingLocked: true });
}}
/>
</TableCell>
</>
);
}
/* ============================================================================
* StockStatusBadge — coloured pill keyed by `StockStatus`.
*