diff --git a/src/app/(app)/invoicing/page.test.tsx b/src/app/(app)/invoicing/page.test.tsx index 9611e10..025add4 100644 --- a/src/app/(app)/invoicing/page.test.tsx +++ b/src/app/(app)/invoicing/page.test.tsx @@ -368,7 +368,7 @@ describe("InvoicingPage", () => { expect(screen.getByText(/pending/i)).toBeInTheDocument(); }); - it("shows only invoiced rows from the default last-3-days range", async () => { + it("shows only invoiced rows from the default last-3-days range (or unprinted, regardless of date)", async () => { vi.useFakeTimers(); try { vi.setSystemTime(new Date("2026-07-09T12:00:00.000Z")); @@ -405,14 +405,56 @@ describe("InvoicingPage", () => { }, }, }), + // Older than the 3-day window, but ALREADY printed — this + // is the row the new filter excludes (printed === true drops + // out of the unprinted branch). makeRawRow({ - id: "row-invoiced-old", + id: "row-invoiced-old-printed", description: "Older invoice item", item_status: "invoiced", order_item_details: { invoice: { date: "2026-07-05", - number: "INV-OLD", + number: "INV-OLD-PRINTED", + setBy: "u-1", + setByName: "Op", + setAt: "2026-07-05T08:00:00.000Z", + }, + print: { + printed: true, + printedAt: "2026-07-05T11:00:00.000Z", + printedBy: "user-1", + printedByName: "Op", + printCount: 1, + }, + }, + expand: { + purchase_order: { + id: "po-old-printed", + po_number: "PO-OLD-PRINTED", + expand: { supplier: { id: "sup-old-printed", suppliername: "Beta Co" } }, + }, + order: { + id: "ord-old-printed", + order_number: "SO-OLD-PRINTED", + expand: { + company: { company: "CNL" }, + customer: { customer: "South Pit" }, + }, + }, + }, + }), + // Older than the 3-day window AND still unprinted — this is + // the new behaviour: surfaces on the Invoiced tab so the + // operator can clear the backlog. + makeRawRow({ + id: "row-invoiced-old-unprinted", + description: "Older invoice item", + item_status: "invoiced", + order_item_details: { + invoice: { + date: "2026-07-05", + number: "INV-OLD-UNPRINTED", setBy: "u-1", setByName: "Op", setAt: "2026-07-05T08:00:00.000Z", @@ -420,16 +462,16 @@ describe("InvoicingPage", () => { }, expand: { purchase_order: { - id: "po-old", - po_number: "PO-OLD", - expand: { supplier: { id: "sup-old", suppliername: "Beta Co" } }, + id: "po-old-unprinted", + po_number: "PO-OLD-UNPRINTED", + expand: { supplier: { id: "sup-old-unprinted", suppliername: "Gamma Co" } }, }, order: { - id: "ord-old", - order_number: "SO-OLD", + id: "ord-old-unprinted", + order_number: "SO-OLD-UNPRINTED", expand: { - company: { company: "CNL" }, - customer: { customer: "South Pit" }, + company: { company: "ISHERWOOD" }, + customer: { customer: "North Pit" }, }, }, }, @@ -442,7 +484,12 @@ describe("InvoicingPage", () => { fireEvent.click(screen.getByRole("tab", { name: "Invoiced" })); expect(screen.getByText("INV-RECENT")).toBeInTheDocument(); - expect(screen.queryByText("INV-OLD")).not.toBeInTheDocument(); + // Old + printed is filtered out (no longer in the 3-day window + // and the printed flag means the operator has already handled it). + expect(screen.queryByText("INV-OLD-PRINTED")).not.toBeInTheDocument(); + // Old + unprinted IS shown — the new "in 3 days OR unprinted" + // rule keeps it on the operator's list. + expect(screen.getByText("INV-OLD-UNPRINTED")).toBeInTheDocument(); } finally { vi.useRealTimers(); } @@ -994,7 +1041,7 @@ describe("InvoicingPage", () => { } }); - it("bulk prints only today's unprinted invoices", async () => { + it("bulk prints every unprinted invoice, regardless of date", async () => { const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); const createObjectUrlSpy = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:test"); @@ -1008,6 +1055,7 @@ describe("InvoicingPage", () => { mockedRows.splice( 0, mockedRows.length, + // Today + unprinted → printed. makeRawRow({ id: "row-today-unprinted", item_status: "invoiced", @@ -1033,6 +1081,7 @@ describe("InvoicingPage", () => { }, }, }), + // Today + printed → skipped (already done). makeRawRow({ id: "row-today-printed", item_status: "invoiced", @@ -1065,6 +1114,10 @@ describe("InvoicingPage", () => { }, }, }), + // Older than the 3-day window BUT still unprinted → also + // printed. The new "in 3 days OR unprinted" filter surfaces + // this row on the Invoiced tab, so the bulk button has to + // catch it too. makeRawRow({ id: "row-old-unprinted", item_status: "invoiced", @@ -1111,24 +1164,21 @@ describe("InvoicingPage", () => { render(); fireEvent.click(await screen.findByRole("tab", { name: "Invoiced" })); - fireEvent.click(await screen.findByRole("button", { name: /print today's unprinted/i })); + fireEvent.click(await screen.findByRole("button", { name: /print all unprinted/i })); await waitFor(() => { const printBundleCalls = invoiceWebhookFetchMock.mock.calls .map((call) => call[0]) .filter((input) => typeof input === "string" && input.startsWith("/api/invoicing/print-bundle?")); - expect(printBundleCalls).toEqual([ + // Both unprinted invoices (today + old) get printed. The + // already-printed one is skipped. + expect(printBundleCalls.sort()).toEqual([ + "/api/invoicing/print-bundle?invoiceNumber=INV-OLD-UNPRINTED", "/api/invoicing/print-bundle?invoiceNumber=INV-TODAY-UNPRINTED", ]); }); - expect(invoiceWebhookFetchMock.mock.calls[1]?.[0]).toBe("/api/invoicing/print-tracking"); - expect( - JSON.parse(String((invoiceWebhookFetchMock.mock.calls[1]?.[1] as RequestInit | undefined)?.body)), - ).toMatchObject({ - invoiceNumber: "INV-TODAY-UNPRINTED", - }); - expect(openSpy).toHaveBeenCalledTimes(1); - expect(createObjectUrlSpy).toHaveBeenCalledTimes(1); + expect(openSpy).toHaveBeenCalledTimes(2); + expect(createObjectUrlSpy).toHaveBeenCalledTimes(2); } finally { openSpy.mockRestore(); createObjectUrlSpy.mockRestore(); diff --git a/src/app/(app)/invoicing/page.tsx b/src/app/(app)/invoicing/page.tsx index 358bcf3..3f9054c 100644 --- a/src/app/(app)/invoicing/page.tsx +++ b/src/app/(app)/invoicing/page.tsx @@ -1368,6 +1368,15 @@ export default function InvoicingPage() { () => buildTodayUnprintedInvoiceGroups(invoicingRows, todayIso), [invoicingRows, todayIso], ); + // "All unprinted" — every invoice group that has at least one row + // not yet printed, regardless of date. The Invoiced tab now + // surfaces these on the operator's list (filter rule relaxed from + // "last 3 days" to "last 3 days OR unprinted"), so the bulk-print + // button matches what the operator sees. + const allUnprintedInvoiceGroups = useMemo( + () => invoicePrintGroups.filter((group) => !group.printed), + [invoicePrintGroups], + ); const invoicePrintGroupMap = useMemo( () => new Map(invoicePrintGroups.map((group) => [group.invoiceNumber, group])), [invoicePrintGroups], @@ -1386,6 +1395,11 @@ export default function InvoicingPage() { () => buildTodayUnprintedDeliveryNoteGroups(invoicingRows, todayIso), [invoicingRows, todayIso], ); + // Same "all unprinted" filter as invoices, mirrored for DNs. + const allUnprintedDeliveryNoteGroups = useMemo( + () => deliveryNotePrintGroups.filter((group) => !group.printed), + [deliveryNotePrintGroups], + ); const deliveryNotePrintGroupMap = useMemo( () => new Map(deliveryNotePrintGroups.map((group) => [group.deliveryNoteNumber, group])), [deliveryNotePrintGroups], @@ -1407,15 +1421,25 @@ export default function InvoicingPage() { // The Invoiced tab is now split into two independent sub-sections: // `invoicedGroups` (real invoices) and `deliveryNoteGroups` (items - // marked as delivery notes). They share the same date range - // (`recentInvoiceRange`) so the operator sees a coherent "last 3 days - // of paperwork" view, but the lists are separate — delivery notes - // don't sit next to invoices in the same flat list anymore. + // marked as delivery notes). + // + // Filter rule (was: "last 3 days only"; now: "last 3 days OR + // unprinted"): a row appears if it falls inside the 3-day window + // OR if it has not been printed yet. This means an invoice or DN + // that falls off the 3-day window still surfaces here until the + // operator prints it, so the list doubles as the "what's still + // outstanding" view. Stock bookings use a stricter 3-day window + // only — there's no print-tracking on `stock_purchases` yet so + // "unprinted stock booking" is undefined. const invoicedGroups = useMemo(() => { const eligible = invoicingRows.filter((row) => { if (row.item_status !== "invoiced") return false; if (!row.invoiceDate) return false; - return row.invoiceDate >= recentInvoiceRange.from && row.invoiceDate <= recentInvoiceRange.to; + const inRange = + row.invoiceDate >= recentInvoiceRange.from && + row.invoiceDate <= recentInvoiceRange.to; + const isUnprinted = row.printed !== true; + return inRange || isUnprinted; }); const grouped = groupRowsByOrderNumber(eligible); const q = searchText.trim(); @@ -1429,7 +1453,11 @@ export default function InvoicingPage() { const eligible = invoicingRows.filter((row) => { if (row.item_status !== "delivery_note") return false; if (!row.deliveryNoteDate) return false; - return row.deliveryNoteDate >= recentInvoiceRange.from && row.deliveryNoteDate <= recentInvoiceRange.to; + const inRange = + row.deliveryNoteDate >= recentInvoiceRange.from && + row.deliveryNoteDate <= recentInvoiceRange.to; + const isUnprinted = row.printed !== true; + return inRange || isUnprinted; }); const grouped = groupRowsByOrderNumber(eligible); const q = searchText.trim(); @@ -1704,15 +1732,19 @@ export default function InvoicingPage() { [printingGroupId], ); - // Print today's unprinted INVOICES + DELIVERY NOTES in one - // pass. Each group is printed via its own handler (which opens - // the PDF bundle in a new tab, then stamps the print metadata) - // so the operator gets a sequence of tabs rather than a single - // mega-bundle they have to scroll through. + // Print every unprinted invoice + delivery note in one pass — + // matches what the operator sees on the Invoiced tab (which + // surfaces all unprinted rows, regardless of date). Was scoped to + // "today's" only; broadened so the operator can clear the backlog + // without scrolling the list. Each group is printed via its own + // handler (which opens the PDF bundle in a new tab, then stamps + // the print metadata) so the operator gets a sequence of tabs + // rather than a single mega-bundle they have to scroll through. const handleBulkPrintToday = useCallback(async () => { if ( bulkPrinting || - (todaysUnprintedGroups.length === 0 && todaysUnprintedDeliveryNoteGroups.length === 0) + (allUnprintedInvoiceGroups.length === 0 && + allUnprintedDeliveryNoteGroups.length === 0) ) { return; } @@ -1726,7 +1758,7 @@ export default function InvoicingPage() { try { // Invoices first (matches the existing ordering in the // toolbar button's "invoice then delivery note" visual). - for (const invoiceGroup of todaysUnprintedGroups) { + for (const invoiceGroup of allUnprintedInvoiceGroups) { const success = await handlePrint({ orderNumber: invoiceGroup.rows[0]?.orderNumber ?? invoiceGroup.invoiceNumber, rows: invoiceGroup.rows, @@ -1734,7 +1766,7 @@ export default function InvoicingPage() { if (success) invoiceSuccess += 1; else invoiceFail += 1; } - for (const dnGroup of todaysUnprintedDeliveryNoteGroups) { + for (const dnGroup of allUnprintedDeliveryNoteGroups) { const success = await handlePrintDeliveryNote({ orderNumber: dnGroup.rows[0]?.orderNumber ?? dnGroup.deliveryNoteNumber, rows: dnGroup.rows, @@ -1764,8 +1796,8 @@ export default function InvoicingPage() { bulkPrinting, handlePrint, handlePrintDeliveryNote, - todaysUnprintedGroups, - todaysUnprintedDeliveryNoteGroups, + allUnprintedInvoiceGroups, + allUnprintedDeliveryNoteGroups, ]); // The group object passed to the single-order modal needs a stable @@ -2051,15 +2083,18 @@ export default function InvoicingPage() { disabled={ activeTab !== "invoiced" || bulkPrinting || - (todaysUnprintedGroups.length === 0 && todaysUnprintedDeliveryNoteGroups.length === 0) + (allUnprintedInvoiceGroups.length === 0 && + allUnprintedDeliveryNoteGroups.length === 0) } > {bulkPrinting - ? "Printing today's unprinted…" - : `Print Today's Unprinted${ - todaysUnprintedGroups.length + todaysUnprintedDeliveryNoteGroups.length > 0 - ? ` (${todaysUnprintedGroups.length + todaysUnprintedDeliveryNoteGroups.length})` + ? "Printing all unprinted…" + : `Print All Unprinted${ + allUnprintedInvoiceGroups.length + + allUnprintedDeliveryNoteGroups.length > + 0 + ? ` (${allUnprintedInvoiceGroups.length + allUnprintedDeliveryNoteGroups.length})` : "" }`} @@ -2087,7 +2122,7 @@ export default function InvoicingPage() { )} {rows !== null && rowsView.length > 0 && activeTab === "invoiced" && invoicedGroups.length === 0 && deliveryNoteGroups.length === 0 && stockBookingGroups.length === 0 && ( - + )} {rows !== null && activeTab === "to_invoice" && toInvoiceCards.length > 0 && ( @@ -2206,7 +2241,7 @@ export default function InvoicingPage() { {rows !== null && activeTab === "invoiced" && (invoicedGroups.length > 0 || deliveryNoteGroups.length > 0 || stockBookingGroups.length > 0) && (
- {`Showing invoices, delivery notes, and stock bookings from ${recentInvoiceRange.from} to ${recentInvoiceRange.to}`} + {`Showing invoices and delivery notes from ${recentInvoiceRange.from} to ${recentInvoiceRange.to}, plus any unprinted — stock bookings are windowed to the last 3 days only`}
{/* Invoices sub-section — only the "real" invoiced items go here. */}