feat(collection-confirm): add ItemsTable with inline cost edit

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 01:19:14 +02:00
parent afa3876316
commit 4310e4b211
2 changed files with 333 additions and 0 deletions

View file

@ -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(<ItemsTable rows={baseRows} onCostChange={vi.fn()} />);
expect(screen.getByText("R45.00")).toBeInTheDocument();
expect(screen.getByText("R12.00")).toBeInTheDocument();
});
it("renders the description, ordered qty, and collected qty columns", () => {
render(<ItemsTable rows={baseRows} onCostChange={vi.fn()} />);
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(<ItemsTable rows={[stockRow, ...baseRows]} onCostChange={vi.fn()} />);
// 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(<ItemsTable rows={baseRows} onCostChange={vi.fn()} />);
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(<ItemsTable rows={baseRows} onCostChange={onCostChange} />);
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(<ItemsTable rows={baseRows} onCostChange={onCostChange} />);
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(<ItemsTable rows={baseRows} onCostChange={onCostChange} />);
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(<ItemsTable rows={baseRows} onCostChange={onCostChange} />);
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(<ItemsTable rows={baseRows} onCostChange={onCostChange} />);
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(<ItemsTable rows={baseRows} onCostChange={vi.fn()} />);
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();
});
});

View file

@ -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 `<Input>` 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<EditState | null>(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 (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-2 font-medium">SKU</th>
<th className="py-2 font-medium">Description</th>
<th className="py-2 text-right font-medium">Ord</th>
<th className="py-2 text-right font-medium">Coll</th>
<th className="py-2 text-right font-medium">Cost</th>
<th className="py-2 font-medium">Surplus</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={row.id} className="border-b">
<td className="py-2">{row.sku ?? "—"}</td>
<td className="py-2">{row.description ?? row.po_description ?? "—"}</td>
<td className="py-2 text-right">{ordered}</td>
<td className="py-2 text-right">{collected}</td>
<td className="py-2 text-right">
{isEditing && edit ? (
<div className="flex flex-col items-end gap-1">
<Input
type="number"
min="0"
step="0.01"
autoFocus
value={edit.value}
onValueChange={(value) => 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 ? (
<span
className="text-xs text-red-500"
data-testid={`cost-error-${row.id}`}
>
{edit.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>R{(row.cost_price ?? 0).toFixed(2)}</span>
<button
type="button"
onClick={() => startEdit(row)}
className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label={`Edit cost for ${row.sku ?? row.id}`}
data-testid={`cost-edit-${row.id}`}
>
<Pencil className="h-3 w-3" />
</button>
</div>
)}
</td>
<td className="py-2">{surplus > 0 ? `+${surplus}` : "—"}</td>
</tr>
);
})}
</tbody>
</table>
);
}