feat(collection-confirm): add ItemsTable with inline cost edit
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
afa3876316
commit
4310e4b211
2 changed files with 333 additions and 0 deletions
183
src/components/collection-confirm/items-table.test.tsx
Normal file
183
src/components/collection-confirm/items-table.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
150
src/components/collection-confirm/items-table.tsx
Normal file
150
src/components/collection-confirm/items-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue