diff --git a/src/components/collection-confirm/po-card.test.tsx b/src/components/collection-confirm/po-card.test.tsx
index d091465..c0bece7 100644
--- a/src/components/collection-confirm/po-card.test.tsx
+++ b/src/components/collection-confirm/po-card.test.tsx
@@ -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();
+ 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(
{
/>,
);
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", () => {
,
);
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();
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");
});
});
diff --git a/src/components/collection-confirm/po-card.tsx b/src/components/collection-confirm/po-card.tsx
index 2070b6d..6ad0f3e 100644
--- a/src/components/collection-confirm/po-card.tsx
+++ b/src/components/collection-confirm/po-card.tsx
@@ -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 (
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"
>
- 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`}
>
) : null}
diff --git a/src/lib/collection-ai-ticks.test.ts b/src/lib/collection-ai-ticks.test.ts
index f5a80a7..49d128d 100644
--- a/src/lib/collection-ai-ticks.test.ts
+++ b/src/lib/collection-ai-ticks.test.ts
@@ -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");
+ });
+});
diff --git a/src/lib/collection-ai-ticks.ts b/src/lib/collection-ai-ticks.ts
index 360cdc1..29585c1 100644
--- a/src/lib/collection-ai-ticks.ts
+++ b/src/lib/collection-ai-ticks.ts
@@ -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): 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 };
+}