mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
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:
commit
3cdb5e7d7b
9 changed files with 807 additions and 117 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
107
src/domain/work-orders/utils/completion-doc-pagination.ts
Normal file
107
src/domain/work-orders/utils/completion-doc-pagination.ts
Normal 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]))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue