From c1656f5ccdbfb3d2c3e0e37ea51cbafa24cd1853 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 17 Oct 2026 09:41:52 +0200 Subject: [PATCH] feat(collection-ai): ticks, findings and POS buttons on Collection Confirm Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/collection-confirm/page.tsx | 110 ++++++++++++- .../collection-confirm/ai-findings.test.tsx | 17 ++ .../collection-confirm/ai-findings.tsx | 76 +++++++++ .../collection-confirm/ai-fixtures.ts | 53 +++++++ .../confirm-invoice-dialog.test.tsx | 21 +++ .../items-table-ai.test.tsx | 111 +++++++++++++ .../collection-confirm/items-table.tsx | 148 +++++++++++++++++- .../collection-confirm/po-card-ai.test.tsx | 107 +++++++++++++ src/components/collection-confirm/po-card.tsx | 91 ++++++++++- src/lib/collection-ai-card.test.ts | 52 ++++++ src/lib/collection-ai-card.ts | 70 +++++++++ 11 files changed, 849 insertions(+), 7 deletions(-) create mode 100644 src/components/collection-confirm/ai-findings.test.tsx create mode 100644 src/components/collection-confirm/ai-findings.tsx create mode 100644 src/components/collection-confirm/ai-fixtures.ts create mode 100644 src/components/collection-confirm/items-table-ai.test.tsx create mode 100644 src/components/collection-confirm/po-card-ai.test.tsx create mode 100644 src/lib/collection-ai-card.test.ts create mode 100644 src/lib/collection-ai-card.ts diff --git a/src/app/(app)/collection-confirm/page.tsx b/src/app/(app)/collection-confirm/page.tsx index c575b4e..d409c8f 100644 --- a/src/app/(app)/collection-confirm/page.tsx +++ b/src/app/(app)/collection-confirm/page.tsx @@ -10,7 +10,9 @@ import { ConfirmStockDialog } from "@/components/collection-confirm/confirm-stoc import { InvoiceViewer } from "@/components/collection-confirm/invoice-viewer"; import { PoCard } from "@/components/collection-confirm/po-card"; import type { Attachment } from "@/components/collection-confirm/photo-strip"; +import { aiInvoiceDefaults } from "@/lib/collection-ai-card"; import { getClientIdentity } from "@/lib/dev-identity"; +import { getPocketBase } from "@/lib/pocketbase"; import type { ConfirmRow } from "@/lib/server/collection-confirm-service"; import { @@ -70,6 +72,8 @@ export default function CollectionConfirmPage() { const [confirmState, setConfirmState] = useState<{ poNumber: string; rowIds: string[]; + /** Set by "Confirm and add to POS": customer rows to add once the confirm succeeds. */ + addToPosRowIds?: string[]; } | null>(null); // Stock-side confirm state. Mirrors `confirmState` but for the // warehouse "Confirm collection" flow — opens the @@ -122,8 +126,10 @@ export default function CollectionConfirmPage() { { attachment: Attachment; kind: "popout" | "fullscreen" } | null >(null); - const load = useCallback(async (): Promise => { - setRows(null); + const load = useCallback(async (opts?: { silent?: boolean }): Promise => { + // `silent` keeps the cards mounted (and their open/closed state) while the + // feed refreshes; used by the AI check and Add to POS, which stay on the card. + if (!opts?.silent) setRows(null); try { const response = await fetch("/api/v1/collection-confirm", { headers: { "content-type": "application/json" }, @@ -449,6 +455,83 @@ export default function CollectionConfirmPage() { [rows, endpointFor], ); + /** Authorization header for the browser-session AI / POS routes. */ + const authedJsonHeaders = useCallback((): Record => { + const token = getPocketBase().authStore.token; + return { + "content-type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + }, []); + + /** POST the rows to the add-to-POS route and toast the outcome (no reload). */ + const addRowsToPos = useCallback( + async (rowIds: string[]) => { + if (rowIds.length === 0) return; + try { + const response = await fetch("/api/v1/collection-confirm/add-to-pos", { + method: "POST", + headers: authedJsonHeaders(), + body: JSON.stringify({ row_ids: rowIds }), + }); + const data = (await response.json().catch(() => ({}))) as { + error?: string; + results?: Array<{ status: string }>; + }; + if (!response.ok) { + throw new Error(data.error || `Failed to add to the POS (HTTP ${response.status})`); + } + const created = (data.results ?? []).filter((r) => r.status === "created").length; + toast.success(`Added ${created} item${created === 1 ? "" : "s"} to the POS`); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + }, + [authedJsonHeaders], + ); + + const handleAddToPos = useCallback( + async (rowIds: string[]) => { + await addRowsToPos(rowIds); + await load({ silent: true }); + }, + [addRowsToPos, load], + ); + + const handleCheckWithAi = useCallback( + async (poNumber: string) => { + try { + const response = await fetch("/api/v1/collection-confirm/ai/check", { + method: "POST", + headers: authedJsonHeaders(), + body: JSON.stringify({ po_number: poNumber }), + }); + const data = (await response.json().catch(() => ({}))) as { + files_read?: number; + lines_checked?: number; + skus_filled?: number; + error?: string | null; + }; + if (!response.ok) { + throw new Error(data.error || `AI check failed (HTTP ${response.status})`); + } + if (data.error) { + toast.warning(data.error); + } else { + const files = data.files_read ?? 0; + const lines = data.lines_checked ?? 0; + toast.success( + `AI read ${files} file${files === 1 ? "" : "s"} and checked ${lines} line${lines === 1 ? "" : "s"}`, + ); + } + await load({ silent: true }); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + }, + [authedJsonHeaders, load], + ); + /** * Confirm submit handler wired to . * @@ -485,8 +568,11 @@ export default function CollectionConfirmPage() { // The response is `{ updatedIds }`; this page only needs success. await response.json(); toast.success("Confirmed for invoicing"); + const addToPosRowIds = confirmState.addToPosRowIds ?? []; setConfirmState(null); setCostOverrides({}); + // Only after the confirm succeeded: add the card's not-in-POS lines. + await addRowsToPos(addToPosRowIds); await load(); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -495,7 +581,7 @@ export default function CollectionConfirmPage() { setBusy(false); } }, - [actor, confirmState, load], + [actor, confirmState, load, addRowsToPos], ); /** @@ -880,6 +966,17 @@ export default function CollectionConfirmPage() { onSkuChange={(id, sku) => void handleSkuChange(id, sku)} onQtyChange={(id, qty) => void handleQtyChange(id, qty)} onClearRows={(payload) => handleClearRows(group, payload)} + onAddToPos={(rowId) => void handleAddToPos([rowId])} + onCheckWithAi={() => handleCheckWithAi(group.poNumber)} + onConfirmAndAddToPos={() => + setConfirmState({ + poNumber: group.poNumber, + rowIds: group.rows.map((r) => r.id), + addToPosRowIds: group.rows + .filter((r) => r.source !== "stock" && !r.in_pos) + .map((r) => r.id), + }) + } onConfirm={() => setConfirmState({ poNumber: group.poNumber, @@ -922,6 +1019,13 @@ export default function CollectionConfirmPage() { open={!!confirmState} eyebrow={confirmState?.poNumber ?? ""} rowCount={confirmState?.rowIds.length ?? 0} + defaultValues={ + confirmState + ? aiInvoiceDefaults( + (rows ?? []).filter((r) => confirmState.rowIds.includes(r.id)), + ) + : undefined + } costOverrides={scopedCostOverrides} busy={busy} onOpenChange={(open) => { diff --git a/src/components/collection-confirm/ai-findings.test.tsx b/src/components/collection-confirm/ai-findings.test.tsx new file mode 100644 index 0000000..6e973d1 --- /dev/null +++ b/src/components/collection-confirm/ai-findings.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { AiFindings } from "./ai-findings"; + +describe("AiFindings", () => { + it("renders nothing for an empty list", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders each finding", () => { + render(); + expect(screen.getByText("Freight line not on PO")).toBeInTheDocument(); + expect(screen.getByText("Invoice total differs")).toBeInTheDocument(); + }); +}); diff --git a/src/components/collection-confirm/ai-findings.tsx b/src/components/collection-confirm/ai-findings.tsx new file mode 100644 index 0000000..039f5f3 --- /dev/null +++ b/src/components/collection-confirm/ai-findings.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { Bot } from "lucide-react"; + +import type { CollectionDocLine } from "@/lib/types"; + +const zar = new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR" }); + +/** Box of document-level observations from the AI. Renders nothing when empty. */ +export function AiFindings({ findings }: { findings: string[] }) { + if (findings.length === 0) return null; + return ( +
+

+ + AI findings +

+
    + {findings.map((finding, i) => ( +
  • {finding}
  • + ))} +
+
+ ); +} + +/** The lines the AI extracted from the document, shown beside the photo. */ +export function DocumentLinesPanel({ + poNumber, + lines, +}: { + poNumber: string; + lines: CollectionDocLine[]; +}) { + return ( +
+

+ Lines read from the document +

+ {lines.length === 0 ? ( +

No lines were read.

+ ) : ( + + + + + + + + + + + + {lines.map((line) => ( + + + + + + + + ))} + +
CodeDescriptionQtyPer unitNote
{line.code || "—"}{line.description}{line.quantity ?? "—"} + {line.unit_price == null ? "—" : zar.format(line.unit_price)} + {line.price_note}
+ )} +
+ ); +} diff --git a/src/components/collection-confirm/ai-fixtures.ts b/src/components/collection-confirm/ai-fixtures.ts new file mode 100644 index 0000000..4745a00 --- /dev/null +++ b/src/components/collection-confirm/ai-fixtures.ts @@ -0,0 +1,53 @@ +import type { ConfirmRow } from "@/lib/server/collection-confirm-service"; +import type { CollectionAiCheck } from "@/lib/types"; + +export function makeCheck(over: Partial = {}): CollectionAiCheck { + return { + read_id: "read-1", + document_no: "5028256", + doc_date: "2026-10-16", + doc_type: "tax_invoice", + doc_line: { + line_no: 1, + code: "SK-001", + description: "Item one from supplier", + quantity: 10, + unit: "ea", + unit_price: 45, + line_total: 450, + to_follow: null, + price_note: "", + }, + same_item: "sure", + reason: "", + sku_filled_value: "", + findings: [], + checked_at: "2026-10-17T08:00:00Z", + model: "test", + ...over, + }; +} + +export function makeAiRow(over: Partial = {}): ConfirmRow { + return { + id: "row-1", + sku: "SK-001", + description: "Item 1", + order_qty: 10, + qty_collected: 10, + cost_price: 45, + surplus_qty: 0, + ai_check: makeCheck(), + ai_doc: { + read_id: "read-1", + doc_type: "tax_invoice", + document_no: "5028256", + doc_date: "2026-10-16", + supplier_name: "Acme", + findings: [], + }, + in_pos: false, + pos_stock_item_id: null, + ...over, + }; +} diff --git a/src/components/collection-confirm/confirm-invoice-dialog.test.tsx b/src/components/collection-confirm/confirm-invoice-dialog.test.tsx index 14ea609..e55c996 100644 --- a/src/components/collection-confirm/confirm-invoice-dialog.test.tsx +++ b/src/components/collection-confirm/confirm-invoice-dialog.test.tsx @@ -208,3 +208,24 @@ describe("ConfirmInvoiceDialog", () => { expect(onOpenChange).toHaveBeenCalledWith(false); }); }); + +describe("ConfirmInvoiceDialog AI pre-fill", () => { + it("shows the document number and date passed as defaults", () => { + render( + , + ); + expect(getNumberInput()).toHaveValue("5028256"); + expect(getDateInput()).toHaveValue("2026-10-16"); + }); + + it("falls back to a blank number and today's date without defaults", () => { + render(); + expect(getNumberInput()).toHaveValue(""); + expect(getDateInput()).toHaveValue(new Date().toISOString().slice(0, 10)); + }); +}); diff --git a/src/components/collection-confirm/items-table-ai.test.tsx b/src/components/collection-confirm/items-table-ai.test.tsx new file mode 100644 index 0000000..8d0e383 --- /dev/null +++ b/src/components/collection-confirm/items-table-ai.test.tsx @@ -0,0 +1,111 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import type { ConfirmRow } from "@/lib/server/collection-confirm-service"; +import { makeAiRow, makeCheck } from "./ai-fixtures"; +import { ItemsTable } from "./items-table"; + +const plain: ConfirmRow = { + id: "row-p", + sku: "SK-9", + description: "Plain", + order_qty: 1, + qty_collected: 1, + cost_price: 1, + surplus_qty: 0, +}; + +const noop = vi.fn(); + +describe("ItemsTable AI layer", () => { + it("renders the original six headers and no AI/tick/robot UI without AI data", () => { + render(); + const headers = screen.getAllByRole("columnheader").map((h) => h.textContent); + expect(headers).toEqual(["SKU", "Description", "Ord", "Coll", "Cost", "Surplus"]); + expect(screen.queryByLabelText(/^SKU: /)).not.toBeInTheDocument(); + expect(screen.queryByTestId("ai-sku-robot-row-p")).not.toBeInTheDocument(); + expect(screen.queryByText(/add to pos/i)).not.toBeInTheDocument(); + }); + + it("adds AI and POS headers when a row has ai_check", () => { + render(); + const headers = screen.getAllByRole("columnheader").map((h) => h.textContent); + expect(headers).toEqual(["SKU", "Description", "Ord", "Coll", "Cost", "Surplus", "AI", "POS"]); + }); + + it("renders four ticks with aria-labels from computeLineTicks and ours-vs-document titles", () => { + const row = makeAiRow({ + sku: "SK-001", + qty_collected: 9, + cost_price: 45, + ai_check: makeCheck({ + same_item: "different", + doc_line: { + line_no: 1, + code: "SK-001", + description: "Totally different thing", + quantity: 10, + unit: "ea", + unit_price: null, + line_total: null, + to_follow: null, + price_note: "", + }, + }), + }); + render(); + expect(screen.getByLabelText("SKU: matches")).toBeInTheDocument(); + expect(screen.getByLabelText("Qty: doesn't match")).toBeInTheDocument(); + expect(screen.getByLabelText("Desc: doesn't match")).toBeInTheDocument(); + expect(screen.getByLabelText("Price: can't check")).toBeInTheDocument(); + expect(screen.getByLabelText("Qty: doesn't match")).toHaveAttribute( + "title", + expect.stringContaining("9"), + ); + const desc = screen.getByLabelText("Desc: doesn't match").getAttribute("title") ?? ""; + expect(desc).toContain("Item 1"); + expect(desc).toContain("Totally different thing"); + }); + + it("shows the amber label", () => { + const row = makeAiRow({ ai_check: makeCheck({ same_item: "likely" }) }); + render(); + expect(screen.getByLabelText("Desc: have a look")).toBeInTheDocument(); + }); + + it("shows the robot next to the SKU only while the SKU equals sku_filled_value", () => { + const filled = makeAiRow({ id: "a", sku: "SK-001", ai_check: makeCheck({ sku_filled_value: "SK-001" }) }); + const human = makeAiRow({ id: "b", sku: "HUMAN", ai_check: makeCheck({ sku_filled_value: "SK-001" }) }); + render(); + expect(screen.getByTestId("ai-sku-robot-a")).toBeInTheDocument(); + expect(screen.queryByTestId("ai-sku-robot-b")).not.toBeInTheDocument(); + }); + + it("shows In POS with a stock link when in_pos", () => { + const row = makeAiRow({ in_pos: true, pos_stock_item_id: "stock-77" }); + render(); + const link = screen.getByRole("link", { name: /in pos/i }); + expect(link).toHaveAttribute("href", "/stock/stock-77"); + expect(screen.queryByRole("button", { name: /add to pos/i })).not.toBeInTheDocument(); + }); + + it("calls onAddToPos with the row id when not in POS", async () => { + const onAdd = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: /add to pos/i })); + expect(onAdd).toHaveBeenCalledWith("row-1"); + }); + + it("renders no tick icons for a row without ai_check inside an AI table", () => { + render( + , + ); + const rowEl = screen.getByText("Plain").closest("tr")!; + expect(within(rowEl).queryByLabelText(/^SKU: /)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/collection-confirm/items-table.tsx b/src/components/collection-confirm/items-table.tsx index 1d2373a..f9281c2 100644 --- a/src/components/collection-confirm/items-table.tsx +++ b/src/components/collection-confirm/items-table.tsx @@ -1,9 +1,22 @@ "use client"; -import { Pencil } from "lucide-react"; +import Link from "next/link"; +import { + AlertTriangle, + Bot, + CheckCircle2, + MinusCircle, + Pencil, + XCircle, +} from "lucide-react"; import { useState } from "react"; import { Input } from "@/components/ui/input"; +import { + computeLineTicks, + showsAiFilledSku, + type TickState, +} from "@/lib/collection-ai-ticks"; import type { ConfirmRow } from "@/lib/server/collection-confirm-service"; type Props = { @@ -18,8 +31,88 @@ type Props = { * pencil can hide it. */ onQtyChange?: (id: string, qty: number) => void; + /** "Add to POS" click for a line that is not in the POS yet. */ + onAddToPos?: (rowId: string) => void; + /** + * Force the AI + POS columns on or off. Defaults to on when some row + * carries an `ai_check` or a known `in_pos`, so a table with neither + * renders exactly as before. + */ + showAi?: boolean; }; +const TICK_LABEL: Record = { + green: "matches", + amber: "have a look", + red: "doesn't match", + grey: "can't check", +}; + +const TICK_CLASS: Record = { + green: "text-success", + amber: "text-warn", + red: "text-destructive", + grey: "text-muted-foreground", +}; + +function TickIcon({ state }: { state: TickState }) { + const cls = "size-4"; + if (state === "green") return ; + if (state === "amber") return ; + if (state === "red") return ; + return ; +} + +function Tick({ name, state, detail }: { name: string; state: TickState; detail: string }) { + return ( + + + + ); +} + +function AiTicks({ row }: { row: ConfirmRow }) { + const check = row.ai_check; + if (!check) return null; + const doc = check.doc_line; + const t = computeLineTicks(row, check); + const ordered = row.order_qty ? row.order_qty : (row.quantity ?? 0); + const priceOurs = row.cost_price == null ? "none" : `R${row.cost_price.toFixed(2)}`; + const priceDoc = doc?.unit_price == null ? "none" : `R${doc.unit_price.toFixed(2)}`; + const note = doc?.price_note ? ` (${doc.price_note})` : ""; + return ( +
+ + + + +
+ ); +} + type EditState = { id: string; value: string; @@ -76,7 +169,11 @@ export function ItemsTable({ onCostChange, onSkuChange, onQtyChange, + onAddToPos, + showAi, }: Props) { + const aiColumns = + showAi ?? rows.some((r) => !!r.ai_check || typeof r.in_pos === "boolean"); const [edit, setEdit] = useState(null); const startCostEdit = (row: ConfirmRow) => { @@ -165,7 +262,9 @@ export function ItemsTable({ }; return ( - +
{/* Explicit column widths. `table-fixed` honours these, so a cell that goes into edit mode can no longer steal width from its @@ -186,6 +285,8 @@ export function ItemsTable({ + {aiColumns ? : null} + {aiColumns ? : null} @@ -195,6 +296,8 @@ export function ItemsTable({ + {aiColumns ? : null} + {aiColumns ? : null} @@ -229,6 +332,15 @@ export function ItemsTable({ {row.sku ?? "—"} + {showsAiFilledSku(row, row.ai_check) ? ( + + + + ) : null} + {aiColumns ? ( + + ) : null} + {aiColumns ? ( + + ) : null} ); })} @@ -390,7 +532,7 @@ export function ItemsTable({ */}
Coll Cost SurplusAIPOS
{surplus > 0 ? `+${surplus}` : "—"} + + + {row.in_pos ? ( + row.pos_stock_item_id ? ( + + In POS + + ) : ( + In POS + ) + ) : row.in_pos === false && onAddToPos ? ( + + ) : null} +
Tap the pencil to edit · Enter saves · Esc cancels diff --git a/src/components/collection-confirm/po-card-ai.test.tsx b/src/components/collection-confirm/po-card-ai.test.tsx new file mode 100644 index 0000000..044143a --- /dev/null +++ b/src/components/collection-confirm/po-card-ai.test.tsx @@ -0,0 +1,107 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import type { ConfirmRow } from "@/lib/server/collection-confirm-service"; +import { makeAiRow } from "./ai-fixtures"; +import { PoCard } from "./po-card"; + +const props = { + poNumber: "PO-1", + supplier: { id: "s", name: "Acme" }, + onCostChange: vi.fn(), + onSkuChange: vi.fn(), + onConfirm: vi.fn(), + initiallyOpen: true, +}; + +const plain: ConfirmRow = { + id: "p1", + sku: "X", + description: "Plain", + order_qty: 1, + qty_collected: 1, + cost_price: 1, + surplus_qty: 0, +}; + +describe("PoCard AI layer", () => { + it("shows no chip, findings, or add-to-POS confirm without AI data", () => { + render(); + expect(screen.queryByTestId("ai-doc-chip-PO-1")).not.toBeInTheDocument(); + expect(screen.queryByTestId("ai-findings")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /check with ai/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /confirm and add to pos/i })).not.toBeInTheDocument(); + }); + + it("renders the document chip and opens the extracted lines panel", async () => { + render(); + const chip = screen.getByTestId("ai-doc-chip-PO-1"); + expect(chip).toHaveTextContent("Tax invoice 5028256 · 16 Oct 2026 · read from driver photo"); + await userEvent.click(chip); + const panel = screen.getByTestId("ai-doc-lines-PO-1"); + expect(panel).toHaveTextContent("SK-001"); + expect(panel).toHaveTextContent("Item one from supplier"); + }); + + it("labels a delivery note", () => { + const row = makeAiRow({ + ai_doc: { + read_id: "r", + doc_type: "delivery_note", + document_no: "DN1", + doc_date: "2026-10-16", + supplier_name: "S", + findings: [], + }, + }); + render(); + expect(screen.getByTestId("ai-doc-chip-PO-1")).toHaveTextContent(/^Delivery note DN1/); + }); + + it("shows the findings box with the union of findings", () => { + const row = makeAiRow({ + ai_doc: { + read_id: "r", + doc_type: "tax_invoice", + document_no: "1", + doc_date: "2026-10-16", + supplier_name: "S", + findings: ["Freight not on PO"], + }, + }); + render(); + expect(screen.getByTestId("ai-findings")).toHaveTextContent("Freight not on PO"); + }); + + it("Check with AI calls the handler and shows a spinner while pending", async () => { + let resolve!: () => void; + const onCheck = vi.fn(() => new Promise((r) => (resolve = r))); + render(); + const btn = screen.getByRole("button", { name: /check with ai/i }); + await userEvent.click(btn); + expect(onCheck).toHaveBeenCalledTimes(1); + expect(btn).toBeDisabled(); + expect(screen.getByTestId("ai-check-spinner")).toBeInTheDocument(); + resolve(); + await waitFor(() => expect(btn).not.toBeDisabled()); + }); + + it("renders Confirm and add to POS only when a customer row is not in POS", async () => { + const onGo = vi.fn(); + const { rerender } = render( + , + ); + await userEvent.click(screen.getByRole("button", { name: /confirm and add to pos/i })); + expect(onGo).toHaveBeenCalledTimes(1); + + rerender( + , + ); + expect(screen.queryByRole("button", { name: /confirm and add to pos/i })).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/collection-confirm/po-card.tsx b/src/components/collection-confirm/po-card.tsx index 04c6ce4..88ab4cb 100644 --- a/src/components/collection-confirm/po-card.tsx +++ b/src/components/collection-confirm/po-card.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import { Camera, Check, ChevronDown, FileText, FileUp, Loader2, Maximize2, PictureInPicture2 } from "lucide-react"; +import { Bot, Camera, Check, ChevronDown, FileText, FileUp, Loader2, Maximize2, PictureInPicture2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Splitter } from "@/components/ui/splitter"; @@ -13,6 +13,13 @@ import { earliestCollectedAt, formatCollectedDate, } from "@/app/(app)/collection-confirm/view-model"; +import { + cardDocument, + cardFindings, + documentChipLabel, +} from "@/lib/collection-ai-card"; +import type { CollectionDocLine } from "@/lib/types"; +import { AiFindings, DocumentLinesPanel } from "./ai-findings"; import { InvoiceViewer } from "./invoice-viewer"; import { ItemsTable } from "./items-table"; import { ClearPanel, type ClearPayload } from "./clear-panel"; @@ -126,6 +133,12 @@ type Props = { onFullscreen?: (attachment: Attachment) => void; /** Buyer-side invoice upload for counter collections or late email invoices. */ onInvoiceUpload?: (file: File) => Promise; + /** Per-row "Add to POS" click, passed through to the items table. */ + onAddToPos?: (rowId: string) => void; + /** Runs the AI check for this card; the button shows a spinner until it settles. */ + onCheckWithAi?: () => Promise; + /** Confirm for invoicing, then add every not-in-POS customer line to the POS. */ + onConfirmAndAddToPos?: () => void; }; /** @@ -181,8 +194,30 @@ export function PoCard({ onPopOut, onFullscreen, onInvoiceUpload, + onAddToPos, + onCheckWithAi, + onConfirmAndAddToPos, }: Props) { const itemCount = rows.length; + const [checkingAi, setCheckingAi] = useState(false); + const [docPanelOpen, setDocPanelOpen] = useState(false); + const aiDoc = useMemo(() => cardDocument(rows), [rows]); + const aiFindings = useMemo(() => cardFindings(rows), [rows]); + // Lines the AI extracted from the chip's document (those paired with a row), by line number. + const aiDocLines = useMemo(() => { + const byNo = new Map(); + for (const row of rows) { + const check = row.ai_check; + if (!aiDoc || !check?.doc_line || check.read_id !== aiDoc.read_id) continue; + if (!byNo.has(check.doc_line.line_no)) byNo.set(check.doc_line.line_no, check.doc_line); + } + return [...byNo.values()].sort((a, b) => a.line_no - b.line_no); + }, [rows, aiDoc]); + const hasUnpostedCustomerRows = rows.some((r) => r.source !== "stock" && !r.in_pos); + const aiDocPanel = + docPanelOpen && aiDoc ? ( + + ) : null; const uploadInputRef = useRef(null); // Ref to the split-view body container; the Splitter uses it to // measure its own width when computing the cursor's ratio. @@ -527,6 +562,21 @@ export function PoCard({ data-testid={`po-card-body-${poNumber}`} className="mt-3" > + {aiDoc ? ( +
+ +
+ ) : null} + {attachments.length > 0 ? (
@@ -704,6 +754,7 @@ export function PoCard({ onCostChange={onCostChange} onSkuChange={onSkuChange} onQtyChange={onQtyChange} + onAddToPos={onAddToPos} />
@@ -722,6 +773,7 @@ export function PoCard({ style={{ flexBasis: `${(1 - ratio) * 100}%`, flexGrow: 1, flexShrink: 1 }} data-testid={`po-card-preview-pane-${poNumber}`} > + {aiDocPanel} {activeAttachment ? (
+ {aiDocPanel ?
{aiDocPanel}
: null} + {activeAttachment ? (
)} + +

Drag the divider to give the invoice more room.

+ {onCheckWithAi ? ( + + ) : null} {hasStockRows && onConfirmStock ? ( + ) : null}
diff --git a/src/lib/collection-ai-card.test.ts b/src/lib/collection-ai-card.test.ts new file mode 100644 index 0000000..7a0c500 --- /dev/null +++ b/src/lib/collection-ai-card.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { makeAiRow, makeCheck } from "@/components/collection-confirm/ai-fixtures"; +import { aiInvoiceDefaults, cardDocument, cardFindings } from "./collection-ai-card"; + +const doc = (read_id: string, findings: string[], doc_date = "2026-10-16") => ({ + read_id, + doc_type: "tax_invoice" as const, + document_no: read_id, + doc_date, + supplier_name: "S", + findings, +}); + +describe("collection-ai-card", () => { + it("unions findings across distinct reads, deduped in order", () => { + const rows = [ + makeAiRow({ id: "1", ai_doc: doc("r1", ["a", "b"]) }), + makeAiRow({ id: "2", ai_doc: doc("r1", ["a", "b"]) }), + makeAiRow({ id: "3", ai_doc: doc("r2", ["b", "c"]) }), + makeAiRow({ id: "4", ai_doc: null }), + ]; + expect(cardFindings(rows)).toEqual(["a", "b", "c"]); + }); + + it("picks the newest document for the chip", () => { + const rows = [ + makeAiRow({ id: "1", ai_doc: doc("r1", [], "2026-10-10") }), + makeAiRow({ id: "2", ai_doc: doc("r2", [], "2026-10-16") }), + ]; + expect(cardDocument(rows)?.read_id).toBe("r2"); + expect(cardDocument([makeAiRow({ ai_doc: null })])).toBeNull(); + }); + + it("derives invoice defaults from the first row with a document number", () => { + const rows = [ + makeAiRow({ id: "1", ai_check: null }), + makeAiRow({ id: "2", ai_check: makeCheck({ document_no: "5028256", doc_date: "2026-10-16" }) }), + ]; + expect(aiInvoiceDefaults(rows)).toEqual({ number: "5028256", date: "2026-10-16" }); + }); + + it("returns undefined without any document number", () => { + expect(aiInvoiceDefaults([makeAiRow({ ai_check: null })])).toBeUndefined(); + expect(aiInvoiceDefaults([makeAiRow({ ai_check: makeCheck({ document_no: "" }) })])).toBeUndefined(); + }); + + it("omits an invalid date", () => { + const rows = [makeAiRow({ ai_check: makeCheck({ doc_date: "" }) })]; + expect(aiInvoiceDefaults(rows)).toEqual({ number: "5028256" }); + }); +}); diff --git a/src/lib/collection-ai-card.ts b/src/lib/collection-ai-card.ts new file mode 100644 index 0000000..ba85636 --- /dev/null +++ b/src/lib/collection-ai-card.ts @@ -0,0 +1,70 @@ +import type { ConfirmRow } from "@/lib/server/collection-confirm-service"; +import type { CollectionDocType } from "@/lib/types"; + +type AiDoc = NonNullable; + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/** Union of document findings across a card's distinct reads, deduped, in order. */ +export function cardFindings(rows: ConfirmRow[]): string[] { + const seenReads = new Set(); + const seen = new Set(); + const out: string[] = []; + for (const row of rows) { + const doc = row.ai_doc; + if (!doc || seenReads.has(doc.read_id)) continue; + seenReads.add(doc.read_id); + for (const finding of doc.findings ?? []) { + if (!finding || seen.has(finding)) continue; + seen.add(finding); + out.push(finding); + } + } + return out; +} + +/** The card's newest document (first non-null wins on equal dates). */ +export function cardDocument(rows: ConfirmRow[]): AiDoc | null { + let best: AiDoc | null = null; + for (const row of rows) { + const doc = row.ai_doc; + if (!doc) continue; + if (!best || (doc.doc_date ?? "") > (best.doc_date ?? "")) best = doc; + } + return best; +} + +/** Pre-fill for the confirm dialog from the first row whose check has a document number. */ +export function aiInvoiceDefaults( + rows: ConfirmRow[], +): { number: string; date?: string } | undefined { + for (const row of rows) { + const number = row.ai_check?.document_no?.trim(); + if (!number) continue; + const date = row.ai_check?.doc_date ?? ""; + return /^\d{4}-\d{2}-\d{2}$/.test(date) ? { number, date } : { number }; + } + return undefined; +} + +export function docTypeLabel(type: CollectionDocType): string { + if (type === "tax_invoice") return "Tax invoice"; + if (type === "delivery_note") return "Delivery note"; + return "Document"; +} + +/** "2026-10-16" -> "16 Oct 2026"; anything else is returned as is. */ +export function formatDocDate(iso: string): string { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec((iso ?? "").trim()); + if (!m) return iso ?? ""; + const month = Number(m[2]) - 1; + if (month < 0 || month > 11) return iso; + return `${Number(m[3])} ${MONTHS[month]} ${m[1]}`; +} + +export function documentChipLabel(doc: AiDoc): string { + const head = [docTypeLabel(doc.doc_type), doc.document_no].filter(Boolean).join(" "); + return [head, formatDocDate(doc.doc_date), "read from driver photo"] + .filter(Boolean) + .join(" · "); +}