From 3bc4633587cadfb2f5189e71d86ad990aa1f1ae0 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 8 Oct 2026 00:58:16 +0200 Subject: [PATCH] feat(invoicing): cancel a dead order off the To Invoice list, with a reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lines were getting stuck on To Invoice cards because the page pulls every sibling line of an order that has one ready line, and outstanding is defined as "not ready_invoice and not invoiced". Nothing auto-advances checked_in or collected to ready_invoice, so a card can sit there for months with no way to express "this order is dead, take it off my screen". Live data: 24 checked_in, 6 with_driver, 5 collected, 3 po_generated across the 16 orders that currently have ready lines. Adds a destructive "Cancel order" button next to Invoice in QuickBooks and Delivery Note. Order-level rather than per-line, because line items aren't editable from this page — the buyer owns those. The reason is required and stamped into order_item_details.cancellation on every cancelled row. Lines already invoiced, on a delivery note, or cancelled are left alone; the dialog states that split before you commit. Also fixes the reason the button would otherwise be worthless: buildOrderInvoiceCards treated cancelled and removed as outstanding, so flipping item_status to cancelled would leave the line sitting right where the operator was trying to get rid of it. Both now drop out. --- src/app/(app)/invoicing/page.test.tsx | 103 ++++++++ src/app/(app)/invoicing/page.tsx | 241 +++++++++++++++++- src/app/(app)/invoicing/view-model.test.ts | 61 +++++ src/app/(app)/invoicing/view-model.ts | 23 +- .../api/invoicing/cancel-order/route.test.ts | 180 +++++++++++++ src/app/api/invoicing/cancel-order/route.ts | 200 +++++++++++++++ 6 files changed, 806 insertions(+), 2 deletions(-) create mode 100644 src/app/api/invoicing/cancel-order/route.test.ts create mode 100644 src/app/api/invoicing/cancel-order/route.ts diff --git a/src/app/(app)/invoicing/page.test.tsx b/src/app/(app)/invoicing/page.test.tsx index 34c231e..26b737f 100644 --- a/src/app/(app)/invoicing/page.test.tsx +++ b/src/app/(app)/invoicing/page.test.tsx @@ -1811,3 +1811,106 @@ describe("supplier-side line toggle (exclude_from_invoice)", () => { expect(screen.queryByText(/hidden/i)).not.toBeInTheDocument(); }); }); + +describe("cancel whole order from the To Invoice queue", () => { + beforeEach(() => { + mockedRows.splice(0, mockedRows.length); + // This block is a TOP-LEVEL describe, so it does not inherit the + // fetch stub from describe("InvoicingPage"). Without its own the + // component hits the real network and the click silently hangs. + invoiceWebhookFetchMock.mockReset(); + invoiceWebhookFetchMock.mockResolvedValue( + new Response(JSON.stringify({ ok: true, cancelled: 1, alreadyClosed: 0 }), { + status: 200, + }), + ); + vi.stubGlobal("fetch", invoiceWebhookFetchMock); + }); + + afterEach(() => { + cleanup(); + }); + + it("keeps the confirm disabled until a reason is typed, then posts it", async () => { + mockedRows.push(makeRawRow({ description: "DISC, 115mm, FLAPPING P 60" })); + invoiceWebhookFetchMock.mockResolvedValue( + new Response(JSON.stringify({ ok: true, cancelled: 1, alreadyClosed: 0 }), { + status: 200, + }), + ); + + render(); + + const cancelButton = await screen.findByRole("button", { name: /cancel order/i }); + fireEvent.click(cancelButton); + + const confirm = await screen.findByRole("button", { name: /cancel 1 line/i }); + // Reason is mandatory — no accidental one-click kills. + expect(confirm).toBeDisabled(); + + fireEvent.change(screen.getByLabelText(/why are you cancelling/i), { + target: { value: "Customer cancelled the job" }, + }); + expect(confirm).not.toBeDisabled(); + + fireEvent.click(confirm); + + await waitFor(() => { + const call = invoiceWebhookFetchMock.mock.calls.find( + ([url]) => url === "/api/invoicing/cancel-order", + ); + expect(call).toBeDefined(); + const body = JSON.parse((call?.[1] as RequestInit).body as string); + expect(body).toMatchObject({ + orderId: "ord-1", + reason: "Customer cancelled the job", + }); + }); + + // Dialog closes once the write lands. + await waitFor(() => { + expect(screen.queryByLabelText(/why are you cancelling/i)).not.toBeInTheDocument(); + }); + }); + + it("surfaces the route's own message when the cancel is refused", async () => { + mockedRows.push(makeRawRow({ description: "Dead order line" })); + invoiceWebhookFetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + error: + "Every line on this order is already invoiced, on a delivery note, or cancelled — nothing to cancel.", + }), + { status: 409 }, + ), + ); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /cancel order/i })); + fireEvent.change(await screen.findByLabelText(/why are you cancelling/i), { + target: { value: "too late" }, + }); + fireEvent.click(await screen.findByRole("button", { name: /cancel 1 line/i })); + + await waitFor(() => { + expect(invoiceWebhookFetchMock).toHaveBeenCalled(); + }); + // Dialog stays open on failure so the reason isn't lost. + expect(await screen.findByLabelText(/why are you cancelling/i)).toBeInTheDocument(); + }); + + it("disables cancel when the card's lines carry no order id", async () => { + mockedRows.push( + makeRawRow({ + description: "Orphan line", + expand: { purchase_order: { id: "po-1", po_number: "PO-1" } }, + }), + ); + + render(); + + const cancelButton = await screen.findByRole("button", { name: /cancel order/i }); + expect(cancelButton).toBeDisabled(); + }); +}); diff --git a/src/app/(app)/invoicing/page.tsx b/src/app/(app)/invoicing/page.tsx index 2bb4e9a..a310758 100644 --- a/src/app/(app)/invoicing/page.tsx +++ b/src/app/(app)/invoicing/page.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; -import { Search, Building2, UserRound, FileText, ExternalLink, RefreshCw, Printer, Zap, Loader2, Truck, Boxes, Check, Receipt } from "lucide-react"; +import { Search, Building2, UserRound, FileText, ExternalLink, RefreshCw, Printer, Zap, Loader2, Truck, Boxes, Check, Receipt, Ban } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -41,6 +41,7 @@ import { surplusQty, type InvoiceLineOverride, type InvoicingRow, + type OrderInvoiceCard, } from "./view-model"; const POCKETBASE_FILE_BASE_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL ?? "http://127.0.0.1:8090"; @@ -1084,6 +1085,109 @@ type SubmitDeliveryNote = ( }, ) => Promise; +/** + * Whole-order cancel confirmation. + * + * Deliberately NOT a `window.confirm()`: cancelling is irreversible + * from this page and the operator needs to state *why* in their own + * words. A native confirm box can only offer OK / Cancel, and a reason + * typed into a prompt() is easy to fat-finger and impossible to review + * before it commits. + * + * The dialog states the blast radius up front — which order, how many + * lines go, and that invoiced lines are untouched — because "cancel" + * on an invoiced order is the mistake that costs real money. + */ +function CancelOrderDialog({ + card, + reason, + onReasonChange, + onClose, + onConfirm, + busy, +}: { + card: OrderInvoiceCard | null; + reason: string; + onReasonChange: (next: string) => void; + onClose: () => void; + onConfirm: () => void; + busy: boolean; +}) { + const orderNumber = card?.orderNumber?.trim() || "this order (no order number)"; + const lineCount = card?.rows.length ?? 0; + // Invoiced lines keep their status — the customer already holds that + // document — so we tell the operator up front what is protected. + const protectedCount = card + ? card.rows.filter((row) => row.item_status === "invoiced").length + : 0; + const willCancel = Math.max(lineCount - protectedCount, 0); + const canConfirm = reason.trim().length > 0 && !busy; + + return ( + { + if (!state && !busy) onClose(); + }} + size={{ base: "md", md: "lg" }} + title="Cancel this order" + description={`Take order ${orderNumber} off the To Invoice list. This does not delete anything — it marks the lines as cancelled with your reason against them.`} + footer={ +
+ + +
+ } + > +
+
+

+ {`${willCancel} line${willCancel === 1 ? "" : "s"} will be marked cancelled`} +

+

+ {protectedCount > 0 + ? `${protectedCount} line${protectedCount === 1 ? " has" : "s have"} already been invoiced and will be left alone — the customer holds that document.` + : "No lines on this order have been invoiced yet."} +

+
+ +