Merge pull request #281 from Sea-Haven-Industries/feat/ab/sh-317-a4-pagination

feat(comp-doc): A4 multi-page completion document preview
This commit is contained in:
Alexandre Brandizzi 2026-09-25 22:16:32 +00:00 • committed by GitHub
commit 3cdb5e7d7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 807 additions and 117 deletions

View file

@ -1,4 +1,5 @@
import { FileText } from "lucide-react"; import { FileText } from "lucide-react";
import { useState, type ReactNode } from "react";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { CompDocDocumentFrame } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-document-frame"; 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"; 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"; } from "@/domain/completion-templates/constants/completion-template-constants";
import type { CompletionTemplateProcedure } from "@/domain/completion-templates/types/completion-template"; import type { CompletionTemplateProcedure } from "@/domain/completion-templates/types/completion-template";
import { descriptionLines } from "@/domain/completion-templates/utils/description-lines"; import { descriptionLines } from "@/domain/completion-templates/utils/description-lines";
import {
pageIndicatorLabel,
type PagePosition,
} from "@/domain/work-orders/utils/completion-doc-pagination";
export interface CompletionTemplatePreviewValues { export interface CompletionTemplatePreviewValues {
name: string; name: string;
@ -22,7 +27,7 @@ type CompletionTemplatePreviewProps = {
isEditing: boolean; isEditing: boolean;
}; };
function PreviewPill({ isEditing }: { isEditing: boolean }) { function PreviewPill({ isEditing, page }: { isEditing: boolean; page: PagePosition }) {
return ( return (
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}> <div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
<span <span
@ -40,6 +45,7 @@ function PreviewPill({ isEditing }: { isEditing: boolean }) {
> >
<FileText size={13} /> <FileText size={13} />
{isEditing ? "Live preview" : "Document preview"} {isEditing ? "Live preview" : "Document preview"}
{pageIndicatorLabel(page)}
</span> </span>
</div> </div>
); );
@ -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) { if (procedures.length === 0) {
return ( return [
<Text <Text
key="empty"
variant="body" variant="body"
sx={{ fontSize: 12.5, color: "#9CA3AF", margin: "16px 0 0", fontStyle: "italic" }} sx={{ fontSize: 12.5, color: "#9CA3AF", margin: "16px 0 0", fontStyle: "italic" }}
> >
{COMPLETION_TEMPLATE_COPY.noProcedurePreview} {COMPLETION_TEMPLATE_COPY.noProcedurePreview}
</Text> </Text>,
); ];
} }
return ( return procedures.map((procedure, index) => (
<> <section key={index} aria-label={`Preview procedure ${index + 1}`}>
{procedures.map((procedure, index) => ( <CompDocProcedureHeading>
<section key={index} aria-label={`Preview procedure ${index + 1}`}> {procedure.name.trim() || `Procedure ${index + 1}`}
<CompDocProcedureHeading> </CompDocProcedureHeading>
{procedure.name.trim() || `Procedure ${index + 1}`} {descriptionLines(procedure.description).map((line, lineIndex) => (
</CompDocProcedureHeading> <Text
{descriptionLines(procedure.description).map((line, lineIndex) => ( key={lineIndex}
<Text variant="body"
key={lineIndex} sx={{ fontSize: 13, lineHeight: 1.5, color: "#161E38", margin: "4px 0" }}
variant="body" >
sx={{ fontSize: 13, lineHeight: 1.5, color: "#161E38", margin: "4px 0" }} {line}
> </Text>
{line}
</Text>
))}
</section>
))} ))}
</> </section>
); ));
} }
/** The template rendered on the same page, header and safety bullets as a work order's document. */ /** The template rendered on the same page, header and safety bullets as a work order's document. */
export function CompletionTemplatePreview({ values, isEditing }: CompletionTemplatePreviewProps) { export function CompletionTemplatePreview({ values, isEditing }: CompletionTemplatePreviewProps) {
const [page, setPage] = useState<PagePosition>({ current: 1, total: 1 });
const note = values.extraSafetyNote?.trim() ?? ""; const note = values.extraSafetyNote?.trim() ?? "";
return ( return (
<div <div
@ -115,18 +120,19 @@ export function CompletionTemplatePreview({ values, isEditing }: CompletionTempl
background: "var(--color-bg-muted)", background: "var(--color-bg-muted)",
}} }}
> >
<PreviewPill isEditing={isEditing} /> <PreviewPill isEditing={isEditing} page={page} />
<div <div className="flex min-h-0 flex-1 flex-col" data-testid="completion-template-preview">
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto" <CompDocDocumentFrame
style={{ padding: "0 20px 20px" }} onPageChange={setPage}
data-testid="completion-template-preview" header={
> <>
<CompDocDocumentFrame> <CompDocPreviewHeader />
<CompDocPreviewHeader /> <PreviewTitle name={values.name} />
<PreviewTitle name={values.name} /> <CompDocSafetyBullets extra={note === "" ? [] : [note]} />
<CompDocSafetyBullets extra={note === "" ? [] : [note]} /> </>
<PreviewProcedures procedures={values.procedures} /> }
</CompDocDocumentFrame> sections={previewSections(values.procedures)}
/>
</div> </div>
</div> </div>
); );

