diff --git a/src/app/(app)/invoicing/page.test.tsx b/src/app/(app)/invoicing/page.test.tsx index 1dab4ca..e133313 100644 --- a/src/app/(app)/invoicing/page.test.tsx +++ b/src/app/(app)/invoicing/page.test.tsx @@ -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>; + totalItems: number; + totalPages: number; + page: number; + perPage: number; +}; +function buildPageResult(items: Array>, 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> = []; + 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>, - makeRawRow: (overrides: Record = {}) => ({ + ]); + const getFullListMock = vi.fn(async () => mockedRows); + const getListMock = vi.fn< + (page?: number, perPage?: number, opts?: ListOpts) => Promise + >(async (page = 1, perPage = 500) => buildPageResult(mockedRows, page, perPage)); + const makeRawRow = (overrides: Record = {}): Record => ({ 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(); + + 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 { diff --git a/src/app/(app)/invoicing/page.tsx b/src/app/(app)/invoicing/page.tsx index 0d346bf..f5ba82c 100644 --- a/src/app/(app)/invoicing/page.tsx +++ b/src/app/(app)/invoicing/page.tsx @@ -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(); + 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, + filter: string, +): Promise { + const collection = pb.collection(ORDER_ITEMS_COLLECTION); + const query = { + filter, + sort: "-updated", + expand: INVOICING_EXPAND, + fields: INVOICING_FIELDS, + }; + const firstPage = await collection.getList(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(index + 2, INVOICING_PAGE_SIZE, query), + ), + ); + + return [firstPage, ...rest].flatMap((page) => page.items); +} + +async function loadInvoicingRows(pb: ReturnType): Promise { + 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({ - 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);