Prosento_RepEx / frontend /src /components /ReportPageCanvas.tsx
ChristopherJKoen's picture
Refresh sections UI and templates
15a4294
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import type { FileMeta, Page, Session, TemplateFields } from "../types/session";
import { BASE_H, BASE_W } from "../lib/report";
import { JobSheetTemplate } from "./JobSheetTemplate";
type ReportPageCanvasProps = {
session: Session | null;
page: Page | null;
pageIndex: number;
pageCount: number;
scale: number;
template?: TemplateFields;
sectionLabel?: string;
className?: string;
adaptive?: boolean;
};
export function ReportPageCanvas({
session,
page,
pageIndex,
pageCount,
scale,
template,
sectionLabel,
className = "",
adaptive = false,
}: ReportPageCanvasProps) {
const items = page?.items ?? [];
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
const pageVariant = page?.variant ?? "full";
const photos = resolvePagePhotos(session, page, pageIndex);
const sheets = adaptive ? [photos] : [photos];
const sheetRefs = useRef<Array<HTMLDivElement | null>>([]);
const [sheetHeights, setSheetHeights] = useState<number[]>([]);
const defaultHeight = BASE_H * safeScale;
const resolvedHeights = useMemo(() => {
if (sheetHeights.length !== sheets.length) {
return sheets.map(() => defaultHeight);
}
return sheetHeights.map((height) => (height > 0 ? height : defaultHeight));
}, [defaultHeight, sheetHeights, sheets.length]);
const offsets = useMemo(() => {
const values: number[] = [];
let running = 0;
resolvedHeights.forEach((height) => {
values.push(running);
running += height;
});
return values;
}, [resolvedHeights]);
const containerHeight = resolvedHeights.reduce((sum, height) => sum + height, 0);
const measureHeights = () => {
const next = sheets.map((_, index) => {
const node = sheetRefs.current[index];
if (!node) return defaultHeight;
const rect = node.getBoundingClientRect();
return rect.height || defaultHeight;
});
setSheetHeights((prev) => {
if (prev.length === next.length && prev.every((val, idx) => Math.abs(val - next[idx]) < 1)) {
return prev;
}
return next;
});
};
useLayoutEffect(() => {
measureHeights();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sheets.length, safeScale]);
useEffect(() => {
const observer = new ResizeObserver(() => measureHeights());
sheetRefs.current.forEach((node) => {
if (node) observer.observe(node);
});
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sheets.length]);
return (
<div
className={["relative w-full", className].join(" ")}
style={{ height: `${containerHeight}px` }}
>
<div className="absolute inset-0 pointer-events-none">
{sheets.map((sheetPhotos, sheetIndex) => (
<div
key={`sheet-${sheetIndex}`}
style={{
position: "absolute",
top: `${offsets[sheetIndex] ?? 0}px`,
left: 0,
width: `${BASE_W * safeScale}px`,
height: `${resolvedHeights[sheetIndex] ?? defaultHeight}px`,
}}
>
<div
ref={(node) => {
sheetRefs.current[sheetIndex] = node;
}}
style={{
width: `${BASE_W}px`,
minHeight: `${BASE_H}px`,
transform: `scale(${safeScale})`,
transformOrigin: "top left",
}}
>
{page?.blank ? (
<div className="w-full h-full bg-white" />
) : (
<JobSheetTemplate
session={session}
pageIndex={pageIndex}
pageCount={pageCount}
template={template}
photos={sheetPhotos}
orderLocked={page?.photo_order_locked ?? false}
variant={pageVariant}
sectionLabel={sectionLabel}
photoLayout={page?.photo_layout}
/>
)}
</div>
</div>
))}
</div>
{items
.slice()
.sort((a, b) => (a.z ?? 0) - (b.z ?? 0))
.map((item) => {
const itemStyle: CSSProperties = {
position: "absolute",
left: `${item.x * safeScale}px`,
top: `${item.y * safeScale}px`,
width: `${item.w * safeScale}px`,
height: `${item.h * safeScale}px`,
zIndex: item.z ?? 0,
};
if (item.type === "text") {
return (
<div key={item.id} style={itemStyle}>
<div
className="w-full h-full p-2 overflow-hidden"
style={{
whiteSpace: "pre-wrap",
fontSize: `${(item.style?.fontSize ?? 14) * safeScale}px`,
fontWeight: item.style?.bold ? 700 : 400,
fontStyle: item.style?.italic ? "italic" : "normal",
textDecoration: item.style?.underline ? "underline" : "none",
color: item.style?.color ?? "#111827",
textAlign: item.style?.align ?? "left",
}}
>
{item.content ?? ""}
</div>
</div>
);
}
if (item.type === "image") {
return (
<div key={item.id} style={itemStyle}>
<img
src={item.src}
alt={item.name ?? "Image"}
className="w-full h-full object-contain bg-white"
loading="eager"
/>
</div>
);
}
return (
<div key={item.id} style={itemStyle}>
<div
className="w-full h-full"
style={{
background: item.style?.fill ?? "#ffffff",
borderStyle: "solid",
borderColor: item.style?.stroke ?? "#111827",
borderWidth: `${(item.style?.strokeWidth ?? 1) * safeScale}px`,
}}
/>
</div>
);
})}
</div>
);
}
function resolvePagePhotos(
session: Session | null,
page: Page | null | undefined,
pageIndex: number,
): FileMeta[] {
if (!session) return [];
const uploads = session.uploads?.photos ?? [];
const byId = new Map(uploads.map((photo) => [photo.id, photo]));
const explicit = page?.photo_ids ?? [];
if (explicit.length) {
return explicit.map((id) => byId.get(id)).filter(Boolean) as FileMeta[];
}
return [];
}