feat(stock): edit-pricing mode + dirty rows + apply bar
This commit is contained in:
parent
104cf4cc6c
commit
759e05048c
2 changed files with 260 additions and 10 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in a new issue