feat(invoicing): surface all unprinted invoices + delivery notes on the Invoiced tab
The Invoiced tab used to filter to the last 3 days only, which meant an invoice or delivery note that fell off the window would disappear from the operator's list even if they hadn't printed it yet. The only way back was the per-row "Print" button on a row that no longer existed in the tab. Change the filter rule from "in last 3 days" to "in last 3 days OR unprinted". A row surfaces if either: - its date falls inside recentInvoiceRange, OR - it has not been printed yet (printed !== true). This means an invoice or DN stays on the operator's list until it's printed, regardless of age. The list doubles as the "what's still outstanding" view — print a row, it drops off the next reload. Stock bookings are unchanged (still windowed to the last 3 days only) — there's no print tracking on stock_purchases yet, so "unprinted stock booking" is undefined. Also broaden the bulk-print button from "Print Today's Unprinted" to "Print All Unprinted" so the operator can clear the backlog in one pass instead of having to print each row individually. The button iterates every invoice + DN group whose items haven't all been printed, regardless of date. Section header + empty state text updated to reflect the new rule. Files: - src/app/(app)/invoicing/page.tsx — filter logic, button copy, section header, empty state, bulk-print handler - src/app/(app)/invoicing/page.test.tsx — updated existing tests to match the new behaviour; renamed "bulk prints only today's unprinted invoices" to "bulk prints every unprinted invoice, regardless of date"
This commit is contained in:
parent
3a59fa104c
commit
228caf1680
2 changed files with 130 additions and 45 deletions
|
|
@ -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(<Page />);
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
>
|
||||
<Printer className="mr-1 h-3.5 w-3.5" />
|
||||
{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})`
|
||||
: ""
|
||||
}`}
|
||||
</Button>
|
||||
|
|
@ -2087,7 +2122,7 @@ export default function InvoicingPage() {
|
|||
)}
|
||||
|
||||
{rows !== null && rowsView.length > 0 && activeTab === "invoiced" && invoicedGroups.length === 0 && deliveryNoteGroups.length === 0 && stockBookingGroups.length === 0 && (
|
||||
<EmptyState message="No recent invoices, delivery notes, or stock bookings match the current filters." />
|
||||
<EmptyState message="No invoices, delivery notes, or stock bookings match the current filters (last 3 days + unprinted)." />
|
||||
)}
|
||||
|
||||
{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) && (
|
||||
<section className="space-y-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{`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`}
|
||||
</div>
|
||||
|
||||
{/* Invoices sub-section — only the "real" invoiced items go here. */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue