fix(collection-confirm): show AI ticks under the description; always fill the SKU
- The AI and POS columns sat at the far right of the items table, hidden behind the document preview in the split view. Ticks (now labelled SKU / Qty / Desc / Price) and the POS status render under each description, and the table is back to its original six columns and width. - Owner's call: fill an empty SKU on a sure pairing even if the code may be misread; the rep edits it and a near-miss still helps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a4345c1f85
commit
4eb4a24b84
4 changed files with 75 additions and 62 deletions
|
|
@ -28,21 +28,30 @@ describe("ItemsTable AI layer", () => {
|
|||
expect(screen.queryByText(/add to pos/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows only the POS column (no AI column, no AI min-width) when no row has ai_check", () => {
|
||||
it("keeps the original six columns and shows POS status under the description when no row has ai_check", () => {
|
||||
const { container } = render(
|
||||
<ItemsTable rows={[{ ...plain, in_pos: false }]} onCostChange={noop} onSkuChange={noop} onAddToPos={noop} />,
|
||||
);
|
||||
const headers = screen.getAllByRole("columnheader").map((h) => h.textContent);
|
||||
expect(headers).toEqual(["SKU", "Description", "Ord", "Coll", "Cost", "Surplus", "POS"]);
|
||||
expect(container.querySelector("table")?.className).not.toContain("min-w-[860px]");
|
||||
expect(container.querySelectorAll("col")).toHaveLength(7);
|
||||
expect(screen.getByText(/tap the pencil/i).closest("td")).toHaveAttribute("colspan", "7");
|
||||
expect(headers).toEqual(["SKU", "Description", "Ord", "Coll", "Cost", "Surplus"]);
|
||||
expect(container.querySelector("table")?.className).toContain("min-w-[640px]");
|
||||
expect(container.querySelectorAll("col")).toHaveLength(6);
|
||||
expect(screen.getByText(/tap the pencil/i).closest("td")).toHaveAttribute("colspan", "6");
|
||||
const extra = screen.getByTestId(`ai-pos-row-${plain.id}`);
|
||||
expect(extra.closest("td")?.textContent).toContain(plain.description ?? "");
|
||||
expect(extra.textContent).toContain("Add to POS");
|
||||
expect(container.querySelector('[data-testid^="ai-ticks-"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("adds AI and POS headers when a row has ai_check", () => {
|
||||
render(<ItemsTable rows={[makeAiRow()]} onCostChange={noop} onSkuChange={noop} />);
|
||||
it("shows the ticks under the description (no extra columns) when a row has ai_check", () => {
|
||||
const row = makeAiRow();
|
||||
render(<ItemsTable rows={[row]} onCostChange={noop} onSkuChange={noop} />);
|
||||
const headers = screen.getAllByRole("columnheader").map((h) => h.textContent);
|
||||
expect(headers).toEqual(["SKU", "Description", "Ord", "Coll", "Cost", "Surplus", "AI", "POS"]);
|
||||
expect(headers).toEqual(["SKU", "Description", "Ord", "Coll", "Cost", "Surplus"]);
|
||||
const ticks = screen.getByTestId(`ai-ticks-${row.id}`);
|
||||
expect(ticks.closest("td")?.textContent).toContain(row.description ?? "");
|
||||
expect(ticks.textContent).toContain("SKU");
|
||||
expect(ticks.textContent).toContain("Price");
|
||||
});
|
||||
|
||||
it("renders four ticks with aria-labels from computeLineTicks and ours-vs-document titles", () => {
|
||||
|
|
|
|||
|
|
@ -73,9 +73,12 @@ function Tick({ name, state, detail }: { name: string; state: TickState; detail:
|
|||
title={detail}
|
||||
data-tick={name.toLowerCase()}
|
||||
data-state={state}
|
||||
className={`inline-flex ${TICK_CLASS[state]}`}
|
||||
className={`inline-flex items-center gap-0.5 ${TICK_CLASS[state]}`}
|
||||
>
|
||||
<TickIcon state={state} />
|
||||
<span aria-hidden className="text-[11px] font-medium">
|
||||
{name}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -90,7 +93,7 @@ function AiTicks({ row }: { row: ConfirmRow }) {
|
|||
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}`}>
|
||||
<div className="flex items-center gap-2" data-testid={`ai-ticks-${row.id}`}>
|
||||
<Tick
|
||||
name="SKU"
|
||||
state={t.sku}
|
||||
|
|
@ -115,6 +118,38 @@ function AiTicks({ row }: { row: ConfirmRow }) {
|
|||
);
|
||||
}
|
||||
|
||||
/** "In POS" (linked) or an "Add to POS" button; nothing when POS status is unknown. */
|
||||
function PosStatus({ row, onAddToPos }: { row: ConfirmRow; onAddToPos?: (rowId: string) => void }) {
|
||||
if (row.in_pos) {
|
||||
return 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>
|
||||
);
|
||||
}
|
||||
if (row.in_pos === false && onAddToPos) {
|
||||
const hasSku = !!effectiveSku(row).trim();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddToPos(row.id)}
|
||||
disabled={!hasSku}
|
||||
title={hasSku ? undefined : "Set a SKU first"}
|
||||
className="rounded border px-1.5 py-0.5 text-xs font-medium hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
data-testid={`add-to-pos-${row.id}`}
|
||||
>
|
||||
Add to POS
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Stock rows show their own line SKU when set; everything else as before. */
|
||||
function shownSku(row: ConfirmRow): string | undefined {
|
||||
return row.line_sku || row.sku;
|
||||
|
|
@ -181,7 +216,7 @@ export function ItemsTable({
|
|||
}: Props) {
|
||||
const aiColumn = showAi ?? rows.some((r) => !!r.ai_check);
|
||||
const posColumn = showAi ?? rows.some((r) => typeof r.in_pos === "boolean");
|
||||
const columnCount = 6 + (aiColumn ? 1 : 0) + (posColumn ? 1 : 0);
|
||||
const columnCount = 6;
|
||||
const [edit, setEdit] = useState<EditState | null>(null);
|
||||
|
||||
const startCostEdit = (row: ConfirmRow) => {
|
||||
|
|
@ -270,11 +305,7 @@ export function ItemsTable({
|
|||
};
|
||||
|
||||
return (
|
||||
<table
|
||||
className={`w-full table-fixed text-sm ${
|
||||
aiColumn ? "min-w-[860px]" : posColumn ? "min-w-[750px]" : "min-w-[640px]"
|
||||
}`}
|
||||
>
|
||||
<table className="w-full min-w-[640px] table-fixed text-sm">
|
||||
{/*
|
||||
Explicit column widths. `table-fixed` honours these, so a cell
|
||||
that goes into edit mode can no longer steal width from its
|
||||
|
|
@ -295,8 +326,6 @@ export function ItemsTable({
|
|||
<col className="w-[5rem]" />
|
||||
<col className="w-[8rem]" />
|
||||
<col className="w-[5rem]" />
|
||||
{aiColumn ? <col className="w-[7rem]" /> : null}
|
||||
{posColumn ? <col className="w-[7rem]" /> : null}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="border-b text-left">
|
||||
|
|
@ -306,8 +335,6 @@ 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>
|
||||
{aiColumn ? <th className="px-3 py-2 font-medium">AI</th> : null}
|
||||
{posColumn ? <th className="px-3 py-2 font-medium">POS</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -378,6 +405,18 @@ export function ItemsTable({
|
|||
<span className="line-clamp-2">
|
||||
{row.description ?? row.po_description ?? "—"}
|
||||
</span>
|
||||
{/* AI ticks and POS status sit under the description, not in
|
||||
extra columns: the table shares its pane with the document
|
||||
preview, and trailing columns ended up hidden behind it. */}
|
||||
{(aiColumn && row.ai_check) || (posColumn && typeof row.in_pos === "boolean") ? (
|
||||
<div
|
||||
className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs"
|
||||
data-testid={`ai-pos-row-${row.id}`}
|
||||
>
|
||||
{aiColumn && row.ai_check ? <AiTicks row={row} /> : null}
|
||||
{posColumn ? <PosStatus row={row} onAddToPos={onAddToPos} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right align-top tabular-nums">{ordered}</td>
|
||||
<td className="border-l border-border/60 px-3 py-2 text-right align-top tabular-nums">
|
||||
|
|
@ -498,38 +537,6 @@ export function ItemsTable({
|
|||
<td className="px-3 py-2 text-right align-top tabular-nums">
|
||||
{surplus > 0 ? `+${surplus}` : "—"}
|
||||
</td>
|
||||
{aiColumn ? (
|
||||
<td className="px-3 py-2 align-top">
|
||||
<AiTicks row={row} />
|
||||
</td>
|
||||
) : null}
|
||||
{posColumn ? (
|
||||
<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)}
|
||||
disabled={!effectiveSku(row).trim()}
|
||||
title={effectiveSku(row).trim() ? undefined : "Set a SKU first"}
|
||||
className="rounded border px-1.5 py-0.5 text-xs font-medium hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
data-testid={`add-to-pos-${row.id}`}
|
||||
>
|
||||
Add to POS
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -728,7 +728,7 @@ describe("checkCollectionCard", () => {
|
|||
expect(r.skus_filled).toBe(1);
|
||||
});
|
||||
|
||||
it("does not fill a SKU the AI couldn't read with certainty, even on a 'sure' pairing", async () => {
|
||||
it("still fills a SKU the AI wasn't certain it read (the rep edits it if wrong)", async () => {
|
||||
seedNss();
|
||||
const { deps } = makeDeps({
|
||||
readDocument: vi.fn(async () =>
|
||||
|
|
@ -740,11 +740,9 @@ describe("checkCollectionCard", () => {
|
|||
|
||||
const r = await checkCollectionCard(PO_CEM, { deps });
|
||||
|
||||
expect(skuCalls).toEqual([]);
|
||||
expect(table("order_items").get("l1")?.sku).toBe("");
|
||||
expect(aiCheck("order_items", "l1")?.sku_filled_value).toBe("");
|
||||
expect(aiCheck("order_items", "l1")?.doc_line?.code).toBe("0515315");
|
||||
expect(r.skus_filled).toBe(0);
|
||||
expect(table("order_items").get("l1")?.sku).toBe("0515315");
|
||||
expect(aiCheck("order_items", "l1")?.sku_filled_value).toBe("0515315");
|
||||
expect(r.skus_filled).toBe(1);
|
||||
});
|
||||
|
||||
it("never changes an existing SKU, even on a 'sure' pairing", async () => {
|
||||
|
|
|
|||
|
|
@ -828,10 +828,9 @@ async function checkCard(
|
|||
let skuFilled =
|
||||
previous?.sku_filled_value && str(fresh.sku) === previous.sku_filled_value ? previous.sku_filled_value : "";
|
||||
const code = chosen.docLine?.code.trim() ?? "";
|
||||
// Only a code the AI read character-for-character may become a SKU:
|
||||
// a sure pairing with a misread digit would still be a wrong SKU.
|
||||
const codeCertain = chosen.docLine?.code_certain === true;
|
||||
if (!ownSku && code && codeCertain && code.length <= MAX_SKU_LENGTH && chosen.decision?.same_item === "sure") {
|
||||
// Owner's call (2026-10-17): fill even when a digit may be misread on a
|
||||
// blurry photo; the rep edits it, and a near-miss code still helps.
|
||||
if (!ownSku && code && code.length <= MAX_SKU_LENGTH && chosen.decision?.same_item === "sure") {
|
||||
// A failed fill must not cost the line its check.
|
||||
try {
|
||||
if (line.kind === "customer") {
|
||||
|
|
|
|||
Loading…
Reference in a new issue