fix(collection-confirm): show AI ticks under the description; always fill the SKU

- The AI and POS columns sat at the far right of the items table, hidden
  behind the document preview in the split view. Ticks (now labelled SKU /
  Qty / Desc / Price) and the POS status render under each description,
  and the table is back to its original six columns and width.
- Owner's call: fill an empty SKU on a sure pairing even if the code may
  be misread; the rep edits it and a near-miss still helps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-17 16:45:34 +02:00
parent a4345c1f85
commit 4eb4a24b84
4 changed files with 75 additions and 62 deletions

View file

@ -28,21 +28,30 @@ describe("ItemsTable AI layer", () => {
expect(screen.queryByText(/add to pos/i)).not.toBeInTheDocument();
});
it("shows only the POS column (no AI column, no AI min-width) when no row has ai_check", () => {
it("keeps the original six columns and shows POS status under the description when no row has ai_check", () => {
const { container } = render(
<ItemsTable rows={[{ ...plain, in_pos: false }]} onCostChange={noop} onSkuChange={noop} onAddToPos={noop} />,
);
const headers = screen.getAllByRole("columnheader").map((h) => h.textContent);
expect(headers).toEqual(["SKU", "Description", "Ord", "Coll", "Cost", "Surplus", "POS"]);
expect(container.querySelector("table")?.className).not.toContain("min-w-[860px]");
expect(container.querySelectorAll("col")).toHaveLength(7);
expect(screen.getByText(/tap the pencil/i).closest("td")).toHaveAttribute("colspan", "7");
expect(headers).toEqual(["SKU", "Description", "Ord", "Coll", "Cost", "Surplus"]);
expect(container.querySelector("table")?.className).toContain("min-w-[640px]");
expect(container.querySelectorAll("col")).toHaveLength(6);
expect(screen.getByText(/tap the pencil/i).closest("td")).toHaveAttribute("colspan", "6");
const extra = screen.getByTestId(`ai-pos-row-${plain.id}`);
expect(extra.closest("td")?.textContent).toContain(plain.description ?? "");
expect(extra.textContent).toContain("Add to POS");
expect(container.querySelector('[data-testid^="ai-ticks-"]')).toBeNull();
});
it("adds AI and POS headers when a row has ai_check", () => {
render(<ItemsTable rows={[makeAiRow()]} onCostChange={noop} onSkuChange={noop} />);
it("shows the ticks under the description (no extra columns) when a row has ai_check", () => {
const row = makeAiRow();
render(<ItemsTable rows={[row]} onCostChange={noop} onSkuChange={noop} />);
const headers = screen.getAllByRole("columnheader").map((h) => h.textContent);
expect(headers).toEqual(["SKU", "Description", "Ord", "Coll", "Cost", "Surplus", "AI", "POS"]);
expect(headers).toEqual(["SKU", "Description", "Ord", "Coll", "Cost", "Surplus"]);
const ticks = screen.getByTestId(`ai-ticks-${row.id}`);
expect(ticks.closest("td")?.textContent).toContain(row.description ?? "");
expect(ticks.textContent).toContain("SKU");
expect(ticks.textContent).toContain("Price");
});
it("renders four ticks with aria-labels from computeLineTicks and ours-vs-document titles", () => {

View file

@ -73,9 +73,12 @@ function Tick({ name, state, detail }: { name: string; state: TickState; detail:
title={detail}
data-tick={name.toLowerCase()}
data-state={state}
className={`inline-flex ${TICK_CLASS[state]}`}
className={`inline-flex items-center gap-0.5 ${TICK_CLASS[state]}`}
>
<TickIcon state={state} />
<span aria-hidden className="text-[11px] font-medium">
{name}
</span>
</span>
);
}
@ -90,7 +93,7 @@ function AiTicks({ row }: { row: ConfirmRow }) {
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}`}>
<div className="flex items-center gap-2" data-testid={`ai-ticks-${row.id}`}>
<Tick
name="SKU"
state={t.sku}
@ -115,6 +118,38 @@ function AiTicks({ row }: { row: ConfirmRow }) {
);
}
/** "In POS" (linked) or an "Add to POS" button; nothing when POS status is unknown. */
function PosStatus({ row, onAddToPos }: { row: ConfirmRow; onAddToPos?: (rowId: string) => void }) {
if (row.in_pos) {
return 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>
);
}
if (row.in_pos === false && onAddToPos) {
const hasSku = !!effectiveSku(row).trim();
return (
<button
type="button"
onClick={() => onAddToPos(row.id)}
disabled={!hasSku}
title={hasSku ? undefined : "Set a SKU first"}
className="rounded border px-1.5 py-0.5 text-xs font-medium hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
data-testid={`add-to-pos-${row.id}`}
>
Add to POS
</button>
);
}
return null;
}
/** Stock rows show their own line SKU when set; everything else as before. */
function shownSku(row: ConfirmRow): string | undefined {
return row.line_sku || row.sku;
@ -181,7 +216,7 @@ export function ItemsTable({
}: Props) {
const aiColumn = showAi ?? rows.some((r) => !!r.ai_check);
const posColumn = showAi ?? rows.some((r) => typeof r.in_pos === "boolean");
const columnCount = 6 + (aiColumn ? 1 : 0) + (posColumn ? 1 : 0);
const columnCount = 6;
const [edit, setEdit] = useState<EditState | null>(null);
const startCostEdit = (row: ConfirmRow) => {
@ -270,11 +305,7 @@ export function ItemsTable({
};
return (
<table
className={`w-full table-fixed text-sm ${
aiColumn ? "min-w-[860px]" : posColumn ? "min-w-[750px]" : "min-w-[640px]"
}`}
>
<table className="w-full min-w-[640px] table-fixed text-sm">
{/*
Explicit column widths. `table-fixed` honours these, so a cell
that goes into edit mode can no longer steal width from its
@ -295,8 +326,6 @@ export function ItemsTable({
<col className="w-[5rem]" />
<col className="w-[8rem]" />
<col className="w-[5rem]" />
{aiColumn ? <col className="w-[7rem]" /> : null}
{posColumn ? <col className="w-[7rem]" /> : null}
</colgroup>
<thead>
<tr className="border-b text-left">
@ -306,8 +335,6 @@ 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>
{aiColumn ? <th className="px-3 py-2 font-medium">AI</th> : null}
{posColumn ? <th className="px-3 py-2 font-medium">POS</th> : null}
</tr>
</thead>
<tbody>
@ -378,6 +405,18 @@ export function ItemsTable({
<span className="line-clamp-2">
{row.description ?? row.po_description ?? "—"}
</span>
{/* AI ticks and POS status sit under the description, not in
extra columns: the table shares its pane with the document
preview, and trailing columns ended up hidden behind it. */}
{(aiColumn && row.ai_check) || (posColumn && typeof row.in_pos === "boolean") ? (
<div
className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs"
data-testid={`ai-pos-row-${row.id}`}
>
{aiColumn && row.ai_check ? <AiTicks row={row} /> : null}
{posColumn ? <PosStatus row={row} onAddToPos={onAddToPos} /> : null}
</div>
) : null}
</td>
<td className="px-3 py-2 text-right align-top tabular-nums">{ordered}</td>
<td className="border-l border-border/60 px-3 py-2 text-right align-top tabular-nums">
@ -498,38 +537,6 @@ export function ItemsTable({
<td className="px-3 py-2 text-right align-top tabular-nums">
{surplus > 0 ? `+${surplus}` : "—"}
</td>
{aiColumn ? (
<td className="px-3 py-2 align-top">
<AiTicks row={row} />
</td>
) : null}
{posColumn ? (
<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)}
disabled={!effectiveSku(row).trim()}
title={effectiveSku(row).trim() ? undefined : "Set a SKU first"}
className="rounded border px-1.5 py-0.5 text-xs font-medium hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
data-testid={`add-to-pos-${row.id}`}
>
Add to POS
</button>
) : null}
</td>
) : null}
</tr>
);
})}

View file

@ -728,7 +728,7 @@ describe("checkCollectionCard", () => {
expect(r.skus_filled).toBe(1);
});
it("does not fill a SKU the AI couldn't read with certainty, even on a 'sure' pairing", async () => {
it("still fills a SKU the AI wasn't certain it read (the rep edits it if wrong)", async () => {
seedNss();
const { deps } = makeDeps({
readDocument: vi.fn(async () =>
@ -740,11 +740,9 @@ describe("checkCollectionCard", () => {
const r = await checkCollectionCard(PO_CEM, { deps });
expect(skuCalls).toEqual([]);
expect(table("order_items").get("l1")?.sku).toBe("");
expect(aiCheck("order_items", "l1")?.sku_filled_value).toBe("");
expect(aiCheck("order_items", "l1")?.doc_line?.code).toBe("0515315");
expect(r.skus_filled).toBe(0);
expect(table("order_items").get("l1")?.sku).toBe("0515315");
expect(aiCheck("order_items", "l1")?.sku_filled_value).toBe("0515315");
expect(r.skus_filled).toBe(1);
});
it("never changes an existing SKU, even on a 'sure' pairing", async () => {

View file

@ -828,10 +828,9 @@ async function checkCard(
let skuFilled =
previous?.sku_filled_value && str(fresh.sku) === previous.sku_filled_value ? previous.sku_filled_value : "";
const code = chosen.docLine?.code.trim() ?? "";
// Only a code the AI read character-for-character may become a SKU:
// a sure pairing with a misread digit would still be a wrong SKU.
const codeCertain = chosen.docLine?.code_certain === true;
if (!ownSku && code && codeCertain && code.length <= MAX_SKU_LENGTH && chosen.decision?.same_item === "sure") {
// Owner's call (2026-10-17): fill even when a digit may be misread on a
// blurry photo; the rep edits it, and a near-miss code still helps.
if (!ownSku && code && code.length <= MAX_SKU_LENGTH && chosen.decision?.same_item === "sure") {
// A failed fill must not cost the line its check.
try {
if (line.kind === "customer") {