View file

@ -1,55 +1,121 @@
import type { ReactNode } from "react"; 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 = { type CompDocDocumentFrameProps = {
children: ReactNode; /** Letterhead and opening content, printed once at the top of page 1. */
/** Greyed-out page, as for a document marked not required. */ 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; dimmed?: boolean;
/** Rendered over the page, outside the dimmed layer. */ /** Stamped over the first sheet, outside the dimmed layer. */
overlay?: ReactNode; 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({ export function CompDocDocumentFrame({
children, header,
sections,
dimmed = false, dimmed = false,
overlay, overlay,
onPageChange,
}: CompDocDocumentFrameProps) { }: CompDocDocumentFrameProps) {
const { pages, pageHeight, scrollerRef, sheetsRef, headerRef, blockRefs, pageRefs, onScroll } =
useCompDocPagination(sections.length, onPageChange);
return ( return (
<div <div
className="relative break-words" ref={scrollerRef}
style={{ onScroll={onScroll}
background: "#ffffff", className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
borderRadius: 8, style={{ padding: "0 20px 20px", scrollbarGutter: "stable" }}
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)", data-testid="comp-doc-document"
overflow: "hidden",
fontFamily: "var(--font-sans)",
overflowWrap: "break-word",
wordBreak: "break-word",
}}
> >
<div <div ref={sheetsRef} className="relative">
style={{ {pages.map((blockIndices, pageIndex) => (
opacity: dimmed ? 0.5 : 1, <div
filter: dimmed ? "grayscale(0.4)" : "none", key={pageIndex}
}} ref={(el) => {
> pageRefs.current[pageIndex] = el;
<div }}
style={{ data-testid="comp-doc-page"
height: 12, className="relative flex flex-col break-words"
background: "linear-gradient(100deg,#27AAE1,#1C75BC)", style={{
}} marginTop: pageIndex > 0 ? PAGE_GAP_PX : 0,
/> minHeight: pageHeight,
<div style={{ padding: "0 28px 28px" }}>{children}</div> background: "#ffffff",
<div borderRadius: 8,
style={{ boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
height: 24, overflow: "hidden",
background: "linear-gradient(100deg,#1C75BC,#27AAE1)", fontFamily: "var(--font-sans)",
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)", overflowWrap: "break-word",
}} wordBreak: "break-word",
/> }}
</div> >
<div
className="flex flex-1 flex-col"
style={{
opacity: dimmed ? 0.5 : 1,
filter: dimmed ? "grayscale(0.4)" : "none",
}}
>
<div
data-testid="comp-doc-page-top-bar"
style={{
height: PAGE_TOP_BAR_PX,
flexShrink: 0,
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
}}
/>
<div style={{ padding: `0 28px ${PAGE_BOTTOM_PADDING_PX}px` }}>
{pageIndex === 0 && (
<div ref={headerRef} style={flowRoot}>
{header}
</div>
)}
{blockIndices.map((blockIndex) => (
<div
key={blockIndex}
ref={(el) => {
blockRefs.current[blockIndex] = el;
}}
style={flowRoot}
>
{sections[blockIndex]}
</div>
))}
</div>
<div
data-testid="comp-doc-page-bottom-bar"
style={{
height: PAGE_BOTTOM_BAR_PX,
flexShrink: 0,
marginTop: "auto",
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
}}
/>
</div>
{overlay} {pageIndex === 0 && overlay}
</div>
))}
</div>
</div> </div>
); );
} }

