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:
parent
25466d4e64
commit
0e9d85d536
3 changed files with 261 additions and 0 deletions
86
src/components/collection-confirm/ai-notes.test.tsx
Normal file
86
src/components/collection-confirm/ai-notes.test.tsx
Normal 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/);
|
||||
});
|
||||
});
|
||||
156
src/components/collection-confirm/ai-notes.tsx
Normal file
156
src/components/collection-confirm/ai-notes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}`}
|
||||
|
|
|
|||
Loading…
Reference in a new issue