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:
parent
db2d43ae84
commit
5291a044ed
7 changed files with 1082 additions and 16 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
347
src/components/collection-confirm/invoice-viewer.test.tsx
Normal file
347
src/components/collection-confirm/invoice-viewer.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
419
src/components/collection-confirm/invoice-viewer.tsx
Normal file
419
src/components/collection-confirm/invoice-viewer.tsx
Normal 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;
|
||||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue