feat(invoicing): include sibling order items in invoicing rows

- extract reusable constants and helper functions including escapeFilterValue, dedupeRawRows, listAllInvoicingRows, and loadInvoicingRows
- add safe filter value escaping to prevent query injection risks
- rewrite data fetching to pull all related order items for orders with ready-to-invoice rows, then deduplicate results
- replace raw getFullList calls with paginated PocketBase API usage
- update test suite to mock getList instead of getFullList and add coverage for new behavior
- clean up redundant eslint disable comments in modal components
This commit is contained in:
SJCoetz 2026-08-05 23:17:06 +02:00
parent 9f601002af
commit d8dea63d16
2 changed files with 245 additions and 17 deletions

View file

@ -14,15 +14,47 @@ const YESTERDAY_ISO = (() => {
})();
const YESTERDAY_ISO_AT_8AM = `${YESTERDAY_ISO}T08:00:00.000Z`;
const { fetchCustomersMock, getFullListMock, mockedRows, makeRawRow } = vi.hoisted(() => ({
fetchCustomersMock: vi.fn(async () => [
type ListOpts = {
filter?: string;
sort?: string;
expand?: string;
fields?: string;
};
type PageResult = {
items: Array<Record<string, unknown>>;
totalItems: number;
totalPages: number;
page: number;
perPage: number;
};
function buildPageResult(items: Array<Record<string, unknown>>, page = 1, perPage = 500): PageResult {
return {
items,
totalItems: items.length,
totalPages: Math.max(1, Math.ceil(items.length / perPage)),
page,
perPage,
};
}
const {
fetchCustomersMock,
getFullListMock,
getListMock,
mockedRows,
makeRawRow,
} = vi.hoisted(() => {
const mockedRows: Array<Record<string, unknown>> = [];
const fetchCustomersMock = vi.fn(async () => [
{ id: "qb-1", name: "Adal Engineering" },
{ id: "qb-2", name: "Anglo Mining" },
{ id: "qb-3", name: "GIELIE Trading" },
]),
getFullListMock: vi.fn(async () => mockedRows),
mockedRows: [] as Array<Record<string, unknown>>,
makeRawRow: (overrides: Record<string, unknown> = {}) => ({
]);
const getFullListMock = vi.fn(async () => mockedRows);
const getListMock = vi.fn<
(page?: number, perPage?: number, opts?: ListOpts) => Promise<PageResult>
>(async (page = 1, perPage = 500) => buildPageResult(mockedRows, page, perPage));
const makeRawRow = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
id: "row-1",
description: "Wheel hub kit",
item_status: "ready_invoice",
@ -51,8 +83,16 @@ const { fetchCustomersMock, getFullListMock, mockedRows, makeRawRow } = vi.hoist
},
},
...overrides,
}),
}));
});
return {
fetchCustomersMock,
getFullListMock,
getListMock,
mockedRows,
makeRawRow,
};
});
vi.mock("@/lib/pocketbase", () => ({
getPocketBase: () => ({
@ -65,6 +105,7 @@ vi.mock("@/lib/pocketbase", () => ({
},
collection: () => ({
getFullList: getFullListMock,
getList: getListMock,
}),
}),
}));
@ -85,6 +126,10 @@ describe("InvoicingPage", () => {
beforeEach(() => {
getFullListMock.mockReset();
getFullListMock.mockImplementation(async () => mockedRows);
getListMock.mockReset();
getListMock.mockImplementation(async (page = 1, perPage = 500) =>
buildPageResult(mockedRows, page, perPage),
);
mockedRows.splice(
0,
mockedRows.length,
@ -186,6 +231,115 @@ describe("InvoicingPage", () => {
expect(screen.getByRole("tab", { name: "Invoiced" })).toBeInTheDocument();
});
it("loads targeted invoicing rows and keeps sibling outstanding rows for ready orders", async () => {
const readyRow = makeRawRow({
id: "row-ready",
description: "Pump shaft",
item_status: "ready_invoice",
expand: {
purchase_order: {
id: "po-ready",
po_number: "PO-READY",
expand: { supplier: { id: "sup-ready", suppliername: "Alpha Bearings" } },
},
order: {
id: "ord-ready",
order_number: "SO-READY",
expand: {
company: { company: "Adal Engineering" },
customer: { customer: "GIELIE" },
},
},
},
});
const siblingOutstanding = makeRawRow({
id: "row-outstanding",
description: "Seal kit",
item_status: "pending",
expand: {
purchase_order: {
id: "po-outstanding",
po_number: "PO-OUTSTANDING",
expand: { supplier: { id: "sup-outstanding", suppliername: "Beta Co" } },
},
order: {
id: "ord-ready",
order_number: "SO-READY",
expand: {
company: { company: "Adal Engineering" },
customer: { customer: "GIELIE" },
},
},
},
});
const recentInvoice = makeRawRow({
id: "row-invoiced-recent",
description: "Brake disc",
item_status: "invoiced",
order_item_details: {
invoice: {
date: YESTERDAY_ISO,
number: "INV-RECENT",
setBy: "u-1",
setByName: "Op",
setAt: YESTERDAY_ISO_AT_8AM,
},
},
expand: {
purchase_order: {
id: "po-invoiced",
po_number: "PO-INVOICED",
expand: { supplier: { id: "sup-invoiced", suppliername: "Gamma Co" } },
},
order: {
id: "ord-invoiced",
order_number: "SO-INVOICED",
expand: {
company: { company: "CNL" },
customer: { customer: "South Pit" },
},
},
},
});
mockedRows.splice(0, mockedRows.length, readyRow, siblingOutstanding, recentInvoice);
getListMock.mockImplementation(async (page = 1, perPage = 500, opts?: ListOpts) => {
const filter = opts?.filter ?? "";
const items = filter.includes('order = "ord-ready"')
? [readyRow, siblingOutstanding]
: [readyRow, recentInvoice];
return buildPageResult(items, page, perPage);
});
render(<Page />);
expect(await screen.findByText("1 ready / 2 total")).toBeInTheDocument();
expect(screen.getByText("Has outstanding")).toBeInTheDocument();
expect(getFullListMock).not.toHaveBeenCalled();
expect(getListMock).toHaveBeenCalledTimes(2);
expect(getListMock).toHaveBeenNthCalledWith(
1,
1,
500,
expect.objectContaining({
expand: "purchase_order.supplier,order.company,order.customer",
}),
);
expect(String((getListMock.mock.calls[0]?.[2] as { filter?: string } | undefined)?.filter)).toContain(
'item_status = "ready_invoice"',
);
expect(String((getListMock.mock.calls[0]?.[2] as { filter?: string } | undefined)?.filter)).toContain(
'item_status = "invoiced"',
);
expect(String((getListMock.mock.calls[1]?.[2] as { filter?: string } | undefined)?.filter)).toContain(
'order = "ord-ready"',
);
fireEvent.click(screen.getByRole("button", { name: "Show outstanding" }));
expect(await screen.findByText("Seal kit")).toBeInTheDocument();
expect(screen.getByText(/pending/i)).toBeInTheDocument();
});
it("shows only invoiced rows from the default last-3-days range", async () => {
vi.useFakeTimers();
try {
@ -803,7 +957,7 @@ describe("InvoicingPage", () => {
printedBy: "user-42",
printedByName: "Casey Booker",
});
await waitFor(() => expect(getFullListMock).toHaveBeenCalledTimes(2));
await waitFor(() => expect(getListMock).toHaveBeenCalledTimes(2));
expect(openSpy).toHaveBeenCalledWith("blob:test", "_blank", "noopener,noreferrer");
expect(createObjectUrlSpy).toHaveBeenCalledTimes(1);
} finally {

View file

@ -67,6 +67,87 @@ type RawInvoicingRow = {
};
};
const ORDER_ITEMS_COLLECTION = "order_items";
const INVOICING_PAGE_SIZE = 500;
const INVOICING_EXPAND = "purchase_order.supplier,order.company,order.customer";
const INVOICING_FIELDS = [
"id",
"collectionId",
"description",
"item_status",
"qb_invoice_pdf",
"order_qty",
"qty_collected",
"cost_price",
"selling_price",
"delivery_note_number",
"order_item_details",
"order",
"purchase_order",
].join(",");
const INVOICING_STATUS_FILTER =
'(item_status = "ready_invoice" || item_status = "invoiced" || item_status = "delivery_note")';
function escapeFilterValue(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function dedupeRawRows(rows: RawInvoicingRow[]): RawInvoicingRow[] {
const seen = new Set<string>();
const out: RawInvoicingRow[] = [];
for (const row of rows) {
if (seen.has(row.id)) continue;
seen.add(row.id);
out.push(row);
}
return out;
}
async function listAllInvoicingRows(
pb: ReturnType<typeof getPocketBase>,
filter: string,
): Promise<RawInvoicingRow[]> {
const collection = pb.collection(ORDER_ITEMS_COLLECTION);
const query = {
filter,
sort: "-updated",
expand: INVOICING_EXPAND,
fields: INVOICING_FIELDS,
};
const firstPage = await collection.getList<RawInvoicingRow>(1, INVOICING_PAGE_SIZE, query);
if (firstPage.totalPages <= 1) return firstPage.items;
const rest = await Promise.all(
Array.from({ length: firstPage.totalPages - 1 }, (_, index) =>
collection.getList<RawInvoicingRow>(index + 2, INVOICING_PAGE_SIZE, query),
),
);
return [firstPage, ...rest].flatMap((page) => page.items);
}
async function loadInvoicingRows(pb: ReturnType<typeof getPocketBase>): Promise<RawInvoicingRow[]> {
const seedRows = await listAllInvoicingRows(pb, INVOICING_STATUS_FILTER);
const readyOrderIds = Array.from(
new Set(
seedRows
.filter((row) => row.item_status === "ready_invoice")
.map((row) => row.expand?.order?.id?.trim() ?? "")
.filter(Boolean),
),
);
if (readyOrderIds.length === 0) {
return seedRows;
}
const siblingFilter = readyOrderIds
.map((orderId) => `order = "${escapeFilterValue(orderId)}"`)
.join(" || ");
const siblingRows = await listAllInvoicingRows(pb, siblingFilter);
return dedupeRawRows([...seedRows, ...siblingRows]);
}
function rowPoNumber(row: RawInvoicingRow): string {
return row.expand?.purchase_order?.po_number ?? "";
}
@ -538,7 +619,6 @@ function SingleOrderQbModal({
// open/close transition — not on every parent re-render.
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional open/close seed
setOrderNumberOverride(group.orderNumber);
// eslint-disable-next-line react-hooks/set-state-in-effect -- see note above
setLineOverrides(
Object.fromEntries(
group.rows.map((row) => {
@ -549,7 +629,6 @@ function SingleOrderQbModal({
);
// Default to "all rows on this invoice" — the common case. Operator
// can uncheck the ones they don't want.
// eslint-disable-next-line react-hooks/set-state-in-effect -- see note above
setIncludedRowIds(new Set(group.rows.map((row) => row.id)));
}, [open, group]);
@ -858,7 +937,6 @@ function SingleDeliveryNoteModal({
if (!open || !group) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional open/close seed
setOrderNumberOverride(group.orderNumber);
// eslint-disable-next-line react-hooks/set-state-in-effect -- see note above
setLineOverrides(
Object.fromEntries(
group.rows.map((row) => {
@ -867,7 +945,6 @@ function SingleDeliveryNoteModal({
}),
),
);
// eslint-disable-next-line react-hooks/set-state-in-effect -- see note above
setIncludedRowIds(new Set(group.rows.map((row) => row.id)));
}, [open, group]);
@ -1106,10 +1183,7 @@ export default function InvoicingPage() {
setRows(null);
try {
const pb = getPocketBase();
const items = await pb.collection("order_items").getFullList<RawInvoicingRow>({
sort: "-updated",
expand: "purchase_order.supplier,order.company,order.customer",
});
const items = await loadInvoicingRows(pb);
setRows(items);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);