diff --git a/src/components/collection-confirm/items-table.test.tsx b/src/components/collection-confirm/items-table.test.tsx new file mode 100644 index 0000000..2465a62 --- /dev/null +++ b/src/components/collection-confirm/items-table.test.tsx @@ -0,0 +1,183 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { ConfirmRow } from "@/lib/server/collection-confirm-service"; +import { ItemsTable } from "./items-table"; + +const baseRows: ConfirmRow[] = [ + { + id: "row-1", + sku: "SK-001", + description: "Item 1", + order_qty: 10, + qty_collected: 10, + cost_price: 45, + surplus_qty: 0, + }, + { + id: "row-2", + sku: "SK-002", + description: "Item 2", + order_qty: 20, + qty_collected: 22, + cost_price: 12, + surplus_qty: 2, + }, +]; + +const stockRow: ConfirmRow = { + id: "row-3", + description: "Stock item without SKU", + order_qty: 5, + qty_collected: 5, + cost_price: 7.5, + source: "stock", +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("ItemsTable", () => { + it("renders the rows with cost in R-formatted text", () => { + render(); + + expect(screen.getByText("R45.00")).toBeInTheDocument(); + expect(screen.getByText("R12.00")).toBeInTheDocument(); + }); + + it("renders the description, ordered qty, and collected qty columns", () => { + render(); + + expect(screen.getByText("Item 1")).toBeInTheDocument(); + expect(screen.getByText("Item 2")).toBeInTheDocument(); + // row-1: ord=10, coll=10 — both columns render 10, so getAllByText. + expect(screen.getAllByText("10").length).toBeGreaterThanOrEqual(2); + expect(screen.getByText("20")).toBeInTheDocument(); + expect(screen.getByText("22")).toBeInTheDocument(); + }); + + it("shows a dash for missing SKU and a surplus marker when headroom exists", () => { + render(); + + // stock row has no SKU — the first cell should render a dash. + expect(screen.getByText("R7.50")).toBeInTheDocument(); + // row-2 has surplus_qty = 2 — the surplus cell should show +2. + expect(screen.getByText("+2")).toBeInTheDocument(); + }); + + it("swaps the cost cell to an input when the pencil is clicked", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("cost-edit-row-1")); + + const input = screen.getByTestId("cost-input-row-1"); + expect(input).toBeInTheDocument(); + expect(input).toHaveValue(45); + }); + + it("calls onCostChange on Enter with the new value", async () => { + const user = userEvent.setup(); + const onCostChange = vi.fn(); + render(); + + await user.click(screen.getByTestId("cost-edit-row-1")); + const input = screen.getByTestId("cost-input-row-1"); + + // Clear and type the new value. The Input wrapper uses + // onValueChange, so fire the change via user.type which simulates + // real keyboard input. + await user.clear(input); + await user.type(input, "50"); + await user.keyboard("{Enter}"); + + expect(onCostChange).toHaveBeenCalledWith("row-1", 50); + // Input has been swapped back to read-only. The optimistic + // update of the displayed value is the parent's responsibility + // — this component just surfaces the new number. + expect(screen.queryByTestId("cost-input-row-1")).not.toBeInTheDocument(); + }); + + it("calls onCostChange on blur with the new value", async () => { + const user = userEvent.setup(); + const onCostChange = vi.fn(); + render(); + + await user.click(screen.getByTestId("cost-edit-row-1")); + const input = screen.getByTestId("cost-input-row-1"); + + await user.clear(input); + await user.type(input, "33.5"); + // Blur by clicking on the body. + await user.click(document.body); + + expect(onCostChange).toHaveBeenCalledWith("row-1", 33.5); + }); + + it("reverts on Esc without calling onCostChange", async () => { + const user = userEvent.setup(); + const onCostChange = vi.fn(); + render(); + + await user.click(screen.getByTestId("cost-edit-row-1")); + const input = screen.getByTestId("cost-input-row-1"); + + await user.clear(input); + await user.type(input, "999"); + await user.keyboard("{Escape}"); + + expect(onCostChange).not.toHaveBeenCalled(); + expect(screen.queryByTestId("cost-input-row-1")).not.toBeInTheDocument(); + expect(screen.getByText("R45.00")).toBeInTheDocument(); + }); + + it("rejects negative input with an inline error and keeps the input open", async () => { + const user = userEvent.setup(); + const onCostChange = vi.fn(); + render(); + + await user.click(screen.getByTestId("cost-edit-row-1")); + const input = screen.getByTestId("cost-input-row-1"); + + await user.clear(input); + await user.type(input, "-5"); + await user.keyboard("{Enter}"); + + expect(onCostChange).not.toHaveBeenCalled(); + expect(screen.getByTestId("cost-input-row-1")).toBeInTheDocument(); + expect( + screen.getByTestId("cost-error-row-1"), + ).toHaveTextContent(/Must be a non-negative number/i); + }); + + it("rejects empty input with an inline error", async () => { + const user = userEvent.setup(); + const onCostChange = vi.fn(); + render(); + + await user.click(screen.getByTestId("cost-edit-row-1")); + const input = screen.getByTestId("cost-input-row-1"); + + await user.clear(input); + await user.keyboard("{Enter}"); + + expect(onCostChange).not.toHaveBeenCalled(); + expect(screen.getByTestId("cost-input-row-1")).toBeInTheDocument(); + expect(screen.getByTestId("cost-error-row-1")).toHaveTextContent(/Required/i); + }); + + it("only allows one row to be in edit mode at a time", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("cost-edit-row-1")); + expect(screen.getByTestId("cost-input-row-1")).toBeInTheDocument(); + + await user.click(screen.getByTestId("cost-edit-row-2")); + // row-1's input must be gone, row-2's input must be present. + expect(screen.queryByTestId("cost-input-row-1")).not.toBeInTheDocument(); + expect(screen.getByTestId("cost-input-row-2")).toBeInTheDocument(); + }); +}); diff --git a/src/components/collection-confirm/items-table.tsx b/src/components/collection-confirm/items-table.tsx new file mode 100644 index 0000000..48c95c9 --- /dev/null +++ b/src/components/collection-confirm/items-table.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { Pencil } from "lucide-react"; +import { useState } from "react"; + +import { Input } from "@/components/ui/input"; +import type { ConfirmRow } from "@/lib/server/collection-confirm-service"; + +type Props = { + rows: ConfirmRow[]; + onCostChange: (id: string, cost: number) => void; +}; + +type EditState = { + id: string; + value: string; + error: string | null; +}; + +/** + * Compact 5-column table for the collection-confirm review view. + * + * Each Cost cell renders read-only by default with a pencil icon; clicking + * the pencil swaps the cell to an inline `` that auto-focuses and + * selects the current value. Enter / blur save (via `onCostChange`), Esc + * reverts. Validation: the value must parse as a non-negative number — + * empty / negative / NaN inputs render an inline error and keep the input + * open. + * + * The parent owns the optimistic update + rollback; this component just + * surfaces a callback with the new number. The spinner state and toast + * notifications live with the parent so they can coordinate with the rest + * of the page's mutation lifecycle. + */ +export function ItemsTable({ rows, onCostChange }: Props) { + const [edit, setEdit] = useState(null); + + const startEdit = (row: ConfirmRow) => { + setEdit({ + id: row.id, + value: String(row.cost_price ?? 0), + error: null, + }); + }; + + const cancel = () => setEdit(null); + + const save = (rowId: string) => { + setEdit((current) => { + if (!current || current.id !== rowId) return current; + const trimmed = current.value.trim(); + if (trimmed === "") { + return { ...current, error: "Required" }; + } + const parsed = Number(trimmed); + if (Number.isNaN(parsed) || parsed < 0) { + return { ...current, error: "Must be a non-negative number" }; + } + onCostChange(rowId, parsed); + return null; + }); + }; + + const updateValue = (rowId: string, value: string) => { + setEdit((current) => { + if (!current || current.id !== rowId) return current; + return { ...current, value, error: null }; + }); + }; + + return ( + + + + + + + + + + + + + {rows.map((row) => { + const isEditing = edit?.id === row.id; + const ordered = row.order_qty ?? row.quantity ?? 0; + const collected = row.qty_collected ?? 0; + const surplus = Number(row.surplus_qty ?? 0); + return ( + + + + + + + + + ); + })} + +
SKUDescriptionOrdCollCostSurplus
{row.sku ?? "—"}{row.description ?? row.po_description ?? "—"}{ordered}{collected} + {isEditing && edit ? ( +
+ updateValue(row.id, value)} + onBlur={() => save(row.id)} + onKeyDown={(e) => { + if (e.key === "Enter") save(row.id); + if (e.key === "Escape") cancel(); + }} + className="h-8 w-24 px-2 py-1 text-right" + aria-label="Edit cost" + data-testid={`cost-input-${row.id}`} + aria-invalid={edit.error ? true : undefined} + /> + {edit.error ? ( + + {edit.error} + + ) : ( + + Enter to save, Esc to cancel + + )} +
+ ) : ( +
+ R{(row.cost_price ?? 0).toFixed(2)} + +
+ )} +
{surplus > 0 ? `+${surplus}` : "—"}
+ ); +}