diff --git a/src/app/(app)/collection-confirm/page.test.tsx b/src/app/(app)/collection-confirm/page.test.tsx index 9e5a332..72faa0f 100644 --- a/src/app/(app)/collection-confirm/page.test.tsx +++ b/src/app/(app)/collection-confirm/page.test.tsx @@ -146,8 +146,7 @@ describe("CollectionConfirmPage", () => { ); }); - it("renders a photo strip chip per distinct filename; clicking opens the file via window.open", async () => { - const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + it("renders a photo strip chip per distinct filename; clicking opens the inline viewer (no tab switch)", async () => { const photoRows: ConfirmRow[] = [ makeRow({ id: "row-photo-1", @@ -177,10 +176,170 @@ describe("CollectionConfirmPage", () => { screen.getByRole("button", { name: /Invoice 2 invoice-2\.pdf/i }), ).toBeInTheDocument(); - // Clicking a chip opens the file-stream URL in a new tab. + // Clicking a chip opens the inline viewer — NOT a new tab. The + // window.open spy is asserted clean to catch a regression to the + // old behaviour. + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); await user.click( screen.getByRole("button", { name: /Invoice 1 invoice-1\.pdf/i }), ); + + const viewer = await screen.findByTestId("invoice-viewer"); + expect(viewer).toBeInTheDocument(); + const iframe = within(viewer).getByTestId("invoice-viewer-iframe"); + expect(iframe).toHaveAttribute( + "src", + "/api/v1/files/order_items/row-photo-1/invoice-1.pdf", + ); + // The chip is now marked active so the operator knows which + // invoice is being viewed. + expect( + screen.getByRole("button", { name: /Invoice 1 invoice-1\.pdf/i }), + ).toHaveAttribute("aria-pressed", "true"); + + // No tab opened — the operator stays on the page to compare the + // invoice against the items table. + expect(openSpy).not.toHaveBeenCalled(); + }); + + it("toggles the inline invoice viewer: clicking the active chip closes it", async () => { + const photoRows: ConfirmRow[] = [ + makeRow({ + id: "row-photo-1", + order_item_attachements: ["invoice-1.pdf"], + }), + ]; + buildFetchSpy(photoRows); + + const user = userEvent.setup(); + render(); + + await waitFor(() => + expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument(), + ); + + const chip = screen.getByRole("button", { name: /Invoice 1 invoice-1\.pdf/i }); + + // Open the viewer. + await user.click(chip); + expect(screen.getByTestId("invoice-viewer")).toBeInTheDocument(); + + // Click the same chip again → viewer closes (toggle). + await user.click(chip); + expect(screen.queryByTestId("invoice-viewer")).not.toBeInTheDocument(); + expect(chip).toHaveAttribute("aria-pressed", "false"); + }); + + it("swaps the invoice viewer content when a different chip is clicked", async () => { + const photoRows: ConfirmRow[] = [ + makeRow({ + id: "row-photo-1", + order_item_attachements: ["invoice-1.pdf"], + }), + makeRow({ + id: "row-photo-2", + order_item_attachements: ["invoice-2.pdf"], + }), + ]; + buildFetchSpy(photoRows); + + const user = userEvent.setup(); + render(); + + await waitFor(() => + expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument(), + ); + + await user.click( + screen.getByRole("button", { name: /Invoice 1 invoice-1\.pdf/i }), + ); + let viewer = await screen.findByTestId("invoice-viewer"); + expect( + within(viewer).getByTestId("invoice-viewer-iframe"), + ).toHaveAttribute("src", "/api/v1/files/order_items/row-photo-1/invoice-1.pdf"); + + // Click the second chip — the viewer stays open and content swaps. + await user.click( + screen.getByRole("button", { name: /Invoice 2 invoice-2\.pdf/i }), + ); + viewer = screen.getByTestId("invoice-viewer"); + expect( + within(viewer).getByTestId("invoice-viewer-iframe"), + ).toHaveAttribute("src", "/api/v1/files/order_items/row-photo-2/invoice-2.pdf"); + + // Chip 2 is now the active one; chip 1 is no longer pressed. + expect( + screen.getByRole("button", { name: /Invoice 1 invoice-1\.pdf/i }), + ).toHaveAttribute("aria-pressed", "false"); + expect( + screen.getByRole("button", { name: /Invoice 2 invoice-2\.pdf/i }), + ).toHaveAttribute("aria-pressed", "true"); + }); + + it("closes the inline invoice viewer via the X button and via Esc", async () => { + const photoRows: ConfirmRow[] = [ + makeRow({ + id: "row-photo-1", + order_item_attachements: ["invoice-1.pdf"], + }), + ]; + buildFetchSpy(photoRows); + + const user = userEvent.setup(); + render(); + + await waitFor(() => + expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument(), + ); + + // Open. + await user.click( + screen.getByRole("button", { name: /Invoice 1 invoice-1\.pdf/i }), + ); + const viewer = await screen.findByTestId("invoice-viewer"); + + // Close via X button. + await user.click(within(viewer).getByTestId("invoice-viewer-close")); + expect(screen.queryByTestId("invoice-viewer")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Invoice 1 invoice-1\.pdf/i }), + ).toHaveAttribute("aria-pressed", "false"); + + // Re-open and close via Esc. + await user.click( + screen.getByRole("button", { name: /Invoice 1 invoice-1\.pdf/i }), + ); + expect(screen.getByTestId("invoice-viewer")).toBeInTheDocument(); + await user.keyboard("{Escape}"); + expect(screen.queryByTestId("invoice-viewer")).not.toBeInTheDocument(); + }); + + it("still offers an in-viewer 'open in new tab' escape hatch", async () => { + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + const photoRows: ConfirmRow[] = [ + makeRow({ + id: "row-photo-1", + order_item_attachements: ["invoice-1.pdf"], + }), + ]; + buildFetchSpy(photoRows); + + const user = userEvent.setup(); + render(); + + await waitFor(() => + expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument(), + ); + + await user.click( + screen.getByRole("button", { name: /Invoice 1 invoice-1\.pdf/i }), + ); + const viewer = await screen.findByTestId("invoice-viewer"); + await user.click(within(viewer).getByTestId("invoice-viewer-open-new-tab")); + + // The button explicitly opts into the historical new-tab flow, + // so window.open is invoked — but only because the operator + // asked for it via the viewer header, not from the chip itself. expect(openSpy).toHaveBeenCalledWith( "/api/v1/files/order_items/row-photo-1/invoice-1.pdf", "_blank", diff --git a/src/app/(app)/collection-confirm/page.tsx b/src/app/(app)/collection-confirm/page.tsx index ecabde9..a103e7c 100644 --- a/src/app/(app)/collection-confirm/page.tsx +++ b/src/app/(app)/collection-confirm/page.tsx @@ -6,7 +6,9 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { ConfirmInvoiceDialog } from "@/components/collection-confirm/confirm-invoice-dialog"; +import { InvoiceViewer } from "@/components/collection-confirm/invoice-viewer"; import { PoCard } from "@/components/collection-confirm/po-card"; +import type { Attachment } from "@/components/collection-confirm/photo-strip"; import { getClientIdentity } from "@/lib/dev-identity"; import type { ConfirmRow } from "@/lib/server/collection-confirm-service"; @@ -60,6 +62,25 @@ export default function CollectionConfirmPage() { rowIds: string[]; } | null>(null); const [busy, setBusy] = useState(false); + // Floating in-app invoice viewer. The operator clicks an invoice + // chip on a PoCard to open the PDF inline (instead of getting + // bounced to a new tab and losing the side-by-side comparison + // with the items table). `null` means the viewer is closed. + // + // Toggle behaviour: clicking the chip for the currently-active + // invoice closes the viewer; clicking a different invoice swaps + // content. The handler below encapsulates that logic so the + // PhotoStrip stays unaware of the toggle semantics. + const [viewer, setViewer] = useState<{ filename: string; url: string } | null>(null); + + const handleInvoiceOpen = useCallback((attachment: Attachment) => { + setViewer((current) => + current?.filename === attachment.filename ? null : { + filename: attachment.filename, + url: attachment.url, + }, + ); + }, []); const load = useCallback(async () => { setRows(null); @@ -369,6 +390,8 @@ export default function CollectionConfirmPage() { ? () => void handleBookStock(stockRowIds) : undefined } + onInvoiceOpen={handleInvoiceOpen} + activeInvoiceFilename={viewer?.filename ?? null} /> ); })} @@ -387,6 +410,13 @@ export default function CollectionConfirmPage() { }} onSubmit={(args) => void handleConfirmSubmit(args)} /> + + setViewer(null)} + /> ); } diff --git a/src/components/collection-confirm/invoice-viewer.test.tsx b/src/components/collection-confirm/invoice-viewer.test.tsx new file mode 100644 index 0000000..9f51b6b --- /dev/null +++ b/src/components/collection-confirm/invoice-viewer.test.tsx @@ -0,0 +1,347 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { InvoiceViewer } from "./invoice-viewer"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const URL = "/api/v1/files/order_items/row-1/invoice.pdf"; +const FILENAME = "invoice.pdf"; + +/** + * Helper: jsdom doesn't implement matchMedia or layout, so for the + * mobile-detection tests we stub `window.matchMedia` to return the + * desired match state. Tests that need desktop behaviour don't + * stub it — the default jsdom implementation reports no match, + * which means `useMediaQuery` returns `false` (desktop mode). + */ +function mockMatchMedia(matches: boolean) { + const listeners: Array<(event: MediaQueryListEvent) => void> = []; + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => { + listeners.push(listener); + }, + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + return listeners; +} + +describe("InvoiceViewer", () => { + it("renders nothing when closed", () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders an iframe with the file URL when open", () => { + render(); + const iframe = screen.getByTestId("invoice-viewer-iframe"); + expect(iframe).toHaveAttribute("src", URL); + expect(iframe).toHaveAttribute("title", FILENAME); + }); + + it("shows the filename in the header", () => { + render(); + expect(screen.getByText(FILENAME)).toBeInTheDocument(); + }); + + it("invokes onClose when the X button is clicked", async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("invoice-viewer-close")); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("invokes onClose when Esc is pressed", async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + render(); + await user.keyboard("{Escape}"); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not listen for Esc when closed", async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + render(); + await user.keyboard("{Escape}"); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("opens the file in a new tab via the explicit button (opt-in escape hatch)", async () => { + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("invoice-viewer-open-new-tab")); + expect(openSpy).toHaveBeenCalledWith(URL, "_blank", "noopener,noreferrer"); + }); + + it("moves the panel when the header is dragged", () => { + // jsdom doesn't lay out real boxes, so seed the header's + // bounding rect with a known origin (top-left = 100,100) and + // width 200. A mousedown at (120,110) creates an offset of + // (20,10); dragging to (300,400) must place the panel at + // (280, 390). + const header = (() => { + render(); + return screen.getByTestId("invoice-viewer-header"); + })(); + + vi.spyOn(header, "getBoundingClientRect").mockReturnValue({ + x: 100, + y: 100, + left: 100, + top: 100, + right: 300, + bottom: 140, + width: 200, + height: 40, + toJSON: () => ({}), + }); + + fireEvent.mouseDown(header, { clientX: 120, clientY: 110 }); + fireEvent.mouseMove(window, { clientX: 300, clientY: 400 }); + fireEvent.mouseUp(window); + + const panel = screen.getByTestId("invoice-viewer"); + expect(panel.style.left).toBe("280px"); + expect(panel.style.top).toBe("390px"); + }); + + it("does not start a drag when mousedown lands on a button in the header", async () => { + const closeBtn = (() => { + render(); + return screen.getByTestId("invoice-viewer-close"); + })(); + const header = screen.getByTestId("invoice-viewer-header"); + + vi.spyOn(header, "getBoundingClientRect").mockReturnValue({ + x: 100, + y: 100, + left: 100, + top: 100, + right: 300, + bottom: 140, + width: 200, + height: 40, + toJSON: () => ({}), + }); + + // mousedown on the close button — the handler must NOT begin a drag. + fireEvent.mouseDown(closeBtn, { clientX: 280, clientY: 110 }); + fireEvent.mouseMove(window, { clientX: 999, clientY: 999 }); + + const panel = screen.getByTestId("invoice-viewer"); + // Position unchanged from the default (jsdom default viewport is + // 1024x768 → defaultPosition() = { x: 280, y: 184 }). + expect(panel.style.left).toBe("280px"); + expect(panel.style.top).toBe("184px"); + }); + + describe("zoom controls", () => { + it("starts at 100% zoom", () => { + render(); + expect(screen.getByTestId("invoice-viewer-zoom-level")).toHaveTextContent("100%"); + }); + + it("zooms in by one step on + and out by one step on −", async () => { + const user = userEvent.setup(); + render(); + + const level = screen.getByTestId("invoice-viewer-zoom-level"); + const zoomIn = screen.getByTestId("invoice-viewer-zoom-in"); + const zoomOut = screen.getByTestId("invoice-viewer-zoom-out"); + + expect(level).toHaveTextContent("100%"); + + await user.click(zoomIn); + expect(level).toHaveTextContent("125%"); + await user.click(zoomIn); + expect(level).toHaveTextContent("150%"); + + await user.click(zoomOut); + expect(level).toHaveTextContent("125%"); + await user.click(zoomOut); + expect(level).toHaveTextContent("100%"); + await user.click(zoomOut); + expect(level).toHaveTextContent("75%"); + }); + + it("clamps zoom in at 300% and disables the + button there", async () => { + const user = userEvent.setup(); + render(); + const zoomIn = screen.getByTestId("invoice-viewer-zoom-in"); + // 100 → 125 → 150 → 200 → 300 → clamped + for (let i = 0; i < 10; i++) await user.click(zoomIn); + expect(screen.getByTestId("invoice-viewer-zoom-level")).toHaveTextContent("300%"); + expect(zoomIn).toBeDisabled(); + }); + + it("clamps zoom out at 50% and disables the − button there", async () => { + const user = userEvent.setup(); + render(); + const zoomOut = screen.getByTestId("invoice-viewer-zoom-out"); + for (let i = 0; i < 10; i++) await user.click(zoomOut); + expect(screen.getByTestId("invoice-viewer-zoom-level")).toHaveTextContent("50%"); + expect(zoomOut).toBeDisabled(); + }); + + it("Reset snaps back to 100% from any zoom level", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("invoice-viewer-zoom-in")); + await user.click(screen.getByTestId("invoice-viewer-zoom-in")); + await user.click(screen.getByTestId("invoice-viewer-zoom-in")); + expect(screen.getByTestId("invoice-viewer-zoom-level")).toHaveTextContent("200%"); + await user.click(screen.getByTestId("invoice-viewer-zoom-reset")); + expect(screen.getByTestId("invoice-viewer-zoom-level")).toHaveTextContent("100%"); + }); + + it("Reset is disabled at the default 100% zoom (no-op to invoke)", () => { + render(); + expect(screen.getByTestId("invoice-viewer-zoom-reset")).toBeDisabled(); + expect(screen.getByTestId("invoice-viewer-zoom-level")).toBeDisabled(); + }); + + it("clicking the zoom level label also resets (it's a button)", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("invoice-viewer-zoom-in")); + await user.click(screen.getByTestId("invoice-viewer-zoom-level")); + expect(screen.getByTestId("invoice-viewer-zoom-level")).toHaveTextContent("100%"); + }); + + it("scales the PDF wrapper element width with the zoom level", async () => { + const user = userEvent.setup(); + render(); + const wrapper = screen.getByTestId("invoice-viewer-pdf-wrapper"); + const iframe = screen.getByTestId("invoice-viewer-iframe"); + // 100% → wrapper is 100% × 100%, iframe CSS box is 100% × 100%, + // transform: scale(1) (no visual change). + expect(wrapper.style.width).toBe("100%"); + expect(wrapper.style.height).toBe("100%"); + expect(iframe.style.width).toBe("100%"); + expect(iframe.style.height).toBe("100%"); + expect(iframe.style.transform).toBe("scale(1)"); + + // 200% → wrapper is 200% × 200% (scrollbars appear so the + // operator can pan around the zoomed PDF). iframe CSS box + // shrinks to 50% × 50% and transform scales it back up. + await user.click(screen.getByTestId("invoice-viewer-zoom-in")); + await user.click(screen.getByTestId("invoice-viewer-zoom-in")); + await user.click(screen.getByTestId("invoice-viewer-zoom-in")); + expect(wrapper.style.width).toBe("200%"); + expect(wrapper.style.height).toBe("200%"); + expect(iframe.style.width).toBe("50%"); + expect(iframe.style.height).toBe("50%"); + expect(iframe.style.transform).toBe("scale(2)"); + + // 50% → wrapper collapses to 50% × 50% (but min 100% keeps + // the scrollable area usable), iframe CSS grows to 200% × + // 200% and transform shrinks it back. + await user.click(screen.getByTestId("invoice-viewer-zoom-reset")); + for (let i = 0; i < 10; i++) await user.click(screen.getByTestId("invoice-viewer-zoom-out")); + expect(wrapper.style.width).toBe("50%"); + expect(wrapper.style.height).toBe("50%"); + expect(iframe.style.width).toBe("200%"); + expect(iframe.style.height).toBe("200%"); + expect(iframe.style.transform).toBe("scale(0.5)"); + }); + }); + + describe("mobile layout", () => { + it("renders full-screen and hides the drag affordances when the viewport is narrow", () => { + mockMatchMedia(true); // simulate ≤ 640px viewport + render(); + + const panel = screen.getByTestId("invoice-viewer"); + expect(panel.getAttribute("data-mobile")).toBe("true"); + expect(panel.style.width).toBe("100vw"); + expect(panel.style.height).toBe("100dvh"); + expect(panel.style.left).toBe("0px"); + expect(panel.style.top).toBe("0px"); + + // The grip icon is hidden on mobile (nothing to drag). + expect(screen.queryByTestId("invoice-viewer-grip")).not.toBeInTheDocument(); + // The "Viewing" badge is also hidden on mobile (the panel + // fills the screen, so it's obvious what's being viewed). + expect(screen.queryByText(/Viewing/i)).not.toBeInTheDocument(); + // The header still has a grab cursor style on desktop but + // not on mobile — checked via the absence of cursor-grab. + const header = screen.getByTestId("invoice-viewer-header"); + expect(header.className).not.toContain("cursor-grab"); + }); + + it("still exposes zoom controls on mobile", async () => { + mockMatchMedia(true); + const user = userEvent.setup(); + render(); + + const level = screen.getByTestId("invoice-viewer-zoom-level"); + expect(level).toHaveTextContent("100%"); + await user.click(screen.getByTestId("invoice-viewer-zoom-in")); + expect(level).toHaveTextContent("125%"); + await user.click(screen.getByTestId("invoice-viewer-zoom-out")); + expect(level).toHaveTextContent("100%"); + }); + + it("does not start a drag from a header mousedown on mobile", () => { + mockMatchMedia(true); + const header = (() => { + render(); + return screen.getByTestId("invoice-viewer-header"); + })(); + + vi.spyOn(header, "getBoundingClientRect").mockReturnValue({ + x: 100, + y: 100, + left: 100, + top: 100, + right: 300, + bottom: 140, + width: 200, + height: 40, + toJSON: () => ({}), + }); + + fireEvent.mouseDown(header, { clientX: 120, clientY: 110 }); + fireEvent.mouseMove(window, { clientX: 9999, clientY: 9999 }); + + // Position unchanged — no drag occurred on mobile. + const panel = screen.getByTestId("invoice-viewer"); + expect(panel.style.left).toBe("0px"); + expect(panel.style.top).toBe("0px"); + }); + + it("flips from mobile to desktop layout when the viewport widens", () => { + const listeners = mockMatchMedia(false); // start desktop + render(); + // Desktop: floating box, has the grip icon + Viewing badge. + expect(screen.getByTestId("invoice-viewer").getAttribute("data-mobile")).toBe("false"); + expect(screen.getByTestId("invoice-viewer-grip")).toBeInTheDocument(); + expect(screen.getByText(/Viewing/i)).toBeInTheDocument(); + + // Simulate the media query flipping to mobile (e.g. operator + // rotates their phone, or resizes a desktop browser window). + // Wrap the synchronous listener dispatch in `act` so React + // commits the resulting re-render before we assert on it. + act(() => { + listeners.forEach((listener) => listener({ matches: true } as MediaQueryListEvent)); + }); + expect(screen.getByTestId("invoice-viewer").getAttribute("data-mobile")).toBe("true"); + expect(screen.queryByText(/Viewing/i)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/collection-confirm/invoice-viewer.tsx b/src/components/collection-confirm/invoice-viewer.tsx new file mode 100644 index 0000000..11f02d9 --- /dev/null +++ b/src/components/collection-confirm/invoice-viewer.tsx @@ -0,0 +1,419 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { ExternalLink, GripVertical, Minus, Plus, RotateCcw, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; + +/** + * Floating, draggable invoice PDF viewer for the collection-confirm + * page. + * + * Why this exists: the original flow was `window.open(url, "_blank")` + * on the photo-strip chip, which kicks the operator out to a new + * tab to verify the invoice against the items table. The tab switch + * breaks the side-by-side comparison the operator actually wants to + * do (look at a line on the invoice, look at the same line in the + * table, look back). + * + * This panel keeps the operator in the page: + * + * - Renders the PDF inside an `