View file

@ -1,10 +1,15 @@
import { CheckCircle2, FileText } from "lucide-react"; import { CheckCircle2, FileText } from "lucide-react";
import {
pageIndicatorLabel,
type PagePosition,
} from "@/domain/work-orders/utils/completion-doc-pagination";
type CompDocPreviewBadgeProps = { type CompDocPreviewBadgeProps = {
isGenerated: boolean; isGenerated: boolean;
page: PagePosition;
}; };
export function CompDocPreviewBadge({ isGenerated }: CompDocPreviewBadgeProps) { export function CompDocPreviewBadge({ isGenerated, page }: CompDocPreviewBadgeProps) {
return ( return (
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}> <div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
<span <span
@ -26,6 +31,7 @@ export function CompDocPreviewBadge({ isGenerated }: CompDocPreviewBadgeProps) {
<FileText size={13} /> <FileText size={13} />
)} )}
{isGenerated ? "Generated document" : "Preview — not yet generated"} {isGenerated ? "Generated document" : "Preview — not yet generated"}
{pageIndicatorLabel(page)}
</span> </span>
</div> </div>
); );

View file

@ -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 { procedureFor } from "@/domain/work-orders/utils/completion-doc-procedures";
import { CompDocProcedureHeading } from "./comp-doc-procedure-heading"; import { CompDocProcedureHeading } from "./comp-doc-procedure-heading";
import { CompDocProcedureList } from "./comp-doc-procedure-list"; import { CompDocProcedureList } from "./comp-doc-procedure-list";
import { CompDocSafetyBullets } from "./comp-doc-safety-bullets";
type CompDocPreviewProcedureProps = { type CompDocPreviewProcedureProps = {
row: WorkOrderTableRow; 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); const proc = procedureFor(row.pm);
return ( return (
<> <>
<CompDocSafetyBullets />
<CompDocProcedureHeading>{proc.heading}</CompDocProcedureHeading> <CompDocProcedureHeading>{proc.heading}</CompDocProcedureHeading>
<Text <Text
variant="body" variant="body"
@ -30,24 +27,27 @@ export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureP
{proc.intro} {proc.intro}
</Text> </Text>
<CompDocProcedureList proc={proc} /> <CompDocProcedureList proc={proc} />
</>
{notes.length > 0 && ( );
<> }
<CompDocProcedureHeading>PM Comments</CompDocProcedureHeading>
<Text /** The PM's free-text comments printed after the procedure. */
variant="body" export function CompDocPreviewComments({ notes }: { notes: string }) {
sx={{ return (
fontSize: 12.5, <>
lineHeight: 1.5, <CompDocProcedureHeading>PM Comments</CompDocProcedureHeading>
color: "#161E38", <Text
margin: 0, variant="body"
whiteSpace: "pre-wrap", sx={{
}} fontSize: 12.5,
> lineHeight: 1.5,
{notes} color: "#161E38",
</Text> margin: 0,
</> whiteSpace: "pre-wrap",
)} }}
>
{notes}
</Text>
</> </>
); );
} }

View file

