diff --git a/src/app/(app)/stock-orders/[id]/page.test.tsx b/src/app/(app)/stock-orders/[id]/page.test.tsx index 63aeb1b..ce8c10d 100644 --- a/src/app/(app)/stock-orders/[id]/page.test.tsx +++ b/src/app/(app)/stock-orders/[id]/page.test.tsx @@ -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(" — Cancel", () => { ); }); }); + +describe(" — 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"; + } + }); +}); diff --git a/src/app/(app)/stock-orders/[id]/page.tsx b/src/app/(app)/stock-orders/[id]/page.tsx index 58cbe51..54834a8 100644 --- a/src/app/(app)/stock-orders/[id]/page.tsx +++ b/src/app/(app)/stock-orders/[id]/page.tsx @@ -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>({}); + // 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 (
@@ -888,7 +960,81 @@ export default function StockOrderDetailPage({ {ordered} - {formatCurrency(Number(line.cost_price ?? 0))} + {costEdits[line.id] ? ( +
+ + 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 ? ( + + {costEdits[line.id].error} + + ) : ( + + Enter to save, Esc to cancel + + )} +
+ ) : ( +
+ + {formatCurrency(Number(line.cost_price ?? 0))} + + {purchase.status === "sent" ? ( + + ) : null} +
+ )}
{already}