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."} +

+
+ +