diff --git a/src/app/(protected)/completion-docs/_components/completion-template-preview.tsx b/src/app/(protected)/completion-docs/_components/completion-template-preview.tsx index df596fd4..62f5a731 100644 --- a/src/app/(protected)/completion-docs/_components/completion-template-preview.tsx +++ b/src/app/(protected)/completion-docs/_components/completion-template-preview.tsx @@ -1,4 +1,5 @@ import { FileText } from "lucide-react"; +import { useState, type ReactNode } from "react"; import { Text } from "@/components/ui/text"; import { CompDocDocumentFrame } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-document-frame"; import { CompDocPreviewHeader } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-header"; @@ -10,6 +11,10 @@ import { } from "@/domain/completion-templates/constants/completion-template-constants"; import type { CompletionTemplateProcedure } from "@/domain/completion-templates/types/completion-template"; import { descriptionLines } from "@/domain/completion-templates/utils/description-lines"; +import { + pageIndicatorLabel, + type PagePosition, +} from "@/domain/work-orders/utils/completion-doc-pagination"; export interface CompletionTemplatePreviewValues { name: string; @@ -22,7 +27,7 @@ type CompletionTemplatePreviewProps = { isEditing: boolean; }; -function PreviewPill({ isEditing }: { isEditing: boolean }) { +function PreviewPill({ isEditing, page }: { isEditing: boolean; page: PagePosition }) { return (
{isEditing ? "Live preview" : "Document preview"} + {pageIndicatorLabel(page)}
); @@ -70,41 +76,40 @@ function PreviewTitle({ name }: { name: string }) { ); } -function PreviewProcedures({ procedures }: { procedures: CompletionTemplateProcedure[] }) { +/** One block per procedure (each kept whole on a page), or the empty-state line. */ +function previewSections(procedures: CompletionTemplateProcedure[]): ReactNode[] { if (procedures.length === 0) { - return ( + return [ {COMPLETION_TEMPLATE_COPY.noProcedurePreview} - - ); + , + ]; } - return ( - <> - {procedures.map((procedure, index) => ( -
- - {procedure.name.trim() || `Procedure ${index + 1}`} - - {descriptionLines(procedure.description).map((line, lineIndex) => ( - - {line} - - ))} -
+ return procedures.map((procedure, index) => ( +
+ + {procedure.name.trim() || `Procedure ${index + 1}`} + + {descriptionLines(procedure.description).map((line, lineIndex) => ( + + {line} + ))} - - ); +
+ )); } /** The template rendered on the same page, header and safety bullets as a work order's document. */ export function CompletionTemplatePreview({ values, isEditing }: CompletionTemplatePreviewProps) { + const [page, setPage] = useState({ current: 1, total: 1 }); const note = values.extraSafetyNote?.trim() ?? ""; return (
- -
- - - - - - + +
+ + + + + + } + 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 (
-
-
-
{children}
-
-
+
+ {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); + }); +});