feat(collection-ai): ticks, findings and POS buttons on Collection Confirm

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-17 09:41:52 +02:00
parent cc3e1d040b
commit c1656f5ccd
11 changed files with 849 additions and 7 deletions

View file

@ -10,7 +10,9 @@ import { ConfirmStockDialog } from "@/components/collection-confirm/confirm-stoc
import { InvoiceViewer } from "@/components/collection-confirm/invoice-viewer";
import { PoCard } from "@/components/collection-confirm/po-card";
import type { Attachment } from "@/components/collection-confirm/photo-strip";
import { aiInvoiceDefaults } from "@/lib/collection-ai-card";
import { getClientIdentity } from "@/lib/dev-identity";
import { getPocketBase } from "@/lib/pocketbase";
import type { ConfirmRow } from "@/lib/server/collection-confirm-service";
import {
@ -70,6 +72,8 @@ export default function CollectionConfirmPage() {
const [confirmState, setConfirmState] = useState<{
poNumber: string;
rowIds: string[];
/** Set by "Confirm and add to POS": customer rows to add once the confirm succeeds. */
addToPosRowIds?: string[];
} | null>(null);
// Stock-side confirm state. Mirrors `confirmState` but for the
// warehouse "Confirm collection" flow — opens the
@ -122,8 +126,10 @@ export default function CollectionConfirmPage() {
{ attachment: Attachment; kind: "popout" | "fullscreen" } | null
>(null);
const load = useCallback(async (): Promise<ConfirmRow[]> => {
setRows(null);
const load = useCallback(async (opts?: { silent?: boolean }): Promise<ConfirmRow[]> => {
// `silent` keeps the cards mounted (and their open/closed state) while the
// feed refreshes; used by the AI check and Add to POS, which stay on the card.
if (!opts?.silent) setRows(null);
try {
const response = await fetch("/api/v1/collection-confirm", {
headers: { "content-type": "application/json" },
@ -449,6 +455,83 @@ export default function CollectionConfirmPage() {
[rows, endpointFor],
);
/** Authorization header for the browser-session AI / POS routes. */
const authedJsonHeaders = useCallback((): Record<string, string> => {
const token = getPocketBase().authStore.token;
return {
"content-type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}, []);
/** POST the rows to the add-to-POS route and toast the outcome (no reload). */
const addRowsToPos = useCallback(
async (rowIds: string[]) => {
if (rowIds.length === 0) return;
try {
const response = await fetch("/api/v1/collection-confirm/add-to-pos", {
method: "POST",
headers: authedJsonHeaders(),
body: JSON.stringify({ row_ids: rowIds }),
});
const data = (await response.json().catch(() => ({}))) as {
error?: string;
results?: Array<{ status: string }>;
};
if (!response.ok) {
throw new Error(data.error || `Failed to add to the POS (HTTP ${response.status})`);
}
const created = (data.results ?? []).filter((r) => r.status === "created").length;
toast.success(`Added ${created} item${created === 1 ? "" : "s"} to the POS`);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
},
[authedJsonHeaders],
);
const handleAddToPos = useCallback(
async (rowIds: string[]) => {
await addRowsToPos(rowIds);
await load({ silent: true });
},
[addRowsToPos, load],
);
const handleCheckWithAi = useCallback(
async (poNumber: string) => {
try {
const response = await fetch("/api/v1/collection-confirm/ai/check", {
method: "POST",
headers: authedJsonHeaders(),
body: JSON.stringify({ po_number: poNumber }),
});
const data = (await response.json().catch(() => ({}))) as {
files_read?: number;
lines_checked?: number;
skus_filled?: number;
error?: string | null;
};
if (!response.ok) {
throw new Error(data.error || `AI check failed (HTTP ${response.status})`);
}
if (data.error) {
toast.warning(data.error);
} else {
const files = data.files_read ?? 0;
const lines = data.lines_checked ?? 0;
toast.success(
`AI read ${files} file${files === 1 ? "" : "s"} and checked ${lines} line${lines === 1 ? "" : "s"}`,
);
}
await load({ silent: true });
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
},
[authedJsonHeaders, load],
);
/**
* Confirm submit handler wired to <ConfirmInvoiceDialog>.
*
@ -485,8 +568,11 @@ export default function CollectionConfirmPage() {
// The response is `{ updatedIds }`; this page only needs success.
await response.json();
toast.success("Confirmed for invoicing");
const addToPosRowIds = confirmState.addToPosRowIds ?? [];
setConfirmState(null);
setCostOverrides({});
// Only after the confirm succeeded: add the card's not-in-POS lines.
await addRowsToPos(addToPosRowIds);
await load();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@ -495,7 +581,7 @@ export default function CollectionConfirmPage() {
setBusy(false);
}
},
[actor, confirmState, load],
[actor, confirmState, load, addRowsToPos],
);
/**
@ -880,6 +966,17 @@ export default function CollectionConfirmPage() {
onSkuChange={(id, sku) => void handleSkuChange(id, sku)}
onQtyChange={(id, qty) => void handleQtyChange(id, qty)}
onClearRows={(payload) => handleClearRows(group, payload)}
onAddToPos={(rowId) => void handleAddToPos([rowId])}
onCheckWithAi={() => handleCheckWithAi(group.poNumber)}
onConfirmAndAddToPos={() =>
setConfirmState({
poNumber: group.poNumber,
rowIds: group.rows.map((r) => r.id),
addToPosRowIds: group.rows
.filter((r) => r.source !== "stock" && !r.in_pos)
.map((r) => r.id),
})
}
onConfirm={() =>
setConfirmState({
poNumber: group.poNumber,
@ -922,6 +1019,13 @@ export default function CollectionConfirmPage() {
open={!!confirmState}
eyebrow={confirmState?.poNumber ?? ""}
rowCount={confirmState?.rowIds.length ?? 0}
defaultValues={
confirmState
? aiInvoiceDefaults(
(rows ?? []).filter((r) => confirmState.rowIds.includes(r.id)),
)
: undefined
}
costOverrides={scopedCostOverrides}
busy={busy}
onOpenChange={(open) => {

View file

@ -0,0 +1,17 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AiFindings } from "./ai-findings";
describe("AiFindings", () => {
it("renders nothing for an empty list", () => {
const { container } = render(<AiFindings findings={[]} />);
expect(container).toBeEmptyDOMElement();
});
it("renders each finding", () => {
render(<AiFindings findings={["Freight line not on PO", "Invoice total differs"]} />);
expect(screen.getByText("Freight line not on PO")).toBeInTheDocument();
expect(screen.getByText("Invoice total differs")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,76 @@
"use client";
import { Bot } from "lucide-react";
import type { CollectionDocLine } from "@/lib/types";
const zar = new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR" });
/** Box of document-level observations from the AI. Renders nothing when empty. */
export function AiFindings({ findings }: { findings: string[] }) {
if (findings.length === 0) return null;
return (
<div
className="mb-3 rounded-lg border border-ai/30 bg-ai/10 px-3 py-2"
data-testid="ai-findings"
>
<p className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-ai">
<Bot className="size-3.5" aria-hidden />
AI findings
</p>
<ul className="list-disc pl-5 text-xs">
{findings.map((finding, i) => (
<li key={`${i}-${finding}`}>{finding}</li>
))}
</ul>
</div>
);
}
/** The lines the AI extracted from the document, shown beside the photo. */
export function DocumentLinesPanel({
poNumber,
lines,
}: {
poNumber: string;
lines: CollectionDocLine[];
}) {
return (
<div
className="overflow-auto border-b bg-background p-2 text-xs"
data-testid={`ai-doc-lines-${poNumber}`}
>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-ai">
Lines read from the document
</p>
{lines.length === 0 ? (
<p className="text-muted-foreground">No lines were read.</p>
) : (
<table className="w-full text-left">
<thead>
<tr className="text-muted-foreground">
<th className="pr-2 font-medium">Code</th>
<th className="pr-2 font-medium">Description</th>
<th className="pr-2 text-right font-medium">Qty</th>
<th className="pr-2 text-right font-medium">Per unit</th>
<th className="font-medium">Note</th>
</tr>
</thead>
<tbody>
{lines.map((line) => (
<tr key={line.line_no} className="align-top">
<td className="pr-2 font-mono">{line.code || "—"}</td>
<td className="pr-2">{line.description}</td>
<td className="pr-2 text-right tabular-nums">{line.quantity ?? "—"}</td>
<td className="pr-2 text-right tabular-nums">
{line.unit_price == null ? "—" : zar.format(line.unit_price)}
</td>
<td className="text-muted-foreground">{line.price_note}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

View file

@ -0,0 +1,53 @@
import type { ConfirmRow } from "@/lib/server/collection-confirm-service";
import type { CollectionAiCheck } from "@/lib/types";
export function makeCheck(over: Partial<CollectionAiCheck> = {}): CollectionAiCheck {
return {
read_id: "read-1",
document_no: "5028256",
doc_date: "2026-10-16",
doc_type: "tax_invoice",
doc_line: {
line_no: 1,
code: "SK-001",
description: "Item one from supplier",
quantity: 10,
unit: "ea",
unit_price: 45,
line_total: 450,
to_follow: null,
price_note: "",
},
same_item: "sure",
reason: "",
sku_filled_value: "",
findings: [],
checked_at: "2026-10-17T08:00:00Z",
model: "test",
...over,
};
}
export function makeAiRow(over: Partial<ConfirmRow> = {}): ConfirmRow {
return {
id: "row-1",
sku: "SK-001",
description: "Item 1",
order_qty: 10,
qty_collected: 10,
cost_price: 45,
surplus_qty: 0,
ai_check: makeCheck(),
ai_doc: {
read_id: "read-1",
doc_type: "tax_invoice",
document_no: "5028256",
doc_date: "2026-10-16",
supplier_name: "Acme",
findings: [],
},
in_pos: false,
pos_stock_item_id: null,
...over,
};
}

View file

@ -208,3 +208,24 @@ describe("ConfirmInvoiceDialog", () => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
describe("ConfirmInvoiceDialog AI pre-fill", () => {
it("shows the document number and date passed as defaults", () => {
render(
<ConfirmInvoiceDialog
open
onOpenChange={vi.fn()}
onSubmit={vi.fn()}
defaultValues={{ number: "5028256", date: "2026-10-16" }}
/>,
);
expect(getNumberInput()).toHaveValue("5028256");
expect(getDateInput()).toHaveValue("2026-10-16");
});
it("falls back to a blank number and today's date without defaults", () => {
render(<ConfirmInvoiceDialog open onOpenChange={vi.fn()} onSubmit={vi.fn()} />);
expect(getNumberInput()).toHaveValue("");
expect(getDateInput()).toHaveValue(new Date().toISOString().slice(0, 10));
});
});

View file

@ -0,0 +1,111 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { ConfirmRow } from "@/lib/server/collection-confirm-service";
import { makeAiRow, makeCheck } from "./ai-fixtures";
import { ItemsTable } from "./items-table";
const plain: ConfirmRow = {
id: "row-p",
sku: "SK-9",
description: "Plain",
order_qty: 1,
qty_collected: 1,
cost_price: 1,
surplus_qty: 0,
};
const noop = vi.fn();
describe("ItemsTable AI layer", () => {
it("renders the original six headers and no AI/tick/robot UI without AI data", () => {
render(<ItemsTable rows={[plain]} onCostChange={noop} onSkuChange={noop} />);
const headers = screen.getAllByRole("columnheader").map((h) => h.textContent);
expect(headers).toEqual(["SKU", "Description", "Ord", "Coll", "Cost", "Surplus"]);
expect(screen.queryByLabelText(/^SKU: /)).not.toBeInTheDocument();
expect(screen.queryByTestId("ai-sku-robot-row-p")).not.toBeInTheDocument();
expect(screen.queryByText(/add to pos/i)).not.toBeInTheDocument();
});
it("adds AI and POS headers when a row has ai_check", () => {
render(<ItemsTable rows={[makeAiRow()]} onCostChange={noop} onSkuChange={noop} />);
const headers = screen.getAllByRole("columnheader").map((h) => h.textContent);
expect(headers).toEqual(["SKU", "Description", "Ord", "Coll", "Cost", "Surplus", "AI", "POS"]);
});
it("renders four ticks with aria-labels from computeLineTicks and ours-vs-document titles", () => {
const row = makeAiRow({
sku: "SK-001",
qty_collected: 9,
cost_price: 45,
ai_check: makeCheck({
same_item: "different",
doc_line: {
line_no: 1,
code: "SK-001",
description: "Totally different thing",
quantity: 10,
unit: "ea",
unit_price: null,
line_total: null,
to_follow: null,
price_note: "",
},
}),
});
render(<ItemsTable rows={[row]} onCostChange={noop} onSkuChange={noop} />);
expect(screen.getByLabelText("SKU: matches")).toBeInTheDocument();
expect(screen.getByLabelText("Qty: doesn't match")).toBeInTheDocument();
expect(screen.getByLabelText("Desc: doesn't match")).toBeInTheDocument();
expect(screen.getByLabelText("Price: can't check")).toBeInTheDocument();
expect(screen.getByLabelText("Qty: doesn't match")).toHaveAttribute(
"title",
expect.stringContaining("9"),
);
const desc = screen.getByLabelText("Desc: doesn't match").getAttribute("title") ?? "";
expect(desc).toContain("Item 1");
expect(desc).toContain("Totally different thing");
});
it("shows the amber label", () => {
const row = makeAiRow({ ai_check: makeCheck({ same_item: "likely" }) });
render(<ItemsTable rows={[row]} onCostChange={noop} onSkuChange={noop} />);
expect(screen.getByLabelText("Desc: have a look")).toBeInTheDocument();
});
it("shows the robot next to the SKU only while the SKU equals sku_filled_value", () => {
const filled = makeAiRow({ id: "a", sku: "SK-001", ai_check: makeCheck({ sku_filled_value: "SK-001" }) });
const human = makeAiRow({ id: "b", sku: "HUMAN", ai_check: makeCheck({ sku_filled_value: "SK-001" }) });
render(<ItemsTable rows={[filled, human]} onCostChange={noop} onSkuChange={noop} />);
expect(screen.getByTestId("ai-sku-robot-a")).toBeInTheDocument();
expect(screen.queryByTestId("ai-sku-robot-b")).not.toBeInTheDocument();
});
it("shows In POS with a stock link when in_pos", () => {
const row = makeAiRow({ in_pos: true, pos_stock_item_id: "stock-77" });
render(<ItemsTable rows={[row]} onCostChange={noop} onSkuChange={noop} onAddToPos={noop} />);
const link = screen.getByRole("link", { name: /in pos/i });
expect(link).toHaveAttribute("href", "/stock/stock-77");
expect(screen.queryByRole("button", { name: /add to pos/i })).not.toBeInTheDocument();
});
it("calls onAddToPos with the row id when not in POS", async () => {
const onAdd = vi.fn();
render(<ItemsTable rows={[makeAiRow()]} onCostChange={noop} onSkuChange={noop} onAddToPos={onAdd} />);
await userEvent.click(screen.getByRole("button", { name: /add to pos/i }));
expect(onAdd).toHaveBeenCalledWith("row-1");
});
it("renders no tick icons for a row without ai_check inside an AI table", () => {
render(
<ItemsTable
rows={[makeAiRow(), { ...plain, in_pos: false }]}
onCostChange={noop}
onSkuChange={noop}
/>,
);
const rowEl = screen.getByText("Plain").closest("tr")!;
expect(within(rowEl).queryByLabelText(/^SKU: /)).not.toBeInTheDocument();
});
});

View file

@ -1,9 +1,22 @@
"use client";
import { Pencil } from "lucide-react";
import Link from "next/link";
import {
AlertTriangle,
Bot,
CheckCircle2,
MinusCircle,
Pencil,
XCircle,
} from "lucide-react";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import {
computeLineTicks,
showsAiFilledSku,
type TickState,
} from "@/lib/collection-ai-ticks";
import type { ConfirmRow } from "@/lib/server/collection-confirm-service";
type Props = {
@ -18,8 +31,88 @@ type Props = {
* pencil can hide it.
*/
onQtyChange?: (id: string, qty: number) => void;
/** "Add to POS" click for a line that is not in the POS yet. */
onAddToPos?: (rowId: string) => void;
/**
* Force the AI + POS columns on or off. Defaults to on when some row
* carries an `ai_check` or a known `in_pos`, so a table with neither
* renders exactly as before.
*/
showAi?: boolean;
};
const TICK_LABEL: Record<TickState, string> = {
green: "matches",
amber: "have a look",
red: "doesn't match",
grey: "can't check",
};
const TICK_CLASS: Record<TickState, string> = {
green: "text-success",
amber: "text-warn",
red: "text-destructive",
grey: "text-muted-foreground",
};
function TickIcon({ state }: { state: TickState }) {
const cls = "size-4";
if (state === "green") return <CheckCircle2 className={cls} aria-hidden />;
if (state === "amber") return <AlertTriangle className={cls} aria-hidden />;
if (state === "red") return <XCircle className={cls} aria-hidden />;
return <MinusCircle className={cls} aria-hidden />;
}
function Tick({ name, state, detail }: { name: string; state: TickState; detail: string }) {
return (
<span
role="img"
aria-label={`${name}: ${TICK_LABEL[state]}`}
title={detail}
data-tick={name.toLowerCase()}
data-state={state}
className={`inline-flex ${TICK_CLASS[state]}`}
>
<TickIcon state={state} />
</span>
);
}
function AiTicks({ row }: { row: ConfirmRow }) {
const check = row.ai_check;
if (!check) return null;
const doc = check.doc_line;
const t = computeLineTicks(row, check);
const ordered = row.order_qty ? row.order_qty : (row.quantity ?? 0);
const priceOurs = row.cost_price == null ? "none" : `R${row.cost_price.toFixed(2)}`;
const priceDoc = doc?.unit_price == null ? "none" : `R${doc.unit_price.toFixed(2)}`;
const note = doc?.price_note ? ` (${doc.price_note})` : "";
return (
<div className="flex items-center gap-1" data-testid={`ai-ticks-${row.id}`}>
<Tick
name="SKU"
state={t.sku}
detail={`Ours: ${row.sku || "none"} / Document: ${doc?.code || "none"}`}
/>
<Tick
name="Qty"
state={t.qty}
detail={`Ours: ${row.qty_collected ?? 0} collected (${ordered} ordered) / Document: ${doc?.quantity ?? "none"}`}
/>
<Tick
name="Desc"
state={t.desc}
detail={`Ours: ${row.description ?? row.po_description ?? "none"} / Document: ${doc?.description || "none"}${check.reason ? ` — ${check.reason}` : ""}`}
/>
<Tick
name="Price"
state={t.price}
detail={`Ours: ${priceOurs} / Document: ${priceDoc}${note}`}
/>
</div>
);
}
type EditState = {
id: string;
value: string;
@ -76,7 +169,11 @@ export function ItemsTable({
onCostChange,
onSkuChange,
onQtyChange,
onAddToPos,
showAi,
}: Props) {
const aiColumns =
showAi ?? rows.some((r) => !!r.ai_check || typeof r.in_pos === "boolean");
const [edit, setEdit] = useState<EditState | null>(null);
const startCostEdit = (row: ConfirmRow) => {
@ -165,7 +262,9 @@ export function ItemsTable({
};
return (
<table className="w-full min-w-[640px] table-fixed text-sm">
<table
className={`w-full table-fixed text-sm ${aiColumns ? "min-w-[860px]" : "min-w-[640px]"}`}
>
{/*
Explicit column widths. `table-fixed` honours these, so a cell
that goes into edit mode can no longer steal width from its
@ -186,6 +285,8 @@ export function ItemsTable({
<col className="w-[5rem]" />
<col className="w-[8rem]" />
<col className="w-[5rem]" />
{aiColumns ? <col className="w-[7rem]" /> : null}
{aiColumns ? <col className="w-[7rem]" /> : null}
</colgroup>
<thead>
<tr className="border-b text-left">
@ -195,6 +296,8 @@ export function ItemsTable({
<th className="border-l border-border/60 px-3 py-2 text-right font-medium">Coll</th>
<th className="px-3 py-2 text-right font-medium">Cost</th>
<th className="px-3 py-2 text-right font-medium">Surplus</th>
{aiColumns ? <th className="px-3 py-2 font-medium">AI</th> : null}
{aiColumns ? <th className="px-3 py-2 font-medium">POS</th> : null}
</tr>
</thead>
<tbody>
@ -229,6 +332,15 @@ export function ItemsTable({
<span data-sku-value={row.sku ?? ""} className="min-w-0 flex-1 truncate">
{row.sku ?? "—"}
</span>
{showsAiFilledSku(row, row.ai_check) ? (
<span
data-testid={`ai-sku-robot-${row.id}`}
title="SKU filled in by the AI from the document"
className="inline-flex shrink-0 text-ai"
>
<Bot className="size-3.5" aria-label="Filled by AI" />
</span>
) : null}
<button
type="button"
onClick={() => startSkuEdit(row)}
@ -376,6 +488,36 @@ export function ItemsTable({
<td className="px-3 py-2 text-right align-top tabular-nums">
{surplus > 0 ? `+${surplus}` : "—"}
</td>
{aiColumns ? (
<td className="px-3 py-2 align-top">
<AiTicks row={row} />
</td>
) : null}
{aiColumns ? (
<td className="px-3 py-2 align-top text-xs">
{row.in_pos ? (
row.pos_stock_item_id ? (
<Link
href={`/stock/${row.pos_stock_item_id}`}
className="font-medium text-success underline-offset-2 hover:underline"
>
In POS
</Link>
) : (
<span className="font-medium text-success">In POS</span>
)
) : row.in_pos === false && onAddToPos ? (
<button
type="button"
onClick={() => onAddToPos(row.id)}
className="rounded border px-1.5 py-0.5 text-xs font-medium hover:bg-accent"
data-testid={`add-to-pos-${row.id}`}
>
Add to POS
</button>
) : null}
</td>
) : null}
</tr>
);
})}
@ -390,7 +532,7 @@ export function ItemsTable({
*/}
<tr>
<td
colSpan={6}
colSpan={aiColumns ? 8 : 6}
className="px-3 pb-1 pt-2 text-[11px] text-muted-foreground"
>
Tap the pencil to edit · Enter saves · Esc cancels

View file

@ -0,0 +1,107 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { ConfirmRow } from "@/lib/server/collection-confirm-service";
import { makeAiRow } from "./ai-fixtures";
import { PoCard } from "./po-card";
const props = {
poNumber: "PO-1",
supplier: { id: "s", name: "Acme" },
onCostChange: vi.fn(),
onSkuChange: vi.fn(),
onConfirm: vi.fn(),
initiallyOpen: true,
};
const plain: ConfirmRow = {
id: "p1",
sku: "X",
description: "Plain",
order_qty: 1,
qty_collected: 1,
cost_price: 1,
surplus_qty: 0,
};
describe("PoCard AI layer", () => {
it("shows no chip, findings, or add-to-POS confirm without AI data", () => {
render(<PoCard rows={[plain]} {...props} />);
expect(screen.queryByTestId("ai-doc-chip-PO-1")).not.toBeInTheDocument();
expect(screen.queryByTestId("ai-findings")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /check with ai/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /confirm and add to pos/i })).not.toBeInTheDocument();
});
it("renders the document chip and opens the extracted lines panel", async () => {
render(<PoCard rows={[makeAiRow()]} {...props} />);
const chip = screen.getByTestId("ai-doc-chip-PO-1");
expect(chip).toHaveTextContent("Tax invoice 5028256 · 16 Oct 2026 · read from driver photo");
await userEvent.click(chip);
const panel = screen.getByTestId("ai-doc-lines-PO-1");
expect(panel).toHaveTextContent("SK-001");
expect(panel).toHaveTextContent("Item one from supplier");
});
it("labels a delivery note", () => {
const row = makeAiRow({
ai_doc: {
read_id: "r",
doc_type: "delivery_note",
document_no: "DN1",
doc_date: "2026-10-16",
supplier_name: "S",
findings: [],
},
});
render(<PoCard rows={[row]} {...props} />);
expect(screen.getByTestId("ai-doc-chip-PO-1")).toHaveTextContent(/^Delivery note DN1/);
});
it("shows the findings box with the union of findings", () => {
const row = makeAiRow({
ai_doc: {
read_id: "r",
doc_type: "tax_invoice",
document_no: "1",
doc_date: "2026-10-16",
supplier_name: "S",
findings: ["Freight not on PO"],
},
});
render(<PoCard rows={[row]} {...props} />);
expect(screen.getByTestId("ai-findings")).toHaveTextContent("Freight not on PO");
});
it("Check with AI calls the handler and shows a spinner while pending", async () => {
let resolve!: () => void;
const onCheck = vi.fn(() => new Promise<void>((r) => (resolve = r)));
render(<PoCard rows={[plain]} {...props} onCheckWithAi={onCheck} />);
const btn = screen.getByRole("button", { name: /check with ai/i });
await userEvent.click(btn);
expect(onCheck).toHaveBeenCalledTimes(1);
expect(btn).toBeDisabled();
expect(screen.getByTestId("ai-check-spinner")).toBeInTheDocument();
resolve();
await waitFor(() => expect(btn).not.toBeDisabled());
});
it("renders Confirm and add to POS only when a customer row is not in POS", async () => {
const onGo = vi.fn();
const { rerender } = render(
<PoCard rows={[makeAiRow({ in_pos: false })]} {...props} onConfirmAndAddToPos={onGo} />,
);
await userEvent.click(screen.getByRole("button", { name: /confirm and add to pos/i }));
expect(onGo).toHaveBeenCalledTimes(1);
rerender(
<PoCard
rows={[makeAiRow({ in_pos: true, pos_stock_item_id: "s1" })]}
{...props}
onConfirmAndAddToPos={onGo}
/>,
);
expect(screen.queryByRole("button", { name: /confirm and add to pos/i })).not.toBeInTheDocument();
});
});

View file

@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Camera, Check, ChevronDown, FileText, FileUp, Loader2, Maximize2, PictureInPicture2 } from "lucide-react";
import { Bot, Camera, Check, ChevronDown, FileText, FileUp, Loader2, Maximize2, PictureInPicture2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Splitter } from "@/components/ui/splitter";
@ -13,6 +13,13 @@ import {
earliestCollectedAt,
formatCollectedDate,
} from "@/app/(app)/collection-confirm/view-model";
import {
cardDocument,
cardFindings,
documentChipLabel,
} from "@/lib/collection-ai-card";
import type { CollectionDocLine } from "@/lib/types";
import { AiFindings, DocumentLinesPanel } from "./ai-findings";
import { InvoiceViewer } from "./invoice-viewer";
import { ItemsTable } from "./items-table";
import { ClearPanel, type ClearPayload } from "./clear-panel";
@ -126,6 +133,12 @@ type Props = {
onFullscreen?: (attachment: Attachment) => void;
/** Buyer-side invoice upload for counter collections or late email invoices. */
onInvoiceUpload?: (file: File) => Promise<void>;
/** Per-row "Add to POS" click, passed through to the items table. */
onAddToPos?: (rowId: string) => void;
/** Runs the AI check for this card; the button shows a spinner until it settles. */
onCheckWithAi?: () => Promise<void>;
/** Confirm for invoicing, then add every not-in-POS customer line to the POS. */
onConfirmAndAddToPos?: () => void;
};
/**
@ -181,8 +194,30 @@ export function PoCard({
onPopOut,
onFullscreen,
onInvoiceUpload,
onAddToPos,
onCheckWithAi,
onConfirmAndAddToPos,
}: Props) {
const itemCount = rows.length;
const [checkingAi, setCheckingAi] = useState(false);
const [docPanelOpen, setDocPanelOpen] = useState(false);
const aiDoc = useMemo(() => cardDocument(rows), [rows]);
const aiFindings = useMemo(() => cardFindings(rows), [rows]);
// Lines the AI extracted from the chip's document (those paired with a row), by line number.
const aiDocLines = useMemo(() => {
const byNo = new Map<number, CollectionDocLine>();
for (const row of rows) {
const check = row.ai_check;
if (!aiDoc || !check?.doc_line || check.read_id !== aiDoc.read_id) continue;
if (!byNo.has(check.doc_line.line_no)) byNo.set(check.doc_line.line_no, check.doc_line);
}
return [...byNo.values()].sort((a, b) => a.line_no - b.line_no);
}, [rows, aiDoc]);
const hasUnpostedCustomerRows = rows.some((r) => r.source !== "stock" && !r.in_pos);
const aiDocPanel =
docPanelOpen && aiDoc ? (
<DocumentLinesPanel poNumber={poNumber} lines={aiDocLines} />
) : null;
const uploadInputRef = useRef<HTMLInputElement>(null);
// Ref to the split-view body container; the Splitter uses it to
// measure its own width when computing the cursor's ratio.
@ -527,6 +562,21 @@ export function PoCard({
data-testid={`po-card-body-${poNumber}`}
className="mt-3"
>
{aiDoc ? (
<div className="mb-3">
<button
type="button"
onClick={() => setDocPanelOpen((open) => !open)}
aria-expanded={docPanelOpen}
data-testid={`ai-doc-chip-${poNumber}`}
className="inline-flex items-center gap-1.5 rounded-md border border-ai/30 bg-ai/10 px-2 py-1 text-xs font-medium text-ai transition-colors hover:bg-ai/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Bot className="size-3.5" aria-hidden />
{documentChipLabel(aiDoc)}
</button>
</div>
) : null}
{attachments.length > 0 ? (
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-1.5">
@ -704,6 +754,7 @@ export function PoCard({
onCostChange={onCostChange}
onSkuChange={onSkuChange}
onQtyChange={onQtyChange}
onAddToPos={onAddToPos}
/>
</div>
</div>
@ -722,6 +773,7 @@ export function PoCard({
style={{ flexBasis: `${(1 - ratio) * 100}%`, flexGrow: 1, flexShrink: 1 }}
data-testid={`po-card-preview-pane-${poNumber}`}
>
{aiDocPanel}
{activeAttachment ? (
<div
className="h-full overflow-hidden bg-background"
@ -769,9 +821,12 @@ export function PoCard({
onCostChange={onCostChange}
onSkuChange={onSkuChange}
onQtyChange={onQtyChange}
onAddToPos={onAddToPos}
/>
</div>
{aiDocPanel ? <div className="mb-3 rounded-lg border">{aiDocPanel}</div> : null}
{activeAttachment ? (
<div
className={`mb-3 overflow-hidden rounded-lg border bg-background ${STACKED_PREVIEW_HEIGHT}`}
@ -790,11 +845,36 @@ export function PoCard({
</div>
)}
<AiFindings findings={aiFindings} />
<footer className="flex items-center justify-between gap-2">
<p className="hidden text-[11px] text-muted-foreground lg:block">
Drag the divider to give the invoice more room.
</p>
<div className="ml-auto flex gap-2">
{onCheckWithAi ? (
<Button
type="button"
variant="outline"
disabled={checkingAi}
onClick={async () => {
setCheckingAi(true);
try {
await onCheckWithAi();
} finally {
setCheckingAi(false);
}
}}
data-testid={`po-card-check-ai-${poNumber}`}
>
{checkingAi ? (
<Loader2 className="h-4 w-4 animate-spin" data-testid="ai-check-spinner" />
) : (
<Bot className="h-4 w-4 text-ai" />
)}
Check with AI
</Button>
) : null}
{hasStockRows && onConfirmStock ? (
<Button
type="button"
@ -813,6 +893,15 @@ export function PoCard({
Confirm for invoicing
</Button>
) : null}
{hasCustomerRows && hasUnpostedCustomerRows && onConfirmAndAddToPos ? (
<Button
type="button"
onClick={onConfirmAndAddToPos}
data-testid={`po-card-confirm-add-pos-${poNumber}`}
>
Confirm and add to POS
</Button>
) : null}
</div>
</footer>
</div>

View file

@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { makeAiRow, makeCheck } from "@/components/collection-confirm/ai-fixtures";
import { aiInvoiceDefaults, cardDocument, cardFindings } from "./collection-ai-card";
const doc = (read_id: string, findings: string[], doc_date = "2026-10-16") => ({
read_id,
doc_type: "tax_invoice" as const,
document_no: read_id,
doc_date,
supplier_name: "S",
findings,
});
describe("collection-ai-card", () => {
it("unions findings across distinct reads, deduped in order", () => {
const rows = [
makeAiRow({ id: "1", ai_doc: doc("r1", ["a", "b"]) }),
makeAiRow({ id: "2", ai_doc: doc("r1", ["a", "b"]) }),
makeAiRow({ id: "3", ai_doc: doc("r2", ["b", "c"]) }),
makeAiRow({ id: "4", ai_doc: null }),
];
expect(cardFindings(rows)).toEqual(["a", "b", "c"]);
});
it("picks the newest document for the chip", () => {
const rows = [
makeAiRow({ id: "1", ai_doc: doc("r1", [], "2026-10-10") }),
makeAiRow({ id: "2", ai_doc: doc("r2", [], "2026-10-16") }),
];
expect(cardDocument(rows)?.read_id).toBe("r2");
expect(cardDocument([makeAiRow({ ai_doc: null })])).toBeNull();
});
it("derives invoice defaults from the first row with a document number", () => {
const rows = [
makeAiRow({ id: "1", ai_check: null }),
makeAiRow({ id: "2", ai_check: makeCheck({ document_no: "5028256", doc_date: "2026-10-16" }) }),
];
expect(aiInvoiceDefaults(rows)).toEqual({ number: "5028256", date: "2026-10-16" });
});
it("returns undefined without any document number", () => {
expect(aiInvoiceDefaults([makeAiRow({ ai_check: null })])).toBeUndefined();
expect(aiInvoiceDefaults([makeAiRow({ ai_check: makeCheck({ document_no: "" }) })])).toBeUndefined();
});
it("omits an invalid date", () => {
const rows = [makeAiRow({ ai_check: makeCheck({ doc_date: "" }) })];
expect(aiInvoiceDefaults(rows)).toEqual({ number: "5028256" });
});
});

View file

@ -0,0 +1,70 @@
import type { ConfirmRow } from "@/lib/server/collection-confirm-service";
import type { CollectionDocType } from "@/lib/types";
type AiDoc = NonNullable<ConfirmRow["ai_doc"]>;
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** Union of document findings across a card's distinct reads, deduped, in order. */
export function cardFindings(rows: ConfirmRow[]): string[] {
const seenReads = new Set<string>();
const seen = new Set<string>();
const out: string[] = [];
for (const row of rows) {
const doc = row.ai_doc;
if (!doc || seenReads.has(doc.read_id)) continue;
seenReads.add(doc.read_id);
for (const finding of doc.findings ?? []) {
if (!finding || seen.has(finding)) continue;
seen.add(finding);
out.push(finding);
}
}
return out;
}
/** The card's newest document (first non-null wins on equal dates). */
export function cardDocument(rows: ConfirmRow[]): AiDoc | null {
let best: AiDoc | null = null;
for (const row of rows) {
const doc = row.ai_doc;
if (!doc) continue;
if (!best || (doc.doc_date ?? "") > (best.doc_date ?? "")) best = doc;
}
return best;
}
/** Pre-fill for the confirm dialog from the first row whose check has a document number. */
export function aiInvoiceDefaults(
rows: ConfirmRow[],
): { number: string; date?: string } | undefined {
for (const row of rows) {
const number = row.ai_check?.document_no?.trim();
if (!number) continue;
const date = row.ai_check?.doc_date ?? "";
return /^\d{4}-\d{2}-\d{2}$/.test(date) ? { number, date } : { number };
}
return undefined;
}
export function docTypeLabel(type: CollectionDocType): string {
if (type === "tax_invoice") return "Tax invoice";
if (type === "delivery_note") return "Delivery note";
return "Document";
}
/** "2026-10-16" -> "16 Oct 2026"; anything else is returned as is. */
export function formatDocDate(iso: string): string {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec((iso ?? "").trim());
if (!m) return iso ?? "";
const month = Number(m[2]) - 1;
if (month < 0 || month > 11) return iso;
return `${Number(m[3])} ${MONTHS[month]} ${m[1]}`;
}
export function documentChipLabel(doc: AiDoc): string {
const head = [docTypeLabel(doc.doc_type), doc.document_no].filter(Boolean).join(" ");
return [head, formatDocDate(doc.doc_date), "read from driver photo"]
.filter(Boolean)
.join(" · ");
}