feat(collection-confirm): in-app invoice viewer with zoom + mobile layout

The photo-strip chips on the collection-confirm page used to call
`window.open(url, "_blank")` for the supplier invoice, kicking the
operator out to a new tab to verify the invoice against the items
table. That tab switch broke 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.

Replace that flow with a floating, draggable invoice viewer
that lives on the page:

- New `InvoiceViewer` component renders the PDF in an `<iframe>`
  against the existing `/api/v1/files/order_items/<id>/` route,
  which already serves PDFs with `Content-Disposition: inline`.
- Desktop: floating box anchored to the bottom-right (720×560),
  header is draggable. Position is clamped to the viewport so
  the close button can't be dragged off-screen. State lives in
  the component; not persisted to localStorage.
- Mobile (≤ 640px): full-screen panel (`100vw × 100dvh`,
  border-radius 0). Drag affordance, grip icon, and "Viewing"
  badge are dropped — they're meaningless on touch or when the
  panel fills the screen. Layout flips live on viewport change
  via a small `useMediaQuery` hook.
- Zoom controls: + / − / Reset with discrete levels
  (50 / 75 / 100 / 125 / 150 / 200 / 300%). The % display is
  itself a click-to-reset button. Implemented as
  `transform: scale()` on the iframe with a pre-shrunk CSS box
  (`${100 / zoom}%`) and a `${zoom * 100}%` scrollable wrapper
  so scrollbars appear at higher zoom levels. CSS-resize-only
  does not work for PDF iframes — the browser's native PDF
  viewer caches the initial render size and ignores post-load
  CSS resizes, so the page content never actually re-renders.
  `transform: scale()` works around that across Chrome / Safari
  / Firefox.
- "Open in new tab" header button is preserved as an opt-in
  escape hatch (landscape invoices, full-page viewing).

The PhotoStrip no longer opens a new tab by default. Clicking a
chip calls a page-level `onOpen` handler that toggles the viewer
(open / swap / close); the matching chip is highlighted with
`aria-pressed="true"` + a primary variant so the operator knows
which invoice is open. Clicking the active chip closes the
viewer. The chip falls back to `window.open(...)` only when no
`onOpen` is passed, so the strip stays usable in isolation.

Tests: 126 pass across the collection-confirm suite. New tests
cover viewer open / close (X + Esc), drag math + drag-bail-out
on header buttons, the active-state highlight, swap-on-different
chip, the in-viewer new-tab escape hatch, every zoom level +
clamping + reset, the PDF wrapper / iframe transform math at
multiple zooms, and the desktop ↔ mobile layout flip on a
mocked viewport change.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-04 01:38:25 +02:00
parent db2d43ae84
commit 5291a044ed
7 changed files with 1082 additions and 16 deletions

View file

@ -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(<Page />);
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(<Page />);
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(<Page />);
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(<Page />);
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",

View file

@ -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<boolean>(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)}
/>
<InvoiceViewer
open={!!viewer}
filename={viewer?.filename ?? ""}
url={viewer?.url ?? ""}
onClose={() => setViewer(null)}
/>
</div>
);
}

View file

