Spaces:
Sleeping
Sleeping
Commit ·
eb56c6b
1
Parent(s): 39f81e0
Website
Browse files
frontend/src/components/JobSheetTemplate.tsx
CHANGED
|
@@ -164,6 +164,7 @@ function PhotoSlot({ url, label, className = "", imageClassName = "" }: PhotoSlo
|
|
| 164 |
"min-h-[120px] w-full rounded-lg border border-dashed border-gray-300 bg-gray-50 flex items-center justify-center text-xs text-gray-500 break-inside-avoid mb-3",
|
| 165 |
className,
|
| 166 |
].join(" ")}
|
|
|
|
| 167 |
>
|
| 168 |
No photo selected
|
| 169 |
</div>
|
|
@@ -175,6 +176,7 @@ function PhotoSlot({ url, label, className = "", imageClassName = "" }: PhotoSlo
|
|
| 175 |
"rounded-lg border border-gray-200 bg-gray-50 p-2 break-inside-avoid mb-3",
|
| 176 |
className,
|
| 177 |
].join(" ")}
|
|
|
|
| 178 |
>
|
| 179 |
<img
|
| 180 |
src={url}
|
|
|
|
| 164 |
"min-h-[120px] w-full rounded-lg border border-dashed border-gray-300 bg-gray-50 flex items-center justify-center text-xs text-gray-500 break-inside-avoid mb-3",
|
| 165 |
className,
|
| 166 |
].join(" ")}
|
| 167 |
+
style={{ breakInside: "avoid", pageBreakInside: "avoid" }}
|
| 168 |
>
|
| 169 |
No photo selected
|
| 170 |
</div>
|
|
|
|
| 176 |
"rounded-lg border border-gray-200 bg-gray-50 p-2 break-inside-avoid mb-3",
|
| 177 |
className,
|
| 178 |
].join(" ")}
|
| 179 |
+
style={{ breakInside: "avoid", pageBreakInside: "avoid" }}
|
| 180 |
>
|
| 181 |
<img
|
| 182 |
src={url}
|
frontend/src/pages/PrintReportPage.tsx
CHANGED
|
@@ -1,11 +1,153 @@
|
|
| 1 |
import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
| 2 |
import { useSearchParams } from "react-router-dom";
|
| 3 |
|
| 4 |
import { request } from "../lib/api";
|
| 5 |
-
import { BASE_W } from "../lib/report";
|
| 6 |
import { getSessionId } from "../lib/session";
|
| 7 |
-
import type { Page, Session } from "../types/session";
|
| 8 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
export default function PrintReportPage() {
|
| 11 |
const [searchParams] = useSearchParams();
|
|
@@ -16,12 +158,14 @@ export default function PrintReportPage() {
|
|
| 16 |
const [scale, setScale] = useState(1);
|
| 17 |
const [hasMeasured, setHasMeasured] = useState(false);
|
| 18 |
const [error, setError] = useState("");
|
|
|
|
| 19 |
|
| 20 |
const pageRef = useRef<HTMLDivElement | null>(null);
|
| 21 |
|
| 22 |
useEffect(() => {
|
| 23 |
if (!sessionId) {
|
| 24 |
setError("Missing session id.");
|
|
|
|
| 25 |
return;
|
| 26 |
}
|
| 27 |
async function load() {
|
|
@@ -36,6 +180,8 @@ export default function PrintReportPage() {
|
|
| 36 |
const message =
|
| 37 |
err instanceof Error ? err.message : "Failed to load session.";
|
| 38 |
setError(message);
|
|
|
|
|
|
|
| 39 |
}
|
| 40 |
}
|
| 41 |
load();
|
|
@@ -43,7 +189,11 @@ export default function PrintReportPage() {
|
|
| 43 |
|
| 44 |
useEffect(() => {
|
| 45 |
const updateScale = () => {
|
| 46 |
-
if (!pageRef.current)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
const width = pageRef.current.clientWidth;
|
| 48 |
if (width > 0) {
|
| 49 |
setScale(width / BASE_W);
|
|
@@ -55,27 +205,34 @@ export default function PrintReportPage() {
|
|
| 55 |
return () => window.removeEventListener("resize", updateScale);
|
| 56 |
}, []);
|
| 57 |
|
| 58 |
-
const
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
const
|
| 62 |
|
| 63 |
-
const
|
| 64 |
-
() =>
|
| 65 |
-
[
|
| 66 |
);
|
| 67 |
|
|
|
|
|
|
|
| 68 |
return (
|
| 69 |
<div
|
| 70 |
data-print-ready={ready ? "true" : "false"}
|
| 71 |
className="bg-white text-gray-900"
|
| 72 |
>
|
| 73 |
<style>{`
|
| 74 |
-
@page { size: A4; margin:
|
| 75 |
@media print {
|
| 76 |
html, body { background: #fff !important; margin: 0; }
|
| 77 |
}
|
| 78 |
-
.print-page { width: 210mm; min-height: 297mm; break-after: page; }
|
| 79 |
.print-page:last-child { break-after: auto; }
|
| 80 |
`}</style>
|
| 81 |
|
|
@@ -83,23 +240,32 @@ export default function PrintReportPage() {
|
|
| 83 |
<div className="p-6 text-sm text-red-600">{error}</div>
|
| 84 |
) : (
|
| 85 |
<div className="flex flex-col items-center gap-6 py-6">
|
| 86 |
-
{
|
| 87 |
-
<div
|
| 88 |
-
|
| 89 |
-
ref={index === 0 ? pageRef : undefined}
|
| 90 |
-
className="print-page"
|
| 91 |
-
>
|
| 92 |
-
<ReportPageCanvas
|
| 93 |
-
session={session}
|
| 94 |
-
page={pages[index] ?? { items: [] }}
|
| 95 |
-
pageIndex={index}
|
| 96 |
-
pageCount={totalPages}
|
| 97 |
-
scale={scale}
|
| 98 |
-
template={pages[index]?.template}
|
| 99 |
-
adaptive
|
| 100 |
-
/>
|
| 101 |
</div>
|
| 102 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
</div>
|
| 104 |
)}
|
| 105 |
</div>
|
|
|
|
| 1 |
import { useEffect, useMemo, useRef, useState } from "react";
|
| 2 |
+
import type { CSSProperties } from "react";
|
| 3 |
import { useSearchParams } from "react-router-dom";
|
| 4 |
|
| 5 |
import { request } from "../lib/api";
|
| 6 |
+
import { BASE_W, getPhotosForPage, getSelectedPhotos } from "../lib/report";
|
| 7 |
import { getSessionId } from "../lib/session";
|
| 8 |
+
import type { FileMeta, Page, PageItem, Session, TemplateFields } from "../types/session";
|
| 9 |
+
import { JobSheetTemplate } from "../components/JobSheetTemplate";
|
| 10 |
+
|
| 11 |
+
const PHOTOS_PER_SHEET = 6;
|
| 12 |
+
|
| 13 |
+
type PrintSheet = {
|
| 14 |
+
pageIndex: number;
|
| 15 |
+
sheetIndex: number;
|
| 16 |
+
page: Page;
|
| 17 |
+
photos: FileMeta[];
|
| 18 |
+
variant: "full" | "photos";
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
function hasTemplateContent(template?: TemplateFields) {
|
| 22 |
+
if (!template) return false;
|
| 23 |
+
return Object.values(template).some((value) => {
|
| 24 |
+
if (typeof value === "string") return value.trim().length > 0;
|
| 25 |
+
return value != null;
|
| 26 |
+
});
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function resolvePagePhotos(
|
| 30 |
+
session: Session | null,
|
| 31 |
+
page: Page | null | undefined,
|
| 32 |
+
pageIndex: number,
|
| 33 |
+
): FileMeta[] {
|
| 34 |
+
if (!session) return [];
|
| 35 |
+
const uploads = session.uploads?.photos ?? [];
|
| 36 |
+
const byId = new Map(uploads.map((photo) => [photo.id, photo]));
|
| 37 |
+
const explicit = page?.photo_ids ?? [];
|
| 38 |
+
if (explicit.length) {
|
| 39 |
+
return explicit.map((id) => byId.get(id)).filter(Boolean) as FileMeta[];
|
| 40 |
+
}
|
| 41 |
+
return getPhotosForPage(session, pageIndex, 1);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function chunkPhotos(photos: FileMeta[], perSheet: number) {
|
| 45 |
+
if (!photos.length) return [[]];
|
| 46 |
+
const slices: FileMeta[][] = [];
|
| 47 |
+
for (let i = 0; i < photos.length; i += perSheet) {
|
| 48 |
+
slices.push(photos.slice(i, i + perSheet));
|
| 49 |
+
}
|
| 50 |
+
return slices;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function buildPrintSheets(pages: Page[], session: Session | null): PrintSheet[] {
|
| 54 |
+
const sheets: PrintSheet[] = [];
|
| 55 |
+
pages.forEach((page, pageIndex) => {
|
| 56 |
+
const photos = resolvePagePhotos(session, page, pageIndex);
|
| 57 |
+
const hasItems = (page?.items?.length ?? 0) > 0;
|
| 58 |
+
const hasTemplate = hasTemplateContent(page?.template);
|
| 59 |
+
const hasPhotos = photos.length > 0;
|
| 60 |
+
if (!hasItems && !hasTemplate && !hasPhotos) return;
|
| 61 |
+
|
| 62 |
+
if (!hasPhotos) {
|
| 63 |
+
sheets.push({
|
| 64 |
+
pageIndex,
|
| 65 |
+
sheetIndex: 0,
|
| 66 |
+
page,
|
| 67 |
+
photos: [],
|
| 68 |
+
variant: "full",
|
| 69 |
+
});
|
| 70 |
+
return;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
const photoSheets = chunkPhotos(photos, PHOTOS_PER_SHEET);
|
| 74 |
+
photoSheets.forEach((chunk, sheetIndex) => {
|
| 75 |
+
sheets.push({
|
| 76 |
+
pageIndex,
|
| 77 |
+
sheetIndex,
|
| 78 |
+
page,
|
| 79 |
+
photos: chunk,
|
| 80 |
+
variant: sheetIndex === 0 ? "full" : "photos",
|
| 81 |
+
});
|
| 82 |
+
});
|
| 83 |
+
});
|
| 84 |
+
return sheets;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
function renderItems(items: PageItem[], scale: number) {
|
| 88 |
+
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
|
| 89 |
+
return items
|
| 90 |
+
.slice()
|
| 91 |
+
.sort((a, b) => (a.z ?? 0) - (b.z ?? 0))
|
| 92 |
+
.map((item) => {
|
| 93 |
+
const itemStyle: CSSProperties = {
|
| 94 |
+
position: "absolute",
|
| 95 |
+
left: `${item.x * safeScale}px`,
|
| 96 |
+
top: `${item.y * safeScale}px`,
|
| 97 |
+
width: `${item.w * safeScale}px`,
|
| 98 |
+
height: `${item.h * safeScale}px`,
|
| 99 |
+
zIndex: item.z ?? 0,
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
if (item.type === "text") {
|
| 103 |
+
return (
|
| 104 |
+
<div key={item.id} style={itemStyle}>
|
| 105 |
+
<div
|
| 106 |
+
className="w-full h-full p-2 overflow-hidden"
|
| 107 |
+
style={{
|
| 108 |
+
whiteSpace: "pre-wrap",
|
| 109 |
+
fontSize: `${(item.style?.fontSize ?? 14) * safeScale}px`,
|
| 110 |
+
fontWeight: item.style?.bold ? 700 : 400,
|
| 111 |
+
fontStyle: item.style?.italic ? "italic" : "normal",
|
| 112 |
+
textDecoration: item.style?.underline ? "underline" : "none",
|
| 113 |
+
color: item.style?.color ?? "#111827",
|
| 114 |
+
textAlign: item.style?.align ?? "left",
|
| 115 |
+
}}
|
| 116 |
+
>
|
| 117 |
+
{item.content ?? ""}
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
if (item.type === "image") {
|
| 124 |
+
return (
|
| 125 |
+
<div key={item.id} style={itemStyle}>
|
| 126 |
+
<img
|
| 127 |
+
src={item.src}
|
| 128 |
+
alt={item.name ?? "Image"}
|
| 129 |
+
className="w-full h-full object-contain bg-white"
|
| 130 |
+
loading="eager"
|
| 131 |
+
/>
|
| 132 |
+
</div>
|
| 133 |
+
);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
return (
|
| 137 |
+
<div key={item.id} style={itemStyle}>
|
| 138 |
+
<div
|
| 139 |
+
className="w-full h-full"
|
| 140 |
+
style={{
|
| 141 |
+
background: item.style?.fill ?? "#ffffff",
|
| 142 |
+
borderStyle: "solid",
|
| 143 |
+
borderColor: item.style?.stroke ?? "#111827",
|
| 144 |
+
borderWidth: `${(item.style?.strokeWidth ?? 1) * safeScale}px`,
|
| 145 |
+
}}
|
| 146 |
+
/>
|
| 147 |
+
</div>
|
| 148 |
+
);
|
| 149 |
+
});
|
| 150 |
+
}
|
| 151 |
|
| 152 |
export default function PrintReportPage() {
|
| 153 |
const [searchParams] = useSearchParams();
|
|
|
|
| 158 |
const [scale, setScale] = useState(1);
|
| 159 |
const [hasMeasured, setHasMeasured] = useState(false);
|
| 160 |
const [error, setError] = useState("");
|
| 161 |
+
const [loaded, setLoaded] = useState(false);
|
| 162 |
|
| 163 |
const pageRef = useRef<HTMLDivElement | null>(null);
|
| 164 |
|
| 165 |
useEffect(() => {
|
| 166 |
if (!sessionId) {
|
| 167 |
setError("Missing session id.");
|
| 168 |
+
setLoaded(true);
|
| 169 |
return;
|
| 170 |
}
|
| 171 |
async function load() {
|
|
|
|
| 180 |
const message =
|
| 181 |
err instanceof Error ? err.message : "Failed to load session.";
|
| 182 |
setError(message);
|
| 183 |
+
} finally {
|
| 184 |
+
setLoaded(true);
|
| 185 |
}
|
| 186 |
}
|
| 187 |
load();
|
|
|
|
| 189 |
|
| 190 |
useEffect(() => {
|
| 191 |
const updateScale = () => {
|
| 192 |
+
if (!pageRef.current) {
|
| 193 |
+
setScale(1);
|
| 194 |
+
setHasMeasured(true);
|
| 195 |
+
return;
|
| 196 |
+
}
|
| 197 |
const width = pageRef.current.clientWidth;
|
| 198 |
if (width > 0) {
|
| 199 |
setScale(width / BASE_W);
|
|
|
|
| 205 |
return () => window.removeEventListener("resize", updateScale);
|
| 206 |
}, []);
|
| 207 |
|
| 208 |
+
const basePages = useMemo(() => {
|
| 209 |
+
if (pages.length) return pages;
|
| 210 |
+
if (!session) return [];
|
| 211 |
+
const selected = getSelectedPhotos(session);
|
| 212 |
+
const count = Math.max(1, selected.length || session.page_count || 1);
|
| 213 |
+
return Array.from({ length: count }, () => ({ items: [] as PageItem[] }));
|
| 214 |
+
}, [pages, session]);
|
| 215 |
|
| 216 |
+
const totalPages = basePages.length || 1;
|
| 217 |
|
| 218 |
+
const printSheets = useMemo(
|
| 219 |
+
() => buildPrintSheets(basePages, session),
|
| 220 |
+
[basePages, session],
|
| 221 |
);
|
| 222 |
|
| 223 |
+
const ready = Boolean(sessionId && hasMeasured && loaded);
|
| 224 |
+
|
| 225 |
return (
|
| 226 |
<div
|
| 227 |
data-print-ready={ready ? "true" : "false"}
|
| 228 |
className="bg-white text-gray-900"
|
| 229 |
>
|
| 230 |
<style>{`
|
| 231 |
+
@page { size: A4; margin: 0; }
|
| 232 |
@media print {
|
| 233 |
html, body { background: #fff !important; margin: 0; }
|
| 234 |
}
|
| 235 |
+
.print-page { width: 210mm; min-height: 297mm; break-after: page; position: relative; }
|
| 236 |
.print-page:last-child { break-after: auto; }
|
| 237 |
`}</style>
|
| 238 |
|
|
|
|
| 240 |
<div className="p-6 text-sm text-red-600">{error}</div>
|
| 241 |
) : (
|
| 242 |
<div className="flex flex-col items-center gap-6 py-6">
|
| 243 |
+
{printSheets.length === 0 ? (
|
| 244 |
+
<div className="p-6 text-sm text-gray-600">
|
| 245 |
+
No printable content available.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
</div>
|
| 247 |
+
) : (
|
| 248 |
+
printSheets.map((sheet, index) => (
|
| 249 |
+
<div
|
| 250 |
+
key={`print-page-${sheet.pageIndex}-${sheet.sheetIndex}`}
|
| 251 |
+
ref={index === 0 ? pageRef : undefined}
|
| 252 |
+
className="print-page"
|
| 253 |
+
>
|
| 254 |
+
<JobSheetTemplate
|
| 255 |
+
session={session}
|
| 256 |
+
pageIndex={sheet.pageIndex}
|
| 257 |
+
pageCount={totalPages}
|
| 258 |
+
template={sheet.page.template}
|
| 259 |
+
photos={sheet.photos}
|
| 260 |
+
orderLocked={sheet.page.photo_order_locked ?? false}
|
| 261 |
+
variant={sheet.variant}
|
| 262 |
+
/>
|
| 263 |
+
{sheet.sheetIndex === 0
|
| 264 |
+
? renderItems(sheet.page.items ?? [], scale)
|
| 265 |
+
: null}
|
| 266 |
+
</div>
|
| 267 |
+
))
|
| 268 |
+
)}
|
| 269 |
</div>
|
| 270 |
)}
|
| 271 |
</div>
|
server/app/services/pdf_export.py
CHANGED
|
@@ -34,12 +34,7 @@ async def _render_pdf_async(session_id: str, output_path: Path) -> Path:
|
|
| 34 |
format="A4",
|
| 35 |
print_background=True,
|
| 36 |
prefer_css_page_size=True,
|
| 37 |
-
margin={
|
| 38 |
-
"top": "10mm",
|
| 39 |
-
"right": "10mm",
|
| 40 |
-
"bottom": "10mm",
|
| 41 |
-
"left": "10mm",
|
| 42 |
-
},
|
| 43 |
)
|
| 44 |
|
| 45 |
await context.close()
|
|
|
|
| 34 |
format="A4",
|
| 35 |
print_background=True,
|
| 36 |
prefer_css_page_size=True,
|
| 37 |
+
margin={"top": "0mm", "right": "0mm", "bottom": "0mm", "left": "0mm"},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
)
|
| 39 |
|
| 40 |
await context.close()
|