fix(collection-ai): pre-fill only from the chip's tax invoice, AI column only with ai_check
M2: the confirm dialog pre-fill uses the card's newest document (as the chip) and only when it is a tax invoice. M3: the AI ticks column (and its min-width) shows only when a row has ai_check; the POS column stays whenever in_pos is known. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f60b1d893c
commit
8efee9db42
4 changed files with 63 additions and 27 deletions
|
|
@ -28,6 +28,17 @@ describe("ItemsTable AI layer", () => {
|
||||||
expect(screen.queryByText(/add to pos/i)).not.toBeInTheDocument();
|
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", () => {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
it("adds AI and POS headers when a row has ai_check", () => {
|
it("adds AI and POS headers when a row has ai_check", () => {
|
||||||
render(<ItemsTable rows={[makeAiRow()]} onCostChange={noop} onSkuChange={noop} />);
|
render(<ItemsTable rows={[makeAiRow()]} onCostChange={noop} onSkuChange={noop} />);
|
||||||
const headers = screen.getAllByRole("columnheader").map((h) => h.textContent);
|
const headers = screen.getAllByRole("columnheader").map((h) => h.textContent);
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,10 @@ type Props = {
|
||||||
/** "Add to POS" click for a line that is not in the POS yet. */
|
/** "Add to POS" click for a line that is not in the POS yet. */
|
||||||
onAddToPos?: (rowId: string) => void;
|
onAddToPos?: (rowId: string) => void;
|
||||||
/**
|
/**
|
||||||
* Force the AI + POS columns on or off. Defaults to on when some row
|
* Force the AI + POS columns on or off. By default the AI column shows
|
||||||
* carries an `ai_check` or a known `in_pos`, so a table with neither
|
* only when some row carries an `ai_check`, and the POS column when some
|
||||||
* renders exactly as before.
|
* row has a known `in_pos`, so a table with neither renders exactly as
|
||||||
|
* before.
|
||||||
*/
|
*/
|
||||||
showAi?: boolean;
|
showAi?: boolean;
|
||||||
};
|
};
|
||||||
|
|
@ -178,8 +179,9 @@ export function ItemsTable({
|
||||||
onAddToPos,
|
onAddToPos,
|
||||||
showAi,
|
showAi,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const aiColumns =
|
const aiColumn = showAi ?? rows.some((r) => !!r.ai_check);
|
||||||
showAi ?? rows.some((r) => !!r.ai_check || typeof r.in_pos === "boolean");
|
const posColumn = showAi ?? rows.some((r) => typeof r.in_pos === "boolean");
|
||||||
|
const columnCount = 6 + (aiColumn ? 1 : 0) + (posColumn ? 1 : 0);
|
||||||
const [edit, setEdit] = useState<EditState | null>(null);
|
const [edit, setEdit] = useState<EditState | null>(null);
|
||||||
|
|
||||||
const startCostEdit = (row: ConfirmRow) => {
|
const startCostEdit = (row: ConfirmRow) => {
|
||||||
|
|
@ -269,7 +271,9 @@ export function ItemsTable({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table
|
<table
|
||||||
className={`w-full table-fixed text-sm ${aiColumns ? "min-w-[860px]" : "min-w-[640px]"}`}
|
className={`w-full table-fixed text-sm ${
|
||||||
|
aiColumn ? "min-w-[860px]" : posColumn ? "min-w-[750px]" : "min-w-[640px]"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{/*
|
{/*
|
||||||
Explicit column widths. `table-fixed` honours these, so a cell
|
Explicit column widths. `table-fixed` honours these, so a cell
|
||||||
|
|
@ -291,8 +295,8 @@ export function ItemsTable({
|
||||||
<col className="w-[5rem]" />
|
<col className="w-[5rem]" />
|
||||||
<col className="w-[8rem]" />
|
<col className="w-[8rem]" />
|
||||||
<col className="w-[5rem]" />
|
<col className="w-[5rem]" />
|
||||||
{aiColumns ? <col className="w-[7rem]" /> : null}
|
{aiColumn ? <col className="w-[7rem]" /> : null}
|
||||||
{aiColumns ? <col className="w-[7rem]" /> : null}
|
{posColumn ? <col className="w-[7rem]" /> : null}
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b text-left">
|
<tr className="border-b text-left">
|
||||||
|
|
@ -302,8 +306,8 @@ export function ItemsTable({
|
||||||
<th className="border-l border-border/60 px-3 py-2 text-right font-medium">Coll</th>
|
<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">Cost</th>
|
||||||
<th className="px-3 py-2 text-right font-medium">Surplus</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}
|
{aiColumn ? <th className="px-3 py-2 font-medium">AI</th> : null}
|
||||||
{aiColumns ? <th className="px-3 py-2 font-medium">POS</th> : null}
|
{posColumn ? <th className="px-3 py-2 font-medium">POS</th> : null}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -494,12 +498,12 @@ export function ItemsTable({
|
||||||
<td className="px-3 py-2 text-right align-top tabular-nums">
|
<td className="px-3 py-2 text-right align-top tabular-nums">
|
||||||
{surplus > 0 ? `+${surplus}` : "—"}
|
{surplus > 0 ? `+${surplus}` : "—"}
|
||||||
</td>
|
</td>
|
||||||
{aiColumns ? (
|
{aiColumn ? (
|
||||||
<td className="px-3 py-2 align-top">
|
<td className="px-3 py-2 align-top">
|
||||||
<AiTicks row={row} />
|
<AiTicks row={row} />
|
||||||
</td>
|
</td>
|
||||||
) : null}
|
) : null}
|
||||||
{aiColumns ? (
|
{posColumn ? (
|
||||||
<td className="px-3 py-2 align-top text-xs">
|
<td className="px-3 py-2 align-top text-xs">
|
||||||
{row.in_pos ? (
|
{row.in_pos ? (
|
||||||
row.pos_stock_item_id ? (
|
row.pos_stock_item_id ? (
|
||||||
|
|
@ -540,7 +544,7 @@ export function ItemsTable({
|
||||||
*/}
|
*/}
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={aiColumns ? 8 : 6}
|
colSpan={columnCount}
|
||||||
className="px-3 pb-1 pt-2 text-[11px] text-muted-foreground"
|
className="px-3 pb-1 pt-2 text-[11px] text-muted-foreground"
|
||||||
>
|
>
|
||||||
Tap the pencil to edit · Enter saves · Esc cancels
|
Tap the pencil to edit · Enter saves · Esc cancels
|
||||||
|
|
|
||||||
|
|
@ -72,21 +72,39 @@ describe("collection-ai-card", () => {
|
||||||
expect(cardDocument([makeAiRow({ ai_doc: null })])).toBeNull();
|
expect(cardDocument([makeAiRow({ ai_doc: null })])).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("derives invoice defaults from the first row with a document number", () => {
|
it("derives invoice defaults from the card's (newest) tax invoice", () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
makeAiRow({ id: "1", ai_check: null }),
|
makeAiRow({ id: "1", ai_check: null, ai_doc: null }),
|
||||||
makeAiRow({ id: "2", ai_check: makeCheck({ document_no: "5028256", doc_date: "2026-10-16" }) }),
|
makeAiRow({ id: "2", ai_doc: doc("5028256", [], "2026-10-16") }),
|
||||||
];
|
];
|
||||||
expect(aiInvoiceDefaults(rows)).toEqual({ number: "5028256", date: "2026-10-16" });
|
expect(aiInvoiceDefaults(rows)).toEqual({ number: "5028256", date: "2026-10-16" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the same newest document as the chip", () => {
|
||||||
|
const rows = [
|
||||||
|
makeAiRow({ id: "1", ai_doc: doc("OLD-1", [], "2026-10-10") }),
|
||||||
|
makeAiRow({ id: "2", ai_doc: doc("NEW-2", [], "2026-10-16") }),
|
||||||
|
];
|
||||||
|
expect(aiInvoiceDefaults(rows)).toEqual({ number: "NEW-2", date: "2026-10-16" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never pre-fills from a delivery note", () => {
|
||||||
|
const dn = { ...doc("DN-7", [], "2026-10-16"), doc_type: "delivery_note" as const };
|
||||||
|
const rows = [
|
||||||
|
makeAiRow({ id: "1", ai_doc: doc("INV-1", [], "2026-10-10") }),
|
||||||
|
makeAiRow({ id: "2", ai_doc: dn }),
|
||||||
|
];
|
||||||
|
// The chip shows the newer delivery note, so nothing is pre-filled.
|
||||||
|
expect(aiInvoiceDefaults(rows)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("returns undefined without any document number", () => {
|
it("returns undefined without any document number", () => {
|
||||||
expect(aiInvoiceDefaults([makeAiRow({ ai_check: null })])).toBeUndefined();
|
expect(aiInvoiceDefaults([makeAiRow({ ai_check: null, ai_doc: null })])).toBeUndefined();
|
||||||
expect(aiInvoiceDefaults([makeAiRow({ ai_check: makeCheck({ document_no: "" }) })])).toBeUndefined();
|
expect(aiInvoiceDefaults([makeAiRow({ ai_doc: doc("", []) })])).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits an invalid date", () => {
|
it("omits an invalid date", () => {
|
||||||
const rows = [makeAiRow({ ai_check: makeCheck({ doc_date: "" }) })];
|
const rows = [makeAiRow({ ai_doc: doc("5028256", [], "") })];
|
||||||
expect(aiInvoiceDefaults(rows)).toEqual({ number: "5028256" });
|
expect(aiInvoiceDefaults(rows)).toEqual({ number: "5028256" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -41,17 +41,20 @@ export function cardDocument(rows: ConfirmRow[]): AiDoc | null {
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pre-fill for the confirm dialog from the first row whose check has a document number. */
|
/**
|
||||||
|
* Pre-fill for the confirm dialog: the number and date of the card's
|
||||||
|
* document (the same newest document the chip shows), only when it is a
|
||||||
|
* tax invoice — a delivery note's number is not the invoice number.
|
||||||
|
*/
|
||||||
export function aiInvoiceDefaults(
|
export function aiInvoiceDefaults(
|
||||||
rows: ConfirmRow[],
|
rows: ConfirmRow[],
|
||||||
): { number: string; date?: string } | undefined {
|
): { number: string; date?: string } | undefined {
|
||||||
for (const row of rows) {
|
const doc = cardDocument(rows);
|
||||||
const number = row.ai_check?.document_no?.trim();
|
if (!doc || doc.doc_type !== "tax_invoice") return undefined;
|
||||||
if (!number) continue;
|
const number = (doc.document_no ?? "").trim();
|
||||||
const date = row.ai_check?.doc_date ?? "";
|
if (!number) return undefined;
|
||||||
return /^\d{4}-\d{2}-\d{2}$/.test(date) ? { number, date } : { number };
|
const date = doc.doc_date ?? "";
|
||||||
}
|
return /^\d{4}-\d{2}-\d{2}$/.test(date) ? { number, date } : { number };
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Toast for an Add to POS response: plain success, or the counts when any row didn't go in. */
|
/** Toast for an Add to POS response: plain success, or the counts when any row didn't go in. */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue