ChristopherJKoen commited on
Commit
eb56c6b
·
1 Parent(s): 39f81e0
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 { ReportPageCanvas } from "../components/ReportPageCanvas";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) return;
 
 
 
 
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 totalPages =
59
- pages.length > 0 ? pages.length : Math.max(1, session?.page_count ?? 0);
 
 
 
 
 
60
 
61
- const ready = Boolean(sessionId && !error && hasMeasured && (session || pages.length));
62
 
63
- const pageList = useMemo(
64
- () => Array.from({ length: totalPages }, (_, index) => index),
65
- [totalPages],
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: 10mm; }
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
- {pageList.map((index) => (
87
- <div
88
- key={`print-page-${index}`}
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()