feat(collection-confirm): add PoCard composing PhotoStrip+ItemsTable

Wraps a single PO group on the collection-confirm review page.
Renders the photo strip + items table + confirm button, with the
PO number and supplier name in the header. The cost edit/read
flow stays inside ItemsTable; the parent owns onCostChange
optimistic updates and the actual confirm mutation.

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

View file

@ -0,0 +1,109 @@
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 { PoCard } from "./po-card";
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 defaultProps = {
poNumber: "PO-1",
supplier: { id: "s-1", name: "Acme Steel" },
onCostChange: vi.fn(),
onConfirm: vi.fn(),
};
afterEach(() => {
vi.restoreAllMocks();
});
describe("PoCard", () => {
it("renders the PO number and supplier name in the header", () => {
render(<PoCard rows={baseRows} {...defaultProps} />);
expect(screen.getByTestId("po-card-title")).toHaveTextContent(
"PO PO-1 — Acme Steel",
);
});
it("renders the item count in the header (pluralised)", () => {
const { rerender } = render(<PoCard rows={baseRows} {...defaultProps} />);
expect(screen.getByText(/2 items/i)).toBeInTheDocument();
rerender(
<PoCard rows={[baseRows[0]]} {...defaultProps} />,
);
expect(screen.getByText(/1 item$/i)).toBeInTheDocument();
});
it("renders the photo strip empty state when no attachments are present", () => {
render(<PoCard rows={baseRows} {...defaultProps} />);
expect(screen.getByText(/No invoice photo attached/i)).toBeInTheDocument();
});
it("renders photo strip chips for attached invoices", () => {
const rowsWithPhoto: ConfirmRow[] = [
{ ...baseRows[0], order_item_attachements: ["invoice.pdf"] },
];
render(<PoCard rows={rowsWithPhoto} {...defaultProps} />);
expect(
screen.getByRole("button", { name: /Invoice 1 invoice\.pdf/i }),
).toBeInTheDocument();
});
it("renders the items table with R-formatted cost values", () => {
render(<PoCard rows={baseRows} {...defaultProps} />);
expect(screen.getByText("R45.00")).toBeInTheDocument();
expect(screen.getByText("R12.00")).toBeInTheDocument();
});
it("calls onConfirm when the Confirm button is clicked", async () => {
const user = userEvent.setup();
const onConfirm = vi.fn();
render(<PoCard rows={baseRows} {...defaultProps} onConfirm={onConfirm} />);
await user.click(screen.getByTestId("po-card-confirm-PO-1"));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it("forwards onCostChange when a row cost is edited and saved", async () => {
const user = userEvent.setup();
const onCostChange = vi.fn();
render(
<PoCard rows={baseRows} {...defaultProps} 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, "50");
await user.keyboard("{Enter}");
expect(onCostChange).toHaveBeenCalledWith("row-1", 50);
});
});

View file

@ -0,0 +1,66 @@
"use client";
import { Button } from "@/components/ui/button";
import type { ConfirmRow } from "@/lib/server/collection-confirm-service";
import { ItemsTable } from "./items-table";
import { PhotoStrip } from "./photo-strip";
type Props = {
rows: ConfirmRow[];
poNumber: string;
supplier: { id: string; name: string };
onCostChange: (id: string, cost: number) => void;
onConfirm: () => void;
};
/**
* Single PO group on the collection-confirm review page.
*
* Composes the photo strip + items table into one card with a confirm
* button at the bottom. The cost edit / read flow lives in `ItemsTable`;
* the parent owns the optimistic update + the actual confirm mutation.
*
* Layout:
* - Header: PO number + supplier name + item count
* - Photo strip (or empty state)
* - Items table
* - Confirm button (footer)
*/
export function PoCard({ rows, poNumber, supplier, onCostChange, onConfirm }: Props) {
const itemCount = rows.length;
return (
<section
className="rounded-xl border bg-card p-4 shadow-sm"
data-testid={`po-card-${poNumber}`}
>
<header className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold" data-testid="po-card-title">
PO {poNumber} — {supplier.name}
</h2>
<p className="text-sm text-muted-foreground">
{itemCount} item{itemCount === 1 ? "" : "s"}
</p>
</div>
</header>
<div className="mb-4">
<PhotoStrip rows={rows} />
</div>
<div className="mb-4">
<ItemsTable rows={rows} onCostChange={onCostChange} />
</div>
<footer className="flex justify-end">
<Button
type="button"
onClick={onConfirm}
data-testid={`po-card-confirm-${poNumber}`}
>
Confirm for invoicing
</Button>
</footer>
</section>
);
}