feat(collection-confirm): AI notes button on each PO card

A robot button beside each PO opens the notes the AI made: card findings,
reading notes, and per line its reason, any discount it read (with the
arithmetic), price notes and a filled-in SKU. An amber dot shows when the
AI raised a finding or read a discount.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-30 13:56:16 +02:00
parent 25466d4e64
commit 0e9d85d536
3 changed files with 261 additions and 0 deletions

View file

@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import type { CollectionAiCheck } from "@/lib/types";
import { AiNotesButton, AiNotesPanel, hasAiNotes, lineNotes } from "./ai-notes";
function check(over: Partial<CollectionAiCheck> = {}, doc: Record<string, unknown> = {}): CollectionAiCheck {
return {
read_id: "r1",
document_no: "JIN614962",
doc_date: "2026-10-29",
doc_type: "tax_invoice",
doc_line: {
line_no: 1, code: "74/40-RED", description: "PADLOCK", quantity: 20,
unit_price: 146.47, net_unit_price: null, discount_percent: null, doc_discount_percent: null,
...doc,
} as CollectionAiCheck["doc_line"],
same_item: "sure",
reason: "Both describe an Abus 40mm padlock.",
sku_filled_value: "",
findings: [],
checked_at: "2026-10-29T12:25:25.496Z",
model: "m",
...over,
};
}
describe("lineNotes", () => {
it("quotes the AI's reason and states a read discount without changing anything", () => {
const [l] = lineNotes([{ id: "a", description: "Padlock", ai_check: check({}, { doc_discount_percent: 15, net_unit_price: 124.4995 }) }]);
expect(l.discount).toBe(true);
expect(l.notes[0]).toBe("Both describe an Abus 40mm padlock.");
expect(l.notes.join(" ")).toMatch(/Discount read from JIN614962: R146\.47 less 15% invoice discount = R124\.50/);
expect(l.notes.join(" ")).toMatch(/does not change our cost/);
});
it("lists findings, price notes, an AI-filled SKU and an old cost change", () => {
const [l] = lineNotes([
{
id: "a",
ai_check: check(
{ findings: ["Not found on JIN614962."], sku_filled_value: "74/40-RED", cost_changed: { from: 1, to: 2, note: "Cost changed from R1.00 to R2.00." } },
{ price_note: "collect 1 BOX" },
),
},
]);
expect(l.discount).toBe(false);
expect(l.notes).toEqual(expect.arrayContaining([
"Not found on JIN614962.",
"Price note on the document: collect 1 BOX",
"SKU filled in by the AI: 74/40-RED",
"Cost changed from R1.00 to R2.00.",
]));
});
it("skips unchecked lines", () => {
expect(lineNotes([{ id: "a", ai_check: null }])).toEqual([]);
expect(hasAiNotes([{ id: "a", ai_check: null }])).toBe(false);
expect(hasAiNotes([{ id: "a", ai_check: check() }])).toBe(true);
});
});
describe("AiNotesButton and AiNotesPanel", () => {
it("shows the attention dot only when asked and fires onToggle", () => {
let clicks = 0;
const { rerender } = render(<AiNotesButton poNumber="PO1" open={false} onToggle={() => clicks++} attention={false} />);
expect(screen.queryByTestId("ai-notes-attention-PO1")).toBeNull();
rerender(<AiNotesButton poNumber="PO1" open={false} onToggle={() => clicks++} attention />);
expect(screen.getByTestId("ai-notes-attention-PO1")).toBeTruthy();
fireEvent.click(screen.getByTestId("ai-notes-button-PO1"));
expect(clicks).toBe(1);
});
it("renders card findings, reading notes and each line's notes", () => {
render(
<AiNotesPanel
poNumber="PO1"
rows={[{ id: "a", description: "Padlock", ai_check: check({}, { doc_discount_percent: 15, net_unit_price: 124.4995 }) }]}
findings={["Invoice total differs."]}
notes={["Read with the photo turned 90°."]}
/>,
);
expect(screen.getByText("Invoice total differs.")).toBeTruthy();
expect(screen.getByText("Read with the photo turned 90°.")).toBeTruthy();
expect(screen.getByTestId("ai-notes-line-a").textContent).toMatch(/15% invoice discount/);
});
});

View file

