feat(stock-orders): inline cost_price editing on lines table

Co-authored-by: Orca <help@stably.ai>
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
SJCoetz 2026-08-06 23:36:27 +02:00
parent d5ed390de4
commit d167f81ad5
2 changed files with 246 additions and 2 deletions

View file

@ -36,10 +36,14 @@ const state = vi.hoisted(() => ({
}>,
getOneCalls: [] as Array<{ collection: string; id: string }>,
usersFetched: false,
// Toggleable per-test so the "pencil hidden when received" test
// can flip the seeded purchase status without re-mocking.
purchaseStatus: "sent" as "sent" | "received" | "draft" | "cancelled",
reset() {
this.updateCalls.length = 0;
this.getOneCalls.length = 0;
this.usersFetched = false;
this.purchaseStatus = "sent";
},
}));
@ -62,7 +66,7 @@ vi.mock("@/lib/pocketbase", () => {
return {
id,
purchase_number: "PO26080601STK",
status: "sent",
status: state.purchaseStatus,
supplier: "sup-1",
notes: "",
email_payload: {},
@ -372,3 +376,97 @@ describe("<StockOrderDetailPage /> — Cancel", () => {
);
});
});
describe("<StockOrderDetailPage /> — inline cost edit", () => {
it("shows a pencil next to each cost when status is sent", async () => {
await renderPage();
// Seeded data has cost_price=2.5 for si-1; the page renders the
// formatted value next to a Pencil button.
const pencils = await screen.findAllByTestId(/^stock-order-cost-edit-/);
expect(pencils.length).toBeGreaterThanOrEqual(2);
});
it("clicking the pencil swaps the cell to a number input pre-filled with the current cost", async () => {
await renderPage();
const pencil = await screen.findByTestId("stock-order-cost-edit-si-1");
fireEvent.click(pencil);
const input = await screen.findByTestId("stock-order-cost-input-si-1");
expect((input as HTMLInputElement).value).toBe("2.5");
});
it("Enter saves via stock_purchase_items.update with the new cost_price", async () => {
await renderPage();
fireEvent.click(await screen.findByTestId("stock-order-cost-edit-si-1"));
const input = await screen.findByTestId(
"stock-order-cost-input-si-1",
) as HTMLInputElement;
fireEvent.change(input, { target: { value: "3.75" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() =>
expect(
state.updateCalls.some(
(c) =>
c.collection === "stock_purchase_items" &&
c.id === "si-1" &&
c.patch.cost_price === 3.75,
),
).toBe(true),
);
});
it("Escape cancels without writing", async () => {
await renderPage();
fireEvent.click(await screen.findByTestId("stock-order-cost-edit-si-1"));
const input = await screen.findByTestId(
"stock-order-cost-input-si-1",
) as HTMLInputElement;
fireEvent.change(input, { target: { value: "99" } });
fireEvent.keyDown(input, { key: "Escape" });
// Input disappears, no PB update was issued.
await waitFor(() =>
expect(
screen.queryByTestId("stock-order-cost-input-si-1"),
).not.toBeInTheDocument(),
);
expect(
state.updateCalls.some((c) => c.id === "si-1"),
).toBe(false);
});
it("rejects negative or non-numeric input with an inline error and no PB write", async () => {
await renderPage();
fireEvent.click(await screen.findByTestId("stock-order-cost-edit-si-1"));
const input = await screen.findByTestId(
"stock-order-cost-input-si-1",
) as HTMLInputElement;
// Clear the pre-filled value first, then enter empty — the page
// treats "" as <0 and shows the inline error without writing.
fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() =>
expect(
screen.getByTestId("stock-order-cost-error-si-1"),
).toBeInTheDocument(),
);
expect(
state.updateCalls.some(
(c) => c.collection === "stock_purchase_items" && c.id === "si-1",
),
).toBe(false);
});
it("does NOT show pencils once the purchase status moves to 'received'", async () => {
// Override the seeded purchase status for this one test. We
// don't reset state — the hoisted collection mock reads the
// current `state.purchaseStatus` so we can flip it in place.
state.purchaseStatus = "received";
try {
await renderPage();
expect(
screen.queryAllByTestId(/^stock-order-cost-edit-/),
).toHaveLength(0);
} finally {
state.purchaseStatus = "sent";
}
});
});

View file

@ -46,6 +46,7 @@ import {
Truck,
ChevronsUpDown,
Ban,
Pencil,
} from "lucide-react";
import { toast } from "sonner";
import getPocketBase from "@/lib/pocketbase";
@ -173,6 +174,18 @@ export default function StockOrderDetailPage({
const [sendMessageLoading, setSendMessageLoading] = useState(false);
// Per-line override (editable while status="sent"). Keyed by item id.
const [overrides, setOverrides] = useState<Record<string, string>>({});
// Per-line cost edit (also editable while status="sent"). When
// null, the line renders its existing cost_price + pencil; when
// set, the cell renders an input pre-filled with the current value
// and a Save callback writes to `stock_purchase_items.cost_price`.
// Distinct from `overrides` (which is for received-qty) so the two
// editors don't share state. Both share the same `sent`-only gate.
const [costEdits, setCostEdits] = useState<
Record<
string,
{ value: string; saving: boolean; error: string | null }
>
>({});
// Driver picker state. The picker is a small inline dropdown in the
// header — it lists all PB users (so the operator can pick any
// driver), and treats the user named "delivery" (case-insensitive)
@ -583,6 +596,65 @@ export default function StockOrderDetailPage({
}
};
/**
* Inline cost edit. Mirrors the pattern in
* `src/components/collection-confirm/items-table.tsx` so the same
* mental model works for the operator on both pages.
*
* Only callable while the purchase is in `sent` — once received,
* historical pricing is locked (audit trail).
*
* Writes to `stock_purchase_items.cost_price` via the client PB
* (the REST routes that mirror this are AGENT_API_KEY-gated, so
* the page's own PB client is the canonical write path).
*/
const saveCostEdit = async (lineId: string) => {
const draft = costEdits[lineId];
if (!draft) return;
const trimmed = draft.value.trim();
const parsed = Number(trimmed);
if (trimmed === "" || !Number.isFinite(parsed) || parsed < 0) {
setCostEdits((prev) => ({
...prev,
[lineId]: { ...prev[lineId], error: "Cost must be ≥ 0" },
}));
return;
}
setCostEdits((prev) => ({
...prev,
[lineId]: { value: trimmed, saving: true, error: null },
}));
try {
const pb = getPocketBase();
await pb.collection("stock_purchase_items").update(lineId, {
cost_price: parsed,
});
toast.success("Cost updated");
setCostEdits((prev) => {
const next = { ...prev };
delete next[lineId];
return next;
});
await fetchAll();
} catch (err: unknown) {
const msg =
err instanceof Error ? err.message : "Failed to save cost";
toast.error(msg);
setCostEdits((prev) => ({
...prev,
[lineId]: { ...prev[lineId], saving: false, error: msg },
}));
}
};
const cancelCostEdit = (lineId: string) => {
setCostEdits((prev) => {
const next = { ...prev };
delete next[lineId];
return next;
});
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
@ -888,7 +960,81 @@ export default function StockOrderDetailPage({
{ordered}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(Number(line.cost_price ?? 0))}
{costEdits[line.id] ? (
<div className="flex flex-col items-end gap-1">
<Input
type="number"
min="0"
step="0.01"
autoFocus
value={costEdits[line.id].value}
onChange={(e) =>
setCostEdits((prev) => ({
...prev,
[line.id]: {
value: e.target.value.replace(
/[^\d.]/g,
"",
),
saving: false,
error: null,
},
}))
}
onBlur={() => void saveCostEdit(line.id)}
onKeyDown={(e) => {
if (e.key === "Enter")
void saveCostEdit(line.id);
if (e.key === "Escape")
cancelCostEdit(line.id);
}}
className="h-8 w-24 px-2 py-1 text-right"
aria-label={`Edit cost for ${line.description}`}
data-testid={`stock-order-cost-input-${line.id}`}
aria-invalid={
costEdits[line.id].error ? true : undefined
}
/>
{costEdits[line.id].error ? (
<span
className="text-xs text-destructive"
data-testid={`stock-order-cost-error-${line.id}`}
>
{costEdits[line.id].error}
</span>
) : (
<span className="text-xs text-muted-foreground">
Enter to save, Esc to cancel
</span>
)}
</div>
) : (
<div className="flex items-center justify-end gap-1">
<span data-cost-value={String(line.cost_price ?? 0)}>
{formatCurrency(Number(line.cost_price ?? 0))}
</span>
{purchase.status === "sent" ? (
<button
type="button"
onClick={() =>
setCostEdits((prev) => ({
...prev,
[line.id]: {
value: String(line.cost_price ?? 0),
saving: false,
error: null,
},
}))
}
aria-label={`Edit cost for ${line.description}`}
data-testid={`stock-order-cost-edit-${line.id}`}
className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Pencil className="h-3 w-3" />
</button>
) : null}
</div>
)}
</TableCell>
<TableCell className="text-right tabular-nums">
{already}