@ -1,11 +1,14 @@
import { useState } from "react";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc"; import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; 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 { CompDocDocumentFrame } from "./comp-doc-document-frame";
import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp"; import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp";
import { CompDocPreviewBadge } from "./comp-doc-preview-badge"; import { CompDocPreviewBadge } from "./comp-doc-preview-badge";
import { CompDocPreviewHeader } from "./comp-doc-preview-header"; 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 { CompDocPreviewSummary } from "./comp-doc-preview-summary";
import { CompDocSafetyBullets } from "./comp-doc-safety-bullets";
type CompDocPreviewProps = { type CompDocPreviewProps = {
row: WorkOrderTableRow; row: WorkOrderTableRow;
@ -24,6 +27,10 @@ export function CompDocPreview({
isGenerated, isGenerated,
isNotRequired, isNotRequired,
}: CompDocPreviewProps) { }: CompDocPreviewProps) {
const [page, setPage] = useState<PagePosition>({ current: 1, total: 1 });
const sections = [<CompDocPreviewProcedure key="procedure" row={row} />];
if (notes.length > 0) sections.push(<CompDocPreviewComments key="notes" notes={notes} />);
return ( return (
<div <div
className="flex min-h-0 flex-col" className="flex min-h-0 flex-col"
@ -33,26 +40,26 @@ export function CompDocPreview({
background: "var(--color-bg-muted)", background: "var(--color-bg-muted)",
}} }}
> >
<CompDocPreviewBadge isGenerated={isGenerated} /> <CompDocPreviewBadge isGenerated={isGenerated} page={page} />
<div <CompDocDocumentFrame
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto" dimmed={isNotRequired}
style={{ padding: "0 20px 20px" }} overlay={isNotRequired && <CompDocNotRequiredStamp />}
> onPageChange={setPage}
<CompDocDocumentFrame header={
dimmed={isNotRequired} <>
overlay={isNotRequired && <CompDocNotRequiredStamp />} <CompDocPreviewHeader />
> <CompDocPreviewSummary
<CompDocPreviewHeader /> row={row}
<CompDocPreviewSummary site={site}
row={row} generatedAt={generatedAt}
site={site} isGenerated={isGenerated}
generatedAt={generatedAt} />
isGenerated={isGenerated} <CompDocSafetyBullets />
/> </>
<CompDocPreviewProcedure row={row} notes={notes} /> }
</CompDocDocumentFrame> sections={sections}
</div> />
</div> </div>
); );
} }

View file

@ -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<HTMLDivElement>(null);
const sheetsRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
const pageRefs = useRef<(HTMLDivElement | null)[]>([]);
const [layout, setLayout] = useState<PageLayout>({ 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<PagePosition | null>(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,
};
}

View file

@ -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<number>();
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]))
);
}

View file

@ -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(
<CompletionTemplatePreview
values={{ name: "Backflow test", extraSafetyNote: null, procedures }}
isEditing={false}
/>,
);
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(
<CompletionTemplatePreview
values={{ name: "Backflow test", extraSafetyNote: null, procedures }}
isEditing
/>,
);
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(
<CompletionTemplatePreview
values={{ name: "Short", extraSafetyNote: null, procedures: procedures.slice(0, 1) }}
isEditing={false}
/>,
);
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(
<CompletionTemplatePreview
values={{ name: "Grows", extraSafetyNote: null, procedures: procedures.slice(0, 2) }}
isEditing
/>,
);
expect(sheets()).toHaveLength(2);
rerender(
<CompletionTemplatePreview
values={{ name: "Grows", extraSafetyNote: null, procedures }}
isEditing
/>,
);
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(
<CompletionTemplatePreview
values={{ name: "Edits", extraSafetyNote: null, procedures }}
isEditing
/>,
);
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(
<CompletionTemplatePreview
values={{
name: "Edits",
extraSafetyNote: null,
procedures: [{ ...procedures[0], description: "Longer text" }, ...procedures.slice(1)],
}}
isEditing
/>,
);
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(
<CompDocPreview
row={row}
site={site}
notes="Replaced the filter on unit 3."
generatedAt=""
isGenerated={false}
isNotRequired={false}
/>,
);
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(
<CompDocPreview
row={row}
site={site}
notes="Replaced the filter on unit 3."
generatedAt=""
isGenerated={false}
isNotRequired
/>,
);
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(
<CompDocPreview
row={row}
site={site}
notes=""
generatedAt=""
isGenerated={false}
isNotRequired={false}
/>,
);
expect(sheets()).toHaveLength(1);
expectEverySheetFramed();
expect(screen.queryByText("PM Comments")).not.toBeInTheDocument();
});
});

View file

@ -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);
});
});