@ -0,0 +1,156 @@
"use client";
import { Bot } from "lucide-react";
import { docPriceLabel } from "@/lib/collection-ai-ticks";
import type { CollectionAiCheck } from "@/lib/types";
type NoteRow = {
id: string;
description?: string;
po_description?: string;
ai_check?: CollectionAiCheck | null;
};
export interface LineNotes {
id: string;
description: string;
/** What the AI said about this line, in reading order. */
notes: string[];
/** The document shows a discount on this line (line or whole-invoice). */
discount: boolean;
}
function hasDiscount(check: CollectionAiCheck): boolean {
const d = check.doc_line;
return !!d && (d.net_unit_price != null || !!d.discount_percent || !!d.doc_discount_percent);
}
/** What the AI wrote about each checked line: its reason, findings, price and discount remarks. */
export function lineNotes(rows: NoteRow[]): LineNotes[] {
const out: LineNotes[] = [];
for (const row of rows) {
const check = row.ai_check;
if (!check) continue;
const notes: string[] = [];
const doc = check.doc_line;
if (check.reason) notes.push(check.reason);
notes.push(...check.findings);
if (doc && hasDiscount(check)) {
notes.push(
`Discount read from ${check.document_no || "the document"}: ${docPriceLabel(doc)}. The AI does not change our cost; the Price tick shows any difference.`,
);
}
if (doc?.price_note) notes.push(`Price note on the document: ${doc.price_note}`);
if (check.sku_filled_value) notes.push(`SKU filled in by the AI: ${check.sku_filled_value}`);
// Earlier versions changed costs; the change stays on record.
if (check.cost_changed) notes.push(check.cost_changed.note);
out.push({
id: row.id,
description: row.description || row.po_description || "Line",
notes,
discount: hasDiscount(check),
});
}
return out;
}
/** True when the card has anything for the AI notes panel to show. */
export function hasAiNotes(rows: NoteRow[]): boolean {
return rows.some((r) => !!r.ai_check);
}
/**
* The AI mark next to a PO. Sits beside the card's expand button (never inside
* it) and opens the panel of notes the AI made. The dot shows when the AI
* raised a finding or read a discount, so those are not missed.
*/
export function AiNotesButton({
poNumber,
open,
onToggle,
attention,
}: {
poNumber: string;
open: boolean;
onToggle: () => void;
attention: boolean;
}) {
return (
<button
type="button"
onClick={onToggle}
aria-expanded={open}
aria-controls={`ai-notes-${poNumber}`}
aria-label={`AI notes for PO ${poNumber}`}
title="What the AI noticed on this PO"
data-testid={`ai-notes-button-${poNumber}`}
className={`relative mt-1 inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-ai/30 text-ai transition-colors hover:bg-ai/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
open ? "bg-ai/20" : "bg-ai/10"
}`}
>
<Bot className="size-4" aria-hidden />
{attention ? (
<span
className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full bg-amber-500 ring-2 ring-background"
data-testid={`ai-notes-attention-${poNumber}`}
/>
) : null}
</button>
);
}
/** The notes the AI made on this PO: card findings, reading notes, then each line. */
export function AiNotesPanel({
poNumber,
rows,
findings,
notes,
}: {
poNumber: string;
rows: NoteRow[];
findings: string[];
notes: string[];
}) {
const lines = lineNotes(rows);
return (
<div
id={`ai-notes-${poNumber}`}
data-testid={`ai-notes-panel-${poNumber}`}
className="mt-2 rounded-lg border border-ai/30 bg-ai/10 px-3 py-2 text-xs"
>
<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 notes
</p>
{findings.length > 0 ? (
<ul className="list-disc pl-5" data-testid="ai-notes-findings">
{findings.map((f, i) => (
<li key={`${i}-${f}`}>{f}</li>
))}
</ul>
) : null}
{notes.length > 0 ? (
<ul className="mt-1 list-disc pl-5 text-muted-foreground" data-testid="ai-notes-reading">
{notes.map((n, i) => (
<li key={`${i}-${n}`}>{n}</li>
))}
</ul>
) : null}
{lines.map((l) => (
<div key={l.id} className="mt-2" data-testid={`ai-notes-line-${l.id}`}>
<p className="font-medium">{l.description}</p>
{l.notes.length > 0 ? (
<ul className="list-disc pl-5 text-muted-foreground">
{l.notes.map((n, i) => (
<li key={`${i}-${n}`}>{n}</li>
))}
</ul>
) : (
<p className="text-muted-foreground">Nothing to add.</p>
)}
</div>
))}
</div>
);
}

View file

@ -22,6 +22,7 @@ import {
import type { CollectionDocLine } from "@/lib/types";
import { cardAiState } from "@/lib/collection-ai-ticks";
import { AiFindings, DocumentLinesPanel } from "./ai-findings";
import { AiNotesButton, AiNotesPanel, hasAiNotes, lineNotes } from "./ai-notes";
import { InvoiceViewer } from "./invoice-viewer";
import { ItemsTable } from "./items-table";
import { ClearPanel, type ClearPayload } from "./clear-panel";
@ -212,9 +213,14 @@ export function PoCard({
const [returnRows, setReturnRows] = useState<ConfirmRow[] | null>(null);
const [checkingAi, setCheckingAi] = useState(false);
const [docPanelOpen, setDocPanelOpen] = useState(false);
const [aiNotesOpen, setAiNotesOpen] = useState(false);
const aiDoc = useMemo(() => cardDocument(rows), [rows]);
const aiFindings = useMemo(() => cardFindings(rows), [rows]);
const aiNotes = useMemo(() => cardNotes(rows), [rows]);
const aiNotesAttention = useMemo(
() => aiFindings.length > 0 || lineNotes(rows).some((l) => l.discount),
[aiFindings, rows],
);
// A photo whose invoice names another PO (the driver attached it to
// the wrong order). One notice per photo.
const wrongPo = useMemo(() => {
@ -640,6 +646,15 @@ export function PoCard({
</div>
</button>
{hasAiNotes(rows) ? (
<AiNotesButton
poNumber={poNumber}
open={aiNotesOpen}
onToggle={() => setAiNotesOpen((o) => !o)}
attention={aiNotesAttention}
/>
) : null}
{hasCustomerRows && onClearRows ? (
<ClearPanel
poNumber={poNumber}
@ -649,6 +664,10 @@ export function PoCard({
) : null}
</div>
{aiNotesOpen && hasAiNotes(rows) ? (
<AiNotesPanel poNumber={poNumber} rows={rows} findings={aiFindings} notes={aiNotes} />
) : null}
{isOpen ? (
<div
id={`po-card-body-${poNumber}`}