diff --git a/src/components/collection-confirm/ai-notes.test.tsx b/src/components/collection-confirm/ai-notes.test.tsx new file mode 100644 index 0000000..c262644 --- /dev/null +++ b/src/components/collection-confirm/ai-notes.test.tsx @@ -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 = {}, doc: Record = {}): 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( clicks++} attention={false} />); + expect(screen.queryByTestId("ai-notes-attention-PO1")).toBeNull(); + rerender( 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( + , + ); + 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/); + }); +}); diff --git a/src/components/collection-confirm/ai-notes.tsx b/src/components/collection-confirm/ai-notes.tsx new file mode 100644 index 0000000..4a23385 --- /dev/null +++ b/src/components/collection-confirm/ai-notes.tsx @@ -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 ( + + ); +} + +/** 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 ( +
+

+ + AI notes +

+ {findings.length > 0 ? ( +
    + {findings.map((f, i) => ( +
  • {f}
  • + ))} +
+ ) : null} + {notes.length > 0 ? ( +
    + {notes.map((n, i) => ( +
  • {n}
  • + ))} +
+ ) : null} + {lines.map((l) => ( +
+

{l.description}

+ {l.notes.length > 0 ? ( +
    + {l.notes.map((n, i) => ( +
  • {n}
  • + ))} +
+ ) : ( +

Nothing to add.

+ )} +
+ ))} +
+ ); +} diff --git a/src/components/collection-confirm/po-card.tsx b/src/components/collection-confirm/po-card.tsx index 139c5f2..6b9fd25 100644 --- a/src/components/collection-confirm/po-card.tsx +++ b/src/components/collection-confirm/po-card.tsx @@ -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(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({ + {hasAiNotes(rows) ? ( + setAiNotesOpen((o) => !o)} + attention={aiNotesAttention} + /> + ) : null} + {hasCustomerRows && onClearRows ? ( + {aiNotesOpen && hasAiNotes(rows) ? ( + + ) : null} + {isOpen ? (