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", () => {
|
describe("Stock list — missing-pricing-only filter", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_resetState();
|
_resetState();
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,10 @@ export default function StockListPage() {
|
||||||
const [showPricingColumns, setShowPricingColumns] = useState(false);
|
const [showPricingColumns, setShowPricingColumns] = useState(false);
|
||||||
const [missingPricingOnly, setMissingPricingOnly] = useState(false);
|
const [missingPricingOnly, setMissingPricingOnly] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = 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 () => {
|
const fetchItems = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -207,6 +211,57 @@ export default function StockListPage() {
|
||||||
const handleSearchChange = (v: string) => setSearchInput(v);
|
const handleSearchChange = (v: string) => setSearchInput(v);
|
||||||
const clearSearch = () => setSearchInput("");
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
<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" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
New stock item
|
New stock item
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -362,14 +429,23 @@ export default function StockListPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.map((item) => (
|
{items.map((item) => {
|
||||||
<StockRow
|
const dirty = Boolean(pricingDraft[item.id]);
|
||||||
key={item.id}
|
return (
|
||||||
item={item}
|
<StockRow
|
||||||
showPricingColumns={showPricingColumns}
|
key={item.id}
|
||||||
onOpen={() => router.push(`/stock/${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>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -387,6 +463,22 @@ export default function StockListPage() {
|
||||||
router.push(`/stock/${created.id}`);
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -406,10 +498,25 @@ export default function StockListPage() {
|
||||||
function StockRow({
|
function StockRow({
|
||||||
item,
|
item,
|
||||||
showPricingColumns,
|
showPricingColumns,
|
||||||
|
editPricing,
|
||||||
|
draft,
|
||||||
|
onDraftChange,
|
||||||
|
dirty,
|
||||||
onOpen,
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
item: StockItem;
|
item: StockItem;
|
||||||
showPricingColumns: boolean;
|
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;
|
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);
|
||||||
|
|
@ -430,7 +537,10 @@ function StockRow({
|
||||||
onOpen();
|
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}`}
|
aria-label={`Open ${item.name}`}
|
||||||
>
|
>
|
||||||
<TableCell className="font-mono text-xs">{item.sku || "—"}</TableCell>
|
<TableCell className="font-mono text-xs">{item.sku || "—"}</TableCell>
|
||||||
|
|
@ -463,7 +573,7 @@ function StockRow({
|
||||||
<span className="italic">—</span>
|
<span className="italic">—</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{showPricingColumns && (
|
{showPricingColumns && !editPricing && (
|
||||||
<>
|
<>
|
||||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||||
{formatPrice(item.cost_price)}
|
{formatPrice(item.cost_price)}
|
||||||
|
|
@ -476,6 +586,13 @@ function StockRow({
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{showPricingColumns && editPricing && (
|
||||||
|
<PricingEditCells
|
||||||
|
item={item}
|
||||||
|
draft={draft}
|
||||||
|
onChange={onDraftChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<StockStatusBadge status={status} />
|
<StockStatusBadge status={status} />
|
||||||
</TableCell>
|
</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`.
|
* StockStatusBadge — coloured pill keyed by `StockStatus`.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue