feat(collection-ai): ticks, findings and POS buttons on Collection Confirm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
cc3e1d040b
commit
c1656f5ccd
11 changed files with 849 additions and 7 deletions
|
|
@ -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<ConfirmRow[]> => {
|
||||
setRows(null);
|
||||
const load = useCallback(async (opts?: { silent?: boolean }): Promise<ConfirmRow[]> => {
|
||||
// `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<string, string> => {
|
||||
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 <ConfirmInvoiceDialog>.
|
||||
*
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
17
src/components/collection-confirm/ai-findings.test.tsx
Normal file
17
src/components/collection-confirm/ai-findings.test.tsx
Normal file
|
|
@ -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(<AiFindings findings={[]} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders each finding", () => {
|
||||
render(<AiFindings findings={["Freight line not on PO", "Invoice total differs"]} />);
|
||||
expect(screen.getByText("Freight line not on PO")).toBeInTheDocument();
|
||||
expect(screen.getByText("Invoice total differs")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
76
src/components/collection-confirm/ai-findings.tsx
Normal file
76
src/components/collection-confirm/ai-findings.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
className="mb-3 rounded-lg border border-ai/30 bg-ai/10 px-3 py-2"
|
||||
data-testid="ai-findings"
|
||||
>
|
||||
<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 findings
|
||||
</p>
|
||||
<ul className="list-disc pl-5 text-xs">
|
||||
{findings.map((finding, i) => (
|
||||
<li key={`${i}-${finding}`}>{finding}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The lines the AI extracted from the document, shown beside the photo. */
|
||||
export function DocumentLinesPanel({
|
||||
poNumber,
|
||||
lines,
|
||||
}: {
|
||||
poNumber: string;
|
||||
lines: CollectionDocLine[];
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="overflow-auto border-b bg-background p-2 text-xs"
|
||||
data-testid={`ai-doc-lines-${poNumber}`}
|
||||
>
|
||||
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-ai">
|
||||
Lines read from the document
|
||||
</p>
|
||||
{lines.length === 0 ? (
|
||||
<p className="text-muted-foreground">No lines were read.</p>
|
||||
) : (
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground">
|
||||
<th className="pr-2 font-medium">Code</th>
|
||||
<th className="pr-2 font-medium">Description</th>
|
||||
<th className="pr-2 text-right font-medium">Qty</th>
|
||||
<th className="pr-2 text-right font-medium">Per unit</th>
|
||||
<th className="font-medium">Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((line) => (
|
||||
<tr key={line.line_no} className="align-top">
|
||||
<td className="pr-2 font-mono">{line.code || "—"}</td>
|
||||
<td className="pr-2">{line.description}</td>
|
||||
<td className="pr-2 text-right tabular-nums">{line.quantity ?? "—"}</td>
|
||||
<td className="pr-2 text-right tabular-nums">
|
||||
{line.unit_price == null ? "—" : zar.format(line.unit_price)}
|
||||
</td>
|
||||
<td className="text-muted-foreground">{line.price_note}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/components/collection-confirm/ai-fixtures.ts
Normal file
53
src/components/collection-confirm/ai-fixtures.ts
Normal file
|
|
@ -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> = {}): 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> = {}): 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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(
|
||||
<ConfirmInvoiceDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
defaultValues={{ number: "5028256", date: "2026-10-16" }}
|
||||
/>,
|
||||
);
|
||||
expect(getNumberInput()).toHaveValue("5028256");
|
||||
expect(getDateInput()).toHaveValue("2026-10-16");
|
||||
});
|
||||
|
||||
it("falls back to a blank number and today's date without defaults", () => {
|
||||
render(<ConfirmInvoiceDialog open onOpenChange={vi.fn()} onSubmit={vi.fn()} />);
|
||||
expect(getNumberInput()).toHaveValue("");
|
||||
expect(getDateInput()).toHaveValue(new Date().toISOString().slice(0, 10));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
111
src/components/collection-confirm/items-table-ai.test.tsx
Normal file
111
src/components/collection-confirm/items-table-ai.test.tsx
Normal file
|
|
@ -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(<ItemsTable rows={[plain]} onCostChange={noop} onSkuChange={noop} />);
|
||||
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(<ItemsTable rows={[makeAiRow()]} onCostChange={noop} onSkuChange={noop} />);
|
||||
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(<ItemsTable rows={[row]} onCostChange={noop} onSkuChange={noop} />);
|
||||
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(<ItemsTable rows={[row]} onCostChange={noop} onSkuChange={noop} />);
|
||||
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(<ItemsTable rows={[filled, human]} onCostChange={noop} onSkuChange={noop} />);
|
||||
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(<ItemsTable rows={[row]} onCostChange={noop} onSkuChange={noop} onAddToPos={noop} />);
|
||||
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(<ItemsTable rows={[makeAiRow()]} onCostChange={noop} onSkuChange={noop} onAddToPos={onAdd} />);
|
||||
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(
|
||||
<ItemsTable
|
||||
rows={[makeAiRow(), { ...plain, in_pos: false }]}
|
||||
onCostChange={noop}
|
||||
onSkuChange={noop}
|
||||
/>,
|
||||
);
|
||||
const rowEl = screen.getByText("Plain").closest("tr")!;
|
||||
expect(within(rowEl).queryByLabelText(/^SKU: /)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<TickState, string> = {
|
||||
green: "matches",
|
||||
amber: "have a look",
|
||||
red: "doesn't match",
|
||||
grey: "can't check",
|
||||
};
|
||||
|
||||
const TICK_CLASS: Record<TickState, string> = {
|
||||
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 <CheckCircle2 className={cls} aria-hidden />;
|
||||
if (state === "amber") return <AlertTriangle className={cls} aria-hidden />;
|
||||
if (state === "red") return <XCircle className={cls} aria-hidden />;
|
||||
return <MinusCircle className={cls} aria-hidden />;
|
||||
}
|
||||
|
||||
function Tick({ name, state, detail }: { name: string; state: TickState; detail: string }) {
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={`${name}: ${TICK_LABEL[state]}`}
|
||||
title={detail}
|
||||
data-tick={name.toLowerCase()}
|
||||
data-state={state}
|
||||
className={`inline-flex ${TICK_CLASS[state]}`}
|
||||
>
|
||||
<TickIcon state={state} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-1" data-testid={`ai-ticks-${row.id}`}>
|
||||
<Tick
|
||||
name="SKU"
|
||||
state={t.sku}
|
||||
detail={`Ours: ${row.sku || "none"} / Document: ${doc?.code || "none"}`}
|
||||
/>
|
||||
<Tick
|
||||
name="Qty"
|
||||
state={t.qty}
|
||||
detail={`Ours: ${row.qty_collected ?? 0} collected (${ordered} ordered) / Document: ${doc?.quantity ?? "none"}`}
|
||||
/>
|
||||
<Tick
|
||||
name="Desc"
|
||||
state={t.desc}
|
||||
detail={`Ours: ${row.description ?? row.po_description ?? "none"} / Document: ${doc?.description || "none"}${check.reason ? ` — ${check.reason}` : ""}`}
|
||||
/>
|
||||
<Tick
|
||||
name="Price"
|
||||
state={t.price}
|
||||
detail={`Ours: ${priceOurs} / Document: ${priceDoc}${note}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<EditState | null>(null);
|
||||
|
||||
const startCostEdit = (row: ConfirmRow) => {
|
||||
|
|
@ -165,7 +262,9 @@ export function ItemsTable({
|
|||
};
|
||||
|
||||
return (
|
||||
<table className="w-full min-w-[640px] table-fixed text-sm">
|
||||
<table
|
||||
className={`w-full table-fixed text-sm ${aiColumns ? "min-w-[860px]" : "min-w-[640px]"}`}
|
||||
>
|
||||
{/*
|
||||
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({
|
|||
<col className="w-[5rem]" />
|
||||
<col className="w-[8rem]" />
|
||||
<col className="w-[5rem]" />
|
||||
{aiColumns ? <col className="w-[7rem]" /> : null}
|
||||
{aiColumns ? <col className="w-[7rem]" /> : null}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="border-b text-left">
|
||||
|
|
@ -195,6 +296,8 @@ export function ItemsTable({
|
|||
<th className="border-l border-border/60 px-3 py-2 text-right font-medium">Coll</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Surplus</th>
|
||||
{aiColumns ? <th className="px-3 py-2 font-medium">AI</th> : null}
|
||||
{aiColumns ? <th className="px-3 py-2 font-medium">POS</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -229,6 +332,15 @@ export function ItemsTable({
|
|||
<span data-sku-value={row.sku ?? ""} className="min-w-0 flex-1 truncate">
|
||||
{row.sku ?? "—"}
|
||||
</span>
|
||||
{showsAiFilledSku(row, row.ai_check) ? (
|
||||
<span
|
||||
data-testid={`ai-sku-robot-${row.id}`}
|
||||
title="SKU filled in by the AI from the document"
|
||||
className="inline-flex shrink-0 text-ai"
|
||||
>
|
||||
<Bot className="size-3.5" aria-label="Filled by AI" />
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startSkuEdit(row)}
|
||||
|
|
@ -376,6 +488,36 @@ export function ItemsTable({
|
|||
<td className="px-3 py-2 text-right align-top tabular-nums">
|
||||
{surplus > 0 ? `+${surplus}` : "—"}
|
||||
</td>
|
||||
{aiColumns ? (
|
||||
<td className="px-3 py-2 align-top">
|
||||
<AiTicks row={row} />
|
||||
</td>
|
||||
) : null}
|
||||
{aiColumns ? (
|
||||
<td className="px-3 py-2 align-top text-xs">
|
||||
{row.in_pos ? (
|
||||
row.pos_stock_item_id ? (
|
||||
<Link
|
||||
href={`/stock/${row.pos_stock_item_id}`}
|
||||
className="font-medium text-success underline-offset-2 hover:underline"
|
||||
>
|
||||
In POS
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-medium text-success">In POS</span>
|
||||
)
|
||||
) : row.in_pos === false && onAddToPos ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddToPos(row.id)}
|
||||
className="rounded border px-1.5 py-0.5 text-xs font-medium hover:bg-accent"
|
||||
data-testid={`add-to-pos-${row.id}`}
|
||||
>
|
||||
Add to POS
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
|
@ -390,7 +532,7 @@ export function ItemsTable({
|
|||
*/}
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
colSpan={aiColumns ? 8 : 6}
|
||||
className="px-3 pb-1 pt-2 text-[11px] text-muted-foreground"
|
||||
>
|
||||
Tap the pencil to edit · Enter saves · Esc cancels
|
||||
|
|
|
|||
107
src/components/collection-confirm/po-card-ai.test.tsx
Normal file
107
src/components/collection-confirm/po-card-ai.test.tsx
Normal file
|
|
@ -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(<PoCard rows={[plain]} {...props} />);
|
||||
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(<PoCard rows={[makeAiRow()]} {...props} />);
|
||||
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(<PoCard rows={[row]} {...props} />);
|
||||
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(<PoCard rows={[row]} {...props} />);
|
||||
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<void>((r) => (resolve = r)));
|
||||
render(<PoCard rows={[plain]} {...props} onCheckWithAi={onCheck} />);
|
||||
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(
|
||||
<PoCard rows={[makeAiRow({ in_pos: false })]} {...props} onConfirmAndAddToPos={onGo} />,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: /confirm and add to pos/i }));
|
||||
expect(onGo).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<PoCard
|
||||
rows={[makeAiRow({ in_pos: true, pos_stock_item_id: "s1" })]}
|
||||
{...props}
|
||||
onConfirmAndAddToPos={onGo}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /confirm and add to pos/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void>;
|
||||
/** 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<void>;
|
||||
/** 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<number, CollectionDocLine>();
|
||||
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 ? (
|
||||
<DocumentLinesPanel poNumber={poNumber} lines={aiDocLines} />
|
||||
) : null;
|
||||
const uploadInputRef = useRef<HTMLInputElement>(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 ? (
|
||||
<div className="mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDocPanelOpen((open) => !open)}
|
||||
aria-expanded={docPanelOpen}
|
||||
data-testid={`ai-doc-chip-${poNumber}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-ai/30 bg-ai/10 px-2 py-1 text-xs font-medium text-ai transition-colors hover:bg-ai/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Bot className="size-3.5" aria-hidden />
|
||||
{documentChipLabel(aiDoc)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{attachments.length > 0 ? (
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
|
|
@ -704,6 +754,7 @@ export function PoCard({
|
|||
onCostChange={onCostChange}
|
||||
onSkuChange={onSkuChange}
|
||||
onQtyChange={onQtyChange}
|
||||
onAddToPos={onAddToPos}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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 ? (
|
||||
<div
|
||||
className="h-full overflow-hidden bg-background"
|
||||
|
|
@ -769,9 +821,12 @@ export function PoCard({
|
|||
onCostChange={onCostChange}
|
||||
onSkuChange={onSkuChange}
|
||||
onQtyChange={onQtyChange}
|
||||
onAddToPos={onAddToPos}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{aiDocPanel ? <div className="mb-3 rounded-lg border">{aiDocPanel}</div> : null}
|
||||
|
||||
{activeAttachment ? (
|
||||
<div
|
||||
className={`mb-3 overflow-hidden rounded-lg border bg-background ${STACKED_PREVIEW_HEIGHT}`}
|
||||
|
|
@ -790,11 +845,36 @@ export function PoCard({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<AiFindings findings={aiFindings} />
|
||||
|
||||
<footer className="flex items-center justify-between gap-2">
|
||||
<p className="hidden text-[11px] text-muted-foreground lg:block">
|
||||
Drag the divider to give the invoice more room.
|
||||
</p>
|
||||
<div className="ml-auto flex gap-2">
|
||||
{onCheckWithAi ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={checkingAi}
|
||||
onClick={async () => {
|
||||
setCheckingAi(true);
|
||||
try {
|
||||
await onCheckWithAi();
|
||||
} finally {
|
||||
setCheckingAi(false);
|
||||
}
|
||||
}}
|
||||
data-testid={`po-card-check-ai-${poNumber}`}
|
||||
>
|
||||
{checkingAi ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" data-testid="ai-check-spinner" />
|
||||
) : (
|
||||
<Bot className="h-4 w-4 text-ai" />
|
||||
)}
|
||||
Check with AI
|
||||
</Button>
|
||||
) : null}
|
||||
{hasStockRows && onConfirmStock ? (
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -813,6 +893,15 @@ export function PoCard({
|
|||
Confirm for invoicing
|
||||
</Button>
|
||||
) : null}
|
||||
{hasCustomerRows && hasUnpostedCustomerRows && onConfirmAndAddToPos ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onConfirmAndAddToPos}
|
||||
data-testid={`po-card-confirm-add-pos-${poNumber}`}
|
||||
>
|
||||
Confirm and add to POS
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
52
src/lib/collection-ai-card.test.ts
Normal file
52
src/lib/collection-ai-card.test.ts
Normal file
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
70
src/lib/collection-ai-card.ts
Normal file
70
src/lib/collection-ai-card.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { ConfirmRow } from "@/lib/server/collection-confirm-service";
|
||||
import type { CollectionDocType } from "@/lib/types";
|
||||
|
||||
type AiDoc = NonNullable<ConfirmRow["ai_doc"]>;
|
||||
|
||||
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<string>();
|
||||
const seen = new Set<string>();
|
||||
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(" · ");
|
||||
}
|
||||
Loading…
Reference in a new issue