@ -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(
<InvoiceViewer open={false} filename={FILENAME} url={URL} onClose={vi.fn()} />,
);
expect(container).toBeEmptyDOMElement();
});
it("renders an iframe with the file URL when open", () => {
render(<InvoiceViewer open={true} filename={FILENAME} url={URL} onClose={vi.fn()} />);
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(<InvoiceViewer open={true} filename={FILENAME} url={URL} onClose={vi.fn()} />);
expect(screen.getByText(FILENAME)).toBeInTheDocument();
});
it("invokes onClose when the X button is clicked", async () => {
const onClose = vi.fn();
const user = userEvent.setup();
render(<InvoiceViewer open={true} filename={FILENAME} url={URL} onClose={onClose} />);
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(<InvoiceViewer open={true} filename={FILENAME} url={URL} onClose={onClose} />);
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(<InvoiceViewer open={false} filename={FILENAME} url={URL} onClose={onClose} />);
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(<InvoiceViewer open={true} filename={FILENAME} url={URL} onClose={vi.fn()} />);
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(<InvoiceViewer open={true} filename={FILENAME} url={URL} onClose={vi.fn()} />);
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(<InvoiceViewer open={true} filename={FILENAME} url={URL} onClose={vi.fn()} />);
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(<InvoiceViewer open={true} filename={FILENAME} url={URL} onClose={vi.fn()} />);
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(<InvoiceViewer open={true} filename={FILENAME} url={URL} onClose={vi.fn()} />);
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(<InvoiceViewer open={true} filename={FILENAME} url={URL} onClose={vi.fn()} />);
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(<InvoiceViewer open={true} filename={FILENAME} url={URL} onClose={vi.fn()} />);
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(<InvoiceViewer open={true} filename={FILENAME} url={URL} onClose={vi.fn()} />);
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(<InvoiceViewer open={true} filename={FILENAME} url={URL} onClose={vi.fn()} />);
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(<InvoiceViewer open={true} filename={FILENAME} url={URL} onClose={vi.fn()} />);
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(<InvoiceViewer open={true} filename={FILENAME} url={URL} onClose={vi.fn()} />);
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(<InvoiceViewer open={true} filename={FILENAME} url={URL} onClose={vi.fn()} />);
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(<InvoiceViewer open={true} filename={FILENAME} url={URL} onClose={vi.fn()} />);
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(<InvoiceViewer open={true} filename={FILENAME} url={URL} onClose={vi.fn()} />);
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(<InvoiceViewer open={true} filename={FILENAME} url={URL} onClose={vi.fn()} />);
// 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();
});
});
});

View file

