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:
parent
d5ed390de4
commit
d167f81ad5
2 changed files with 246 additions and 2 deletions
|
|
@ -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";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue