fix(collection-confirm): purple card only when the AI found nothing off

- The collapsed PO card showed white instead of purple: it always carried
  `bg-card`, which beat `bg-ai/10` in the CSS (but lost to the green).
  Now the card gets exactly one background.
- Purple now means "the AI checked every line and no tick is red or
  amber" (grey "couldn't check" ticks are fine). A card the AI checked
  with a line off, or with unchecked lines, goes amber and says
  "AI checked N of M lines · K need a look". Green still means "photo,
  not checked yet".
- cardAiState() in collection-ai-ticks.ts works this out from the same
  computeLineTicks the table uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-19 13:20:36 +02:00
parent d73fb6f0ab
commit a6455376e4
4 changed files with 92 additions and 19 deletions

View file

@ -659,7 +659,18 @@ describe("PoCard driver-photo marker", () => {
describe("PoCard AI-checked marker", () => {
const photoRow = { ...baseRows[0], order_item_attachements: ["dn_abc.jpg"] };
it("turns a photographed card purple and says how many lines the AI checked", () => {
it("turns the card purple when the AI checked every line and all its ticks agree", () => {
render(<PoCard {...defaultProps} rows={[{ ...photoRow, ai_check: makeCheck() }]} />);
const card = screen.getByTestId("po-card-PO-1");
expect(card).toHaveAttribute("data-ai-state", "verified");
expect(card.className).toMatch(/bg-ai\//);
// The white card background must not sit under the tint (it won the clash).
expect(card.className).not.toMatch(/\bbg-card\b/);
expect(card.className).not.toMatch(/emerald-50/);
expect(screen.getByTestId("po-card-ai-checked-PO-1")).toHaveTextContent("AI checked all 1 line");
});
it("goes amber, not purple, when a checked card has a line that needs a look", () => {
render(
<PoCard
{...defaultProps}
@ -667,10 +678,10 @@ describe("PoCard AI-checked marker", () => {
/>,
);
const card = screen.getByTestId("po-card-PO-1");
expect(card).toHaveAttribute("data-ai-checked", "true");
expect(card.className).toMatch(/bg-ai\//);
expect(card.className).not.toMatch(/emerald-50/);
expect(screen.getByTestId("po-card-ai-checked-PO-1")).toHaveTextContent("AI checked 1 of 2 lines");
expect(card).toHaveAttribute("data-ai-state", "issues");
expect(card.className).toMatch(/amber-50/);
expect(card.className).not.toMatch(/bg-ai\//);
expect(screen.getByTestId("po-card-ai-checked-PO-1")).toHaveTextContent("AI checked 1 of 2 lines · 1 needs a look");
});
it("stays green when the AI could not pair the document", () => {
@ -678,7 +689,7 @@ describe("PoCard AI-checked marker", () => {
<PoCard {...defaultProps} rows={[{ ...photoRow, ai_check: makeCheck({ pair_failed: true }) }]} />,
);
const card = screen.getByTestId("po-card-PO-1");
expect(card).toHaveAttribute("data-ai-checked", "false");
expect(card).toHaveAttribute("data-ai-state", "unchecked");
expect(card.className).toMatch(/emerald/);
expect(screen.queryByTestId("po-card-ai-checked-PO-1")).not.toBeInTheDocument();
});
@ -694,6 +705,6 @@ describe("PoCard AI-checked marker", () => {
render(<PoCard {...defaultProps} rows={[{ ...photoRow, ai_check: makeCheck() }]} />);
await user.click(screen.getByTestId("po-card-toggle-PO-1"));
expect(screen.getByTestId("po-card-PO-1").className).not.toMatch(/bg-ai\//);
expect(screen.getByTestId("po-card-ai-checked-PO-1")).toHaveTextContent("AI checked 1 of 1 line");
expect(screen.getByTestId("po-card-ai-checked-PO-1")).toHaveTextContent("AI checked all 1 line");
});
});

View file

@ -19,6 +19,7 @@ import {
documentChipLabel,
} from "@/lib/collection-ai-card";
import type { CollectionDocLine } from "@/lib/types";
import { cardAiState } from "@/lib/collection-ai-ticks";
import { AiFindings, DocumentLinesPanel } from "./ai-findings";
import { InvoiceViewer } from "./invoice-viewer";
import { ItemsTable } from "./items-table";
@ -298,10 +299,8 @@ export function PoCard({
* AI purple, so the operator can tell "driver took a photo" apart
* from "the agent has read it and checked the lines".
*/
const aiCheckedCount = useMemo(
() => rows.filter((r) => r.ai_check && !r.ai_check.pair_failed).length,
[rows],
);
const ai = useMemo(() => cardAiState(rows), [rows]);
const aiCheckedCount = ai.checked;
/**
* When the goods were collected. Customer rows carry an
@ -449,26 +448,33 @@ export function PoCard({
return (
<section
className={`rounded-xl border bg-card p-4 shadow-sm ${
className={`rounded-xl border p-4 shadow-sm ${
// Green wash only while collapsed. The operator scans the
// queue as a closed list looking for POs the driver
// photographed; once a card is open they're reading the lines
// and the tint is just noise behind the table.
// Purple wins over green: once the AI has checked lines, that's
// the more useful thing to know about the card.
// Purple = the AI checked every line and nothing is off; amber =
// it checked, but a line needs a look; green = photo, not checked.
// Only one background class at a time: `bg-card` next to a tint
// won the CSS clash and showed the card white.
isOpen
? ""
: aiCheckedCount > 0
? "border-ai/40 bg-ai/10"
: photoCount > 0
? "border-emerald-300 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-950/30"
: ""
? "bg-card"
: ai.state === "verified"
? "border-ai/50 bg-ai/15"
: ai.state === "issues"
? "border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30"
: photoCount > 0
? "border-emerald-300 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-950/30"
: "bg-card"
}`}
data-testid={`po-card-${poNumber}`}
data-open={isOpen}
data-has-photos={photoCount > 0 ? "true" : "false"}
data-photo-count={photoCount}
data-ai-checked={aiCheckedCount > 0 ? "true" : "false"}
data-ai-state={ai.state}
>
{/*
The gear is a SIBLING of the expand/collapse button, never a
@ -557,7 +563,9 @@ export function PoCard({
title="Lines the AI checked against the driver's photo"
>
<Bot className="h-3 w-3" aria-hidden="true" />
AI checked {aiCheckedCount} of {rows.length} line{rows.length === 1 ? "" : "s"}
{ai.state === "verified"
? `AI checked all ${rows.length} line${rows.length === 1 ? "" : "s"}`
: `AI checked ${aiCheckedCount} of ${rows.length} line${rows.length === 1 ? "" : "s"} · ${ai.needLook} need${ai.needLook === 1 ? "s" : ""} a look`}
</span>
</>
) : null}

View file

@ -4,6 +4,7 @@ import {
docUnitCost,
docPriceLabel,
roundCents,
cardAiState,
allGreen,
showsAiFilledSku,
normaliseCode,
@ -202,3 +203,24 @@ describe("roundCents", () => {
expect(roundCents(5.7)).toBe(5.7);
});
});
describe("cardAiState", () => {
const good = { ...row(), ai_check: check() };
it("is verified only when every line was checked and no tick is red or amber", () => {
expect(cardAiState([good, good])).toEqual({ state: "verified", checked: 2, needLook: 0 });
// A grey price (delivery note) is fine: nothing the AI could check is off.
expect(cardAiState([{ ...good, ai_check: check({ doc_type: "delivery_note" }) }]).state).toBe("verified");
});
it("needs a look when a line is off or wasn't checked", () => {
expect(cardAiState([good, { ...row({ cost_price: 99 }), ai_check: check() }])).toEqual({
state: "issues",
checked: 2,
needLook: 1,
});
expect(cardAiState([good, { ...row(), ai_check: null }])).toEqual({ state: "issues", checked: 1, needLook: 1 });
});
it("is unchecked when no line has a usable check", () => {
expect(cardAiState([{ ...row(), ai_check: null }]).state).toBe("unchecked");
expect(cardAiState([{ ...row(), ai_check: check({ pair_failed: true, doc_line: null }) }]).state).toBe("unchecked");
});
});

View file

@ -116,3 +116,35 @@ export function showsAiFilledSku(
): boolean {
return !!check && check.sku_filled_value !== "" && effectiveSku(row) === check.sku_filled_value;
}
export interface CardAiState {
/** verified: every line checked, no red/amber tick. issues: checked, but not all clean. */
state: "verified" | "issues" | "unchecked";
/** Lines with a usable AI check (pairing didn't fail). */
checked: number;
/** Lines that are off (a red/amber tick) or weren't checked. */
needLook: number;
}
/** A line passes when nothing the AI could check is off: no red/amber, at least one green. */
function linePasses(t: LineTicks): boolean {
const all = [t.sku, t.qty, t.desc, t.price];
return all.includes("green") && !all.includes("red") && !all.includes("amber");
}
/** How the AI's checks on a PO card add up — drives the collapsed card's colour. */
export function cardAiState(rows: Array<TickRow & { ai_check?: CollectionAiCheck | null }>): CardAiState {
let checked = 0;
let needLook = 0;
for (const r of rows) {
const c = r.ai_check;
if (!c || c.pair_failed) {
needLook += 1;
continue;
}
checked += 1;
if (!linePasses(computeLineTicks(r, c))) needLook += 1;
}
if (checked === 0) return { state: "unchecked", checked, needLook: 0 };
return { state: needLook === 0 ? "verified" : "issues", checked, needLook };
}