@ -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 `<iframe>` against
* `/api/v1/files/order_items/<id>/`. That route already
* returns `Content-Type: application/pdf` and
* `Content-Disposition: inline`, so the browser's built-in PDF
* viewer takes over inside the panel.
*
* - Desktop: the panel is a floating box anchored to the
* bottom-right. The header bar is draggable — mousedown on the
* bar captures the cursor offset, mousemove (attached to
* `window` so dragging keeps working when the cursor leaves the
* header) translates the panel. The drop releases.
*
* - Mobile (viewport ≤ 640px): the panel becomes a full-screen
* modal. The drag affordance is dropped because there's
* nowhere to drag to, and the close / zoom controls remain
* reachable at the top.
*
* - Zoom: discrete levels (50% / 75% / 100% / 125% / 150% / 200%
* / 300%) via + / − buttons and a Reset button. Implemented
* as a CSS `transform: scale()` on the iframe (with the
* iframe's CSS box pre-shrunk to `${100 / zoom}%` so the
* scale lands it back at the panel's natural size). A
* wrapper sized to `${zoom * 100}%` provides the scrollable
* area so scrollbars appear at the right zoom levels. CSS
* resize-only does NOT work for PDF iframes — Chrome / Safari
* / Firefox's built-in PDF viewers cache the initial render
* size and ignore post-load iframe resizes, so the page
* content visually stays the same. Transform scales the
* already-rendered output and works reliably across browsers.
*
* - Esc closes the panel. The page-level `onClose` callback is
* also invoked so the parent can clear its `viewer` state and
* un-highlight the chip.
*
* - "Open in new tab" is preserved as an opt-in header button
* for full-screen viewing (e.g. landscape invoices). The chip
* itself no longer opens a new tab by default.
*
* Note on accessibility: the panel is a `role="dialog"` with
* `aria-label`. Zoom buttons expose `aria-label` describing the
* action. Focus is left on the triggering chip — we don't trap
* focus inside the iframe (it's the operator's main interaction
* target).
*/
export type InvoiceViewerProps = {
open: boolean;
filename: string;
url: string;
onClose: () => void;
};
type Position = { x: number; y: number };
const DEFAULT_WIDTH = 720;
const DEFAULT_HEIGHT = 560;
const MOBILE_BREAKPOINT = 640;
/**
* Discrete zoom levels, in CSS scale units (1 = 100%).
* Clamped at the low end so the operator can see the whole page
* even on very small viewports; clamped at the high end so a
* runaway click doesn't push the PDF past 8× pixel-density where
* the browser PDF viewer renders blank pages.
*/
const ZOOM_LEVELS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3] as const;
const DEFAULT_ZOOM = 1;
/**
* Cheap inline media-query hook. Re-evaluates on resize so the
* panel can flip between desktop (draggable, floating) and mobile
* (full-screen) layouts when the operator rotates their device.
*
* Initial value is computed lazily on first render via the
* matchMedia API — a window resize listener is attached after
* mount, and the SSR guard returns `false` on the server.
*/
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(() => {
if (typeof window === "undefined") return false;
// jsdom (used by the test suite) doesn't implement matchMedia.
// Guard so the hook defaults to "no match" (desktop) instead
// of throwing — the mobile-specific tests stub matchMedia
// themselves.
if (typeof window.matchMedia !== "function") return false;
try {
return window.matchMedia(query).matches;
} catch {
return false;
}
});
useEffect(() => {
if (typeof window === "undefined") return;
if (typeof window.matchMedia !== "function") return;
const mql = window.matchMedia(query);
const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
function defaultPosition(): Position {
// Bottom-right with a 24px gutter. Computed at mount-time so a
// small viewport doesn't push the panel off-screen.
if (typeof window === "undefined") return { x: 0, y: 0 };
const x = Math.max(16, window.innerWidth - DEFAULT_WIDTH - 24);
const y = Math.max(16, window.innerHeight - DEFAULT_HEIGHT - 24);
return { x, y };
}
export function InvoiceViewer({ open, filename, url, onClose }: InvoiceViewerProps) {
const [position, setPosition] = useState<Position>(() => defaultPosition());
const [dragging, setDragging] = useState<boolean>(false);
// Latest offset between cursor and panel top-left, captured at
// mousedown so a fast drag doesn't snap the panel under the cursor.
const dragOffsetRef = useRef<{ dx: number; dy: number }>({ dx: 0, dy: 0 });
// Discrete zoom level. Steps through ZOOM_LEVELS via + / −; the
// Reset button snaps back to 100%. State stays inside the
// viewer so closing + reopening starts fresh at 100% (matches
// the principle that the viewer is a fresh context per open).
const [zoom, setZoom] = useState<number>(DEFAULT_ZOOM);
const isMobile = useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT}px)`);
// Esc closes — matches the modal-shell convention used elsewhere.
useEffect(() => {
if (!open) return;
const handler = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
// Window-level mousemove / mouseup so the drag survives the
// cursor leaving the header. Cleanup releases the listeners on
// mouseup or unmount. Disabled on mobile where the panel
// fills the screen and dragging is meaningless.
useEffect(() => {
if (!dragging || isMobile) return;
const handleMove = (event: MouseEvent) => {
const nextX = event.clientX - dragOffsetRef.current.dx;
const nextY = event.clientY - dragOffsetRef.current.dy;
// Clamp to viewport so the header can't be dragged off-screen
// (the operator would lose the close button entirely).
const minX = -DEFAULT_WIDTH + 80;
const maxX = window.innerWidth - 80;
const minY = 0;
const maxY = window.innerHeight - 40;
setPosition({
x: Math.min(Math.max(nextX, minX), maxX),
y: Math.min(Math.max(nextY, minY), maxY),
});
};
const handleUp = () => setDragging(false);
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
return () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
};
}, [dragging, isMobile]);
const handleHeaderMouseDown = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
// No drag on mobile — the panel fills the screen anyway.
if (isMobile) return;
// Ignore drags that started on the buttons inside the header —
// those should still click, not initiate a drag.
const target = event.target as HTMLElement;
if (target.closest("button")) return;
const rect = event.currentTarget.getBoundingClientRect();
dragOffsetRef.current = {
dx: event.clientX - rect.left,
dy: event.clientY - rect.top,
};
setDragging(true);
event.preventDefault();
},
[isMobile],
);
const handleOpenInNewTab = useCallback(() => {
window.open(url, "_blank", "noopener,noreferrer");
}, [url]);
/**
* Zoom controls — step to the next / previous discrete level
* in ZOOM_LEVELS. Clamped via array bounds, so the operator
* can't zoom past the limit by spamming the button.
*/
const zoomIn = useCallback(() => {
setZoom((current) => {
const idx = ZOOM_LEVELS.indexOf(current as (typeof ZOOM_LEVELS)[number]);
// If current is somehow not in the list (shouldn't happen,
// but be defensive), fall back to the default.
const nextIdx = idx === -1
? ZOOM_LEVELS.indexOf(DEFAULT_ZOOM as (typeof ZOOM_LEVELS)[number]) + 1
: Math.min(idx + 1, ZOOM_LEVELS.length - 1);
return ZOOM_LEVELS[nextIdx];
});
}, []);
const zoomOut = useCallback(() => {
setZoom((current) => {
const idx = ZOOM_LEVELS.indexOf(current as (typeof ZOOM_LEVELS)[number]);
const nextIdx = idx === -1
? ZOOM_LEVELS.indexOf(DEFAULT_ZOOM as (typeof ZOOM_LEVELS)[number]) - 1
: Math.max(idx - 1, 0);
return ZOOM_LEVELS[nextIdx];
});
}, []);
const zoomReset = useCallback(() => {
setZoom(DEFAULT_ZOOM);
}, []);
if (!open) return null;
// Mobile: full-screen, no drag, no grip, no "Viewing" badge —
// the panel *is* the screen so context is obvious.
// Desktop: floating box, draggable, with all the affordances.
const panelStyle = isMobile
? {
left: 0,
top: 0,
width: "100vw",
height: "100dvh",
maxWidth: "100vw",
maxHeight: "100dvh",
borderRadius: 0,
}
: {
left: position.x,
top: position.y,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
maxWidth: "calc(100vw - 32px)",
maxHeight: "calc(100vh - 32px)",
};
const zoomPercent = Math.round(zoom * 100);
const canZoomIn = zoom < ZOOM_LEVELS[ZOOM_LEVELS.length - 1];
const canZoomOut = zoom > ZOOM_LEVELS[0];
return (
<div
role="dialog"
aria-label={`Invoice viewer — ${filename}`}
data-testid="invoice-viewer"
data-mobile={isMobile ? "true" : "false"}
className={`fixed z-50 flex flex-col bg-background shadow-2xl ${
isMobile ? "rounded-none border-0" : "rounded-lg border"
}`}
style={panelStyle}
>
<div
onMouseDown={handleHeaderMouseDown}
className={`flex shrink-0 items-center justify-between gap-2 border-b bg-muted/50 px-3 py-2 ${
!isMobile && dragging ? "cursor-grabbing select-none" : ""
} ${!isMobile ? "cursor-grab" : ""}`}
data-testid="invoice-viewer-header"
>
<div className="flex min-w-0 flex-1 items-center gap-2">
{!isMobile ? (
<GripVertical
className="h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden="true"
data-testid="invoice-viewer-grip"
/>
) : null}
<span className="truncate text-sm font-medium" title={filename}>
{filename}
</span>
{!isMobile ? (
<span className="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
Viewing
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{/* Zoom controls — grouped so the visual unit is
obvious. `icon-sm` is 32px (a comfortable touch
target on mobile) and the surrounding gap of 0.5
keeps the cluster compact enough to fit on a
360px-wide phone header. */}
<div
className="flex items-center gap-0.5 rounded-md border bg-background px-1 py-0.5"
role="group"
aria-label="Zoom controls"
data-testid="invoice-viewer-zoom"
>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={zoomOut}
disabled={!canZoomOut}
aria-label="Zoom out"
data-testid="invoice-viewer-zoom-out"
>
<Minus className="h-3.5 w-3.5" />
</Button>
<button
type="button"
onClick={zoomReset}
disabled={zoom === DEFAULT_ZOOM}
aria-label={`Reset zoom (currently ${zoomPercent}%)`}
data-testid="invoice-viewer-zoom-level"
className="min-w-[3rem] rounded px-1.5 py-0.5 text-center font-mono text-xs tabular-nums text-foreground transition-colors hover:bg-accent disabled:cursor-default disabled:hover:bg-transparent disabled:opacity-70"
>
{zoomPercent}%
</button>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={zoomIn}
disabled={!canZoomIn}
aria-label="Zoom in"
data-testid="invoice-viewer-zoom-in"
>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={zoomReset}
disabled={zoom === DEFAULT_ZOOM}
aria-label="Reset zoom to 100%"
data-testid="invoice-viewer-zoom-reset"
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleOpenInNewTab}
aria-label={`Open ${filename} in a new tab`}
data-testid="invoice-viewer-open-new-tab"
>
<ExternalLink className="mr-1 h-3.5 w-3.5" />
New tab
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8"
aria-label="Close invoice viewer"
data-testid="invoice-viewer-close"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* PDF area. The wrapper is sized to `${zoom * 100}% × zoom
* 100%` so scrollbars appear at the right zoom levels.
The iframe's CSS box is pre-shrunk to `${100 / zoom}%`
(so its layout footprint matches the wrapper) and
scaled back via `transform: scale(zoom)`. This is the
only reliable way to zoom a PDF iframe — resizing the
iframe's CSS dimensions alone doesn't work because the
browser's PDF viewer caches the initial render size. */}
<div className="relative flex-1 overflow-auto bg-zinc-100 dark:bg-zinc-900">
<div
data-testid="invoice-viewer-pdf-wrapper"
style={{
width: `${zoom * 100}%`,
height: `${zoom * 100}%`,
minWidth: "100%",
minHeight: "100%",
}}
>
<iframe
key={url}
src={url}
title={filename}
data-testid="invoice-viewer-iframe"
className="border-0 bg-white"
style={{
width: `${100 / zoom}%`,
height: `${100 / zoom}%`,
transform: `scale(${zoom})`,
transformOrigin: "top left",
display: "block",
}}
/>
</div>
</div>
</div>
);
}
export default InvoiceViewer;

View file

@ -46,19 +46,56 @@ describe("PhotoStrip", () => {
expect(screen.getByText(/Attach dialog/i)).toBeInTheDocument();
});
it("opens the file from its first row in a new tab", async () => {
const user = userEvent.setup();
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
it("calls onOpen with the attachment when a chip is clicked (page-level toggle path)", async () => {
const onOpen = vi.fn();
const rows: ConfirmRow[] = [
{ id: "row/one", order_item_attachements: ["invoice copy.pdf"] },
{ id: "row-2", order_item_attachements: ["invoice copy.pdf"] },
];
render(<PhotoStrip rows={rows} />);
const user = userEvent.setup();
render(<PhotoStrip rows={rows} onOpen={onOpen} />);
await user.click(screen.getByRole("button", { name: /Invoice 1 invoice copy\.pdf/i }));
expect(onOpen).toHaveBeenCalledTimes(1);
expect(onOpen).toHaveBeenCalledWith({
filename: "invoice copy.pdf",
url: "/api/v1/files/order_items/row%2Fone/invoice%20copy.pdf",
itemId: "row/one",
});
});
it("marks the activeFilename chip with aria-pressed=true", () => {
const rows: ConfirmRow[] = [
{ id: "row-1", order_item_attachements: ["a.pdf"] },
{ id: "row-2", order_item_attachements: ["b.pdf"] },
];
render(<PhotoStrip rows={rows} activeFilename="b.pdf" />);
expect(
screen.getByRole("button", { name: /Invoice 1 a\.pdf/i }),
).toHaveAttribute("aria-pressed", "false");
expect(
screen.getByRole("button", { name: /Invoice 2 b\.pdf/i }),
).toHaveAttribute("aria-pressed", "true");
});
it("falls back to window.open when no onOpen handler is provided", async () => {
// The strip is still useful in isolation (e.g. embedded in a
// future read-only context) — without an onOpen the chip keeps
// the historical "open in new tab" behaviour.
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
const rows: ConfirmRow[] = [
{ id: "row-1", order_item_attachements: ["invoice.pdf"] },
];
const user = userEvent.setup();
render(<PhotoStrip rows={rows} />);
await user.click(screen.getByRole("button", { name: /Invoice 1 invoice\.pdf/i }));
expect(openSpy).toHaveBeenCalledWith(
"/api/v1/files/order_items/row%2Fone/invoice%20copy.pdf",
"/api/v1/files/order_items/row-1/invoice.pdf",
"_blank",
"noopener,noreferrer",
);

View file

@ -4,11 +4,52 @@ import { dedupeAttachments } from "@/app/(app)/collection-confirm/view-model";
import { Button } from "@/components/ui/button";
import type { ConfirmRow } from "@/lib/server/collection-confirm-service";
type PhotoStripProps = {
rows: ConfirmRow[];
export type Attachment = {
filename: string;
url: string;
/**
* First item id that carries this attachment. Used by the
* `/api/v1/files/...` URL pattern (collection/recordId/filename)
* so the same item still resolves the file even if the operator
* uploaded the same filename to multiple rows.
*/
itemId: string;
};
export function PhotoStrip({ rows }: PhotoStripProps) {
type PhotoStripProps = {
rows: ConfirmRow[];
/**
* Called when the operator clicks a chip. The page-level handler
* decides whether to open the floating viewer, swap content, or
* close (toggle) based on whether the clicked attachment matches
* the currently-active one.
*
* If not provided, the chip falls back to opening the file in a
* new tab (the historical behaviour) so the strip still works in
* isolation — useful for tests and any future caller that doesn't
* need the in-app viewer.
*/
onOpen?: (attachment: Attachment) => void;
/**
* Filename currently being viewed in the floating panel, if any.
* When set, the matching chip renders an "active" visual state
* (border + ring) so the operator can tell which invoice is open.
* Clicking that chip again closes the viewer (handled by the
* page-level `onOpen` toggle).
*/
activeFilename?: string | null;
};
/**
* One chip per distinct attachment filename. Clicking a chip
* forwards the attachment to the parent's `onOpen` handler; the
* parent decides whether to render the inline viewer or fall back
* to a new-tab open.
*
* The historical "open in new tab" behaviour is preserved when no
* `onOpen` is supplied (see the prop doc above).
*/
export function PhotoStrip({ rows, onOpen, activeFilename }: PhotoStripProps) {
const attachments = dedupeAttachments(rows);
if (attachments.length === 0) {
@ -30,16 +71,29 @@ export function PhotoStrip({ rows }: PhotoStripProps) {
if (!firstItemId) return null;
const url = `/api/v1/files/order_items/${encodeURIComponent(firstItemId)}/${encodeURIComponent(filename)}`;
const isActive = activeFilename === filename;
return (
<Button
key={filename}
type="button"
variant="outline"
variant={isActive ? "default" : "outline"}
size="sm"
className="h-auto max-w-full py-2"
aria-label={`Invoice ${index + 1} ${filename}`}
onClick={() => window.open(url, "_blank", "noopener,noreferrer")} >
aria-pressed={isActive}
className={`h-auto max-w-full py-2 ${
isActive ? "ring-2 ring-primary ring-offset-1" : ""
}`}
data-testid={`photo-strip-chip-${index + 1}`}
data-active={isActive ? "true" : "false"}
onClick={() => {
if (onOpen) {
onOpen({ filename, url, itemId: firstItemId });
} else {
window.open(url, "_blank", "noopener,noreferrer");
}
}}
>
<span className="font-medium">Invoice {index + 1}</span>
<span className="truncate text-muted-foreground">{filename}</span>
</Button>

View file

@ -3,7 +3,7 @@
import { Button } from "@/components/ui/button";
import type { ConfirmRow } from "@/lib/server/collection-confirm-service";
import { ItemsTable } from "./items-table";
import { PhotoStrip } from "./photo-strip";
import { PhotoStrip, type Attachment } from "./photo-strip";
type Props = {
rows: ConfirmRow[];
@ -19,6 +19,20 @@ type Props = {
* both render.
*/
onBookStock?: () => void;
/**
* Optional handler invoked when an invoice chip is clicked. The
* page-level handler typically toggles the floating in-app viewer
* (open / swap / close) so the operator doesn't have to tab out
* to verify the invoice against the items table. When omitted,
* the chip falls back to opening the file in a new tab.
*/
onInvoiceOpen?: (attachment: Attachment) => void;
/**
* Filename currently being viewed in the page's floating viewer,
* if any. The matching chip on this card renders an active
* visual state so the operator can see which invoice is open.
*/
activeInvoiceFilename?: string | null;
};
/**
@ -47,6 +61,8 @@ export function PoCard({
onCostChange,
onConfirm,
onBookStock,
onInvoiceOpen,
activeInvoiceFilename,
}: Props) {
const itemCount = rows.length;
const hasStockRows = rows.some((r) => r.source === "stock");
@ -68,7 +84,11 @@ export function PoCard({
</header>
<div className="mb-4">
<PhotoStrip rows={rows} />
<PhotoStrip
rows={rows}
onOpen={onInvoiceOpen}
activeFilename={activeInvoiceFilename}
/>
</div>
<div className="mb-4">