-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ >
+ }
+ sections={previewSections(values.procedures)}
+ />
);
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-document-frame.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-document-frame.tsx
index 30b02d01..9da781f1 100644
--- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-document-frame.tsx
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-document-frame.tsx
@@ -1,55 +1,121 @@
import type { ReactNode } from "react";
+import {
+ PAGE_BOTTOM_BAR_PX,
+ PAGE_BOTTOM_PADDING_PX,
+ PAGE_GAP_PX,
+ PAGE_TOP_BAR_PX,
+ type PagePosition,
+} from "@/domain/work-orders/utils/completion-doc-pagination";
+import { useCompDocPagination } from "./use-comp-doc-pagination";
type CompDocDocumentFrameProps = {
- children: ReactNode;
- /** Greyed-out page, as for a document marked not required. */
+ /** Letterhead and opening content, printed once at the top of page 1. */
+ header: ReactNode;
+ /** Blocks (procedures, comments) laid out across pages; never split. */
+ sections: readonly ReactNode[];
+ /** Greyed-out pages, as for a document marked not required. */
dimmed?: boolean;
- /** Rendered over the page, outside the dimmed layer. */
+ /** Stamped over the first sheet, outside the dimmed layer. */
overlay?: ReactNode;
+ /** Page being read and page count, updated on layout and on scroll. */
+ onPageChange?: (position: PagePosition) => void;
};
-/** The printed page: white sheet between the top and bottom brand bands. */
+/** Contains child margins so a block's measured height is its full height. */
+const flowRoot = { display: "flow-root" } as const;
+
+/**
+ * The printed document: A4 sheets stacked in a scrollable column, the letterhead
+ * on the first sheet only and the brand bars at the top and bottom of every sheet.
+ */
export function CompDocDocumentFrame({
- children,
+ header,
+ sections,
dimmed = false,
overlay,
+ onPageChange,
}: CompDocDocumentFrameProps) {
+ const { pages, pageHeight, scrollerRef, sheetsRef, headerRef, blockRefs, pageRefs, onScroll } =
+ useCompDocPagination(sections.length, onPageChange);
+
return (
-
+
+ {pages.map((blockIndices, pageIndex) => (
+
{
+ pageRefs.current[pageIndex] = el;
+ }}
+ data-testid="comp-doc-page"
+ className="relative flex flex-col break-words"
+ style={{
+ marginTop: pageIndex > 0 ? PAGE_GAP_PX : 0,
+ minHeight: pageHeight,
+ background: "#ffffff",
+ borderRadius: 8,
+ boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
+ overflow: "hidden",
+ fontFamily: "var(--font-sans)",
+ overflowWrap: "break-word",
+ wordBreak: "break-word",
+ }}
+ >
+
+
+
+ {pageIndex === 0 && (
+
+ {header}
+
+ )}
+ {blockIndices.map((blockIndex) => (
+
{
+ blockRefs.current[blockIndex] = el;
+ }}
+ style={flowRoot}
+ >
+ {sections[blockIndex]}
+
+ ))}
+
+
+
- {overlay}
+ {pageIndex === 0 && overlay}
+
+ ))}
+
);
}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-badge.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-badge.tsx
index 9df6c499..948be0c2 100644
--- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-badge.tsx
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-badge.tsx
@@ -1,10 +1,15 @@
import { CheckCircle2, FileText } from "lucide-react";
+import {
+ pageIndicatorLabel,
+ type PagePosition,
+} from "@/domain/work-orders/utils/completion-doc-pagination";
type CompDocPreviewBadgeProps = {
isGenerated: boolean;
+ page: PagePosition;
};
-export function CompDocPreviewBadge({ isGenerated }: CompDocPreviewBadgeProps) {
+export function CompDocPreviewBadge({ isGenerated, page }: CompDocPreviewBadgeProps) {
return (
)}
{isGenerated ? "Generated document" : "Preview — not yet generated"}
+ {pageIndicatorLabel(page)}
);
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx
index c6c0bb61..bde55ea7 100644
--- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-procedure.tsx
@@ -3,20 +3,17 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
import { procedureFor } from "@/domain/work-orders/utils/completion-doc-procedures";
import { CompDocProcedureHeading } from "./comp-doc-procedure-heading";
import { CompDocProcedureList } from "./comp-doc-procedure-list";
-import { CompDocSafetyBullets } from "./comp-doc-safety-bullets";
type CompDocPreviewProcedureProps = {
row: WorkOrderTableRow;
- notes: string;
};
-export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureProps) {
+/** The work order's procedure: heading, intro and steps, kept together on one page. */
+export function CompDocPreviewProcedure({ row }: CompDocPreviewProcedureProps) {
const proc = procedureFor(row.pm);
return (
<>
-
-
{proc.heading}
-
- {notes.length > 0 && (
- <>
- PM Comments
-
- {notes}
-
- >
- )}
+ >
+ );
+}
+
+/** The PM's free-text comments printed after the procedure. */
+export function CompDocPreviewComments({ notes }: { notes: string }) {
+ return (
+ <>
+ PM Comments
+
+ {notes}
+
>
);
}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx
index d19d2d13..157e67b8 100644
--- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx
+++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx
@@ -1,11 +1,14 @@
+import { useState } from "react";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { PagePosition } from "@/domain/work-orders/utils/completion-doc-pagination";
import { CompDocDocumentFrame } from "./comp-doc-document-frame";
import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp";
import { CompDocPreviewBadge } from "./comp-doc-preview-badge";
import { CompDocPreviewHeader } from "./comp-doc-preview-header";
-import { CompDocPreviewProcedure } from "./comp-doc-preview-procedure";
+import { CompDocPreviewComments, CompDocPreviewProcedure } from "./comp-doc-preview-procedure";
import { CompDocPreviewSummary } from "./comp-doc-preview-summary";
+import { CompDocSafetyBullets } from "./comp-doc-safety-bullets";
type CompDocPreviewProps = {
row: WorkOrderTableRow;
@@ -24,6 +27,10 @@ export function CompDocPreview({
isGenerated,
isNotRequired,
}: CompDocPreviewProps) {
+ const [page, setPage] = useState({ current: 1, total: 1 });
+ const sections = [];
+ if (notes.length > 0) sections.push();
+
return (
-
+
-
- }
- >
-
-
-
-
-
+
}
+ onPageChange={setPage}
+ header={
+ <>
+
+
+
+ >
+ }
+ sections={sections}
+ />
);
}
diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pagination.ts b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pagination.ts
new file mode 100644
index 00000000..b62308a6
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pagination.ts
@@ -0,0 +1,112 @@
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
+import {
+ a4PageHeight,
+ coverAllBlocks,
+ currentPageAt,
+ pageContentHeight,
+ paginateBlocks,
+ samePages,
+ type PagePosition,
+} from "@/domain/work-orders/utils/completion-doc-pagination";
+
+type PageLayout = {
+ pages: number[][];
+ /** Sheet height in px; undefined until the sheets have a measurable width. */
+ pageHeight: number | undefined;
+};
+
+const heightOf = (el: Element | null | undefined) => el?.getBoundingClientRect().height ?? 0;
+
+function allOnOnePage(count: number): number[][] {
+ return [Array.from({ length: count }, (_, i) => i)];
+}
+
+/**
+ * Measures the rendered letterhead and blocks and splits them into A4 sheets.
+ * Blocks are measured where they are rendered (every sheet has the same width),
+ * and re-measured after every render and whenever anything observed resizes.
+ */
+export function useCompDocPagination(
+ blockCount: number,
+ onPageChange?: (position: PagePosition) => void,
+) {
+ const scrollerRef = useRef(null);
+ const sheetsRef = useRef(null);
+ const headerRef = useRef(null);
+ const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
+ const pageRefs = useRef<(HTMLDivElement | null)[]>([]);
+ const [layout, setLayout] = useState({ pages: [[]], pageHeight: undefined });
+ const pages = coverAllBlocks(layout.pages, blockCount);
+
+ const measure = useCallback(() => {
+ const width = sheetsRef.current?.getBoundingClientRect().width ?? 0;
+ const next: PageLayout =
+ width > 0
+ ? {
+ pages: paginateBlocks({
+ headerHeight: heightOf(headerRef.current),
+ blockHeights: Array.from({ length: blockCount }, (_, i) =>
+ heightOf(blockRefs.current[i]),
+ ),
+ pageHeight: pageContentHeight(width),
+ }),
+ pageHeight: a4PageHeight(width),
+ }
+ : { pages: allOnOnePage(blockCount), pageHeight: undefined };
+ setLayout((prev) =>
+ samePages(prev.pages, next.pages) && prev.pageHeight === next.pageHeight ? prev : next,
+ );
+ }, [blockCount]);
+
+ // Block content can change on any render (typing in the editor), so measure after each one.
+ useLayoutEffect(() => {
+ measure();
+ });
+
+ const onPageChangeRef = useRef(onPageChange);
+ const totalRef = useRef(pages.length);
+ const lastReported = useRef(null);
+ useLayoutEffect(() => {
+ onPageChangeRef.current = onPageChange;
+ totalRef.current = pages.length;
+ });
+
+ const report = useCallback(() => {
+ const total = totalRef.current;
+ const tops = pageRefs.current.slice(0, total).map((el) => el?.offsetTop ?? 0);
+ const current = Math.min(currentPageAt(tops, scrollerRef.current?.scrollTop ?? 0), total);
+ const last = lastReported.current;
+ if (last?.current === current && last.total === total) return;
+ lastReported.current = { current, total };
+ onPageChangeRef.current?.({ current, total });
+ }, []);
+
+ // Any re-layout can move the sheet tops under the reader, even when the page count holds.
+ useLayoutEffect(() => {
+ report();
+ });
+
+ // Width changes, late font/image loads and reflow do not re-render; observe them.
+ const layoutKey = pages.map((page) => page.join(",")).join("|");
+ useEffect(() => {
+ const observer = new ResizeObserver(() => {
+ measure();
+ report();
+ });
+ [sheetsRef.current, headerRef.current, ...blockRefs.current.slice(0, blockCount)].forEach(
+ (el) => el && observer.observe(el),
+ );
+ return () => observer.disconnect();
+ }, [measure, report, blockCount, layoutKey]);
+
+ return {
+ pages,
+ pageHeight: layout.pageHeight,
+ scrollerRef,
+ sheetsRef,
+ headerRef,
+ blockRefs,
+ pageRefs,
+ onScroll: report,
+ };
+}
diff --git a/src/domain/work-orders/utils/completion-doc-pagination.ts b/src/domain/work-orders/utils/completion-doc-pagination.ts
new file mode 100644
index 00000000..ea8d9934
--- /dev/null
+++ b/src/domain/work-orders/utils/completion-doc-pagination.ts
@@ -0,0 +1,107 @@
+/** A4 portrait: sheet height is its width × 297 / 210. */
+export const A4_HEIGHT_RATIO = 297 / 210;
+/** Brand-accent bar at the top of every sheet. */
+export const PAGE_TOP_BAR_PX = 12;
+/** Brand-accent bar at the bottom of every sheet. */
+export const PAGE_BOTTOM_BAR_PX = 24;
+/** Blank space kept between the last block and the bottom bar. */
+export const PAGE_BOTTOM_PADDING_PX = 28;
+/** Vertical gap between stacked sheets. */
+export const PAGE_GAP_PX = 20;
+
+export type PagePosition = { current: number; total: number };
+
+/** Sheet height for a sheet of the given width. */
+export function a4PageHeight(width: number): number {
+ return width * A4_HEIGHT_RATIO;
+}
+
+/** Height a sheet of the given width leaves for the letterhead and blocks. */
+export function pageContentHeight(width: number): number {
+ return a4PageHeight(width) - PAGE_TOP_BAR_PX - PAGE_BOTTOM_BAR_PX - PAGE_BOTTOM_PADDING_PX;
+}
+
+type PaginateInput = {
+ /** Height of the letterhead, printed once at the top of the first page. */
+ headerHeight: number;
+ /** Heights of the blocks (procedures, comments) in document order. */
+ blockHeights: readonly number[];
+ /** Room a sheet has for content between its top bar and bottom padding. */
+ pageHeight: number;
+};
+
+/**
+ * Assigns blocks to pages, in order. A block that does not fit in what is left
+ * of the current page starts the next page, unless the current page holds
+ * nothing yet: a block taller than a whole page then stays on its own page and
+ * that page grows to show it. Blocks are never split, dropped or reordered.
+ * Returns block indices per page; there is always at least one page.
+ */
+export function paginateBlocks({
+ headerHeight,
+ blockHeights,
+ pageHeight,
+}: PaginateInput): number[][] {
+ const pages: number[][] = [[]];
+ let remaining = pageHeight - headerHeight;
+ let pageHasContent = headerHeight > 0;
+
+ blockHeights.forEach((height, index) => {
+ if (pageHasContent && height > remaining) {
+ pages.push([]);
+ remaining = pageHeight;
+ }
+ pages[pages.length - 1].push(index);
+ remaining -= height;
+ pageHasContent = true;
+ });
+
+ return pages;
+}
+
+/**
+ * Makes a (possibly stale) page layout cover exactly blocks 0..count-1: indices
+ * that no longer exist are dropped and new ones join the last page, so a block
+ * added since the last measurement is still rendered (and then measured).
+ */
+export function coverAllBlocks(pages: readonly number[][], count: number): number[][] {
+ const seen = new Set();
+ const covered = pages.map((page) =>
+ page.filter((index) => {
+ if (index >= count || seen.has(index)) return false;
+ seen.add(index);
+ return true;
+ }),
+ );
+ const result = covered.filter((page, i) => i === 0 || page.length > 0);
+ if (result.length === 0) result.push([]);
+ for (let index = 0; index < count; index += 1) {
+ if (!seen.has(index)) result[result.length - 1].push(index);
+ }
+ return result;
+}
+
+/**
+ * The page being read: the last sheet whose top edge (less half the gap
+ * between sheets) has scrolled to the top of the viewport.
+ */
+export function currentPageAt(pageTops: readonly number[], scrollTop: number): number {
+ let current = 1;
+ pageTops.forEach((top, index) => {
+ if (top - PAGE_GAP_PX / 2 <= scrollTop) current = index + 1;
+ });
+ return current;
+}
+
+/** The pill suffix, shown only once the document spans more than one page. */
+export function pageIndicatorLabel({ current, total }: PagePosition): string {
+ return total > 1 ? ` · Page ${current} of ${total}` : "";
+}
+
+/** Page layouts compare equal when every page holds the same blocks. */
+export function samePages(a: readonly number[][], b: readonly number[][]): boolean {
+ return (
+ a.length === b.length &&
+ a.every((page, i) => page.length === b[i].length && page.every((x, j) => x === b[i][j]))
+ );
+}
diff --git a/src/test/app/(protected)/workorders/comp-doc-paginated-preview.test.tsx b/src/test/app/(protected)/workorders/comp-doc-paginated-preview.test.tsx
new file mode 100644
index 00000000..7cc2f88e
--- /dev/null
+++ b/src/test/app/(protected)/workorders/comp-doc-paginated-preview.test.tsx
@@ -0,0 +1,275 @@
+import { act, fireEvent, render, screen, within } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { CompletionTemplatePreview } from "@/app/(protected)/completion-docs/_components/completion-template-preview";
+import { CompDocPreview } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-preview";
+import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+/*
+ * jsdom has no layout, so the geometry the paginator reads is stubbed:
+ * sheets are 420px wide (A4 → 594px tall, 530px of content room), the
+ * letterhead block is 200px and every other block is 300px. Page 1 then holds
+ * one block and every later page holds one block as well.
+ */
+const SHEET_WIDTH = 420;
+const LETTERHEAD_HEIGHT = 200;
+const BLOCK_HEIGHT = 300;
+const SHEET_STRIDE = 614;
+let sheetStride = SHEET_STRIDE;
+
+function isLetterhead(el: HTMLElement) {
+ return el.querySelector('img[alt="Seahaven Industries"]') !== null;
+}
+
+function stubRect(this: HTMLElement): DOMRect {
+ const height =
+ this.style.display === "flow-root"
+ ? isLetterhead(this)
+ ? LETTERHEAD_HEIGHT
+ : BLOCK_HEIGHT
+ : 0;
+ return { width: SHEET_WIDTH, height } as DOMRect;
+}
+
+const offsetTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetTop");
+
+beforeEach(() => {
+ sheetStride = SHEET_STRIDE;
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(stubRect);
+ Object.defineProperty(HTMLElement.prototype, "offsetTop", {
+ configurable: true,
+ get(this: HTMLElement) {
+ if (this.dataset.testid !== "comp-doc-page") return 0;
+ return Array.from(this.parentElement?.children ?? []).indexOf(this) * sheetStride;
+ },
+ });
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ if (offsetTopDescriptor) {
+ Object.defineProperty(HTMLElement.prototype, "offsetTop", offsetTopDescriptor);
+ }
+});
+
+function sheets() {
+ return screen.getAllByTestId("comp-doc-page");
+}
+
+function expectEverySheetFramed() {
+ for (const sheet of sheets()) {
+ expect(within(sheet).getAllByTestId("comp-doc-page-top-bar")).toHaveLength(1);
+ expect(within(sheet).getAllByTestId("comp-doc-page-bottom-bar")).toHaveLength(1);
+ // A4 portrait: 420 × 297 / 210.
+ expect(sheet.style.minHeight).toBe("594px");
+ }
+}
+
+function expectLetterheadOnlyOnFirstSheet() {
+ const [first, ...rest] = sheets();
+ expect(within(first).getByAltText("Seahaven Industries")).toBeInTheDocument();
+ for (const sheet of rest) {
+ expect(within(sheet).queryByAltText("Seahaven Industries")).not.toBeInTheDocument();
+ }
+}
+
+function scrollDocumentTo(scrollTop: number) {
+ const scroller = screen.getByTestId("comp-doc-document");
+ act(() => {
+ scroller.scrollTop = scrollTop;
+ fireEvent.scroll(scroller);
+ });
+}
+
+describe("Completion template preview pagination", () => {
+ const procedures = [1, 2, 3].map((n) => ({
+ name: `Step group ${n}`,
+ description: `Do part ${n} of the work`,
+ }));
+
+ it("lays the template out on separate A4 sheets with the letterhead once and bars on each", () => {
+ render(
+ ,
+ );
+
+ expect(sheets()).toHaveLength(3);
+ expectEverySheetFramed();
+ expectLetterheadOnlyOnFirstSheet();
+ procedures.forEach((procedure, index) => {
+ expect(within(sheets()[index]).getByText(procedure.name)).toBeInTheDocument();
+ });
+ expect(screen.getByText(/Document preview/)).toHaveTextContent(
+ "Document preview · Page 1 of 3",
+ );
+ });
+
+ it("updates Page X of Y as the reader scrolls through the sheets", () => {
+ render(
+ ,
+ );
+
+ scrollDocumentTo(SHEET_STRIDE + 5);
+ expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 2 of 3");
+
+ scrollDocumentTo(2 * SHEET_STRIDE + 5);
+ expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 3 of 3");
+
+ scrollDocumentTo(0);
+ expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 1 of 3");
+ });
+
+ it("renders a short template as one full A4 sheet without a page count", () => {
+ render(
+ ,
+ );
+
+ expect(sheets()).toHaveLength(1);
+ expectEverySheetFramed();
+ expect(screen.getByText(/Document preview/)).toHaveTextContent(/^Document preview$/);
+ });
+
+ it("adds a sheet when a procedure is added while editing", () => {
+ const { rerender } = render(
+ ,
+ );
+ expect(sheets()).toHaveLength(2);
+
+ rerender(
+ ,
+ );
+
+ expect(sheets()).toHaveLength(3);
+ expect(within(sheets()[2]).getByText("Step group 3")).toBeInTheDocument();
+ expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 1 of 3");
+ });
+
+ it("re-reads the page being read when an edit moves the sheets but keeps the page count", () => {
+ const { rerender } = render(
+ ,
+ );
+ scrollDocumentTo(SHEET_STRIDE + 5);
+ expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 2 of 3");
+
+ // The edit makes sheet 1 taller: sheet 2 now starts below the reader's position.
+ sheetStride = 800;
+ rerender(
+ ,
+ );
+
+ expect(sheets()).toHaveLength(3);
+ expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 1 of 3");
+ });
+});
+
+const site: CompDocSiteInfo = {
+ code: "SEA1",
+ street: "1 Main St",
+ city: "Seattle",
+ state: "WA",
+ zip: "98101",
+ label: "Seattle, WA",
+ fullAddress: "1 Main St, Seattle, WA 98101",
+ tech: "Tech One",
+ company: "Vendor Co",
+};
+
+const row = {
+ id: 42,
+ woNumber: "WO-42",
+ site: "SEA1",
+ pm: "HVAC",
+ company: "Vendor Co",
+ tech: "Tech One",
+ scheduledOn: "2026-06-01",
+} as WorkOrderTableRow;
+
+describe("Work order completion document preview pagination", () => {
+ it("pages the procedure and PM comments through the shared A4 frame", () => {
+ render(
+ ,
+ );
+
+ expect(sheets()).toHaveLength(2);
+ expectEverySheetFramed();
+ expectLetterheadOnlyOnFirstSheet();
+ expect(within(sheets()[1]).getByText("PM Comments")).toBeInTheDocument();
+ expect(screen.getByText(/Preview — not yet generated/)).toHaveTextContent(
+ "Preview — not yet generated · Page 1 of 2",
+ );
+
+ scrollDocumentTo(SHEET_STRIDE);
+ expect(screen.getByText(/Preview — not yet generated/)).toHaveTextContent(
+ "Preview — not yet generated · Page 2 of 2",
+ );
+ });
+
+ it("stamps NOT REQUIRED on the first sheet, above the greyed-out content", () => {
+ render(
+ ,
+ );
+
+ const [first, second] = sheets();
+ const stamp = within(first).getByText(/NOT REQUIRED/);
+ expect(stamp.closest('[style*="opacity: 0.5"]')).toBeNull();
+ expect(within(first).getByTestId("comp-doc-page-top-bar").parentElement).toHaveStyle({
+ opacity: "0.5",
+ });
+ expect(within(second).queryByText(/NOT REQUIRED/)).not.toBeInTheDocument();
+ });
+
+ it("keeps a document without comments on a single sheet", () => {
+ render(
+ ,
+ );
+
+ expect(sheets()).toHaveLength(1);
+ expectEverySheetFramed();
+ expect(screen.queryByText("PM Comments")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/test/domain/work-orders/utils/completion-doc-pagination.test.ts b/src/test/domain/work-orders/utils/completion-doc-pagination.test.ts
new file mode 100644
index 00000000..465d5daa
--- /dev/null
+++ b/src/test/domain/work-orders/utils/completion-doc-pagination.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from "vitest";
+import {
+ a4PageHeight,
+ coverAllBlocks,
+ currentPageAt,
+ pageContentHeight,
+ pageIndicatorLabel,
+ paginateBlocks,
+ samePages,
+} from "@/domain/work-orders/utils/completion-doc-pagination";
+
+describe("paginateBlocks", () => {
+ it("keeps a short document on one page", () => {
+ expect(
+ paginateBlocks({ headerHeight: 200, blockHeights: [100, 150], pageHeight: 800 }),
+ ).toEqual([[0, 1]]);
+ });
+
+ it("returns one empty page when there are no blocks", () => {
+ expect(paginateBlocks({ headerHeight: 200, blockHeights: [], pageHeight: 800 })).toEqual([[]]);
+ });
+
+ it("moves a block that no longer fits onto the next page, in order", () => {
+ expect(
+ paginateBlocks({ headerHeight: 300, blockHeights: [400, 200, 500, 300], pageHeight: 800 }),
+ ).toEqual([[0], [1, 2], [3]]);
+ });
+
+ it("charges the letterhead to the first page only", () => {
+ // Page 1 has 800 - 500 = 300 left; later pages have the full 800.
+ expect(
+ paginateBlocks({ headerHeight: 500, blockHeights: [300, 400, 400], pageHeight: 800 }),
+ ).toEqual([[0], [1, 2]]);
+ });
+
+ it("fills a page exactly without spilling", () => {
+ expect(
+ paginateBlocks({ headerHeight: 0, blockHeights: [400, 400, 1], pageHeight: 800 }),
+ ).toEqual([[0, 1], [2]]);
+ });
+
+ it("gives a block taller than a page its own page instead of splitting or hiding it", () => {
+ expect(
+ paginateBlocks({ headerHeight: 200, blockHeights: [100, 2000, 100], pageHeight: 800 }),
+ ).toEqual([[0], [1], [2]]);
+ });
+
+ it("starts the first block on page 2 when the letterhead alone fills page 1", () => {
+ expect(paginateBlocks({ headerHeight: 900, blockHeights: [100], pageHeight: 800 })).toEqual([
+ [],
+ [0],
+ ]);
+ });
+
+ it("keeps an oversized first block on page 1 when there is no letterhead", () => {
+ expect(paginateBlocks({ headerHeight: 0, blockHeights: [2000, 100], pageHeight: 800 })).toEqual(
+ [[0], [1]],
+ );
+ });
+});
+
+describe("coverAllBlocks", () => {
+ it("adds blocks created since the last measurement to the last page", () => {
+ expect(coverAllBlocks([[0], [1]], 4)).toEqual([[0], [1, 2, 3]]);
+ });
+
+ it("drops blocks that were removed and the pages they leave empty", () => {
+ expect(coverAllBlocks([[0], [1, 2], [3]], 2)).toEqual([[0], [1]]);
+ });
+
+ it("always keeps the first page, even with no blocks", () => {
+ expect(coverAllBlocks([[0, 1]], 0)).toEqual([[]]);
+ expect(coverAllBlocks([], 2)).toEqual([[0, 1]]);
+ });
+});
+
+describe("page geometry", () => {
+ it("sizes a sheet as A4 portrait and reserves the bars and bottom padding", () => {
+ expect(a4PageHeight(210)).toBeCloseTo(297);
+ expect(pageContentHeight(420)).toBeCloseTo(594 - 12 - 24 - 28);
+ });
+});
+
+describe("currentPageAt", () => {
+ const tops = [0, 900, 1800];
+
+ it("reads page 1 at the top of the document", () => {
+ expect(currentPageAt(tops, 0)).toBe(1);
+ });
+
+ it("switches page once the next sheet reaches the top of the viewport", () => {
+ expect(currentPageAt(tops, 880)).toBe(1);
+ expect(currentPageAt(tops, 890)).toBe(2);
+ expect(currentPageAt(tops, 2500)).toBe(3);
+ });
+});
+
+describe("pageIndicatorLabel", () => {
+ it("shows Page X of Y only for multi-page documents", () => {
+ expect(pageIndicatorLabel({ current: 1, total: 1 })).toBe("");
+ expect(pageIndicatorLabel({ current: 2, total: 3 })).toBe(" · Page 2 of 3");
+ });
+});
+
+describe("samePages", () => {
+ it("compares page layouts by content", () => {
+ expect(samePages([[0], [1, 2]], [[0], [1, 2]])).toBe(true);
+ expect(samePages([[0], [1, 2]], [[0, 1], [2]])).toBe(false);
+ expect(samePages([[0]], [[0], []])).toBe(false);
+ });
+});