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:
parent
d73fb6f0ab
commit
a6455376e4
4 changed files with 92 additions and 19 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue