ChristopherJKoen commited on
Commit
45527f3
·
2 Parent(s): 299d3ff 4bed098

Merge dev into main

Browse files
README.md CHANGED
@@ -82,14 +82,16 @@ Environment variables for the API:
82
  Frontend environment variables:
83
  - `VITE_API_BASE` (optional, default: `/api`)
84
 
85
- ## Headless PDF Export (Playwright)
86
 
87
- The server can generate PDFs using headless Chrome:
 
 
 
 
 
 
88
 
89
  ```powershell
90
  pip install -r server/requirements.txt
91
- python -m playwright install
92
  ```
93
-
94
- Make sure `FRONTEND_BASE_URL` points to the running frontend that serves
95
- `/print/report?session=...` (Vite dev server or the built frontend served by FastAPI).
 
82
  Frontend environment variables:
83
  - `VITE_API_BASE` (optional, default: `/api`)
84
 
85
+ ## PDF Export (ReportLab)
86
 
87
+ The server generates PDFs using ReportLab at:
88
+
89
+ ```
90
+ GET /api/sessions/{session_id}/export.pdf
91
+ ```
92
+
93
+ Install dependencies:
94
 
95
  ```powershell
96
  pip install -r server/requirements.txt
 
97
  ```
 
 
 
frontend/src/App.tsx CHANGED
@@ -9,7 +9,6 @@ import EditReportPage from "./pages/EditReportPage";
9
  import EditLayoutsPage from "./pages/EditLayoutsPage";
10
  import ExportPage from "./pages/ExportPage";
11
  import RatingsInfoPage from "./pages/RatingsInfoPage";
12
- import PrintReportPage from "./pages/PrintReportPage";
13
 
14
  export default function App() {
15
  return (
@@ -24,7 +23,6 @@ export default function App() {
24
  <Route path="/edit-layouts" element={<EditLayoutsPage />} />
25
  <Route path="/export" element={<ExportPage />} />
26
  <Route path="/info/ratings" element={<RatingsInfoPage />} />
27
- <Route path="/print/report" element={<PrintReportPage />} />
28
  <Route path="*" element={<Navigate to="/" replace />} />
29
  </Routes>
30
  </BrowserRouter>
 
9
  import EditLayoutsPage from "./pages/EditLayoutsPage";
10
  import ExportPage from "./pages/ExportPage";
11
  import RatingsInfoPage from "./pages/RatingsInfoPage";
 
12
 
13
  export default function App() {
14
  return (
 
23
  <Route path="/edit-layouts" element={<EditLayoutsPage />} />
24
  <Route path="/export" element={<ExportPage />} />
25
  <Route path="/info/ratings" element={<RatingsInfoPage />} />
 
26
  <Route path="*" element={<Navigate to="/" replace />} />
27
  </Routes>
28
  </BrowserRouter>
frontend/src/components/InfoMenu.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Info } from "react-feather";
2
+ import { Link } from "react-router-dom";
3
+
4
+ type InfoMenuProps = {
5
+ sessionQuery?: string;
6
+ };
7
+
8
+ export function InfoMenu({ sessionQuery = "" }: InfoMenuProps) {
9
+ const ratingsLink = `/info/ratings${sessionQuery}`;
10
+ return (
11
+ <details className="relative no-print">
12
+ <summary className="list-none">
13
+ <span className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition cursor-pointer">
14
+ <Info className="h-4 w-4" />
15
+ Info
16
+ </span>
17
+ </summary>
18
+ <div className="absolute right-0 mt-2 w-80 rounded-lg border border-gray-200 bg-white shadow-lg p-3 z-20">
19
+ <div className="text-xs font-semibold text-gray-900 mb-2">
20
+ RepEx quick guidance
21
+ </div>
22
+ <ul className="space-y-2 text-xs text-gray-600">
23
+ <li>
24
+ Workflow: Upload → Review → Input Data → Report Viewer → Edit →
25
+ Export.
26
+ </li>
27
+ <li>
28
+ Edit Report is for free‑form layout changes; Input Data updates the
29
+ structured fields.
30
+ </li>
31
+ <li>
32
+ Category and Priority values use the defined rating scales.
33
+ </li>
34
+ <li>
35
+ <Link
36
+ to={ratingsLink}
37
+ className="text-blue-600 hover:text-blue-700 underline"
38
+ >
39
+ View rating scale tables
40
+ </Link>
41
+ </li>
42
+ </ul>
43
+ </div>
44
+ </details>
45
+ );
46
+ }
frontend/src/components/JobSheetTemplate.tsx CHANGED
@@ -11,6 +11,7 @@ type JobSheetTemplateProps = {
11
  photos?: FileMeta[];
12
  orderLocked?: boolean;
13
  variant?: "full" | "photos";
 
14
  };
15
 
16
  type PhotoSlotProps = {
@@ -25,44 +26,107 @@ type LayoutEntry = {
25
  span: boolean;
26
  };
27
 
28
- type ScaleTone = {
29
  label: string;
30
- className: string;
 
 
31
  };
32
 
33
- const CATEGORY_SCALE: Record<string, ScaleTone> = {
34
- "0": { label: "Excellent", className: "bg-green-100 text-green-800 border-green-200" },
35
- "1": { label: "Good", className: "bg-green-200 text-green-800 border-green-200" },
36
- "2": { label: "Fair", className: "bg-yellow-100 text-yellow-800 border-yellow-200" },
37
- "3": { label: "Poor", className: "bg-yellow-200 text-yellow-800 border-yellow-200" },
38
- "4": { label: "Worse", className: "bg-orange-200 text-orange-800 border-orange-200" },
39
- "5": { label: "Severe", className: "bg-red-200 text-red-800 border-red-200" },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  };
41
 
42
- const PRIORITY_SCALE: Record<string, ScaleTone> = {
43
- "1": { label: "Immediate", className: "bg-red-200 text-red-800 border-red-200" },
44
- "2": { label: "1 Year", className: "bg-orange-200 text-orange-800 border-orange-200" },
45
- "3": { label: "3 Years", className: "bg-green-200 text-green-800 border-green-200" },
46
- X: { label: "At Use", className: "bg-purple-200 text-purple-800 border-purple-200" },
47
- M: { label: "Monitor", className: "bg-blue-200 text-blue-800 border-blue-200" },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  };
49
 
50
- function parseScaleCode(value: string) {
51
- const match = value.trim().match(/^[A-Za-z0-9]+/);
52
- return match ? match[0].toUpperCase() : value.trim().toUpperCase();
 
 
 
53
  }
54
 
55
- function buildScaleBadge(value: string, scale: Record<string, ScaleTone>) {
56
- const raw = value.trim();
57
- if (!raw) {
58
- return { text: "-", className: "bg-gray-50 text-gray-700 border-gray-200" };
59
- }
60
- const code = parseScaleCode(raw);
61
- const tone = scale[code];
62
  if (!tone) {
63
- return { text: raw, className: "bg-gray-50 text-gray-700 border-gray-200" };
 
 
 
64
  }
65
- return { text: `${code} - ${tone.label}`, className: tone.className };
 
 
 
66
  }
67
 
68
  function normalizeKey(value: string) {
@@ -173,7 +237,7 @@ function PhotoSlot({ url, label, className = "", imageClassName = "" }: PhotoSlo
173
  return (
174
  <figure
175
  className={[
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" }}
@@ -181,7 +245,7 @@ function PhotoSlot({ url, label, className = "", imageClassName = "" }: PhotoSlo
181
  <img
182
  src={url}
183
  alt={label}
184
- className={["w-full h-auto object-contain", imageClassName].join(" ")}
185
  loading="eager"
186
  />
187
  <figcaption className="mt-1 text-[10px] text-gray-600 text-center">
@@ -199,6 +263,7 @@ export function JobSheetTemplate({
199
  photos,
200
  orderLocked = false,
201
  variant = "full",
 
202
  }: JobSheetTemplateProps) {
203
  const inspectionDate =
204
  template?.inspection_date ?? session?.inspection_date ?? "";
@@ -225,9 +290,8 @@ export function JobSheetTemplate({
225
  const actionText = [actionType, requiredAction]
226
  .filter((value) => value && value.trim())
227
  .join(" - ");
228
-
229
- const categoryBadge = buildScaleBadge(String(category || ""), CATEGORY_SCALE);
230
- const priorityBadge = buildScaleBadge(String(priority || ""), PRIORITY_SCALE);
231
 
232
  const resolvedPhotos =
233
  photos && photos.length ? photos : getPhotosForPage(session, pageIndex, 1);
@@ -268,10 +332,16 @@ export function JobSheetTemplate({
268
  return layout.map((entry) => entry.photo);
269
  }, [limitedPhotos, ratios, orderLocked]);
270
 
271
- const photoColumnsClass = orderedPhotos.length <= 1 ? "columns-1" : "columns-2";
 
 
 
 
 
 
272
 
273
  return (
274
- <div className="w-full h-full p-5 text-[11px] text-gray-700">
275
  <header className="mb-3 border-b border-gray-200 pb-2">
276
  <div className="grid grid-cols-[auto,1fr,auto] items-center gap-3">
277
  <img
@@ -335,7 +405,10 @@ export function JobSheetTemplate({
335
  Category
336
  </div>
337
  <span
338
- className={`template-field inline-flex items-center justify-center rounded-md border px-3 py-1 text-[11px] font-semibold min-w-[120px] ${categoryBadge.className}`}
 
 
 
339
  >
340
  {categoryBadge.text}
341
  </span>
@@ -346,7 +419,10 @@ export function JobSheetTemplate({
346
  Priority
347
  </div>
348
  <span
349
- className={`template-field inline-flex items-center justify-center rounded-md border px-3 py-1 text-[11px] font-semibold min-w-[120px] ${priorityBadge.className}`}
 
 
 
350
  >
351
  {priorityBadge.text}
352
  </span>
@@ -380,24 +456,21 @@ export function JobSheetTemplate({
380
  </section>
381
  ) : null}
382
 
383
- <section className="mb-3 avoid-break">
384
  <div className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
385
  {variant === "photos" ? "Photo Documentation (continued)" : "Photo Documentation"}
386
  </div>
387
- <div
388
- className={`${photoColumnsClass}`}
389
- style={{ columnGap: "0.75rem" }}
390
- >
391
- {orderedPhotos.length === 0 ? (
392
- <PhotoSlot url={undefined} label="No photo selected" className="break-inside-avoid mb-3" />
393
  ) : (
394
- orderedPhotos.map((photo, index) => (
395
  <PhotoSlot
396
  key={photo?.id || `${index}`}
397
  url={photo?.url}
398
  label={photo?.name || `Figure ${index + 1}`}
399
- className="break-inside-avoid mb-3"
400
- imageClassName=""
401
  />
402
  ))
403
  )}
@@ -409,6 +482,7 @@ export function JobSheetTemplate({
409
  <span>Inspector: {inspector || "-"}</span>
410
  <span>Doc: {docNumber || "-"}</span>
411
  <span>Site: {clientSite || "-"}</span>
 
412
  </footer>
413
  </div>
414
  );
 
11
  photos?: FileMeta[];
12
  orderLocked?: boolean;
13
  variant?: "full" | "photos";
14
+ sectionLabel?: string;
15
  };
16
 
17
  type PhotoSlotProps = {
 
26
  span: boolean;
27
  };
28
 
29
+ type RatingTone = {
30
  label: string;
31
+ bg: string;
32
+ text: string;
33
+ border: string;
34
  };
35
 
36
+ const CATEGORY_SCALE: Record<string, RatingTone> = {
37
+ "0": {
38
+ label: "Excellent",
39
+ bg: "bg-green-100",
40
+ text: "text-green-800",
41
+ border: "border-green-200",
42
+ },
43
+ "1": {
44
+ label: "Good",
45
+ bg: "bg-green-200",
46
+ text: "text-green-800",
47
+ border: "border-green-200",
48
+ },
49
+ "2": {
50
+ label: "Fair",
51
+ bg: "bg-yellow-100",
52
+ text: "text-yellow-800",
53
+ border: "border-yellow-200",
54
+ },
55
+ "3": {
56
+ label: "Poor",
57
+ bg: "bg-yellow-200",
58
+ text: "text-yellow-800",
59
+ border: "border-yellow-200",
60
+ },
61
+ "4": {
62
+ label: "Worse",
63
+ bg: "bg-orange-200",
64
+ text: "text-orange-800",
65
+ border: "border-orange-200",
66
+ },
67
+ "5": {
68
+ label: "Severe",
69
+ bg: "bg-red-200",
70
+ text: "text-red-800",
71
+ border: "border-red-200",
72
+ },
73
  };
74
 
75
+ const PRIORITY_SCALE: Record<string, RatingTone> = {
76
+ "1": {
77
+ label: "Immediate",
78
+ bg: "bg-red-200",
79
+ text: "text-red-800",
80
+ border: "border-red-200",
81
+ },
82
+ "2": {
83
+ label: "1 Year",
84
+ bg: "bg-orange-200",
85
+ text: "text-orange-800",
86
+ border: "border-orange-200",
87
+ },
88
+ "3": {
89
+ label: "3 Years",
90
+ bg: "bg-green-200",
91
+ text: "text-green-800",
92
+ border: "border-green-200",
93
+ },
94
+ X: {
95
+ label: "At Use",
96
+ bg: "bg-purple-200",
97
+ text: "text-purple-800",
98
+ border: "border-purple-200",
99
+ },
100
+ M: {
101
+ label: "Monitor",
102
+ bg: "bg-blue-200",
103
+ text: "text-blue-800",
104
+ border: "border-blue-200",
105
+ },
106
  };
107
 
108
+ function ratingKey(value: string) {
109
+ const raw = (value || "").trim();
110
+ if (!raw) return "";
111
+ const match = raw.match(/^([0-9]|[xXmM])/);
112
+ if (match) return match[1].toUpperCase();
113
+ return raw.split("-")[0].trim().toUpperCase();
114
  }
115
 
116
+ function formatRating(value: string, scale: Record<string, RatingTone>) {
117
+ const raw = (value || "").trim();
118
+ const key = ratingKey(raw);
119
+ const tone = key ? scale[key] : undefined;
 
 
 
120
  if (!tone) {
121
+ return {
122
+ text: raw || "-",
123
+ className: "bg-gray-50 text-gray-700 border-gray-200",
124
+ };
125
  }
126
+ return {
127
+ text: `${key} - ${tone.label}`,
128
+ className: `${tone.bg} ${tone.text} ${tone.border}`,
129
+ };
130
  }
131
 
132
  function normalizeKey(value: string) {
 
237
  return (
238
  <figure
239
  className={[
240
+ "rounded-lg border border-gray-200 bg-gray-50 p-2 break-inside-avoid",
241
  className,
242
  ].join(" ")}
243
  style={{ breakInside: "avoid", pageBreakInside: "avoid" }}
 
245
  <img
246
  src={url}
247
  alt={label}
248
+ className={["w-full h-full object-contain", imageClassName].join(" ")}
249
  loading="eager"
250
  />
251
  <figcaption className="mt-1 text-[10px] text-gray-600 text-center">
 
263
  photos,
264
  orderLocked = false,
265
  variant = "full",
266
+ sectionLabel,
267
  }: JobSheetTemplateProps) {
268
  const inspectionDate =
269
  template?.inspection_date ?? session?.inspection_date ?? "";
 
290
  const actionText = [actionType, requiredAction]
291
  .filter((value) => value && value.trim())
292
  .join(" - ");
293
+ const categoryBadge = formatRating(category, CATEGORY_SCALE);
294
+ const priorityBadge = formatRating(priority, PRIORITY_SCALE);
 
295
 
296
  const resolvedPhotos =
297
  photos && photos.length ? photos : getPhotosForPage(session, pageIndex, 1);
 
332
  return layout.map((entry) => entry.photo);
333
  }, [limitedPhotos, ratios, orderLocked]);
334
 
335
+ const displayedPhotos =
336
+ variant === "full" ? orderedPhotos.slice(0, 2) : orderedPhotos;
337
+
338
+ const photoGridClass =
339
+ displayedPhotos.length <= 1
340
+ ? "grid grid-cols-1 gap-3"
341
+ : "grid grid-cols-2 gap-3";
342
 
343
  return (
344
+ <div className="w-full h-full p-5 text-[11px] text-gray-700 flex flex-col">
345
  <header className="mb-3 border-b border-gray-200 pb-2">
346
  <div className="grid grid-cols-[auto,1fr,auto] items-center gap-3">
347
  <img
 
405
  Category
406
  </div>
407
  <span
408
+ className={[
409
+ "template-field inline-flex items-center justify-center rounded-md border px-4 py-1 text-[11px] font-semibold min-w-[120px]",
410
+ categoryBadge.className,
411
+ ].join(" ")}
412
  >
413
  {categoryBadge.text}
414
  </span>
 
419
  Priority
420
  </div>
421
  <span
422
+ className={[
423
+ "template-field inline-flex items-center justify-center rounded-md border px-4 py-1 text-[11px] font-semibold min-w-[120px]",
424
+ priorityBadge.className,
425
+ ].join(" ")}
426
  >
427
  {priorityBadge.text}
428
  </span>
 
456
  </section>
457
  ) : null}
458
 
459
+ <section className="mb-3 avoid-break flex-1 min-h-0 flex flex-col">
460
  <div className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
461
  {variant === "photos" ? "Photo Documentation (continued)" : "Photo Documentation"}
462
  </div>
463
+ <div className={`${photoGridClass} flex-1 items-stretch`}>
464
+ {displayedPhotos.length === 0 ? (
465
+ <PhotoSlot url={undefined} label="No photo selected" className="h-full" />
 
 
 
466
  ) : (
467
+ displayedPhotos.map((photo, index) => (
468
  <PhotoSlot
469
  key={photo?.id || `${index}`}
470
  url={photo?.url}
471
  label={photo?.name || `Figure ${index + 1}`}
472
+ className="h-full"
473
+ imageClassName="h-full"
474
  />
475
  ))
476
  )}
 
482
  <span>Inspector: {inspector || "-"}</span>
483
  <span>Doc: {docNumber || "-"}</span>
484
  <span>Site: {clientSite || "-"}</span>
485
+ {sectionLabel ? <span>{sectionLabel}</span> : null}
486
  </footer>
487
  </div>
488
  );
frontend/src/components/ReportPageCanvas.tsx CHANGED
@@ -12,6 +12,7 @@ type ReportPageCanvasProps = {
12
  pageCount: number;
13
  scale: number;
14
  template?: TemplateFields;
 
15
  className?: string;
16
  adaptive?: boolean;
17
  };
@@ -23,15 +24,15 @@ export function ReportPageCanvas({
23
  pageCount,
24
  scale,
25
  template,
 
26
  className = "",
27
  adaptive = false,
28
  }: ReportPageCanvasProps) {
29
  const items = page?.items ?? [];
30
  const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
 
31
  const photos = resolvePagePhotos(session, page, pageIndex);
32
- const photosPerSheet = 6;
33
- const photoSheets = chunkPhotos(photos, photosPerSheet);
34
- const sheets = adaptive && photoSheets.length > 1 ? photoSheets : [photos];
35
  const sheetRefs = useRef<Array<HTMLDivElement | null>>([]);
36
  const [sheetHeights, setSheetHeights] = useState<number[]>([]);
37
 
@@ -112,15 +113,20 @@ export function ReportPageCanvas({
112
  transformOrigin: "top left",
113
  }}
114
  >
115
- <JobSheetTemplate
116
- session={session}
117
- pageIndex={pageIndex}
118
- pageCount={pageCount}
119
- template={template}
120
- photos={sheetPhotos}
121
- orderLocked={page?.photo_order_locked ?? false}
122
- variant={sheetIndex === 0 ? "full" : "photos"}
123
- />
 
 
 
 
 
124
  </div>
125
  </div>
126
  ))}
@@ -191,15 +197,6 @@ export function ReportPageCanvas({
191
  );
192
  }
193
 
194
- function chunkPhotos(photos: FileMeta[], perSheet: number) {
195
- if (!photos.length) return [[]];
196
- const slices: FileMeta[][] = [];
197
- for (let i = 0; i < photos.length; i += perSheet) {
198
- slices.push(photos.slice(i, i + perSheet));
199
- }
200
- return slices;
201
- }
202
-
203
  function resolvePagePhotos(
204
  session: Session | null,
205
  page: Page | null | undefined,
 
12
  pageCount: number;
13
  scale: number;
14
  template?: TemplateFields;
15
+ sectionLabel?: string;
16
  className?: string;
17
  adaptive?: boolean;
18
  };
 
24
  pageCount,
25
  scale,
26
  template,
27
+ sectionLabel,
28
  className = "",
29
  adaptive = false,
30
  }: ReportPageCanvasProps) {
31
  const items = page?.items ?? [];
32
  const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
33
+ const pageVariant = page?.variant ?? "full";
34
  const photos = resolvePagePhotos(session, page, pageIndex);
35
+ const sheets = adaptive ? [photos] : [photos];
 
 
36
  const sheetRefs = useRef<Array<HTMLDivElement | null>>([]);
37
  const [sheetHeights, setSheetHeights] = useState<number[]>([]);
38
 
 
113
  transformOrigin: "top left",
114
  }}
115
  >
116
+ {page?.blank ? (
117
+ <div className="w-full h-full bg-white" />
118
+ ) : (
119
+ <JobSheetTemplate
120
+ session={session}
121
+ pageIndex={pageIndex}
122
+ pageCount={pageCount}
123
+ template={template}
124
+ photos={sheetPhotos}
125
+ orderLocked={page?.photo_order_locked ?? false}
126
+ variant={pageVariant}
127
+ sectionLabel={sectionLabel}
128
+ />
129
+ )}
130
  </div>
131
  </div>
132
  ))}
 
197
  );
198
  }
199
 
 
 
 
 
 
 
 
 
 
200
  function resolvePagePhotos(
201
  session: Session | null,
202
  page: Page | null | undefined,
frontend/src/components/report-editor.js CHANGED
@@ -48,7 +48,8 @@ class ReportEditor extends HTMLElement {
48
  isOpen: false,
49
  zoom: 1,
50
  activePage: 0,
51
- pages: [], // [{ items: [...] }]
 
52
  selectedId: null,
53
  tool: "select", // select | text | rect
54
  dragging: null, // { id, startX, startY, origX, origY }
@@ -62,6 +63,7 @@ class ReportEditor extends HTMLElement {
62
  this.apiBase = null;
63
  this._saveTimer = null;
64
  this._photoRatios = new Map();
 
65
  }
66
 
67
  connectedCallback() {
@@ -105,12 +107,22 @@ class ReportEditor extends HTMLElement {
105
 
106
  const initialCount = Math.max(Number(totalPages) || 1, 1);
107
 
108
- // Load existing editor pages from storage, else initialize
109
  const stored = this._loadPages();
110
- if (stored && Array.isArray(stored.pages) && stored.pages.length) {
111
- this.state.pages = stored.pages;
 
 
 
 
112
  } else {
113
- this.state.pages = Array.from({ length: initialCount }, () => ({ items: [] }));
 
 
 
 
 
 
114
  this._savePages();
115
  }
116
  this._ensurePageCount(initialCount);
@@ -132,10 +144,10 @@ class ReportEditor extends HTMLElement {
132
  setTimeout(() => this.updateAll(), 0);
133
 
134
  if (this.sessionId) {
135
- this._loadPagesFromServer().then((pages) => {
136
- if (pages && pages.length) {
137
- this.state.pages = pages;
138
- this._ensurePageCount(Math.max(initialCount, pages.length));
139
  this.state.activePage = Math.min(
140
  Math.max(0, pageIndex),
141
  this.state.pages.length - 1
@@ -790,6 +802,7 @@ class ReportEditor extends HTMLElement {
790
  _templateMarkup() {
791
  const session = this.state.payload || {};
792
  const template = this._getTemplate();
 
793
 
794
  const inspectionDate =
795
  template.inspection_date || session.inspection_date || "";
@@ -811,43 +824,46 @@ class ReportEditor extends HTMLElement {
811
  template.condition_description || session.notes || "";
812
  const requiredAction = template.required_action || "";
813
 
814
- const categoryBadge = buildScaleBadge(category, CATEGORY_SCALE);
815
- const priorityBadge = buildScaleBadge(priority, PRIORITY_SCALE);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
816
 
 
 
817
  const photos = this._photosForActivePage(session).slice(0, 6);
818
  this._ensurePhotoRatios(photos);
819
  const orderLocked = !!(this.activePage && this.activePage.photo_order_locked);
820
  const orderedPhotos = orderLocked
821
  ? photos
822
  : this._computePhotoLayout(photos).map((entry) => entry.photo);
823
- const photoColumnsClass = orderedPhotos.length <= 1 ? "columns-1" : "columns-2";
824
- const photoSlots = orderedPhotos.length
825
- ? orderedPhotos
 
 
826
  .map((photo, idx) => this._photoSlot(photo, `Figure ${idx + 1}`))
827
  .join("")
828
  : this._photoSlot(null, "No photo selected");
829
  const pageNum = this.state.activePage + 1;
830
  const pageCount = this.state.pages.length || 1;
831
 
832
- return `
833
- <div class="w-full h-full p-5 text-[11px] text-gray-700" style="color:#374151; font-size:11px;">
834
- <header class="mb-3 border-b border-gray-200 pb-2">
835
- <div class="grid grid-cols-[auto,1fr,auto] items-center gap-3">
836
- <div class="flex items-center">
837
- <img src="/assets/prosento-logo.png" alt="Prosento logo" class="h-10 w-auto object-contain" />
838
- </div>
839
-
840
- <div class="text-center leading-tight">
841
- <h1 class="text-2xl font-bold text-gray-900 whitespace-nowrap">RepEx Inspection Job Sheet</h1>
842
- <p class="text-sm text-gray-600 whitespace-nowrap">Report Express - Inspection Management</p>
843
- </div>
844
-
845
- <div class="flex items-center justify-end">
846
- <img src="${this._escape(this._resolveLogoUrl(session, companyLogo))}" alt="Company logo" class="h-10 w-auto object-contain" />
847
- </div>
848
- </div>
849
- </header>
850
-
851
  <section class="mb-4" aria-labelledby="observations-title">
852
  <h2 id="observations-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
853
  Observations and Findings
@@ -879,7 +895,7 @@ class ReportEditor extends HTMLElement {
879
  "category",
880
  categoryBadge.text,
881
  "Category",
882
- `inline-flex items-center justify-center rounded-md border px-3 py-1 text-sm font-semibold min-w-[120px] ${categoryBadge.className}`
883
  )}
884
  </div>
885
 
@@ -889,7 +905,7 @@ class ReportEditor extends HTMLElement {
889
  "priority",
890
  priorityBadge.text,
891
  "Priority",
892
- `inline-flex items-center justify-center rounded-md border px-3 py-1 text-sm font-semibold min-w-[120px] ${priorityBadge.className}`
893
  )}
894
  </div>
895
 
@@ -917,10 +933,35 @@ class ReportEditor extends HTMLElement {
917
  </div>
918
  </div>
919
  </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
920
 
921
  <section class="mb-4 avoid-break" aria-labelledby="photo-doc-title">
922
  <h2 id="photo-doc-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
923
- Photo Documentation
924
  </h2>
925
 
926
  <div class="${photoColumnsClass}" style="column-gap:0.75rem;">
@@ -933,6 +974,7 @@ class ReportEditor extends HTMLElement {
933
  <span>Inspector: ${this._escape(inspector || "-")}</span>
934
  <span>Doc: ${this._escape(docNumber || "-")}</span>
935
  <span>Site: ${this._escape(clientSite || "-")}</span>
 
936
  </footer>
937
  </div>
938
  `;
@@ -941,9 +983,9 @@ class ReportEditor extends HTMLElement {
941
  // ---------- Storage ----------
942
  _storageKey() {
943
  if (this.sessionId) {
944
- return `repex_report_pages_v1_${this.sessionId}`;
945
  }
946
- return "repex_report_pages_v1";
947
  }
948
 
949
  _loadPages() {
@@ -957,7 +999,11 @@ class ReportEditor extends HTMLElement {
957
 
958
  _savePages(showToast = false) {
959
  try {
960
- localStorage.setItem(this._storageKey(), JSON.stringify({ pages: this.state.pages }));
 
 
 
 
961
  this._scheduleServerSave();
962
  if (showToast) this._toast("Saved");
963
  } catch {
@@ -975,11 +1021,11 @@ class ReportEditor extends HTMLElement {
975
  const base = this._apiRoot();
976
  if (!base || !this.sessionId) return null;
977
  try {
978
- const res = await fetch(`${base}/sessions/${this.sessionId}/pages`);
979
  if (!res.ok) return null;
980
  const data = await res.json();
981
- if (data && Array.isArray(data.pages)) {
982
- return data.pages;
983
  }
984
  } catch {}
985
  return null;
@@ -997,10 +1043,11 @@ class ReportEditor extends HTMLElement {
997
  const base = this._apiRoot();
998
  if (!base || !this.sessionId) return;
999
  try {
1000
- const res = await fetch(`${base}/sessions/${this.sessionId}/pages`, {
 
1001
  method: "PUT",
1002
  headers: { "Content-Type": "application/json" },
1003
- body: JSON.stringify({ pages: this.state.pages }),
1004
  });
1005
  if (!res.ok) {
1006
  throw new Error("Failed");
@@ -1018,11 +1065,160 @@ class ReportEditor extends HTMLElement {
1018
  setTimeout(() => el.remove(), 1200);
1019
  }
1020
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1021
  // ---------- Page list ----------
1022
  renderPageList() {
1023
  this.$pageList.innerHTML = "";
1024
 
1025
  this.state.pages.forEach((_, idx) => {
 
 
 
 
 
1026
  const active = idx === this.state.activePage;
1027
 
1028
  const row = document.createElement("div");
@@ -1037,7 +1233,10 @@ class ReportEditor extends HTMLElement {
1037
  : "border-gray-200 bg-white text-gray-800 hover:bg-gray-50");
1038
  btn.innerHTML = `
1039
  <div class="flex items-center justify-between">
1040
- <div class="text-sm font-semibold">Page ${idx + 1}</div>
 
 
 
1041
  <div class="text-xs ${active ? "text-white/80" : "text-gray-500"}">${this.state.pages[idx].items.length} items</div>
1042
  </div>
1043
  `;
@@ -1069,8 +1268,26 @@ class ReportEditor extends HTMLElement {
1069
 
1070
  addPage() {
1071
  this._pushUndoSnapshot();
1072
- this.state.pages.push({ items: [] });
1073
- this.state.activePage = this.state.pages.length - 1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1074
  this.state.selectedId = null;
1075
  this._savePages();
1076
  this.updateAll();
@@ -1081,7 +1298,14 @@ class ReportEditor extends HTMLElement {
1081
  const idx = typeof index === "number" ? index : this.state.activePage;
1082
  if (idx < 0 || idx >= this.state.pages.length) return;
1083
 
1084
- this.state.pages.splice(idx, 1);
 
 
 
 
 
 
 
1085
  if (this.state.activePage >= this.state.pages.length) {
1086
  this.state.activePage = this.state.pages.length - 1;
1087
  } else if (this.state.activePage > idx) {
@@ -1096,8 +1320,16 @@ class ReportEditor extends HTMLElement {
1096
 
1097
  _ensurePageCount(count) {
1098
  const target = Math.max(Number(count) || 1, 1);
 
 
 
 
 
1099
  while (this.state.pages.length < target) {
1100
- this.state.pages.push({ items: [] });
 
 
 
1101
  }
1102
  }
1103
 
@@ -1124,18 +1356,21 @@ class ReportEditor extends HTMLElement {
1124
 
1125
  const template = document.createElement("div");
1126
  template.className = "absolute inset-0";
1127
- template.style.position = "absolute";
1128
- template.style.inset = "0";
1129
  let templateHtml = "";
1130
- try {
1131
- templateHtml = this._templateMarkup();
1132
- } catch (err) {
1133
- console.error("Template render failed", err);
1134
- templateHtml = `
1135
- <div class="p-4 text-sm text-red-600">
1136
- Template failed to render. Check console for details.
1137
- </div>
1138
- `;
 
 
 
 
1139
  }
1140
  template.innerHTML = `
1141
  <div style="width:${this.BASE_W}px; height:${this.BASE_H}px; transform: scale(${scale}); transform-origin: top left;">
@@ -1143,7 +1378,9 @@ class ReportEditor extends HTMLElement {
1143
  </div>
1144
  `;
1145
  this.$canvas.appendChild(template);
1146
- this._bindTemplateFields();
 
 
1147
 
1148
  const page = this.activePage || { items: [] };
1149
  if (!Array.isArray(page.items)) page.items = [];
 
48
  isOpen: false,
49
  zoom: 1,
50
  activePage: 0,
51
+ pages: [], // flattened pages
52
+ sections: [], // [{ id, title?, pages: [...] }]
53
  selectedId: null,
54
  tool: "select", // select | text | rect
55
  dragging: null, // { id, startX, startY, origX, origY }
 
63
  this.apiBase = null;
64
  this._saveTimer = null;
65
  this._photoRatios = new Map();
66
+ this._indexMap = [];
67
  }
68
 
69
  connectedCallback() {
 
107
 
108
  const initialCount = Math.max(Number(totalPages) || 1, 1);
109
 
110
+ // Load existing editor sections from storage, else initialize
111
  const stored = this._loadPages();
112
+ if (stored && Array.isArray(stored.sections) && stored.sections.length) {
113
+ this._setSections(stored.sections);
114
+ } else if (stored && Array.isArray(stored.pages) && stored.pages.length) {
115
+ this._setSections([
116
+ { id: this._sectionId(), title: "Section 1", pages: stored.pages },
117
+ ]);
118
  } else {
119
+ this._setSections([
120
+ {
121
+ id: this._sectionId(),
122
+ title: "Section 1",
123
+ pages: Array.from({ length: initialCount }, () => ({ items: [] })),
124
+ },
125
+ ]);
126
  this._savePages();
127
  }
128
  this._ensurePageCount(initialCount);
 
144
  setTimeout(() => this.updateAll(), 0);
145
 
146
  if (this.sessionId) {
147
+ this._loadPagesFromServer().then((sections) => {
148
+ if (sections && sections.length) {
149
+ this._setSections(sections);
150
+ this._ensurePageCount(Math.max(initialCount, this.state.pages.length));
151
  this.state.activePage = Math.min(
152
  Math.max(0, pageIndex),
153
  this.state.pages.length - 1
 
802
  _templateMarkup() {
803
  const session = this.state.payload || {};
804
  const template = this._getTemplate();
805
+ const sectionLabel = this._getActiveSectionLabel();
806
 
807
  const inspectionDate =
808
  template.inspection_date || session.inspection_date || "";
 
824
  template.condition_description || session.notes || "";
825
  const requiredAction = template.required_action || "";
826
 
827
+ const categoryScale = {
828
+ "0": { label: "Excellent", bg: "bg-green-100", text: "text-green-800", border: "border-green-200" },
829
+ "1": { label: "Good", bg: "bg-green-200", text: "text-green-800", border: "border-green-200" },
830
+ "2": { label: "Fair", bg: "bg-yellow-100", text: "text-yellow-800", border: "border-yellow-200" },
831
+ "3": { label: "Poor", bg: "bg-yellow-200", text: "text-yellow-800", border: "border-yellow-200" },
832
+ "4": { label: "Worse", bg: "bg-orange-200", text: "text-orange-800", border: "border-orange-200" },
833
+ "5": { label: "Severe", bg: "bg-red-200", text: "text-red-800", border: "border-red-200" },
834
+ };
835
+ const priorityScale = {
836
+ "1": { label: "Immediate", bg: "bg-red-200", text: "text-red-800", border: "border-red-200" },
837
+ "2": { label: "1 Year", bg: "bg-orange-200", text: "text-orange-800", border: "border-orange-200" },
838
+ "3": { label: "3 Years", bg: "bg-green-200", text: "text-green-800", border: "border-green-200" },
839
+ X: { label: "At Use", bg: "bg-purple-200", text: "text-purple-800", border: "border-purple-200" },
840
+ M: { label: "Monitor", bg: "bg-blue-200", text: "text-blue-800", border: "border-blue-200" },
841
+ };
842
+ const categoryBadge = this._ratingBadge(category, categoryScale);
843
+ const priorityBadge = this._ratingBadge(priority, priorityScale);
844
 
845
+ const variant =
846
+ (this.activePage && this.activePage.variant) || "full";
847
  const photos = this._photosForActivePage(session).slice(0, 6);
848
  this._ensurePhotoRatios(photos);
849
  const orderLocked = !!(this.activePage && this.activePage.photo_order_locked);
850
  const orderedPhotos = orderLocked
851
  ? photos
852
  : this._computePhotoLayout(photos).map((entry) => entry.photo);
853
+ const displayedPhotos =
854
+ variant === "full" ? orderedPhotos.slice(0, 2) : orderedPhotos;
855
+ const photoColumnsClass = displayedPhotos.length <= 1 ? "columns-1" : "columns-2";
856
+ const photoSlots = displayedPhotos.length
857
+ ? displayedPhotos
858
  .map((photo, idx) => this._photoSlot(photo, `Figure ${idx + 1}`))
859
  .join("")
860
  : this._photoSlot(null, "No photo selected");
861
  const pageNum = this.state.activePage + 1;
862
  const pageCount = this.state.pages.length || 1;
863
 
864
+ const observationsHtml =
865
+ variant === "full"
866
+ ? `
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
867
  <section class="mb-4" aria-labelledby="observations-title">
868
  <h2 id="observations-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
869
  Observations and Findings
 
895
  "category",
896
  categoryBadge.text,
897
  "Category",
898
+ `inline-flex items-center justify-center rounded-md border px-4 py-1 text-sm font-semibold min-w-[120px] ${categoryBadge.className}`,
899
  )}
900
  </div>
901
 
 
905
  "priority",
906
  priorityBadge.text,
907
  "Priority",
908
+ `inline-flex items-center justify-center rounded-md border px-4 py-1 text-sm font-semibold min-w-[120px] ${priorityBadge.className}`,
909
  )}
910
  </div>
911
 
 
933
  </div>
934
  </div>
935
  </section>
936
+ `
937
+ : "";
938
+ const photoTitle =
939
+ variant === "photos" ? "Photo Documentation (continued)" : "Photo Documentation";
940
+
941
+ return `
942
+ <div class="w-full h-full p-5 text-[11px] text-gray-700" style="color:#374151; font-size:11px;">
943
+ <header class="mb-3 border-b border-gray-200 pb-2">
944
+ <div class="grid grid-cols-[auto,1fr,auto] items-center gap-3">
945
+ <div class="flex items-center">
946
+ <img src="/assets/prosento-logo.png" alt="Prosento logo" class="h-10 w-auto object-contain" />
947
+ </div>
948
+
949
+ <div class="text-center leading-tight">
950
+ <h1 class="text-2xl font-bold text-gray-900 whitespace-nowrap">RepEx Inspection Job Sheet</h1>
951
+ <p class="text-sm text-gray-600 whitespace-nowrap">Report Express - Inspection Management</p>
952
+ </div>
953
+
954
+ <div class="flex items-center justify-end">
955
+ <img src="${this._escape(this._resolveLogoUrl(session, companyLogo))}" alt="Company logo" class="h-10 w-auto object-contain" />
956
+ </div>
957
+ </div>
958
+ </header>
959
+
960
+ ${observationsHtml}
961
 
962
  <section class="mb-4 avoid-break" aria-labelledby="photo-doc-title">
963
  <h2 id="photo-doc-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
964
+ ${photoTitle}
965
  </h2>
966
 
967
  <div class="${photoColumnsClass}" style="column-gap:0.75rem;">
 
974
  <span>Inspector: ${this._escape(inspector || "-")}</span>
975
  <span>Doc: ${this._escape(docNumber || "-")}</span>
976
  <span>Site: ${this._escape(clientSite || "-")}</span>
977
+ ${sectionLabel ? `<span>${this._escape(sectionLabel)}</span>` : ""}
978
  </footer>
979
  </div>
980
  `;
 
983
  // ---------- Storage ----------
984
  _storageKey() {
985
  if (this.sessionId) {
986
+ return `repex_report_sections_v1_${this.sessionId}`;
987
  }
988
+ return "repex_report_sections_v1";
989
  }
990
 
991
  _loadPages() {
 
999
 
1000
  _savePages(showToast = false) {
1001
  try {
1002
+ this._syncSectionsFromPages();
1003
+ localStorage.setItem(
1004
+ this._storageKey(),
1005
+ JSON.stringify({ sections: this.state.sections }),
1006
+ );
1007
  this._scheduleServerSave();
1008
  if (showToast) this._toast("Saved");
1009
  } catch {
 
1021
  const base = this._apiRoot();
1022
  if (!base || !this.sessionId) return null;
1023
  try {
1024
+ const res = await fetch(`${base}/sessions/${this.sessionId}/sections`);
1025
  if (!res.ok) return null;
1026
  const data = await res.json();
1027
+ if (data && Array.isArray(data.sections)) {
1028
+ return data.sections;
1029
  }
1030
  } catch {}
1031
  return null;
 
1043
  const base = this._apiRoot();
1044
  if (!base || !this.sessionId) return;
1045
  try {
1046
+ this._syncSectionsFromPages();
1047
+ const res = await fetch(`${base}/sessions/${this.sessionId}/sections`, {
1048
  method: "PUT",
1049
  headers: { "Content-Type": "application/json" },
1050
+ body: JSON.stringify({ sections: this.state.sections }),
1051
  });
1052
  if (!res.ok) {
1053
  throw new Error("Failed");
 
1065
  setTimeout(() => el.remove(), 1200);
1066
  }
1067
 
1068
+ _sectionId() {
1069
+ return `sec_${Math.random().toString(16).slice(2)}_${Date.now().toString(16)}`;
1070
+ }
1071
+
1072
+ _ratingBadge(value, scale) {
1073
+ const raw = String(value || "").trim();
1074
+ if (!raw) {
1075
+ return { text: "-", className: "bg-gray-50 text-gray-700 border-gray-200" };
1076
+ }
1077
+ const match = raw.match(/^([0-9]|[xXmM])/);
1078
+ const key = match ? match[1].toUpperCase() : raw.split("-")[0].trim().toUpperCase();
1079
+ const tone = scale[key];
1080
+ if (!tone) {
1081
+ return { text: raw, className: "bg-gray-50 text-gray-700 border-gray-200" };
1082
+ }
1083
+ return {
1084
+ text: `${key} - ${tone.label}`,
1085
+ className: `${tone.bg} ${tone.text} ${tone.border}`,
1086
+ };
1087
+ }
1088
+
1089
+ _buildPhotoContinuation(source, photoIds) {
1090
+ return {
1091
+ items: [],
1092
+ template: source.template ? { ...source.template } : undefined,
1093
+ photo_ids: photoIds,
1094
+ photo_order_locked: source.photo_order_locked,
1095
+ variant: "photos",
1096
+ };
1097
+ }
1098
+
1099
+ _splitPagePhotos(page) {
1100
+ const normalized = {
1101
+ ...page,
1102
+ items: Array.isArray(page.items) ? page.items : [],
1103
+ };
1104
+ if (normalized.blank) return [normalized];
1105
+
1106
+ const photoIds = Array.isArray(normalized.photo_ids)
1107
+ ? normalized.photo_ids.filter(Boolean)
1108
+ : [];
1109
+ if (!photoIds.length) return [normalized];
1110
+
1111
+ const chunks = [];
1112
+ for (let i = 0; i < photoIds.length; i += 2) {
1113
+ chunks.push(photoIds.slice(i, i + 2));
1114
+ }
1115
+
1116
+ if (chunks.length <= 1) {
1117
+ return [{ ...normalized, photo_ids: chunks[0] || [], variant: normalized.variant }];
1118
+ }
1119
+
1120
+ if (normalized.variant === "photos") {
1121
+ return chunks.map((chunk, idx) => {
1122
+ if (idx === 0) {
1123
+ return { ...normalized, photo_ids: chunk, variant: "photos" };
1124
+ }
1125
+ return this._buildPhotoContinuation(normalized, chunk);
1126
+ });
1127
+ }
1128
+
1129
+ const basePage = {
1130
+ ...normalized,
1131
+ photo_ids: chunks[0],
1132
+ variant: normalized.variant || "full",
1133
+ };
1134
+ const extraPages = chunks.slice(1).map((chunk) =>
1135
+ this._buildPhotoContinuation(normalized, chunk),
1136
+ );
1137
+ return [basePage, ...extraPages];
1138
+ }
1139
+
1140
+ _normalizeSections(sections) {
1141
+ const source = Array.isArray(sections) ? sections : [];
1142
+ if (!source.length) {
1143
+ return [{ id: this._sectionId(), title: "Section 1", pages: [{ items: [] }] }];
1144
+ }
1145
+ return source.map((section) => {
1146
+ const basePages =
1147
+ Array.isArray(section.pages) && section.pages.length
1148
+ ? section.pages
1149
+ : [{ items: [] }];
1150
+ const normalizedPages = basePages.flatMap((page) => this._splitPagePhotos(page));
1151
+ return {
1152
+ id: section.id || this._sectionId(),
1153
+ title: section.title ?? "Section",
1154
+ pages: normalizedPages.length ? normalizedPages : [{ items: [] }],
1155
+ };
1156
+ });
1157
+ }
1158
+
1159
+ _rebuildFlatPages() {
1160
+ this._indexMap = [];
1161
+ this.state.pages = [];
1162
+ const sections = Array.isArray(this.state.sections) ? this.state.sections : [];
1163
+ sections.forEach((section, sectionIndex) => {
1164
+ const pages = Array.isArray(section.pages) && section.pages.length
1165
+ ? section.pages
1166
+ : [{ items: [] }];
1167
+ section.pages = pages;
1168
+ pages.forEach((page, pageIndex) => {
1169
+ this.state.pages.push(page);
1170
+ this._indexMap.push({ sectionIndex, pageIndex });
1171
+ });
1172
+ });
1173
+ if (!this.state.pages.length) {
1174
+ this.state.sections = [
1175
+ { id: this._sectionId(), title: "Section 1", pages: [{ items: [] }] },
1176
+ ];
1177
+ this._rebuildFlatPages();
1178
+ }
1179
+ }
1180
+
1181
+ _setSections(sections) {
1182
+ this.state.sections = this._normalizeSections(sections);
1183
+ this._rebuildFlatPages();
1184
+ }
1185
+
1186
+ _syncSectionsFromPages() {
1187
+ if (!this._indexMap || this._indexMap.length !== this.state.pages.length) {
1188
+ this._rebuildFlatPages();
1189
+ }
1190
+ const sections = this.state.sections.map((section) => ({
1191
+ ...section,
1192
+ pages: Array.isArray(section.pages) ? [...section.pages] : [{ items: [] }],
1193
+ }));
1194
+ this.state.pages.forEach((page, idx) => {
1195
+ const map = this._indexMap[idx];
1196
+ if (!map) return;
1197
+ if (!sections[map.sectionIndex]) return;
1198
+ sections[map.sectionIndex].pages[map.pageIndex] = page;
1199
+ });
1200
+ this.state.sections = sections;
1201
+ }
1202
+
1203
+ _getActiveSectionLabel() {
1204
+ const map = this._indexMap?.[this.state.activePage];
1205
+ if (!map || !this.state.sections?.[map.sectionIndex]) return "";
1206
+ const section = this.state.sections[map.sectionIndex] || {};
1207
+ const title = section.title || "";
1208
+ if (title) return `Section ${map.sectionIndex + 1} - ${title}`;
1209
+ return `Section ${map.sectionIndex + 1}`;
1210
+ }
1211
+
1212
  // ---------- Page list ----------
1213
  renderPageList() {
1214
  this.$pageList.innerHTML = "";
1215
 
1216
  this.state.pages.forEach((_, idx) => {
1217
+ const map = this._indexMap?.[idx] || { sectionIndex: 0, pageIndex: idx };
1218
+ const section =
1219
+ (this.state.sections && this.state.sections[map.sectionIndex]) || {};
1220
+ const sectionLabel =
1221
+ section.title || `Section ${map.sectionIndex + 1}`;
1222
  const active = idx === this.state.activePage;
1223
 
1224
  const row = document.createElement("div");
 
1233
  : "border-gray-200 bg-white text-gray-800 hover:bg-gray-50");
1234
  btn.innerHTML = `
1235
  <div class="flex items-center justify-between">
1236
+ <div>
1237
+ <div class="text-[11px] ${active ? "text-white/80" : "text-gray-500"}">${sectionLabel}</div>
1238
+ <div class="text-sm font-semibold">Page ${map.pageIndex + 1}</div>
1239
+ </div>
1240
  <div class="text-xs ${active ? "text-white/80" : "text-gray-500"}">${this.state.pages[idx].items.length} items</div>
1241
  </div>
1242
  `;
 
1268
 
1269
  addPage() {
1270
  this._pushUndoSnapshot();
1271
+ const map = this._indexMap?.[this.state.activePage] || {
1272
+ sectionIndex: 0,
1273
+ pageIndex: this.state.activePage,
1274
+ };
1275
+ if (!this.state.sections[map.sectionIndex]) {
1276
+ this.state.sections = [
1277
+ { id: this._sectionId(), title: "Section 1", pages: [{ items: [] }] },
1278
+ ];
1279
+ }
1280
+ const section = this.state.sections[map.sectionIndex];
1281
+ section.pages = section.pages || [];
1282
+ section.pages.splice(map.pageIndex + 1, 0, { items: [] });
1283
+ this._setSections(this.state.sections);
1284
+ const newIndex = this._indexMap.findIndex(
1285
+ (entry) =>
1286
+ entry.sectionIndex === map.sectionIndex &&
1287
+ entry.pageIndex === map.pageIndex + 1
1288
+ );
1289
+ this.state.activePage =
1290
+ newIndex >= 0 ? newIndex : this.state.pages.length - 1;
1291
  this.state.selectedId = null;
1292
  this._savePages();
1293
  this.updateAll();
 
1298
  const idx = typeof index === "number" ? index : this.state.activePage;
1299
  if (idx < 0 || idx >= this.state.pages.length) return;
1300
 
1301
+ const map = this._indexMap?.[idx];
1302
+ if (!map || !this.state.sections[map.sectionIndex]) return;
1303
+ const section = this.state.sections[map.sectionIndex];
1304
+ const pages = section.pages || [];
1305
+ if (pages.length <= 1) return;
1306
+ pages.splice(map.pageIndex, 1);
1307
+ section.pages = pages.length ? pages : [{ items: [] }];
1308
+ this._setSections(this.state.sections);
1309
  if (this.state.activePage >= this.state.pages.length) {
1310
  this.state.activePage = this.state.pages.length - 1;
1311
  } else if (this.state.activePage > idx) {
 
1320
 
1321
  _ensurePageCount(count) {
1322
  const target = Math.max(Number(count) || 1, 1);
1323
+ if (!this.state.sections || !this.state.sections.length) {
1324
+ this.state.sections = [
1325
+ { id: this._sectionId(), title: "Section 1", pages: [{ items: [] }] },
1326
+ ];
1327
+ }
1328
  while (this.state.pages.length < target) {
1329
+ const lastSection = this.state.sections[this.state.sections.length - 1];
1330
+ lastSection.pages = lastSection.pages || [];
1331
+ lastSection.pages.push({ items: [] });
1332
+ this._setSections(this.state.sections);
1333
  }
1334
  }
1335
 
 
1356
 
1357
  const template = document.createElement("div");
1358
  template.className = "absolute inset-0";
1359
+ const active = this.activePage || {};
 
1360
  let templateHtml = "";
1361
+ if (active.blank) {
1362
+ templateHtml = `<div class="w-full h-full bg-white"></div>`;
1363
+ } else {
1364
+ try {
1365
+ templateHtml = this._templateMarkup();
1366
+ } catch (err) {
1367
+ console.error("Template render failed", err);
1368
+ templateHtml = `
1369
+ <div class="p-4 text-sm text-red-600">
1370
+ Template failed to render. Check console for details.
1371
+ </div>
1372
+ `;
1373
+ }
1374
  }
1375
  template.innerHTML = `
1376
  <div style="width:${this.BASE_W}px; height:${this.BASE_H}px; transform: scale(${scale}); transform-origin: top left;">
 
1378
  </div>
1379
  `;
1380
  this.$canvas.appendChild(template);
1381
+ if (!active.blank) {
1382
+ this._bindTemplateFields();
1383
+ }
1384
 
1385
  const page = this.activePage || { items: [] };
1386
  if (!Array.isArray(page.items)) page.items = [];
frontend/src/index.css CHANGED
@@ -67,6 +67,10 @@ report-editor[data-mode="page"] [data-shell] {
67
  box-shadow: none;
68
  }
69
 
 
 
 
 
70
  @media print {
71
  @page {
72
  size: A4;
 
67
  box-shadow: none;
68
  }
69
 
70
+ summary::-webkit-details-marker {
71
+ display: none;
72
+ }
73
+
74
  @media print {
75
  @page {
76
  size: A4;
frontend/src/lib/sections.ts ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { JobsheetSection, Page } from "../types/session";
2
+
3
+ export type FlatPage = {
4
+ sectionId: string;
5
+ sectionIndex: number;
6
+ sectionTitle?: string;
7
+ pageIndex: number;
8
+ page: Page;
9
+ flatIndex: number;
10
+ };
11
+
12
+ const MAX_PHOTOS_PER_PAGE = 2;
13
+
14
+ function cloneTemplate(template?: Page["template"]): Page["template"] {
15
+ if (!template) return undefined;
16
+ return { ...template };
17
+ }
18
+
19
+ function normalizePhotoIds(photoIds?: string[]): string[] {
20
+ if (!Array.isArray(photoIds)) return [];
21
+ return photoIds.filter((value) => Boolean(value));
22
+ }
23
+
24
+ function buildPhotoContinuation(source: Page, photoIds: string[]): Page {
25
+ return {
26
+ items: [],
27
+ template: cloneTemplate(source.template),
28
+ photo_ids: photoIds,
29
+ photo_order_locked: source.photo_order_locked,
30
+ variant: "photos",
31
+ };
32
+ }
33
+
34
+ function splitPagePhotos(page: Page): Page[] {
35
+ const items = Array.isArray(page.items) ? page.items : [];
36
+ const normalized: Page = { ...page, items };
37
+
38
+ if (normalized.blank) {
39
+ return [normalized];
40
+ }
41
+
42
+ const photoIds = normalizePhotoIds(normalized.photo_ids);
43
+ if (!photoIds.length) {
44
+ return [normalized];
45
+ }
46
+
47
+ const chunks: string[][] = [];
48
+ for (let i = 0; i < photoIds.length; i += MAX_PHOTOS_PER_PAGE) {
49
+ chunks.push(photoIds.slice(i, i + MAX_PHOTOS_PER_PAGE));
50
+ }
51
+
52
+ if (chunks.length <= 1) {
53
+ return [
54
+ {
55
+ ...normalized,
56
+ photo_ids: chunks[0] ?? [],
57
+ variant: normalized.variant,
58
+ },
59
+ ];
60
+ }
61
+
62
+ if (normalized.variant === "photos") {
63
+ return chunks.map((chunk, idx) => {
64
+ if (idx === 0) {
65
+ return { ...normalized, photo_ids: chunk, variant: "photos" };
66
+ }
67
+ return buildPhotoContinuation(normalized, chunk);
68
+ });
69
+ }
70
+
71
+ const basePage: Page = {
72
+ ...normalized,
73
+ photo_ids: chunks[0],
74
+ variant: normalized.variant ?? "full",
75
+ };
76
+ const extraPages = chunks.slice(1).map((chunk) =>
77
+ buildPhotoContinuation(normalized, chunk),
78
+ );
79
+ return [basePage, ...extraPages];
80
+ }
81
+
82
+ export function ensureSections(sections?: JobsheetSection[]): JobsheetSection[] {
83
+ if (sections && sections.length) {
84
+ return sections.map((section, index) => {
85
+ const basePages = section.pages?.length ? section.pages : [{ items: [] }];
86
+ const normalizedPages = basePages.flatMap((page) => splitPagePhotos(page));
87
+ return {
88
+ id: section.id,
89
+ title: section.title ?? "Section",
90
+ pages: normalizedPages.length ? normalizedPages : [{ items: [] }],
91
+ };
92
+ });
93
+ }
94
+ return [
95
+ {
96
+ id: crypto.randomUUID(),
97
+ title: "Section 1",
98
+ pages: [{ items: [] }],
99
+ },
100
+ ];
101
+ }
102
+
103
+ export function flattenSections(sections: JobsheetSection[]): FlatPage[] {
104
+ const result: FlatPage[] = [];
105
+ sections.forEach((section, sectionIndex) => {
106
+ const pages = section.pages ?? [];
107
+ pages.forEach((page, pageIndex) => {
108
+ result.push({
109
+ sectionId: section.id,
110
+ sectionIndex,
111
+ sectionTitle: section.title ?? `Section ${sectionIndex + 1}`,
112
+ pageIndex,
113
+ page,
114
+ flatIndex: result.length,
115
+ });
116
+ });
117
+ });
118
+ return result;
119
+ }
120
+
121
+ export function replacePage(
122
+ sections: JobsheetSection[],
123
+ sectionIndex: number,
124
+ pageIndex: number,
125
+ nextPage: Page,
126
+ ): JobsheetSection[] {
127
+ const next = sections.map((section, sIdx) => {
128
+ if (sIdx !== sectionIndex) return section;
129
+ const nextPages = [...(section.pages ?? [])];
130
+ nextPages[pageIndex] = nextPage;
131
+ return { ...section, pages: nextPages };
132
+ });
133
+ return ensureSections(next);
134
+ }
135
+
136
+ export function insertPage(
137
+ sections: JobsheetSection[],
138
+ sectionIndex: number,
139
+ pageIndex: number,
140
+ page: Page,
141
+ ): JobsheetSection[] {
142
+ const next = sections.map((section, sIdx) => {
143
+ if (sIdx !== sectionIndex) return section;
144
+ const nextPages = [...(section.pages ?? [])];
145
+ const safeIndex = Math.max(0, Math.min(pageIndex, nextPages.length));
146
+ nextPages.splice(safeIndex, 0, page);
147
+ return { ...section, pages: nextPages };
148
+ });
149
+ return ensureSections(next);
150
+ }
151
+
152
+ export function removePage(
153
+ sections: JobsheetSection[],
154
+ sectionIndex: number,
155
+ pageIndex: number,
156
+ ): JobsheetSection[] {
157
+ const next = sections.map((section, sIdx) => {
158
+ if (sIdx !== sectionIndex) return section;
159
+ const nextPages = [...(section.pages ?? [])];
160
+ if (nextPages.length <= 1) return section;
161
+ nextPages.splice(pageIndex, 1);
162
+ return { ...section, pages: nextPages.length ? nextPages : [{ items: [] }] };
163
+ });
164
+ return ensureSections(next);
165
+ }
frontend/src/pages/EditLayoutsPage.tsx CHANGED
@@ -15,11 +15,13 @@ import {
15
 
16
  import { putJson, request } from "../lib/api";
17
  import { BASE_W } from "../lib/report";
 
18
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
19
- import type { Page, Session } from "../types/session";
20
  import { PageFooter } from "../components/PageFooter";
21
  import { PageHeader } from "../components/PageHeader";
22
  import { PageShell } from "../components/PageShell";
 
23
  import { ReportPageCanvas } from "../components/ReportPageCanvas";
24
 
25
  export default function EditLayoutsPage() {
@@ -28,7 +30,7 @@ export default function EditLayoutsPage() {
28
  const sessionQuery = buildSessionQuery(sessionId);
29
 
30
  const [session, setSession] = useState<Session | null>(null);
31
- const [pages, setPages] = useState<Page[]>([]);
32
  const [status, setStatus] = useState("");
33
  const [isSaving, setIsSaving] = useState(false);
34
  const canModify = Boolean(sessionId) && !isSaving;
@@ -43,11 +45,10 @@ export default function EditLayoutsPage() {
43
  try {
44
  const data = await request<Session>(`/sessions/${sessionId}`);
45
  setSession(data);
46
- const pageResp = await request<{ pages: Page[] }>(
47
- `/sessions/${sessionId}/pages`,
48
  );
49
- const loaded = Array.isArray(pageResp.pages) ? pageResp.pages : [];
50
- setPages(loaded.length ? loaded : [{ items: [] }]);
51
  } catch (err) {
52
  const message =
53
  err instanceof Error ? err.message : "Failed to load session.";
@@ -57,35 +58,49 @@ export default function EditLayoutsPage() {
57
  load();
58
  }, [sessionId]);
59
 
60
- const totalPages = useMemo(() => Math.max(1, pages.length), [pages.length]);
 
 
 
 
 
 
 
61
  const previewWidth = 220;
62
  const previewScale = previewWidth / BASE_W;
63
 
64
- function hasExplicitPhotos(source: Page[]) {
65
- return source.some((page) => (page.photo_ids ?? []).length > 0);
 
 
66
  }
67
 
68
- function flattenPhotoIds(source: Page[]) {
69
  const seen = new Set<string>();
70
  const result: string[] = [];
71
- source.forEach((page) => {
72
- (page.photo_ids ?? []).forEach((photoId) => {
73
- if (!seen.has(photoId)) {
74
- seen.add(photoId);
75
- result.push(photoId);
76
- }
 
 
77
  });
78
  });
79
  return result;
80
  }
81
 
82
- async function saveLayout(next: Page[], nextSelectedIds?: string[]) {
83
  if (!sessionId) return;
84
  setIsSaving(true);
85
  setStatus("Saving layout changes...");
86
  try {
87
  const requests: Promise<unknown>[] = [
88
- putJson<{ pages: Page[] }>(`/sessions/${sessionId}/pages`, { pages: next }),
 
 
 
89
  ];
90
  if (nextSelectedIds !== undefined) {
91
  requests.push(
@@ -95,10 +110,11 @@ export default function EditLayoutsPage() {
95
  );
96
  }
97
  const [pagesResp, sessionResp] = await Promise.all(requests);
98
- const updatedPages = (pagesResp as { pages?: Page[] }).pages ?? next;
 
99
  const updatedSession = sessionResp as Session | undefined;
100
- const updated = Array.isArray(updatedPages) ? updatedPages : next;
101
- setPages(updated.length ? updated : [{ items: [] }]);
102
  if (updatedSession) {
103
  setSession(updatedSession);
104
  }
@@ -112,14 +128,44 @@ export default function EditLayoutsPage() {
112
  }
113
  }
114
 
115
- async function handleAddPage() {
116
- const next = [...pages, { items: [] }];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  await saveLayout(next);
118
  }
119
 
120
- async function handleRemovePage(index: number) {
121
- if (pages.length <= 1) return;
122
- const next = pages.filter((_, idx) => idx !== index);
 
 
 
 
 
123
  if (session) {
124
  const nextSelected = hasExplicitPhotos(next)
125
  ? flattenPhotoIds(next)
@@ -130,11 +176,15 @@ export default function EditLayoutsPage() {
130
  await saveLayout(next);
131
  }
132
 
133
- async function handleMovePage(index: number, direction: number) {
134
- const target = index + direction;
135
- if (target < 0 || target >= pages.length) return;
136
- const next = [...pages];
137
- [next[index], next[target]] = [next[target], next[index]];
 
 
 
 
138
  if (session) {
139
  const nextSelected = hasExplicitPhotos(next)
140
  ? flattenPhotoIds(next)
@@ -151,13 +201,16 @@ export default function EditLayoutsPage() {
151
  title="RepEx - Report Express"
152
  subtitle="Edit Page Layouts"
153
  right={
154
- <Link
155
- to={`/report-viewer${sessionQuery}`}
156
- className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
157
- >
158
- <ArrowLeft className="h-4 w-4" />
159
- Back
160
- </Link>
 
 
 
161
  }
162
  />
163
 
@@ -210,79 +263,124 @@ export default function EditLayoutsPage() {
210
  Add, remove, or reorder pages, then return to the report viewer to edit content.
211
  </p>
212
  </div>
213
- <button
214
- type="button"
215
- onClick={handleAddPage}
216
- disabled={!canModify}
217
- className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
218
- >
219
- <Plus className="h-4 w-4" />
220
- Add page
221
- </button>
 
 
222
  </div>
223
  {status ? (
224
  <p className="text-sm text-gray-600 mt-3">{status}</p>
225
  ) : null}
226
  </section>
227
 
228
- <section className="grid grid-cols-1 md:grid-cols-2 gap-4">
229
- {pages.map((page, index) => (
230
- <div
231
- key={`page-${index + 1}`}
232
- className="rounded-lg border border-gray-200 bg-white p-3"
233
- >
234
- <div className="flex items-center justify-between mb-2">
235
- <div>
236
- <div className="text-sm font-semibold text-gray-900">
237
- Page {index + 1}
238
- </div>
239
- <div className="text-xs text-gray-500">
240
- {page.items?.length ?? 0} items
241
- </div>
242
- </div>
243
- <div className="flex items-center gap-2">
244
- <button
245
- type="button"
246
- onClick={() => handleMovePage(index, -1)}
247
- disabled={index === 0 || !canModify}
248
- className="inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white p-1.5 text-gray-600 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
249
- aria-label="Move page up"
250
- >
251
- <ChevronUp className="h-3.5 w-3.5" />
252
- </button>
253
- <button
254
- type="button"
255
- onClick={() => handleMovePage(index, 1)}
256
- disabled={index === pages.length - 1 || !canModify}
257
- className="inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white p-1.5 text-gray-600 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
258
- aria-label="Move page down"
259
- >
260
- <ChevronDown className="h-3.5 w-3.5" />
261
- </button>
262
- <button
263
- type="button"
264
- onClick={() => handleRemovePage(index)}
265
- disabled={pages.length <= 1 || !canModify}
266
- className="inline-flex items-center gap-1 rounded-lg border border-red-200 bg-red-50 px-2.5 py-1.5 text-xs font-semibold text-red-700 hover:bg-red-100 transition disabled:opacity-50 disabled:cursor-not-allowed"
267
- >
268
- <Trash2 className="h-3.5 w-3.5" />
269
- Remove
270
- </button>
271
  </div>
272
  </div>
 
 
 
 
 
 
 
 
 
 
273
 
274
- <div
275
- className="mx-auto overflow-hidden rounded-lg border border-gray-200 bg-white"
276
- style={{ width: previewWidth, aspectRatio: "210 / 297" }}
277
- >
278
- <ReportPageCanvas
279
- session={session}
280
- page={page}
281
- pageIndex={index}
282
- pageCount={totalPages}
283
- scale={previewScale}
284
- template={page?.template}
285
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  </div>
287
  </div>
288
  ))}
 
15
 
16
  import { putJson, request } from "../lib/api";
17
  import { BASE_W } from "../lib/report";
18
+ import { ensureSections, flattenSections } from "../lib/sections";
19
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
20
+ import type { JobsheetSection, Session } from "../types/session";
21
  import { PageFooter } from "../components/PageFooter";
22
  import { PageHeader } from "../components/PageHeader";
23
  import { PageShell } from "../components/PageShell";
24
+ import { InfoMenu } from "../components/InfoMenu";
25
  import { ReportPageCanvas } from "../components/ReportPageCanvas";
26
 
27
  export default function EditLayoutsPage() {
 
30
  const sessionQuery = buildSessionQuery(sessionId);
31
 
32
  const [session, setSession] = useState<Session | null>(null);
33
+ const [sections, setSections] = useState<JobsheetSection[]>([]);
34
  const [status, setStatus] = useState("");
35
  const [isSaving, setIsSaving] = useState(false);
36
  const canModify = Boolean(sessionId) && !isSaving;
 
45
  try {
46
  const data = await request<Session>(`/sessions/${sessionId}`);
47
  setSession(data);
48
+ const sectionResp = await request<{ sections: JobsheetSection[] }>(
49
+ `/sessions/${sessionId}/sections`,
50
  );
51
+ setSections(ensureSections(sectionResp.sections));
 
52
  } catch (err) {
53
  const message =
54
  err instanceof Error ? err.message : "Failed to load session.";
 
58
  load();
59
  }, [sessionId]);
60
 
61
+ const flatPages = useMemo(
62
+ () => flattenSections(ensureSections(sections)),
63
+ [sections],
64
+ );
65
+ const totalPages = useMemo(
66
+ () => Math.max(1, flatPages.length),
67
+ [flatPages.length],
68
+ );
69
  const previewWidth = 220;
70
  const previewScale = previewWidth / BASE_W;
71
 
72
+ function hasExplicitPhotos(source: JobsheetSection[]) {
73
+ return source.some((section) =>
74
+ (section.pages ?? []).some((page) => (page.photo_ids ?? []).length > 0),
75
+ );
76
  }
77
 
78
+ function flattenPhotoIds(source: JobsheetSection[]) {
79
  const seen = new Set<string>();
80
  const result: string[] = [];
81
+ source.forEach((section) => {
82
+ (section.pages ?? []).forEach((page) => {
83
+ (page.photo_ids ?? []).forEach((photoId) => {
84
+ if (!seen.has(photoId)) {
85
+ seen.add(photoId);
86
+ result.push(photoId);
87
+ }
88
+ });
89
  });
90
  });
91
  return result;
92
  }
93
 
94
+ async function saveLayout(next: JobsheetSection[], nextSelectedIds?: string[]) {
95
  if (!sessionId) return;
96
  setIsSaving(true);
97
  setStatus("Saving layout changes...");
98
  try {
99
  const requests: Promise<unknown>[] = [
100
+ putJson<{ sections: JobsheetSection[] }>(
101
+ `/sessions/${sessionId}/sections`,
102
+ { sections: next },
103
+ ),
104
  ];
105
  if (nextSelectedIds !== undefined) {
106
  requests.push(
 
110
  );
111
  }
112
  const [pagesResp, sessionResp] = await Promise.all(requests);
113
+ const updatedSections =
114
+ (pagesResp as { sections?: JobsheetSection[] }).sections ?? next;
115
  const updatedSession = sessionResp as Session | undefined;
116
+ const updated = ensureSections(updatedSections);
117
+ setSections(updated);
118
  if (updatedSession) {
119
  setSession(updatedSession);
120
  }
 
128
  }
129
  }
130
 
131
+ async function handleAddSection() {
132
+ const next = [
133
+ ...sections,
134
+ {
135
+ id: crypto.randomUUID(),
136
+ title: `Section ${sections.length + 1}`,
137
+ pages: [{ items: [] }],
138
+ },
139
+ ];
140
+ await saveLayout(next);
141
+ }
142
+
143
+ async function handleAddPage(sectionIndex: number) {
144
+ const next = sections.map((section, idx) => {
145
+ if (idx !== sectionIndex) return section;
146
+ return {
147
+ ...section,
148
+ pages: [...(section.pages ?? []), { items: [], blank: true }],
149
+ };
150
+ });
151
+ if (session) {
152
+ const nextSelected = hasExplicitPhotos(next)
153
+ ? flattenPhotoIds(next)
154
+ : session.selected_photo_ids ?? [];
155
+ await saveLayout(next, nextSelected);
156
+ return;
157
+ }
158
  await saveLayout(next);
159
  }
160
 
161
+ async function handleRemovePage(sectionIndex: number, pageIndex: number) {
162
+ const next = sections.map((section, idx) => {
163
+ if (idx !== sectionIndex) return section;
164
+ const pages = [...(section.pages ?? [])];
165
+ if (pages.length <= 1) return section;
166
+ pages.splice(pageIndex, 1);
167
+ return { ...section, pages: pages.length ? pages : [{ items: [] }] };
168
+ });
169
  if (session) {
170
  const nextSelected = hasExplicitPhotos(next)
171
  ? flattenPhotoIds(next)
 
176
  await saveLayout(next);
177
  }
178
 
179
+ async function handleMovePage(sectionIndex: number, pageIndex: number, direction: number) {
180
+ const next = sections.map((section, idx) => {
181
+ if (idx !== sectionIndex) return section;
182
+ const pages = [...(section.pages ?? [])];
183
+ const target = pageIndex + direction;
184
+ if (target < 0 || target >= pages.length) return section;
185
+ [pages[pageIndex], pages[target]] = [pages[target], pages[pageIndex]];
186
+ return { ...section, pages };
187
+ });
188
  if (session) {
189
  const nextSelected = hasExplicitPhotos(next)
190
  ? flattenPhotoIds(next)
 
201
  title="RepEx - Report Express"
202
  subtitle="Edit Page Layouts"
203
  right={
204
+ <div className="flex items-center gap-2">
205
+ <Link
206
+ to={`/report-viewer${sessionQuery}`}
207
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
208
+ >
209
+ <ArrowLeft className="h-4 w-4" />
210
+ Back
211
+ </Link>
212
+ <InfoMenu sessionQuery={sessionQuery} />
213
+ </div>
214
  }
215
  />
216
 
 
263
  Add, remove, or reorder pages, then return to the report viewer to edit content.
264
  </p>
265
  </div>
266
+ <div className="flex flex-wrap gap-2">
267
+ <button
268
+ type="button"
269
+ onClick={handleAddSection}
270
+ disabled={!canModify}
271
+ className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
272
+ >
273
+ <Plus className="h-4 w-4" />
274
+ Add section
275
+ </button>
276
+ </div>
277
  </div>
278
  {status ? (
279
  <p className="text-sm text-gray-600 mt-3">{status}</p>
280
  ) : null}
281
  </section>
282
 
283
+ <section className="space-y-6">
284
+ {sections.map((section, sectionIndex) => (
285
+ <div key={section.id} className="rounded-lg border border-gray-200 bg-white p-4">
286
+ <div className="flex flex-wrap items-center justify-between gap-3 mb-4">
287
+ <div>
288
+ <div className="text-xs font-semibold text-gray-500">Section {sectionIndex + 1}</div>
289
+ <input
290
+ type="text"
291
+ value={section.title ?? ""}
292
+ onChange={(event) =>
293
+ setSections((prev) =>
294
+ prev.map((item, idx) =>
295
+ idx === sectionIndex ? { ...item, title: event.target.value } : item,
296
+ ),
297
+ )
298
+ }
299
+ placeholder={`Section ${sectionIndex + 1}`}
300
+ className="mt-1 w-60 rounded-md border border-gray-200 px-2 py-1 text-sm font-semibold text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-200"
301
+ />
302
+ <div className="text-xs text-gray-500">
303
+ {section.pages?.length ?? 0} pages
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  </div>
305
  </div>
306
+ <button
307
+ type="button"
308
+ onClick={() => handleAddPage(sectionIndex)}
309
+ disabled={!canModify}
310
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
311
+ >
312
+ <Plus className="h-4 w-4" />
313
+ Add page
314
+ </button>
315
+ </div>
316
 
317
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
318
+ {(section.pages ?? []).map((page, pageIndex) => (
319
+ <div
320
+ key={`${section.id}-page-${pageIndex}`}
321
+ className="rounded-lg border border-gray-200 bg-white p-3"
322
+ >
323
+ <div className="flex items-center justify-between mb-2">
324
+ <div>
325
+ <div className="text-sm font-semibold text-gray-900">
326
+ Page {pageIndex + 1}
327
+ </div>
328
+ <div className="text-xs text-gray-500">
329
+ {page.items?.length ?? 0} items
330
+ </div>
331
+ </div>
332
+ <div className="flex items-center gap-2">
333
+ <button
334
+ type="button"
335
+ onClick={() => handleMovePage(sectionIndex, pageIndex, -1)}
336
+ disabled={pageIndex === 0 || !canModify}
337
+ className="inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white p-1.5 text-gray-600 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
338
+ aria-label="Move page up"
339
+ >
340
+ <ChevronUp className="h-3.5 w-3.5" />
341
+ </button>
342
+ <button
343
+ type="button"
344
+ onClick={() => handleMovePage(sectionIndex, pageIndex, 1)}
345
+ disabled={pageIndex === (section.pages?.length ?? 1) - 1 || !canModify}
346
+ className="inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white p-1.5 text-gray-600 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
347
+ aria-label="Move page down"
348
+ >
349
+ <ChevronDown className="h-3.5 w-3.5" />
350
+ </button>
351
+ <button
352
+ type="button"
353
+ onClick={() => handleRemovePage(sectionIndex, pageIndex)}
354
+ disabled={(section.pages?.length ?? 1) <= 1 || !canModify}
355
+ className="inline-flex items-center gap-1 rounded-lg border border-red-200 bg-red-50 px-2.5 py-1.5 text-xs font-semibold text-red-700 hover:bg-red-100 transition disabled:opacity-50 disabled:cursor-not-allowed"
356
+ >
357
+ <Trash2 className="h-3.5 w-3.5" />
358
+ Remove
359
+ </button>
360
+ </div>
361
+ </div>
362
+
363
+ <div
364
+ className="mx-auto rounded-lg border border-gray-200 bg-white"
365
+ style={{ width: previewWidth }}
366
+ >
367
+ <ReportPageCanvas
368
+ session={session}
369
+ page={page}
370
+ pageIndex={pageIndex}
371
+ pageCount={totalPages}
372
+ scale={previewScale}
373
+ template={page?.template}
374
+ sectionLabel={
375
+ section.title
376
+ ? `Section ${sectionIndex + 1} - ${section.title}`
377
+ : `Section ${sectionIndex + 1}`
378
+ }
379
+ adaptive
380
+ />
381
+ </div>
382
+ </div>
383
+ ))}
384
  </div>
385
  </div>
386
  ))}
frontend/src/pages/EditReportPage.tsx CHANGED
@@ -3,11 +3,13 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom";
3
  import { ArrowLeft, Download, Edit3, Grid, Layout, Table } from "react-feather";
4
 
5
  import { API_BASE, request } from "../lib/api";
 
6
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
7
- import type { Session } from "../types/session";
8
  import { PageFooter } from "../components/PageFooter";
9
  import { PageHeader } from "../components/PageHeader";
10
  import { PageShell } from "../components/PageShell";
 
11
 
12
  export default function EditReportPage() {
13
  const [searchParams] = useSearchParams();
@@ -38,13 +40,16 @@ export default function EditReportPage() {
38
  setStoredSessionId(sessionId);
39
  async function load() {
40
  try {
41
- const [data, pageResp] = await Promise.all([
42
  request<Session>(`/sessions/${sessionId}`),
43
- request<{ pages: { items: unknown[] }[] }>(`/sessions/${sessionId}/pages`),
 
 
44
  ]);
45
  setSession(data);
46
- const loaded = Array.isArray(pageResp.pages) ? pageResp.pages.length : 0;
47
- setPageCount(loaded || null);
 
48
  } catch (err) {
49
  const message =
50
  err instanceof Error ? err.message : "Failed to load session.";
@@ -87,13 +92,16 @@ export default function EditReportPage() {
87
  title="RepEx - Report Express"
88
  subtitle="Edit Report"
89
  right={
90
- <Link
91
- to={`/report-viewer${sessionQuery}`}
92
- className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
93
- >
94
- <ArrowLeft className="h-4 w-4" />
95
- Back
96
- </Link>
 
 
 
97
  }
98
  />
99
 
 
3
  import { ArrowLeft, Download, Edit3, Grid, Layout, Table } from "react-feather";
4
 
5
  import { API_BASE, request } from "../lib/api";
6
+ import { ensureSections, flattenSections } from "../lib/sections";
7
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
8
+ import type { JobsheetSection, Session } from "../types/session";
9
  import { PageFooter } from "../components/PageFooter";
10
  import { PageHeader } from "../components/PageHeader";
11
  import { PageShell } from "../components/PageShell";
12
+ import { InfoMenu } from "../components/InfoMenu";
13
 
14
  export default function EditReportPage() {
15
  const [searchParams] = useSearchParams();
 
40
  setStoredSessionId(sessionId);
41
  async function load() {
42
  try {
43
+ const [data, sectionResp] = await Promise.all([
44
  request<Session>(`/sessions/${sessionId}`),
45
+ request<{ sections: JobsheetSection[] }>(
46
+ `/sessions/${sessionId}/sections`,
47
+ ),
48
  ]);
49
  setSession(data);
50
+ const loadedSections = ensureSections(sectionResp.sections);
51
+ const flatPages = flattenSections(loadedSections);
52
+ setPageCount(flatPages.length || null);
53
  } catch (err) {
54
  const message =
55
  err instanceof Error ? err.message : "Failed to load session.";
 
92
  title="RepEx - Report Express"
93
  subtitle="Edit Report"
94
  right={
95
+ <div className="flex items-center gap-2">
96
+ <Link
97
+ to={`/report-viewer${sessionQuery}`}
98
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
99
+ >
100
+ <ArrowLeft className="h-4 w-4" />
101
+ Back
102
+ </Link>
103
+ <InfoMenu sessionQuery={sessionQuery} />
104
+ </div>
105
  }
106
  />
107
 
frontend/src/pages/ExportPage.tsx CHANGED
@@ -4,11 +4,13 @@ import { ArrowLeft, Download, Edit3, Grid, Layout, Table } from "react-feather";
4
 
5
  import { API_BASE, request } from "../lib/api";
6
  import { BASE_W } from "../lib/report";
 
7
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
8
- import type { Page, Session } from "../types/session";
9
  import { PageFooter } from "../components/PageFooter";
10
  import { PageHeader } from "../components/PageHeader";
11
  import { PageShell } from "../components/PageShell";
 
12
  import { ReportPageCanvas } from "../components/ReportPageCanvas";
13
 
14
  export default function ExportPage() {
@@ -17,7 +19,7 @@ export default function ExportPage() {
17
  const sessionQuery = buildSessionQuery(sessionId);
18
 
19
  const [session, setSession] = useState<Session | null>(null);
20
- const [pages, setPages] = useState<Page[]>([]);
21
  const [error, setError] = useState("");
22
 
23
  const [incPages, setIncPages] = useState(true);
@@ -37,10 +39,10 @@ export default function ExportPage() {
37
  try {
38
  const data = await request<Session>(`/sessions/${sessionId}`);
39
  setSession(data);
40
- const pageResp = await request<{ pages: Page[] }>(
41
- `/sessions/${sessionId}/pages`,
42
  );
43
- setPages(Array.isArray(pageResp.pages) ? pageResp.pages : []);
44
  } catch (err) {
45
  const message =
46
  err instanceof Error ? err.message : "Failed to load session.";
@@ -65,22 +67,26 @@ export default function ExportPage() {
65
  };
66
  }, []);
67
 
 
 
 
 
68
  const totals = useMemo(() => {
69
- const totalItems = pages.reduce(
70
- (acc, page) => acc + (page?.items?.length ?? 0),
71
  0,
72
  );
73
  return {
74
- pages: pages.length,
75
  items: totalItems,
76
  photos: session?.selected_photo_ids?.length ?? 0,
77
  docs: session?.uploads?.documents?.length ?? 0,
78
  data: session?.uploads?.data_files?.length ?? 0,
79
  };
80
- }, [pages, session]);
81
 
82
  const totalPages =
83
- pages.length > 0 ? pages.length : Math.max(1, session?.page_count ?? 0);
84
  const serverExportUrl = sessionId
85
  ? `${API_BASE}/sessions/${sessionId}/export`
86
  : "";
@@ -90,7 +96,7 @@ export default function ExportPage() {
90
 
91
  function downloadJson() {
92
  const pack: Record<string, unknown> = {};
93
- if (incPages) pack.pages = pages;
94
  if (incLayout) pack.layout = (session as Session | null)?.["layout"] ?? null;
95
  if (incPayload) pack.payload = session;
96
  if (incTimestamp) pack.exportedAt = new Date().toISOString();
@@ -117,10 +123,11 @@ export default function ExportPage() {
117
  return (
118
  <PageShell className="print:ring-0 print:shadow-none print:rounded-none print:p-0 print:my-0 print:max-w-none">
119
  <div className="no-print">
120
- <PageHeader
121
- title="RepEx - Report Express"
122
- subtitle="Export"
123
- right={
 
124
  <Link
125
  to={`/report-viewer${sessionQuery}`}
126
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
@@ -128,8 +135,10 @@ export default function ExportPage() {
128
  <ArrowLeft className="h-4 w-4" />
129
  Back
130
  </Link>
131
- }
132
- />
 
 
133
  </div>
134
 
135
  <nav className="mb-6 no-print" aria-label="Report workflow navigation">
@@ -204,7 +213,7 @@ export default function ExportPage() {
204
  checked={incPages}
205
  onChange={(event) => setIncPages(event.target.checked)}
206
  />
207
- Include pages
208
  </label>
209
  <label className="inline-flex items-center gap-2 text-sm text-gray-700">
210
  <input
@@ -264,9 +273,7 @@ export default function ExportPage() {
264
  <div>
265
  <div className="text-sm font-semibold text-gray-900">PDF export</div>
266
  <div className="text-xs text-gray-500">
267
- Use the browser print dialog to save as PDF
268
- <br />
269
- Generate a PDF using headless Chrome for full-page output
270
  </div>
271
  </div>
272
  </div>
@@ -292,6 +299,7 @@ export default function ExportPage() {
292
  </button>
293
  </div>
294
  </div>
 
295
  </div>
296
  </div>
297
 
@@ -305,7 +313,7 @@ export default function ExportPage() {
305
  <div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
306
  <div className="text-xs font-semibold text-gray-600">Pages</div>
307
  <div className="text-sm font-semibold text-gray-900">
308
- {pages.length
309
  ? `${totalPages} pages - ${totals.items} total items`
310
  : "No saved pages yet"}
311
  </div>
@@ -357,16 +365,23 @@ export default function ExportPage() {
357
  >
358
  <div
359
  ref={index === 0 ? previewRef : undefined}
360
- className="relative overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm print:border-0 print:rounded-none print:shadow-none"
361
- style={{ aspectRatio: "210 / 297" }}
362
  >
363
  <ReportPageCanvas
364
  session={session}
365
- page={pages[index] ?? { items: [] }}
366
  pageIndex={index}
367
  pageCount={totalPages}
368
  scale={previewScale}
369
- template={pages[index]?.template}
 
 
 
 
 
 
 
 
370
  />
371
  </div>
372
  </div>
 
4
 
5
  import { API_BASE, request } from "../lib/api";
6
  import { BASE_W } from "../lib/report";
7
+ import { ensureSections, flattenSections } from "../lib/sections";
8
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
9
+ import type { JobsheetSection, Session } from "../types/session";
10
  import { PageFooter } from "../components/PageFooter";
11
  import { PageHeader } from "../components/PageHeader";
12
  import { PageShell } from "../components/PageShell";
13
+ import { InfoMenu } from "../components/InfoMenu";
14
  import { ReportPageCanvas } from "../components/ReportPageCanvas";
15
 
16
  export default function ExportPage() {
 
19
  const sessionQuery = buildSessionQuery(sessionId);
20
 
21
  const [session, setSession] = useState<Session | null>(null);
22
+ const [sections, setSections] = useState<JobsheetSection[]>([]);
23
  const [error, setError] = useState("");
24
 
25
  const [incPages, setIncPages] = useState(true);
 
39
  try {
40
  const data = await request<Session>(`/sessions/${sessionId}`);
41
  setSession(data);
42
+ const sectionResp = await request<{ sections: JobsheetSection[] }>(
43
+ `/sessions/${sessionId}/sections`,
44
  );
45
+ setSections(ensureSections(sectionResp.sections));
46
  } catch (err) {
47
  const message =
48
  err instanceof Error ? err.message : "Failed to load session.";
 
67
  };
68
  }, []);
69
 
70
+ const flatPages = useMemo(
71
+ () => flattenSections(ensureSections(sections)),
72
+ [sections],
73
+ );
74
  const totals = useMemo(() => {
75
+ const totalItems = flatPages.reduce(
76
+ (acc, entry) => acc + (entry.page?.items?.length ?? 0),
77
  0,
78
  );
79
  return {
80
+ pages: flatPages.length,
81
  items: totalItems,
82
  photos: session?.selected_photo_ids?.length ?? 0,
83
  docs: session?.uploads?.documents?.length ?? 0,
84
  data: session?.uploads?.data_files?.length ?? 0,
85
  };
86
+ }, [flatPages, session]);
87
 
88
  const totalPages =
89
+ flatPages.length > 0 ? flatPages.length : Math.max(1, session?.page_count ?? 0);
90
  const serverExportUrl = sessionId
91
  ? `${API_BASE}/sessions/${sessionId}/export`
92
  : "";
 
96
 
97
  function downloadJson() {
98
  const pack: Record<string, unknown> = {};
99
+ if (incPages) pack.sections = sections;
100
  if (incLayout) pack.layout = (session as Session | null)?.["layout"] ?? null;
101
  if (incPayload) pack.payload = session;
102
  if (incTimestamp) pack.exportedAt = new Date().toISOString();
 
123
  return (
124
  <PageShell className="print:ring-0 print:shadow-none print:rounded-none print:p-0 print:my-0 print:max-w-none">
125
  <div className="no-print">
126
+ <PageHeader
127
+ title="RepEx - Report Express"
128
+ subtitle="Export"
129
+ right={
130
+ <div className="flex items-center gap-2">
131
  <Link
132
  to={`/report-viewer${sessionQuery}`}
133
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
 
135
  <ArrowLeft className="h-4 w-4" />
136
  Back
137
  </Link>
138
+ <InfoMenu sessionQuery={sessionQuery} />
139
+ </div>
140
+ }
141
+ />
142
  </div>
143
 
144
  <nav className="mb-6 no-print" aria-label="Report workflow navigation">
 
213
  checked={incPages}
214
  onChange={(event) => setIncPages(event.target.checked)}
215
  />
216
+ Include job sheet sections
217
  </label>
218
  <label className="inline-flex items-center gap-2 text-sm text-gray-700">
219
  <input
 
273
  <div>
274
  <div className="text-sm font-semibold text-gray-900">PDF export</div>
275
  <div className="text-xs text-gray-500">
276
+ Generate a PDF on the server (ReportLab) or use the browser print dialog.
 
 
277
  </div>
278
  </div>
279
  </div>
 
299
  </button>
300
  </div>
301
  </div>
302
+
303
  </div>
304
  </div>
305
 
 
313
  <div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
314
  <div className="text-xs font-semibold text-gray-600">Pages</div>
315
  <div className="text-sm font-semibold text-gray-900">
316
+ {flatPages.length
317
  ? `${totalPages} pages - ${totals.items} total items`
318
  : "No saved pages yet"}
319
  </div>
 
365
  >
366
  <div
367
  ref={index === 0 ? previewRef : undefined}
368
+ className="relative rounded-xl border border-gray-200 bg-white shadow-sm print:border-0 print:rounded-none print:shadow-none"
 
369
  >
370
  <ReportPageCanvas
371
  session={session}
372
+ page={flatPages[index]?.page ?? { items: [] }}
373
  pageIndex={index}
374
  pageCount={totalPages}
375
  scale={previewScale}
376
+ template={flatPages[index]?.page?.template}
377
+ sectionLabel={
378
+ flatPages[index]
379
+ ? flatPages[index].sectionTitle
380
+ ? `Section ${flatPages[index].sectionIndex + 1} - ${flatPages[index].sectionTitle}`
381
+ : `Section ${flatPages[index].sectionIndex + 1}`
382
+ : ""
383
+ }
384
+ adaptive
385
  />
386
  </div>
387
  </div>
frontend/src/pages/InputDataPage.tsx CHANGED
@@ -4,11 +4,19 @@ import { ArrowLeft, Download, Edit3, Grid, Layout, Save, Table } from "react-fea
4
 
5
  import { API_BASE, postForm, putJson, request } from "../lib/api";
6
  import { formatDocNumber } from "../lib/report";
 
 
 
 
 
 
 
7
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
8
- import type { Page, Session, TemplateFields } from "../types/session";
9
  import { PageFooter } from "../components/PageFooter";
10
  import { PageHeader } from "../components/PageHeader";
11
  import { PageShell } from "../components/PageShell";
 
12
 
13
  type FieldDef = {
14
  key: keyof TemplateFields;
@@ -44,12 +52,14 @@ export default function InputDataPage() {
44
  const sessionQuery = buildSessionQuery(sessionId);
45
 
46
  const [session, setSession] = useState<Session | null>(null);
47
- const [pages, setPages] = useState<Page[]>([]);
48
  const [status, setStatus] = useState("");
49
  const [isSaving, setIsSaving] = useState(false);
50
  const [isUploading, setIsUploading] = useState(false);
51
  const [copySourceIndex, setCopySourceIndex] = useState(0);
52
  const [copyTargets, setCopyTargets] = useState("");
 
 
53
  const [showGeneralColumns, setShowGeneralColumns] = useState(false);
54
  const [generalDirty, setGeneralDirty] = useState(false);
55
  const [generalTemplate, setGeneralTemplate] = useState<TemplateFields>({});
@@ -71,11 +81,10 @@ export default function InputDataPage() {
71
  try {
72
  const data = await request<Session>(`/sessions/${sessionId}`);
73
  setSession(data);
74
- const pageResp = await request<{ pages: Page[] }>(
75
- `/sessions/${sessionId}/pages`,
76
  );
77
- const loaded = Array.isArray(pageResp.pages) ? pageResp.pages : [];
78
- setPages(loaded.length ? loaded : [{ items: [] }]);
79
  } catch (err) {
80
  const message =
81
  err instanceof Error ? err.message : "Failed to load session.";
@@ -89,39 +98,36 @@ export default function InputDataPage() {
89
  if (!sessionId) return;
90
  const data = await request<Session>(`/sessions/${sessionId}`);
91
  setSession(data);
92
- const pageResp = await request<{ pages: Page[] }>(
93
- `/sessions/${sessionId}/pages`,
94
  );
95
- const loaded = Array.isArray(pageResp.pages) ? pageResp.pages : [];
96
- setPages(loaded.length ? loaded : [{ items: [] }]);
97
  }
98
 
 
 
 
 
99
  const totalPages = useMemo(() => {
100
- if (pages.length > 0) return pages.length;
101
  return Math.max(1, session?.page_count ?? 0);
102
- }, [pages.length, session?.page_count]);
103
 
104
  useEffect(() => {
105
- if (!sessionId) return;
106
- if (pages.length >= totalPages) return;
107
- setPages((prev) => {
108
- const next = [...prev];
109
- while (next.length < totalPages) {
110
- next.push({ items: [] });
111
- }
112
- return next;
113
- });
114
- }, [pages.length, sessionId, totalPages]);
115
 
116
  useEffect(() => {
117
- if (copySourceIndex >= pages.length) {
118
- setCopySourceIndex(Math.max(0, pages.length - 1));
119
  }
120
- }, [copySourceIndex, pages.length]);
121
 
122
  useEffect(() => {
123
  if (generalDirty) return;
124
- const source = pages[0]?.template ?? {};
125
  const next: TemplateFields = {};
126
  GENERAL_FIELDS.forEach((field) => {
127
  const value = source[field.key] ?? getFallbackValue(field.key);
@@ -130,17 +136,21 @@ export default function InputDataPage() {
130
  }
131
  });
132
  setGeneralTemplate(next);
133
- }, [generalDirty, pages, session]);
134
-
135
- function updateField(pageIndex: number, key: keyof TemplateFields, value: string) {
136
- setPages((prev) =>
137
- prev.map((page, idx) => {
138
- if (idx !== pageIndex) return page;
139
- const template = { ...(page.template ?? {}) };
140
- template[key] = value;
141
- return { ...page, template };
142
- }),
143
- );
 
 
 
 
144
  }
145
 
146
  function updateGeneralField(key: keyof TemplateFields, value: string) {
@@ -149,52 +159,101 @@ export default function InputDataPage() {
149
  }
150
 
151
  function applyRowToAll(pageIndex: number) {
152
- const source = pages[pageIndex]?.template ?? {};
153
- setPages((prev) =>
154
- prev.map((page) => ({
155
- ...page,
156
- template: { ...source },
 
 
 
 
 
157
  })),
158
  );
159
  }
160
 
161
  function applyGeneralToAll() {
162
- if (!pages.length) return;
163
- setPages((prev) =>
164
- prev.map((page) => {
165
- const template = { ...(page.template ?? {}) };
166
- GENERAL_FIELDS.forEach((field) => {
167
- const value = generalTemplate[field.key];
168
- if (value !== undefined) {
169
- template[field.key] = value;
170
- }
171
- });
172
- return { ...page, template };
173
- }),
 
 
 
174
  );
175
  setGeneralDirty(false);
176
  setStatus("Applied general info to all pages.");
177
  }
178
 
179
  function insertPageAt(index: number, templateSource?: TemplateFields) {
180
- setPages((prev) => {
181
- const next = [...prev];
 
 
182
  const fallbackTemplate =
183
  templateSource ??
184
- next[Math.max(0, Math.min(index - 1, next.length - 1))]?.template ??
185
  {};
186
- next.splice(index, 0, { items: [], template: { ...fallbackTemplate } });
187
- return next;
 
 
 
188
  });
189
  }
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  function removePageAt(index: number) {
192
- setPages((prev) => {
193
- if (prev.length <= 1) return prev;
194
- const next = [...prev];
195
- next.splice(index, 1);
196
- return next.length ? next : [{ items: [] }];
197
- });
198
  }
199
 
200
  function updatePhotoSelection(pageIndex: number, value: string) {
@@ -202,57 +261,59 @@ export default function InputDataPage() {
202
  }
203
 
204
  function updatePagePhotos(pageIndex: number, nextIds: string[]) {
205
- setPages((prev) =>
206
- prev.map((page, idx) =>
207
- idx === pageIndex ? { ...page, photo_ids: nextIds } : page,
208
- ),
209
- );
 
210
  }
211
 
212
  function setPhotoOrderLocked(pageIndex: number, locked: boolean) {
213
- setPages((prev) =>
214
- prev.map((page, idx) =>
215
- idx === pageIndex ? { ...page, photo_order_locked: locked } : page,
216
- ),
217
- );
 
218
  }
219
 
220
  function movePhoto(pageIndex: number, from: number, to: number) {
221
- setPages((prev) =>
222
- prev.map((page, idx) => {
223
- if (idx !== pageIndex) return page;
224
- const ids = [...(page.photo_ids ?? [])];
225
- if (from < 0 || from >= ids.length || to < 0 || to >= ids.length) {
226
- return page;
227
- }
228
- const [moved] = ids.splice(from, 1);
229
- ids.splice(to, 0, moved);
230
- return { ...page, photo_ids: ids };
231
- }),
232
- );
233
  }
234
 
235
  function removePhoto(pageIndex: number, index: number) {
236
- setPages((prev) =>
237
- prev.map((page, idx) => {
238
- if (idx !== pageIndex) return page;
239
- const ids = [...(page.photo_ids ?? [])];
240
- ids.splice(index, 1);
241
- return { ...page, photo_ids: ids };
242
- }),
243
- );
244
  }
245
 
246
  function addPhotoToPage(pageIndex: number, photoId: string) {
247
  if (!photoId) return;
248
- setPages((prev) =>
249
- prev.map((page, idx) => {
250
- if (idx !== pageIndex) return page;
251
- const ids = [...(page.photo_ids ?? [])];
252
- if (!ids.includes(photoId)) ids.push(photoId);
253
- return { ...page, photo_ids: ids };
254
- }),
255
- );
256
  }
257
 
258
  function parseTargetPages(value: string, max: number): number[] {
@@ -283,19 +344,28 @@ export default function InputDataPage() {
283
  }
284
 
285
  function copyPageToTargets() {
286
- if (!pages.length) return;
287
- const targets = parseTargetPages(copyTargets, pages.length).filter(
288
  (idx) => idx !== copySourceIndex,
289
  );
290
  if (!targets.length) {
291
  setStatus("No valid target pages selected for copy.");
292
  return;
293
  }
294
- const sourceTemplate = pages[copySourceIndex]?.template ?? {};
295
- setPages((prev) =>
296
- prev.map((page, idx) =>
297
- targets.includes(idx) ? { ...page, template: { ...sourceTemplate } } : page,
298
- ),
 
 
 
 
 
 
 
 
 
299
  );
300
  setStatus(
301
  `Copied page ${copySourceIndex + 1} to ${targets
@@ -325,12 +395,12 @@ export default function InputDataPage() {
325
  setIsSaving(true);
326
  setStatus("Saving input data...");
327
  try {
328
- const resp = await putJson<{ pages: Page[] }>(
329
- `/sessions/${sessionId}/pages`,
330
- { pages },
331
  );
332
- const updated = Array.isArray(resp.pages) ? resp.pages : pages;
333
- setPages(updated.length ? updated : [{ items: [] }]);
334
  setStatus("Input data saved.");
335
  } catch (err) {
336
  const message =
@@ -420,13 +490,16 @@ export default function InputDataPage() {
420
  title="RepEx - Report Express"
421
  subtitle="Input Data"
422
  right={
423
- <Link
424
- to={`/report-viewer${sessionQuery}`}
425
- className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
426
- >
427
- <ArrowLeft className="h-4 w-4" />
428
- Back
429
- </Link>
 
 
 
430
  }
431
  />
432
 
@@ -481,13 +554,35 @@ export default function InputDataPage() {
481
  </p>
482
  </div>
483
  <div className="flex flex-wrap gap-2">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
484
  <button
485
  type="button"
486
- onClick={() => insertPageAt(pages.length, {})}
487
  disabled={!sessionId}
488
- className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
489
  >
490
- Add blank page
491
  </button>
492
  <button
493
  type="button"
@@ -519,7 +614,7 @@ export default function InputDataPage() {
519
  value={copySourceIndex}
520
  onChange={(event) => setCopySourceIndex(Number(event.target.value))}
521
  >
522
- {pages.map((_, idx) => (
523
  <option key={`copy-source-${idx}`} value={idx}>
524
  Page {idx + 1}
525
  </option>
@@ -539,7 +634,7 @@ export default function InputDataPage() {
539
  <button
540
  type="button"
541
  onClick={copyPageToTargets}
542
- disabled={!pages.length}
543
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
544
  >
545
  Copy page data
@@ -727,6 +822,91 @@ export default function InputDataPage() {
727
  </div>
728
  </section>
729
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
730
  <div className="rounded-lg border border-gray-200 bg-white overflow-x-auto">
731
  <div className="px-3 py-2 text-xs text-gray-500 bg-gray-50 border-b border-gray-200">
732
  General Info (double-click the header below to expand/collapse columns)
@@ -790,10 +970,11 @@ export default function InputDataPage() {
790
  </tr>
791
  </thead>
792
  <tbody>
793
- {pages.map((page, pageIndex) => {
794
- const template = page.template ?? {};
795
- const photoIds = page.photo_ids ?? [];
796
- const orderLocked = page.photo_order_locked ?? false;
 
797
  const photoLookup = new Map(
798
  (session?.uploads?.photos ?? []).map((photo) => [photo.id, photo]),
799
  );
@@ -801,9 +982,9 @@ export default function InputDataPage() {
801
  (photo) => !photoIds.includes(photo.id),
802
  );
803
  return (
804
- <tr key={`row-${pageIndex}`} className="border-b border-gray-100">
805
  <td className="px-3 py-2 text-xs font-semibold text-gray-700">
806
- Page {pageIndex + 1}
807
  </td>
808
  {showGeneralColumns ? (
809
  GENERAL_FIELDS.map((field) => (
@@ -974,7 +1155,7 @@ export default function InputDataPage() {
974
  <button
975
  type="button"
976
  onClick={() => removePageAt(pageIndex)}
977
- disabled={pages.length <= 1}
978
  className="inline-flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-semibold text-red-700 hover:bg-red-100 transition disabled:opacity-50 disabled:cursor-not-allowed"
979
  >
980
  Delete page
@@ -991,7 +1172,7 @@ export default function InputDataPage() {
991
  <div className="mt-3 flex justify-end">
992
  <button
993
  type="button"
994
- onClick={() => insertPageAt(pages.length, pages[pages.length - 1]?.template)}
995
  disabled={!sessionId}
996
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
997
  >
 
4
 
5
  import { API_BASE, postForm, putJson, request } from "../lib/api";
6
  import { formatDocNumber } from "../lib/report";
7
+ import {
8
+ ensureSections,
9
+ flattenSections,
10
+ insertPage,
11
+ removePage,
12
+ replacePage,
13
+ } from "../lib/sections";
14
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
15
+ import type { JobsheetSection, Session, TemplateFields } from "../types/session";
16
  import { PageFooter } from "../components/PageFooter";
17
  import { PageHeader } from "../components/PageHeader";
18
  import { PageShell } from "../components/PageShell";
19
+ import { InfoMenu } from "../components/InfoMenu";
20
 
21
  type FieldDef = {
22
  key: keyof TemplateFields;
 
52
  const sessionQuery = buildSessionQuery(sessionId);
53
 
54
  const [session, setSession] = useState<Session | null>(null);
55
+ const [sections, setSections] = useState<JobsheetSection[]>([]);
56
  const [status, setStatus] = useState("");
57
  const [isSaving, setIsSaving] = useState(false);
58
  const [isUploading, setIsUploading] = useState(false);
59
  const [copySourceIndex, setCopySourceIndex] = useState(0);
60
  const [copyTargets, setCopyTargets] = useState("");
61
+ const [addSectionId, setAddSectionId] = useState<string>("");
62
+ const [sectionsCollapsed, setSectionsCollapsed] = useState(true);
63
  const [showGeneralColumns, setShowGeneralColumns] = useState(false);
64
  const [generalDirty, setGeneralDirty] = useState(false);
65
  const [generalTemplate, setGeneralTemplate] = useState<TemplateFields>({});
 
81
  try {
82
  const data = await request<Session>(`/sessions/${sessionId}`);
83
  setSession(data);
84
+ const sectionResp = await request<{ sections: JobsheetSection[] }>(
85
+ `/sessions/${sessionId}/sections`,
86
  );
87
+ setSections(ensureSections(sectionResp.sections));
 
88
  } catch (err) {
89
  const message =
90
  err instanceof Error ? err.message : "Failed to load session.";
 
98
  if (!sessionId) return;
99
  const data = await request<Session>(`/sessions/${sessionId}`);
100
  setSession(data);
101
+ const sectionResp = await request<{ sections: JobsheetSection[] }>(
102
+ `/sessions/${sessionId}/sections`,
103
  );
104
+ setSections(ensureSections(sectionResp.sections));
 
105
  }
106
 
107
+ const flatPages = useMemo(
108
+ () => flattenSections(ensureSections(sections)),
109
+ [sections],
110
+ );
111
  const totalPages = useMemo(() => {
112
+ if (flatPages.length > 0) return flatPages.length;
113
  return Math.max(1, session?.page_count ?? 0);
114
+ }, [flatPages.length, session?.page_count]);
115
 
116
  useEffect(() => {
117
+ if (copySourceIndex >= flatPages.length) {
118
+ setCopySourceIndex(Math.max(0, flatPages.length - 1));
119
+ }
120
+ }, [copySourceIndex, flatPages.length]);
 
 
 
 
 
 
121
 
122
  useEffect(() => {
123
+ if (!addSectionId && sections.length > 0) {
124
+ setAddSectionId(sections[0].id);
125
  }
126
+ }, [addSectionId, sections]);
127
 
128
  useEffect(() => {
129
  if (generalDirty) return;
130
+ const source = flatPages[0]?.page?.template ?? {};
131
  const next: TemplateFields = {};
132
  GENERAL_FIELDS.forEach((field) => {
133
  const value = source[field.key] ?? getFallbackValue(field.key);
 
136
  }
137
  });
138
  setGeneralTemplate(next);
139
+ }, [generalDirty, flatPages, session]);
140
+
141
+ function updateField(
142
+ entryIndex: number,
143
+ key: keyof TemplateFields,
144
+ value: string,
145
+ ) {
146
+ const entry = flatPages[entryIndex];
147
+ if (!entry) return;
148
+ setSections((prev) => {
149
+ const template = { ...(entry.page.template ?? {}) };
150
+ template[key] = value;
151
+ const nextPage = { ...entry.page, template };
152
+ return replacePage(prev, entry.sectionIndex, entry.pageIndex, nextPage);
153
+ });
154
  }
155
 
156
  function updateGeneralField(key: keyof TemplateFields, value: string) {
 
159
  }
160
 
161
  function applyRowToAll(pageIndex: number) {
162
+ const entry = flatPages[pageIndex];
163
+ if (!entry) return;
164
+ const source = entry.page.template ?? {};
165
+ setSections((prev) =>
166
+ prev.map((section) => ({
167
+ ...section,
168
+ pages: (section.pages ?? []).map((page) => ({
169
+ ...page,
170
+ template: { ...source },
171
+ })),
172
  })),
173
  );
174
  }
175
 
176
  function applyGeneralToAll() {
177
+ if (!flatPages.length) return;
178
+ setSections((prev) =>
179
+ prev.map((section) => ({
180
+ ...section,
181
+ pages: (section.pages ?? []).map((page) => {
182
+ const template = { ...(page.template ?? {}) };
183
+ GENERAL_FIELDS.forEach((field) => {
184
+ const value = generalTemplate[field.key];
185
+ if (value !== undefined) {
186
+ template[field.key] = value;
187
+ }
188
+ });
189
+ return { ...page, template };
190
+ }),
191
+ })),
192
  );
193
  setGeneralDirty(false);
194
  setStatus("Applied general info to all pages.");
195
  }
196
 
197
  function insertPageAt(index: number, templateSource?: TemplateFields) {
198
+ const atEnd = index >= flatPages.length;
199
+ const entry = flatPages[index] ?? flatPages[flatPages.length - 1];
200
+ if (!entry) return;
201
+ setSections((prev) => {
202
  const fallbackTemplate =
203
  templateSource ??
204
+ entry.page.template ??
205
  {};
206
+ const pageIndex = atEnd ? entry.pageIndex + 1 : entry.pageIndex;
207
+ return insertPage(prev, entry.sectionIndex, pageIndex, {
208
+ items: [],
209
+ template: { ...fallbackTemplate },
210
+ });
211
  });
212
  }
213
 
214
+ function addSection() {
215
+ const baseTemplate = { ...generalTemplate };
216
+ setSections((prev) => [
217
+ ...prev,
218
+ {
219
+ id: crypto.randomUUID(),
220
+ title: `Section ${prev.length + 1}`,
221
+ pages: [{ items: [], template: baseTemplate }],
222
+ },
223
+ ]);
224
+ }
225
+
226
+ function updateSectionTitle(sectionId: string, value: string) {
227
+ setSections((prev) =>
228
+ prev.map((section) =>
229
+ section.id === sectionId ? { ...section, title: value } : section,
230
+ ),
231
+ );
232
+ }
233
+
234
+ function insertPageIntoSection(sectionId: string, templateSource?: TemplateFields) {
235
+ const sectionIndex = sections.findIndex((section) => section.id === sectionId);
236
+ if (sectionIndex === -1) return;
237
+ const section = sections[sectionIndex];
238
+ const fallbackTemplate =
239
+ templateSource ??
240
+ section.pages?.[section.pages.length - 1]?.template ??
241
+ generalTemplate ??
242
+ {};
243
+ setSections((prev) =>
244
+ insertPage(prev, sectionIndex, section.pages?.length ?? 0, {
245
+ items: [],
246
+ template: { ...fallbackTemplate },
247
+ }),
248
+ );
249
+ }
250
+
251
  function removePageAt(index: number) {
252
+ const entry = flatPages[index];
253
+ if (!entry) return;
254
+ setSections((prev) =>
255
+ removePage(prev, entry.sectionIndex, entry.pageIndex),
256
+ );
 
257
  }
258
 
259
  function updatePhotoSelection(pageIndex: number, value: string) {
 
261
  }
262
 
263
  function updatePagePhotos(pageIndex: number, nextIds: string[]) {
264
+ const entry = flatPages[pageIndex];
265
+ if (!entry) return;
266
+ setSections((prev) => {
267
+ const nextPage = { ...entry.page, photo_ids: nextIds };
268
+ return replacePage(prev, entry.sectionIndex, entry.pageIndex, nextPage);
269
+ });
270
  }
271
 
272
  function setPhotoOrderLocked(pageIndex: number, locked: boolean) {
273
+ const entry = flatPages[pageIndex];
274
+ if (!entry) return;
275
+ setSections((prev) => {
276
+ const nextPage = { ...entry.page, photo_order_locked: locked };
277
+ return replacePage(prev, entry.sectionIndex, entry.pageIndex, nextPage);
278
+ });
279
  }
280
 
281
  function movePhoto(pageIndex: number, from: number, to: number) {
282
+ const entry = flatPages[pageIndex];
283
+ if (!entry) return;
284
+ setSections((prev) => {
285
+ const ids = [...(entry.page.photo_ids ?? [])];
286
+ if (from < 0 || from >= ids.length || to < 0 || to >= ids.length) {
287
+ return prev;
288
+ }
289
+ const [moved] = ids.splice(from, 1);
290
+ ids.splice(to, 0, moved);
291
+ const nextPage = { ...entry.page, photo_ids: ids };
292
+ return replacePage(prev, entry.sectionIndex, entry.pageIndex, nextPage);
293
+ });
294
  }
295
 
296
  function removePhoto(pageIndex: number, index: number) {
297
+ const entry = flatPages[pageIndex];
298
+ if (!entry) return;
299
+ setSections((prev) => {
300
+ const ids = [...(entry.page.photo_ids ?? [])];
301
+ ids.splice(index, 1);
302
+ const nextPage = { ...entry.page, photo_ids: ids };
303
+ return replacePage(prev, entry.sectionIndex, entry.pageIndex, nextPage);
304
+ });
305
  }
306
 
307
  function addPhotoToPage(pageIndex: number, photoId: string) {
308
  if (!photoId) return;
309
+ const entry = flatPages[pageIndex];
310
+ if (!entry) return;
311
+ setSections((prev) => {
312
+ const ids = [...(entry.page.photo_ids ?? [])];
313
+ if (!ids.includes(photoId)) ids.push(photoId);
314
+ const nextPage = { ...entry.page, photo_ids: ids };
315
+ return replacePage(prev, entry.sectionIndex, entry.pageIndex, nextPage);
316
+ });
317
  }
318
 
319
  function parseTargetPages(value: string, max: number): number[] {
 
344
  }
345
 
346
  function copyPageToTargets() {
347
+ if (!flatPages.length) return;
348
+ const targets = parseTargetPages(copyTargets, flatPages.length).filter(
349
  (idx) => idx !== copySourceIndex,
350
  );
351
  if (!targets.length) {
352
  setStatus("No valid target pages selected for copy.");
353
  return;
354
  }
355
+ const sourceTemplate = flatPages[copySourceIndex]?.page?.template ?? {};
356
+ setSections((prev) =>
357
+ prev.map((section, sIdx) => ({
358
+ ...section,
359
+ pages: (section.pages ?? []).map((page, pIdx) => {
360
+ const flatIndex =
361
+ flattenSections(prev).find(
362
+ (entry) =>
363
+ entry.sectionIndex === sIdx && entry.pageIndex === pIdx,
364
+ )?.flatIndex ?? -1;
365
+ if (!targets.includes(flatIndex)) return page;
366
+ return { ...page, template: { ...sourceTemplate } };
367
+ }),
368
+ })),
369
  );
370
  setStatus(
371
  `Copied page ${copySourceIndex + 1} to ${targets
 
395
  setIsSaving(true);
396
  setStatus("Saving input data...");
397
  try {
398
+ const resp = await putJson<{ sections: JobsheetSection[] }>(
399
+ `/sessions/${sessionId}/sections`,
400
+ { sections },
401
  );
402
+ const updated = ensureSections(resp.sections ?? sections);
403
+ setSections(updated);
404
  setStatus("Input data saved.");
405
  } catch (err) {
406
  const message =
 
490
  title="RepEx - Report Express"
491
  subtitle="Input Data"
492
  right={
493
+ <div className="flex items-center gap-2">
494
+ <Link
495
+ to={`/report-viewer${sessionQuery}`}
496
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
497
+ >
498
+ <ArrowLeft className="h-4 w-4" />
499
+ Back
500
+ </Link>
501
+ <InfoMenu sessionQuery={sessionQuery} />
502
+ </div>
503
  }
504
  />
505
 
 
554
  </p>
555
  </div>
556
  <div className="flex flex-wrap gap-2">
557
+ <div className="flex items-center gap-2">
558
+ <select
559
+ className="rounded-md border border-gray-200 px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
560
+ value={addSectionId}
561
+ onChange={(event) => setAddSectionId(event.target.value)}
562
+ disabled={!sections.length}
563
+ >
564
+ {sections.map((section, idx) => (
565
+ <option key={section.id} value={section.id}>
566
+ {section.title || `Section ${idx + 1}`}
567
+ </option>
568
+ ))}
569
+ </select>
570
+ <button
571
+ type="button"
572
+ onClick={() => insertPageIntoSection(addSectionId, generalTemplate)}
573
+ disabled={!sessionId || !addSectionId}
574
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
575
+ >
576
+ Add blank page
577
+ </button>
578
+ </div>
579
  <button
580
  type="button"
581
+ onClick={addSection}
582
  disabled={!sessionId}
583
+ className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
584
  >
585
+ Add section
586
  </button>
587
  <button
588
  type="button"
 
614
  value={copySourceIndex}
615
  onChange={(event) => setCopySourceIndex(Number(event.target.value))}
616
  >
617
+ {flatPages.map((_, idx) => (
618
  <option key={`copy-source-${idx}`} value={idx}>
619
  Page {idx + 1}
620
  </option>
 
634
  <button
635
  type="button"
636
  onClick={copyPageToTargets}
637
+ disabled={!flatPages.length}
638
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
639
  >
640
  Copy page data
 
822
  </div>
823
  </section>
824
 
825
+ <section className="mb-4 rounded-lg border border-gray-200 bg-white p-3">
826
+ <div className="flex flex-wrap items-center justify-between gap-2">
827
+ <div>
828
+ <h3 className="text-sm font-semibold text-gray-900">Job Sheet Sections</h3>
829
+ <p className="text-xs text-gray-600">
830
+ Rename sections and add pages directly inside a section.
831
+ </p>
832
+ </div>
833
+ <div className="flex items-center gap-2">
834
+ <button
835
+ type="button"
836
+ onClick={() => setSectionsCollapsed((prev) => !prev)}
837
+ className="inline-flex items-center gap-2 rounded-md border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 transition"
838
+ >
839
+ {sectionsCollapsed ? "Expand" : "Collapse"}
840
+ </button>
841
+ <button
842
+ type="button"
843
+ onClick={addSection}
844
+ className="inline-flex items-center gap-2 rounded-md border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 transition"
845
+ >
846
+ Add section
847
+ </button>
848
+ </div>
849
+ </div>
850
+ {sectionsCollapsed ? (
851
+ <div className="mt-2 text-[11px] text-gray-500">
852
+ {sections.length} section{sections.length === 1 ? "" : "s"} ·{" "}
853
+ {sections.reduce((sum, section) => sum + (section.pages?.length ?? 0), 0)}{" "}
854
+ page{sections.length === 1 ? "" : "s"} total
855
+ </div>
856
+ ) : (
857
+ <div className="mt-2 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
858
+ {sections.map((section, idx) => (
859
+ <div
860
+ key={section.id}
861
+ className="rounded-md border border-gray-200 bg-gray-50 p-2"
862
+ >
863
+ <div className="flex items-center justify-between gap-2">
864
+ <div className="text-[11px] font-semibold text-gray-700">
865
+ Section {idx + 1}
866
+ </div>
867
+ <button
868
+ type="button"
869
+ onClick={() => insertPageIntoSection(section.id)}
870
+ className="inline-flex items-center gap-1 rounded-md border border-gray-200 bg-white px-2 py-0.5 text-[11px] font-semibold text-gray-700 hover:bg-gray-50 transition"
871
+ >
872
+ Add page
873
+ </button>
874
+ </div>
875
+ <div className="mt-1 text-[11px] text-gray-500">
876
+ Pages: {section.pages?.length ?? 0}
877
+ </div>
878
+ <div className="text-[11px] text-gray-500">
879
+ Page numbers:{" "}
880
+ {(() => {
881
+ const start =
882
+ sections
883
+ .slice(0, idx)
884
+ .reduce((sum, item) => sum + (item.pages?.length ?? 0), 0) +
885
+ 1;
886
+ const count = section.pages?.length ?? 0;
887
+ if (count <= 0) return "-";
888
+ const end = start + count - 1;
889
+ return start === end ? `${start}` : `${start}-${end}`;
890
+ })()}
891
+ </div>
892
+ <label className="mt-1 block text-[11px] text-gray-600">
893
+ Title
894
+ <input
895
+ type="text"
896
+ className="mt-1 w-full rounded-md border border-gray-200 px-2 py-0.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-200"
897
+ value={section.title ?? ""}
898
+ onChange={(event) =>
899
+ updateSectionTitle(section.id, event.target.value)
900
+ }
901
+ placeholder={`Section ${idx + 1}`}
902
+ />
903
+ </label>
904
+ </div>
905
+ ))}
906
+ </div>
907
+ )}
908
+ </section>
909
+
910
  <div className="rounded-lg border border-gray-200 bg-white overflow-x-auto">
911
  <div className="px-3 py-2 text-xs text-gray-500 bg-gray-50 border-b border-gray-200">
912
  General Info (double-click the header below to expand/collapse columns)
 
970
  </tr>
971
  </thead>
972
  <tbody>
973
+ {flatPages.map((entry) => {
974
+ const pageIndex = entry.flatIndex;
975
+ const template = entry.page.template ?? {};
976
+ const photoIds = entry.page.photo_ids ?? [];
977
+ const orderLocked = entry.page.photo_order_locked ?? false;
978
  const photoLookup = new Map(
979
  (session?.uploads?.photos ?? []).map((photo) => [photo.id, photo]),
980
  );
 
982
  (photo) => !photoIds.includes(photo.id),
983
  );
984
  return (
985
+ <tr key={`row-${entry.sectionId}-${entry.pageIndex}`} className="border-b border-gray-100">
986
  <td className="px-3 py-2 text-xs font-semibold text-gray-700">
987
+ Page {entry.flatIndex + 1}
988
  </td>
989
  {showGeneralColumns ? (
990
  GENERAL_FIELDS.map((field) => (
 
1155
  <button
1156
  type="button"
1157
  onClick={() => removePageAt(pageIndex)}
1158
+ disabled={flatPages.length <= 1}
1159
  className="inline-flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-semibold text-red-700 hover:bg-red-100 transition disabled:opacity-50 disabled:cursor-not-allowed"
1160
  >
1161
  Delete page
 
1172
  <div className="mt-3 flex justify-end">
1173
  <button
1174
  type="button"
1175
+ onClick={() => insertPageAt(flatPages.length, flatPages[flatPages.length - 1]?.page?.template)}
1176
  disabled={!sessionId}
1177
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
1178
  >
frontend/src/pages/RatingsInfoPage.tsx CHANGED
@@ -1,36 +1,44 @@
1
  import { BarChart2, ArrowLeft } from "react-feather";
2
- import { Link } from "react-router-dom";
 
 
 
 
3
 
4
  export default function RatingsInfoPage() {
 
 
 
 
5
  return (
6
- <main className="max-w-6xl mx-auto p-4 md:p-6">
7
- <header className="mb-8 flex flex-wrap items-center justify-between gap-3">
8
- <div>
9
- <h1 className="text-3xl font-bold text-gray-800">Assessment Rating Scales</h1>
10
- <p className="text-gray-600 mt-2">
11
- Interactive rating system for condition evaluation.
12
- </p>
13
- </div>
14
- <Link
15
- to="/report-viewer"
16
- className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
17
- >
18
- <ArrowLeft className="h-4 w-4" />
19
- Back to report
20
- </Link>
21
- </header>
22
 
23
  <section className="mt-4 bg-white rounded-xl shadow-sm p-6">
24
- <h2 className="text-xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-4 flex items-center">
25
- <BarChart2 className="mr-2 h-5 w-5" />
26
  Rating Scales
27
  </h2>
28
 
29
  <div className="rounded-lg border border-gray-200 bg-gray-50 p-4 mb-6">
30
  <p className="text-sm text-gray-700">
31
- Use the tables below to interpret <span className="font-semibold">Category</span>{" "}
32
- and <span className="font-semibold">Priority</span> ratings used throughout this
33
- report. Ratings determine severity and recommended response time.
 
 
34
  </p>
35
  </div>
36
 
@@ -40,21 +48,63 @@ export default function RatingsInfoPage() {
40
  </label>
41
 
42
  <div className="grid grid-cols-6 gap-1 text-center mb-2">
43
- <div className="text-xs font-bold bg-green-100 text-green-800 p-1 rounded">0</div>
44
- <div className="text-xs font-bold bg-green-200 text-green-800 p-1 rounded">1</div>
45
- <div className="text-xs font-bold bg-yellow-100 text-yellow-800 p-1 rounded">2</div>
46
- <div className="text-xs font-bold bg-yellow-200 text-yellow-800 p-1 rounded">3</div>
47
- <div className="text-xs font-bold bg-orange-200 text-orange-800 p-1 rounded">4</div>
48
- <div className="text-xs font-bold bg-red-200 text-red-800 p-1 rounded">5</div>
 
 
 
 
 
 
 
 
 
 
 
 
49
  </div>
50
 
51
  <div className="grid grid-cols-6 gap-1 text-center">
52
- <div className="text-xs font-bold bg-green-100 text-green-800 p-1 rounded">Excellent</div>
53
- <div className="text-xs font-bold bg-green-200 text-green-800 p-1 rounded">Good</div>
54
- <div className="text-xs font-bold bg-yellow-100 text-yellow-800 p-1 rounded">Fair</div>
55
- <div className="text-xs font-bold bg-yellow-200 text-yellow-800 p-1 rounded">Poor</div>
56
- <div className="text-xs font-bold bg-orange-200 text-orange-800 p-1 rounded">Worse</div>
57
- <div className="text-xs font-bold bg-red-200 text-red-800 p-1 rounded">Severe</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  </div>
59
 
60
  <div className="mt-3 overflow-x-auto rounded-lg border border-gray-200">
@@ -82,42 +132,56 @@ export default function RatingsInfoPage() {
82
  <tr>
83
  <td className="px-3 py-2 font-semibold text-gray-900">0</td>
84
  <td className="px-3 py-2">Excellent</td>
85
- <td className="px-3 py-2">No deterioration. Structure is safe for use.</td>
 
 
86
  <td className="px-3 py-2">100% Original Strength</td>
87
  <td className="px-3 py-2">None</td>
88
  </tr>
89
  <tr>
90
  <td className="px-3 py-2 font-semibold text-gray-900">1</td>
91
  <td className="px-3 py-2">Good</td>
92
- <td className="px-3 py-2">Slight surface deterioration. Structure is safe for use.</td>
 
 
93
  <td className="px-3 py-2">100% Original Strength</td>
94
  <td className="px-3 py-2">None</td>
95
  </tr>
96
  <tr>
97
  <td className="px-3 py-2 font-semibold text-gray-900">2</td>
98
  <td className="px-3 py-2">Fair</td>
99
- <td className="px-3 py-2">Some deterioration deeper than surface level.</td>
100
- <td className="px-3 py-2">95–100% Original Strength</td>
 
 
 
101
  <td className="px-3 py-2">Minor Works</td>
102
  </tr>
103
  <tr>
104
  <td className="px-3 py-2 font-semibold text-gray-900">3</td>
105
  <td className="px-3 py-2">Poor</td>
106
- <td className="px-3 py-2">Discernible deterioration. Some compromise in safety.</td>
107
- <td className="px-3 py-2">75–95% Original Strength</td>
 
 
108
  <td className="px-3 py-2">Minor Works</td>
109
  </tr>
110
  <tr>
111
  <td className="px-3 py-2 font-semibold text-gray-900">4</td>
112
  <td className="px-3 py-2">Worse</td>
113
- <td className="px-3 py-2">Severe deterioration. Severe compromise in safety.</td>
114
- <td className="px-3 py-2">50–75% Original Strength</td>
 
 
115
  <td className="px-3 py-2">Major Works</td>
116
  </tr>
117
  <tr>
118
  <td className="px-3 py-2 font-semibold text-gray-900">5</td>
119
  <td className="px-3 py-2">Severe</td>
120
- <td className="px-3 py-2">Severe deterioration. Total compromise in safety.</td>
 
 
 
121
  <td className="px-3 py-2">&lt;50% Original Strength</td>
122
  <td className="px-3 py-2">Major Works (Urgently)</td>
123
  </tr>
@@ -132,19 +196,39 @@ export default function RatingsInfoPage() {
132
  </label>
133
 
134
  <div className="grid grid-cols-5 gap-1 text-center mb-2">
135
- <div className="text-xs font-bold bg-red-200 text-red-800 p-1 rounded">1</div>
136
- <div className="text-xs font-bold bg-orange-200 text-orange-800 p-1 rounded">2</div>
137
- <div className="text-xs font-bold bg-green-200 text-green-800 p-1 rounded">3</div>
138
- <div className="text-xs font-bold bg-purple-200 text-purple-800 p-1 rounded">X</div>
139
- <div className="text-xs font-bold bg-blue-200 text-blue-800 p-1 rounded">M</div>
 
 
 
 
 
 
 
 
 
 
140
  </div>
141
 
142
  <div className="grid grid-cols-5 gap-1 text-center">
143
- <div className="text-xs font-bold bg-red-200 text-red-800 p-1 rounded">Immediate</div>
144
- <div className="text-xs font-bold bg-orange-200 text-orange-800 p-1 rounded">1 Year</div>
145
- <div className="text-xs font-bold bg-green-200 text-green-800 p-1 rounded">3 Years</div>
146
- <div className="text-xs font-bold bg-purple-200 text-purple-800 p-1 rounded">At Use</div>
147
- <div className="text-xs font-bold bg-blue-200 text-blue-800 p-1 rounded">Monitor</div>
 
 
 
 
 
 
 
 
 
 
148
  </div>
149
 
150
  <div className="mt-3 overflow-x-auto rounded-lg border border-gray-200">
@@ -166,41 +250,46 @@ export default function RatingsInfoPage() {
166
  <tr>
167
  <td className="px-3 py-2 font-semibold text-gray-900">1</td>
168
  <td className="px-3 py-2">Immediate</td>
169
- <td className="px-3 py-2">Action requires urgent, immediate attention.</td>
 
 
170
  </tr>
171
  <tr>
172
  <td className="px-3 py-2 font-semibold text-gray-900">2</td>
173
  <td className="px-3 py-2">1 Year</td>
174
- <td className="px-3 py-2">Action to be done ASAP, no later than 1 year.</td>
 
 
 
175
  </tr>
176
  <tr>
177
  <td className="px-3 py-2 font-semibold text-gray-900">3</td>
178
  <td className="px-3 py-2">3 Years</td>
179
- <td className="px-3 py-2">Action required within the next 3 years.</td>
 
 
180
  </tr>
181
  <tr>
182
  <td className="px-3 py-2 font-semibold text-gray-900">X</td>
183
  <td className="px-3 py-2">At Use</td>
184
  <td className="px-3 py-2">
185
- Action must be completed before use of the structure.
 
186
  </td>
187
  </tr>
188
  <tr>
189
  <td className="px-3 py-2 font-semibold text-gray-900">M</td>
190
  <td className="px-3 py-2">Monitor</td>
191
  <td className="px-3 py-2">
192
- Monitoring required to evaluate extent of damage.
 
193
  </td>
194
  </tr>
195
  </tbody>
196
  </table>
197
  </div>
198
  </div>
199
-
200
- <p className="mt-4 text-xs text-gray-500">
201
- Note: Scales can be customized per client/site standards.
202
- </p>
203
  </section>
204
- </main>
205
  );
206
  }
 
1
  import { BarChart2, ArrowLeft } from "react-feather";
2
+ import { Link, useSearchParams } from "react-router-dom";
3
+
4
+ import { PageHeader } from "../components/PageHeader";
5
+ import { PageShell } from "../components/PageShell";
6
+ import { buildSessionQuery, getSessionId } from "../lib/session";
7
 
8
  export default function RatingsInfoPage() {
9
+ const [searchParams] = useSearchParams();
10
+ const sessionId = getSessionId(searchParams.toString());
11
+ const sessionQuery = buildSessionQuery(sessionId);
12
+
13
  return (
14
+ <PageShell className="max-w-6xl">
15
+ <PageHeader
16
+ title="Assessment Rating Scales"
17
+ subtitle="Interactive rating system for condition evaluation"
18
+ right={
19
+ <Link
20
+ to={`/report-viewer${sessionQuery}`}
21
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
22
+ >
23
+ <ArrowLeft className="h-4 w-4" />
24
+ Back
25
+ </Link>
26
+ }
27
+ />
 
 
28
 
29
  <section className="mt-4 bg-white rounded-xl shadow-sm p-6">
30
+ <h2 className="text-xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-4 flex items-center gap-2">
31
+ <BarChart2 className="h-5 w-5" />
32
  Rating Scales
33
  </h2>
34
 
35
  <div className="rounded-lg border border-gray-200 bg-gray-50 p-4 mb-6">
36
  <p className="text-sm text-gray-700">
37
+ Use the tables below to interpret{" "}
38
+ <span className="font-semibold">Category</span> and{" "}
39
+ <span className="font-semibold">Priority</span> ratings used
40
+ throughout this report. Ratings determine severity and recommended
41
+ response time.
42
  </p>
43
  </div>
44
 
 
48
  </label>
49
 
50
  <div className="grid grid-cols-6 gap-1 text-center mb-2">
51
+ <div className="text-xs font-bold bg-green-100 text-green-800 p-1 rounded">
52
+ 0
53
+ </div>
54
+ <div className="text-xs font-bold bg-green-200 text-green-800 p-1 rounded">
55
+ 1
56
+ </div>
57
+ <div className="text-xs font-bold bg-yellow-100 text-yellow-800 p-1 rounded">
58
+ 2
59
+ </div>
60
+ <div className="text-xs font-bold bg-yellow-200 text-yellow-800 p-1 rounded">
61
+ 3
62
+ </div>
63
+ <div className="text-xs font-bold bg-orange-200 text-orange-800 p-1 rounded">
64
+ 4
65
+ </div>
66
+ <div className="text-xs font-bold bg-red-200 text-red-800 p-1 rounded">
67
+ 5
68
+ </div>
69
  </div>
70
 
71
  <div className="grid grid-cols-6 gap-1 text-center">
72
+ <div
73
+ className="text-xs font-bold bg-green-100 text-green-800 p-1 rounded"
74
+ title="Excellent Condition: No Deterioration. Structure is Safe for use. 100% Original Strength of Structure. Remedial Action: None"
75
+ >
76
+ Excellent
77
+ </div>
78
+ <div
79
+ className="text-xs font-bold bg-green-200 text-green-800 p-1 rounded"
80
+ title="Good Condition: Slight Surface Deterioration. Structure is safe for use. 100% Original Strength of Structure. Remedial Action: None"
81
+ >
82
+ Good
83
+ </div>
84
+ <div
85
+ className="text-xs font-bold bg-yellow-100 text-yellow-800 p-1 rounded"
86
+ title="Fair Condition: Some Deterioration deeper than surface level. Structure is safe for use. 95-100% Original Strength of Structure. Remedial Action: Minor Works"
87
+ >
88
+ Fair
89
+ </div>
90
+ <div
91
+ className="text-xs font-bold bg-yellow-200 text-yellow-800 p-1 rounded"
92
+ title="Poor Condition: Discernible Deterioration. Some compromise in safety. 75-95% Original Strength of Structure. Remedial Action: Minor Works"
93
+ >
94
+ Poor
95
+ </div>
96
+ <div
97
+ className="text-xs font-bold bg-orange-200 text-orange-800 p-1 rounded"
98
+ title="Worse Condition: Severe Deterioration. Severe compromise in safety. 50-75% Original Strength of Structure. Remedial Action: Major Works"
99
+ >
100
+ Worse
101
+ </div>
102
+ <div
103
+ className="text-xs font-bold bg-red-200 text-red-800 p-1 rounded"
104
+ title="Severe Condition: Severe Deterioration. Total and complete compromise in safety. <50% Original Strength of Structure. Remedial Action: Major Works Urgently"
105
+ >
106
+ Severe
107
+ </div>
108
  </div>
109
 
110
  <div className="mt-3 overflow-x-auto rounded-lg border border-gray-200">
 
132
  <tr>
133
  <td className="px-3 py-2 font-semibold text-gray-900">0</td>
134
  <td className="px-3 py-2">Excellent</td>
135
+ <td className="px-3 py-2">
136
+ No deterioration. Structure is safe for use.
137
+ </td>
138
  <td className="px-3 py-2">100% Original Strength</td>
139
  <td className="px-3 py-2">None</td>
140
  </tr>
141
  <tr>
142
  <td className="px-3 py-2 font-semibold text-gray-900">1</td>
143
  <td className="px-3 py-2">Good</td>
144
+ <td className="px-3 py-2">
145
+ Slight surface deterioration. Structure is safe for use.
146
+ </td>
147
  <td className="px-3 py-2">100% Original Strength</td>
148
  <td className="px-3 py-2">None</td>
149
  </tr>
150
  <tr>
151
  <td className="px-3 py-2 font-semibold text-gray-900">2</td>
152
  <td className="px-3 py-2">Fair</td>
153
+ <td className="px-3 py-2">
154
+ Some deterioration deeper than surface level. Structure is
155
+ safe for use.
156
+ </td>
157
+ <td className="px-3 py-2">95-100% Original Strength</td>
158
  <td className="px-3 py-2">Minor Works</td>
159
  </tr>
160
  <tr>
161
  <td className="px-3 py-2 font-semibold text-gray-900">3</td>
162
  <td className="px-3 py-2">Poor</td>
163
+ <td className="px-3 py-2">
164
+ Discernible deterioration. Some compromise in safety.
165
+ </td>
166
+ <td className="px-3 py-2">75-95% Original Strength</td>
167
  <td className="px-3 py-2">Minor Works</td>
168
  </tr>
169
  <tr>
170
  <td className="px-3 py-2 font-semibold text-gray-900">4</td>
171
  <td className="px-3 py-2">Worse</td>
172
+ <td className="px-3 py-2">
173
+ Severe deterioration. Severe compromise in safety.
174
+ </td>
175
+ <td className="px-3 py-2">50-75% Original Strength</td>
176
  <td className="px-3 py-2">Major Works</td>
177
  </tr>
178
  <tr>
179
  <td className="px-3 py-2 font-semibold text-gray-900">5</td>
180
  <td className="px-3 py-2">Severe</td>
181
+ <td className="px-3 py-2">
182
+ Severe deterioration. Total and complete compromise in
183
+ safety.
184
+ </td>
185
  <td className="px-3 py-2">&lt;50% Original Strength</td>
186
  <td className="px-3 py-2">Major Works (Urgently)</td>
187
  </tr>
 
196
  </label>
197
 
198
  <div className="grid grid-cols-5 gap-1 text-center mb-2">
199
+ <div className="text-xs font-bold bg-red-200 text-red-800 p-1 rounded">
200
+ 1
201
+ </div>
202
+ <div className="text-xs font-bold bg-orange-200 text-orange-800 p-1 rounded">
203
+ 2
204
+ </div>
205
+ <div className="text-xs font-bold bg-green-200 text-green-800 p-1 rounded">
206
+ 3
207
+ </div>
208
+ <div className="text-xs font-bold bg-purple-200 text-purple-800 p-1 rounded">
209
+ X
210
+ </div>
211
+ <div className="text-xs font-bold bg-blue-200 text-blue-800 p-1 rounded">
212
+ M
213
+ </div>
214
  </div>
215
 
216
  <div className="grid grid-cols-5 gap-1 text-center">
217
+ <div className="text-xs font-bold bg-red-200 text-red-800 p-1 rounded">
218
+ Immediate
219
+ </div>
220
+ <div className="text-xs font-bold bg-orange-200 text-orange-800 p-1 rounded">
221
+ 1 Year
222
+ </div>
223
+ <div className="text-xs font-bold bg-green-200 text-green-800 p-1 rounded">
224
+ 3 Years
225
+ </div>
226
+ <div className="text-xs font-bold bg-purple-200 text-purple-800 p-1 rounded">
227
+ At Use
228
+ </div>
229
+ <div className="text-xs font-bold bg-blue-200 text-blue-800 p-1 rounded">
230
+ Monitor
231
+ </div>
232
  </div>
233
 
234
  <div className="mt-3 overflow-x-auto rounded-lg border border-gray-200">
 
250
  <tr>
251
  <td className="px-3 py-2 font-semibold text-gray-900">1</td>
252
  <td className="px-3 py-2">Immediate</td>
253
+ <td className="px-3 py-2">
254
+ Action requires urgent, immediate attention.
255
+ </td>
256
  </tr>
257
  <tr>
258
  <td className="px-3 py-2 font-semibold text-gray-900">2</td>
259
  <td className="px-3 py-2">1 Year</td>
260
+ <td className="px-3 py-2">
261
+ Action to be done ASAP, and no later than 1 year from report
262
+ date.
263
+ </td>
264
  </tr>
265
  <tr>
266
  <td className="px-3 py-2 font-semibold text-gray-900">3</td>
267
  <td className="px-3 py-2">3 Years</td>
268
+ <td className="px-3 py-2">
269
+ Action required within the next 3 years from report date.
270
+ </td>
271
  </tr>
272
  <tr>
273
  <td className="px-3 py-2 font-semibold text-gray-900">X</td>
274
  <td className="px-3 py-2">At Use</td>
275
  <td className="px-3 py-2">
276
+ Action must be completed before use of the non-critical
277
+ service structure.
278
  </td>
279
  </tr>
280
  <tr>
281
  <td className="px-3 py-2 font-semibold text-gray-900">M</td>
282
  <td className="px-3 py-2">Monitor</td>
283
  <td className="px-3 py-2">
284
+ Monitoring required to evaluate the extent of
285
+ damage/deterioration with record keeping.
286
  </td>
287
  </tr>
288
  </tbody>
289
  </table>
290
  </div>
291
  </div>
 
 
 
 
292
  </section>
293
+ </PageShell>
294
  );
295
  }
frontend/src/pages/ReportViewerPage.tsx CHANGED
@@ -14,16 +14,18 @@ import {
14
 
15
  import { request } from "../lib/api";
16
  import { BASE_W } from "../lib/report";
 
17
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
18
- import type { Page, Session } from "../types/session";
19
  import { ReportPageCanvas } from "../components/ReportPageCanvas";
 
20
 
21
  export default function ReportViewerPage() {
22
  const [searchParams] = useSearchParams();
23
  const sessionId = getSessionId(searchParams.toString());
24
 
25
  const [session, setSession] = useState<Session | null>(null);
26
- const [pages, setPages] = useState<Page[]>([]);
27
  const [pageIndex, setPageIndex] = useState(0);
28
  const [scale, setScale] = useState(1);
29
  const [error, setError] = useState("");
@@ -55,11 +57,11 @@ export default function ReportViewerPage() {
55
  try {
56
  const data = await request<Session>(`/sessions/${sessionId}`);
57
  setSession(data);
58
- const pageResp = await request<{ pages: Page[] }>(
59
- `/sessions/${sessionId}/pages`,
60
  );
61
- const loaded = Array.isArray(pageResp.pages) ? pageResp.pages : [];
62
- setPages(loaded);
63
  } catch (err) {
64
  const message =
65
  err instanceof Error ? err.message : "Failed to load session.";
@@ -69,10 +71,14 @@ export default function ReportViewerPage() {
69
  load();
70
  }, [sessionId]);
71
 
 
 
 
 
72
  const totalPages = useMemo(() => {
73
- if (pages.length > 0) return pages.length;
74
  return Math.max(1, session?.page_count ?? 0);
75
- }, [pages.length, session?.page_count]);
76
 
77
  useEffect(() => {
78
  setPageIndex((idx) => Math.min(Math.max(0, idx), totalPages - 1));
@@ -91,7 +97,12 @@ export default function ReportViewerPage() {
91
  return () => window.removeEventListener("keydown", handler);
92
  }, [totalPages]);
93
 
94
- const page = pages[pageIndex] ?? null;
 
 
 
 
 
95
  const template = page?.template;
96
  const sessionQuery = buildSessionQuery(sessionId || "");
97
  const editReportQuery = useMemo(() => {
@@ -107,12 +118,12 @@ export default function ReportViewerPage() {
107
  const selected = session.selected_photo_ids?.length ?? 0;
108
  const docs = session.uploads?.documents?.length ?? 0;
109
  const dataFiles = session.uploads?.data_files?.length ?? 0;
110
- const hasEdits = pages.length > 0;
111
  return (
112
  `Selected example photos: ${selected} - Documents: ${docs} - Data files: ${dataFiles}` +
113
  (hasEdits ? " - Edited pages loaded" : " - No saved edits yet")
114
  );
115
- }, [pages.length, session]);
116
 
117
  return (
118
  <main className="max-w-4xl mx-auto my-8 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8 print:ring-0 print:shadow-none print:rounded-none">
@@ -142,6 +153,7 @@ export default function ReportViewerPage() {
142
  <ArrowLeft className="h-4 w-4" />
143
  Back
144
  </Link>
 
145
  </div>
146
  </div>
147
  </header>
@@ -249,6 +261,7 @@ export default function ReportViewerPage() {
249
  pageCount={totalPages}
250
  scale={scale}
251
  template={template}
 
252
  adaptive
253
  />
254
  </div>
 
14
 
15
  import { request } from "../lib/api";
16
  import { BASE_W } from "../lib/report";
17
+ import { ensureSections, flattenSections } from "../lib/sections";
18
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
19
+ import type { JobsheetSection, Session } from "../types/session";
20
  import { ReportPageCanvas } from "../components/ReportPageCanvas";
21
+ import { InfoMenu } from "../components/InfoMenu";
22
 
23
  export default function ReportViewerPage() {
24
  const [searchParams] = useSearchParams();
25
  const sessionId = getSessionId(searchParams.toString());
26
 
27
  const [session, setSession] = useState<Session | null>(null);
28
+ const [sections, setSections] = useState<JobsheetSection[]>([]);
29
  const [pageIndex, setPageIndex] = useState(0);
30
  const [scale, setScale] = useState(1);
31
  const [error, setError] = useState("");
 
57
  try {
58
  const data = await request<Session>(`/sessions/${sessionId}`);
59
  setSession(data);
60
+ const sectionResp = await request<{ sections: JobsheetSection[] }>(
61
+ `/sessions/${sessionId}/sections`,
62
  );
63
+ const loaded = ensureSections(sectionResp.sections);
64
+ setSections(loaded);
65
  } catch (err) {
66
  const message =
67
  err instanceof Error ? err.message : "Failed to load session.";
 
71
  load();
72
  }, [sessionId]);
73
 
74
+ const flatPages = useMemo(
75
+ () => flattenSections(ensureSections(sections)),
76
+ [sections],
77
+ );
78
  const totalPages = useMemo(() => {
79
+ if (flatPages.length > 0) return flatPages.length;
80
  return Math.max(1, session?.page_count ?? 0);
81
+ }, [flatPages.length, session?.page_count]);
82
 
83
  useEffect(() => {
84
  setPageIndex((idx) => Math.min(Math.max(0, idx), totalPages - 1));
 
97
  return () => window.removeEventListener("keydown", handler);
98
  }, [totalPages]);
99
 
100
+ const page = flatPages[pageIndex]?.page ?? null;
101
+ const sectionLabel = flatPages[pageIndex]?.sectionTitle
102
+ ? `Section ${flatPages[pageIndex].sectionIndex + 1} - ${flatPages[pageIndex].sectionTitle}`
103
+ : flatPages[pageIndex]
104
+ ? `Section ${flatPages[pageIndex].sectionIndex + 1}`
105
+ : "";
106
  const template = page?.template;
107
  const sessionQuery = buildSessionQuery(sessionId || "");
108
  const editReportQuery = useMemo(() => {
 
118
  const selected = session.selected_photo_ids?.length ?? 0;
119
  const docs = session.uploads?.documents?.length ?? 0;
120
  const dataFiles = session.uploads?.data_files?.length ?? 0;
121
+ const hasEdits = flatPages.length > 0;
122
  return (
123
  `Selected example photos: ${selected} - Documents: ${docs} - Data files: ${dataFiles}` +
124
  (hasEdits ? " - Edited pages loaded" : " - No saved edits yet")
125
  );
126
+ }, [flatPages.length, session]);
127
 
128
  return (
129
  <main className="max-w-4xl mx-auto my-8 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8 print:ring-0 print:shadow-none print:rounded-none">
 
153
  <ArrowLeft className="h-4 w-4" />
154
  Back
155
  </Link>
156
+ <InfoMenu sessionQuery={sessionQuery} />
157
  </div>
158
  </div>
159
  </header>
 
261
  pageCount={totalPages}
262
  scale={scale}
263
  template={template}
264
+ sectionLabel={sectionLabel}
265
  adaptive
266
  />
267
  </div>
frontend/src/types/session.ts CHANGED
@@ -29,6 +29,7 @@ export type Session = {
29
  uploads: SessionUploads;
30
  selected_photo_ids: string[];
31
  page_count: number;
 
32
  headings?: Heading[];
33
  layout?: Record<string, unknown> | null;
34
  };
@@ -89,8 +90,20 @@ export type Page = {
89
  template?: TemplateFields;
90
  photo_ids?: string[];
91
  photo_order_locked?: boolean;
 
 
 
 
 
 
 
 
92
  };
93
 
94
  export type PagesResponse = {
95
  pages: Page[];
96
  };
 
 
 
 
 
29
  uploads: SessionUploads;
30
  selected_photo_ids: string[];
31
  page_count: number;
32
+ jobsheet_sections?: JobsheetSection[];
33
  headings?: Heading[];
34
  layout?: Record<string, unknown> | null;
35
  };
 
90
  template?: TemplateFields;
91
  photo_ids?: string[];
92
  photo_order_locked?: boolean;
93
+ blank?: boolean;
94
+ variant?: "full" | "photos";
95
+ };
96
+
97
+ export type JobsheetSection = {
98
+ id: string;
99
+ title?: string;
100
+ pages: Page[];
101
  };
102
 
103
  export type PagesResponse = {
104
  pages: Page[];
105
  };
106
+
107
+ export type SectionsResponse = {
108
+ sections: JobsheetSection[];
109
+ };
package-lock.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "name": "RepEx",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {}
6
+ }
server/app/api/routes/sessions.py CHANGED
@@ -1,6 +1,5 @@
1
  from __future__ import annotations
2
 
3
- import asyncio
4
  import json
5
  from pathlib import Path
6
  from typing import List
@@ -13,6 +12,8 @@ from ..deps import get_session_store
13
  from ..schemas import (
14
  PagesRequest,
15
  PagesResponse,
 
 
16
  SelectionRequest,
17
  SessionResponse,
18
  SessionStatusResponse,
@@ -21,7 +22,7 @@ from ...services import SessionStore
21
  from ...services.session_store import DATA_EXTS, IMAGE_EXTS
22
 
23
  from ...services.data_import import populate_session_from_data_files
24
- from ...services.pdf_export import render_pdf_sync
25
 
26
 
27
  router = APIRouter()
@@ -97,6 +98,7 @@ def get_session(session_id: str, store: SessionStore = Depends(get_session_store
97
  session = store.get_session(session_id)
98
  if not session:
99
  raise HTTPException(status_code=404, detail="Session not found.")
 
100
  return _attach_urls(session)
101
 
102
 
@@ -151,6 +153,32 @@ def save_pages(
151
  return PagesResponse(pages=session.get("pages") or [])
152
 
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  @router.get("/{session_id}/uploads/{file_id}")
155
  def get_upload(
156
  session_id: str,
@@ -249,8 +277,13 @@ def import_json(
249
 
250
  imported_session = payload.get("session") if isinstance(payload, dict) else None
251
  pages = payload.get("pages") if isinstance(payload, dict) else None
 
 
 
252
  if not pages and isinstance(imported_session, dict):
253
  pages = imported_session.get("pages")
 
 
254
  if pages:
255
  session = store.set_pages(session, pages)
256
 
@@ -278,10 +311,13 @@ def export_package(
278
  session = store.get_session(session_id)
279
  if not session:
280
  raise HTTPException(status_code=404, detail="Session not found.")
 
 
281
  export_path = Path(store.session_dir(session_id)) / "export.json"
282
  payload = {
283
  "session": session,
284
- "pages": session.get("pages") or [],
 
285
  "exported_at": session.get("updated_at"),
286
  }
287
  export_path.write_text(
@@ -295,23 +331,22 @@ def export_package(
295
 
296
 
297
  @router.get("/{session_id}/export.pdf")
298
- async def export_pdf(
299
  session_id: str, store: SessionStore = Depends(get_session_store)
300
  ) -> FileResponse:
301
  session_id = _normalize_session_id(session_id, store)
302
  session = store.get_session(session_id)
303
  if not session:
304
  raise HTTPException(status_code=404, detail="Session not found.")
305
-
306
  export_path = Path(store.session_dir(session_id)) / "export.pdf"
307
- await asyncio.to_thread(render_pdf_sync, session_id, export_path)
308
  return FileResponse(
309
  export_path,
310
  media_type="application/pdf",
311
  filename=f"repex_report_{session_id}.pdf",
312
  )
313
 
314
-
315
  @router.get("/{session_id}/export.xlsx")
316
  def export_excel(
317
  session_id: str, store: SessionStore = Depends(get_session_store)
 
1
  from __future__ import annotations
2
 
 
3
  import json
4
  from pathlib import Path
5
  from typing import List
 
12
  from ..schemas import (
13
  PagesRequest,
14
  PagesResponse,
15
+ SectionsRequest,
16
+ SectionsResponse,
17
  SelectionRequest,
18
  SessionResponse,
19
  SessionStatusResponse,
 
22
  from ...services.session_store import DATA_EXTS, IMAGE_EXTS
23
 
24
  from ...services.data_import import populate_session_from_data_files
25
+ from ...services.pdf_reportlab import render_report_pdf
26
 
27
 
28
  router = APIRouter()
 
98
  session = store.get_session(session_id)
99
  if not session:
100
  raise HTTPException(status_code=404, detail="Session not found.")
101
+ store.ensure_sections(session)
102
  return _attach_urls(session)
103
 
104
 
 
153
  return PagesResponse(pages=session.get("pages") or [])
154
 
155
 
156
+ @router.get("/{session_id}/sections", response_model=SectionsResponse)
157
+ def get_sections(
158
+ session_id: str, store: SessionStore = Depends(get_session_store)
159
+ ) -> SectionsResponse:
160
+ session_id = _normalize_session_id(session_id, store)
161
+ session = store.get_session(session_id)
162
+ if not session:
163
+ raise HTTPException(status_code=404, detail="Session not found.")
164
+ sections = store.ensure_sections(session)
165
+ return SectionsResponse(sections=sections)
166
+
167
+
168
+ @router.put("/{session_id}/sections", response_model=SectionsResponse)
169
+ def save_sections(
170
+ session_id: str,
171
+ payload: SectionsRequest,
172
+ store: SessionStore = Depends(get_session_store),
173
+ ) -> SectionsResponse:
174
+ session_id = _normalize_session_id(session_id, store)
175
+ session = store.get_session(session_id)
176
+ if not session:
177
+ raise HTTPException(status_code=404, detail="Session not found.")
178
+ session = store.set_sections(session, payload.sections)
179
+ return SectionsResponse(sections=session.get("jobsheet_sections") or [])
180
+
181
+
182
  @router.get("/{session_id}/uploads/{file_id}")
183
  def get_upload(
184
  session_id: str,
 
277
 
278
  imported_session = payload.get("session") if isinstance(payload, dict) else None
279
  pages = payload.get("pages") if isinstance(payload, dict) else None
280
+ sections = payload.get("sections") if isinstance(payload, dict) else None
281
+ if not sections and isinstance(imported_session, dict):
282
+ sections = imported_session.get("jobsheet_sections")
283
  if not pages and isinstance(imported_session, dict):
284
  pages = imported_session.get("pages")
285
+ if sections:
286
+ session = store.set_sections(session, sections)
287
  if pages:
288
  session = store.set_pages(session, pages)
289
 
 
311
  session = store.get_session(session_id)
312
  if not session:
313
  raise HTTPException(status_code=404, detail="Session not found.")
314
+ sections = store.ensure_sections(session)
315
+ pages = store.ensure_pages(session)
316
  export_path = Path(store.session_dir(session_id)) / "export.json"
317
  payload = {
318
  "session": session,
319
+ "pages": pages,
320
+ "sections": sections,
321
  "exported_at": session.get("updated_at"),
322
  }
323
  export_path.write_text(
 
331
 
332
 
333
  @router.get("/{session_id}/export.pdf")
334
+ def export_pdf(
335
  session_id: str, store: SessionStore = Depends(get_session_store)
336
  ) -> FileResponse:
337
  session_id = _normalize_session_id(session_id, store)
338
  session = store.get_session(session_id)
339
  if not session:
340
  raise HTTPException(status_code=404, detail="Session not found.")
341
+ sections = store.ensure_sections(session)
342
  export_path = Path(store.session_dir(session_id)) / "export.pdf"
343
+ render_report_pdf(store, session, sections, export_path)
344
  return FileResponse(
345
  export_path,
346
  media_type="application/pdf",
347
  filename=f"repex_report_{session_id}.pdf",
348
  )
349
 
 
350
  @router.get("/{session_id}/export.xlsx")
351
  def export_excel(
352
  session_id: str, store: SessionStore = Depends(get_session_store)
server/app/api/schemas.py CHANGED
@@ -31,6 +31,7 @@ class SessionResponse(BaseModel):
31
  selected_photo_ids: List[str] = Field(default_factory=list)
32
  page_count: int = 0
33
  headings: List[Heading] = Field(default_factory=list)
 
34
 
35
 
36
  class SessionStatusResponse(BaseModel):
@@ -49,3 +50,17 @@ class PagesResponse(BaseModel):
49
 
50
  class PagesRequest(BaseModel):
51
  pages: List[dict] = Field(default_factory=list)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  selected_photo_ids: List[str] = Field(default_factory=list)
32
  page_count: int = 0
33
  headings: List[Heading] = Field(default_factory=list)
34
+ jobsheet_sections: List["JobsheetSection"] = Field(default_factory=list)
35
 
36
 
37
  class SessionStatusResponse(BaseModel):
 
50
 
51
  class PagesRequest(BaseModel):
52
  pages: List[dict] = Field(default_factory=list)
53
+
54
+
55
+ class JobsheetSection(BaseModel):
56
+ id: str
57
+ title: Optional[str] = None
58
+ pages: List[dict] = Field(default_factory=list)
59
+
60
+
61
+ class SectionsResponse(BaseModel):
62
+ sections: List[JobsheetSection] = Field(default_factory=list)
63
+
64
+
65
+ class SectionsRequest(BaseModel):
66
+ sections: List[JobsheetSection] = Field(default_factory=list)
server/app/services/data_import.py CHANGED
@@ -396,9 +396,9 @@ def populate_session_from_data_files(
396
  if headings:
397
  session["headings"] = headings
398
 
399
- pages: List[dict] = []
400
  selected_photo_ids: List[str] = []
401
- for item in items:
402
  company_logo = (
403
  general.get("client logo image name")
404
  or general.get("client logo")
@@ -428,13 +428,13 @@ def populate_session_from_data_files(
428
  for photo_id in photo_ids:
429
  if photo_id not in selected_photo_ids:
430
  selected_photo_ids.append(photo_id)
431
- pages.append({"items": [], "template": template, "photo_ids": photo_ids})
 
 
432
 
433
- if pages:
434
- session["pages"] = pages
435
- session["page_count"] = len(pages)
436
  if selected_photo_ids:
437
  session["selected_photo_ids"] = selected_photo_ids
438
- store.update_session(session)
439
 
440
  return session
 
396
  if headings:
397
  session["headings"] = headings
398
 
399
+ sections: List[dict] = []
400
  selected_photo_ids: List[str] = []
401
+ for idx, item in enumerate(items):
402
  company_logo = (
403
  general.get("client logo image name")
404
  or general.get("client logo")
 
428
  for photo_id in photo_ids:
429
  if photo_id not in selected_photo_ids:
430
  selected_photo_ids.append(photo_id)
431
+ page = {"items": [], "template": template, "photo_ids": photo_ids}
432
+ title = item.get("reference") or item.get("area") or f"Section {idx + 1}"
433
+ sections.append({"id": None, "title": title, "pages": [page]})
434
 
435
+ if sections:
 
 
436
  if selected_photo_ids:
437
  session["selected_photo_ids"] = selected_photo_ids
438
+ store.set_sections(session, sections)
439
 
440
  return session
server/app/services/pdf_reportlab.py ADDED
@@ -0,0 +1,660 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from pathlib import Path
5
+ import re
6
+ from typing import Iterable, List, Optional
7
+
8
+ from reportlab.lib import colors
9
+ from reportlab.lib.pagesizes import A4
10
+ from reportlab.lib.units import mm
11
+ from reportlab.lib.utils import ImageReader
12
+ from PIL import Image
13
+ from reportlab.pdfgen import canvas
14
+
15
+ from .session_store import SessionStore
16
+
17
+
18
+ def _has_template_content(template: dict | None) -> bool:
19
+ if not template:
20
+ return False
21
+ for value in template.values():
22
+ if isinstance(value, str) and value.strip():
23
+ return True
24
+ if value not in (None, ""):
25
+ return True
26
+ return False
27
+
28
+
29
+ def _chunk(items: List[str], size: int) -> List[List[str]]:
30
+ if not items:
31
+ return []
32
+ return [items[i : i + size] for i in range(0, len(items), size)]
33
+
34
+
35
+ def _safe_text(value: Optional[str]) -> str:
36
+ return (value or "").strip()
37
+
38
+
39
+ def _wrap_lines(
40
+ pdf: canvas.Canvas,
41
+ text: str,
42
+ width: float,
43
+ max_lines: int,
44
+ font: str,
45
+ size: int,
46
+ ) -> List[str]:
47
+ if not text:
48
+ return []
49
+ pdf.setFont(font, size)
50
+ words = text.split()
51
+ lines: List[str] = []
52
+ current: List[str] = []
53
+ for word in words:
54
+ test = " ".join(current + [word])
55
+ if pdf.stringWidth(test, font, size) <= width or not current:
56
+ current.append(word)
57
+ else:
58
+ lines.append(" ".join(current))
59
+ current = [word]
60
+ if len(lines) >= max_lines:
61
+ current = []
62
+ break
63
+ if current and len(lines) < max_lines:
64
+ lines.append(" ".join(current))
65
+ return lines
66
+
67
+
68
+ def _draw_wrapped(
69
+ pdf: canvas.Canvas,
70
+ text: str,
71
+ x: float,
72
+ y: float,
73
+ width: float,
74
+ leading: float,
75
+ max_lines: int,
76
+ font: str,
77
+ size: int,
78
+ ) -> float:
79
+ lines = _wrap_lines(pdf, text, width, max_lines, font, size)
80
+ if not lines:
81
+ return y
82
+ pdf.setFont(font, size)
83
+ for line in lines:
84
+ pdf.drawString(x, y, line)
85
+ y -= leading
86
+ return y
87
+
88
+
89
+ def _draw_centered_block(
90
+ pdf: canvas.Canvas,
91
+ lines: List[str],
92
+ x_center: float,
93
+ box_bottom: float,
94
+ box_height: float,
95
+ leading: float,
96
+ font: str,
97
+ size: int,
98
+ ) -> None:
99
+ if not lines:
100
+ return
101
+ pdf.setFont(font, size)
102
+ block_h = len(lines) * leading
103
+ start_y = box_bottom + (box_height + block_h) / 2 - leading
104
+ y = start_y
105
+ for line in lines:
106
+ pdf.drawCentredString(x_center, y, line)
107
+ y -= leading
108
+
109
+
110
+ def _draw_label_value(
111
+ pdf: canvas.Canvas,
112
+ label: str,
113
+ value: str,
114
+ x: float,
115
+ y: float,
116
+ label_font: str,
117
+ value_font: str,
118
+ label_size: int,
119
+ value_size: int,
120
+ label_color: colors.Color,
121
+ value_color: colors.Color,
122
+ ) -> float:
123
+ pdf.setFillColor(label_color)
124
+ pdf.setFont(label_font, label_size)
125
+ pdf.drawString(x, y, label)
126
+ y -= label_size + 1
127
+ pdf.setFillColor(value_color)
128
+ pdf.setFont(value_font, value_size)
129
+ pdf.drawString(x, y, value or "-")
130
+ return y
131
+
132
+
133
+ def _badge_style(value: str, scale: dict) -> tuple[str, colors.Color, colors.Color]:
134
+ raw = (value or "").strip()
135
+ key = raw.upper()
136
+ match = re.match(r"^([0-9]|[XM])", key)
137
+ if match:
138
+ key = match.group(1)
139
+ tone = scale.get(key)
140
+ if not tone:
141
+ return (raw or "-"), colors.HexColor("#f9fafb"), colors.HexColor("#374151")
142
+ return f"{key} - {tone['label']}", tone["bg"], tone["text"]
143
+
144
+
145
+ def _resolve_logo_path(store: SessionStore, session: dict, raw: str) -> Optional[Path]:
146
+ value = _safe_text(raw)
147
+ if not value:
148
+ return None
149
+ uploads = (session.get("uploads") or {}).get("photos") or []
150
+ lower = value.lower()
151
+ for item in uploads:
152
+ name = (item.get("name") or "").lower()
153
+ if not name:
154
+ continue
155
+ stem = name.rsplit(".", 1)[0]
156
+ if lower == name or lower == stem:
157
+ path = store.resolve_upload_path(session, item.get("id"))
158
+ if path and path.exists():
159
+ return path
160
+ return None
161
+
162
+
163
+ def _draw_image_fit(
164
+ pdf: canvas.Canvas,
165
+ image_path: Path,
166
+ x: float,
167
+ y: float,
168
+ width: float,
169
+ height: float,
170
+ ) -> bool:
171
+ try:
172
+ reader = ImageReader(str(image_path))
173
+ iw, ih = reader.getSize()
174
+ except Exception:
175
+ try:
176
+ img = Image.open(image_path)
177
+ img.load()
178
+ if img.mode not in ("RGB", "L"):
179
+ img = img.convert("RGB")
180
+ reader = ImageReader(img)
181
+ iw, ih = reader.getSize()
182
+ except Exception:
183
+ try:
184
+ pdf.drawImage(
185
+ str(image_path),
186
+ x,
187
+ y,
188
+ width,
189
+ height,
190
+ preserveAspectRatio=True,
191
+ mask="auto",
192
+ )
193
+ return True
194
+ except Exception:
195
+ return False
196
+ if iw <= 0 or ih <= 0:
197
+ return False
198
+ scale = min(width / iw, height / ih)
199
+ draw_w = iw * scale
200
+ draw_h = ih * scale
201
+ draw_x = x + (width - draw_w) / 2
202
+ draw_y = y + (height - draw_h) / 2
203
+ pdf.drawImage(
204
+ reader,
205
+ draw_x,
206
+ draw_y,
207
+ draw_w,
208
+ draw_h,
209
+ preserveAspectRatio=True,
210
+ mask="auto",
211
+ )
212
+ return True
213
+
214
+
215
+ def render_report_pdf(
216
+ store: SessionStore,
217
+ session: dict,
218
+ sections_or_pages: List[dict],
219
+ output_path: Path,
220
+ ) -> Path:
221
+ width, height = A4
222
+ margin = 10 * mm
223
+ header_h = 20 * mm
224
+ footer_h = 8 * mm
225
+ gap = 4 * mm
226
+ photo_col_gap = 6 * mm
227
+ photo_row_gap = 6 * mm
228
+ photos_header_gap = 8 * mm
229
+ min_photo_cell_w = 80 * mm
230
+ min_photo_cell_h = 60 * mm
231
+ two_col_cell_w = (width - 2 * margin - photo_col_gap) / 2
232
+ columns_for_photos = 2 if two_col_cell_w >= min_photo_cell_w else 1
233
+
234
+ gray_50 = colors.HexColor("#f9fafb")
235
+ gray_200 = colors.HexColor("#e5e7eb")
236
+ gray_500 = colors.HexColor("#6b7280")
237
+ gray_700 = colors.HexColor("#374151")
238
+ gray_800 = colors.HexColor("#1f2937")
239
+ gray_900 = colors.HexColor("#111827")
240
+ gray_600 = colors.HexColor("#4b5563")
241
+ amber_50 = colors.HexColor("#fffbeb")
242
+ amber_300 = colors.HexColor("#fcd34d")
243
+ amber_800 = colors.HexColor("#92400e")
244
+ emerald_50 = colors.HexColor("#ecfdf5")
245
+ emerald_800 = colors.HexColor("#065f46")
246
+ blue_50 = colors.HexColor("#eff6ff")
247
+ blue_300 = colors.HexColor("#93c5fd")
248
+ blue_800 = colors.HexColor("#1e40af")
249
+ blue_200 = colors.HexColor("#bfdbfe")
250
+ blue_900 = colors.HexColor("#1e3a8a")
251
+ purple_200 = colors.HexColor("#e9d5ff")
252
+ purple_800 = colors.HexColor("#6b21a8")
253
+ green_100 = colors.HexColor("#dcfce7")
254
+ green_200 = colors.HexColor("#bbf7d0")
255
+ yellow_100 = colors.HexColor("#fef9c3")
256
+ yellow_200 = colors.HexColor("#fef08a")
257
+ orange_200 = colors.HexColor("#fed7aa")
258
+ red_200 = colors.HexColor("#fecaca")
259
+
260
+ output_path.parent.mkdir(parents=True, exist_ok=True)
261
+ pdf = canvas.Canvas(str(output_path), pagesize=A4)
262
+
263
+ uploads = (session.get("uploads") or {}).get("photos") or []
264
+ by_id = {item.get("id"): item for item in uploads if item.get("id")}
265
+
266
+ content_top = height - margin - header_h - gap
267
+ content_bottom = margin + footer_h + gap
268
+ photo_area_height_photos = max(0, content_top - photos_header_gap - content_bottom)
269
+ max_rows_photos = max(
270
+ 1,
271
+ int((photo_area_height_photos + photo_row_gap) // (min_photo_cell_h + photo_row_gap)),
272
+ )
273
+ max_photos_photos = max(1, max_rows_photos * columns_for_photos)
274
+
275
+ sections: List[dict]
276
+ if sections_or_pages and isinstance(sections_or_pages[0], dict) and "pages" in sections_or_pages[0]:
277
+ sections = sections_or_pages
278
+ else:
279
+ sections = [{"id": "section-1", "title": "Section 1", "pages": sections_or_pages or []}]
280
+
281
+ print_pages: List[dict] = []
282
+ for section_index, section in enumerate(sections):
283
+ section_pages = section.get("pages") or []
284
+ for page_index, page in enumerate(section_pages):
285
+ template = page.get("template") or {}
286
+ base_variant = (
287
+ (page.get("variant") or "").strip().lower() if isinstance(page, dict) else ""
288
+ )
289
+ if base_variant not in ("full", "photos"):
290
+ base_variant = "full" if page_index == 0 else "photos"
291
+ photo_ids = page.get("photo_ids") or []
292
+ photo_entries = []
293
+ for pid in photo_ids:
294
+ item = by_id.get(pid)
295
+ if not item:
296
+ continue
297
+ path = store.resolve_upload_path(session, pid)
298
+ if path and path.exists():
299
+ label = _safe_text(item.get("name") or path.name)
300
+ photo_entries.append({"path": path, "label": label})
301
+ if base_variant == "photos":
302
+ chunks = _chunk(photo_entries, max_photos_photos) or [[]]
303
+ for chunk in chunks:
304
+ print_pages.append(
305
+ {
306
+ "page_index": page_index,
307
+ "template": template,
308
+ "photos": chunk,
309
+ "variant": "photos",
310
+ "section_index": section_index,
311
+ }
312
+ )
313
+ else:
314
+ first_chunk = photo_entries[:2]
315
+ remainder = photo_entries[2:]
316
+ chunks = [first_chunk]
317
+ if remainder:
318
+ chunks.extend(_chunk(remainder, max_photos_photos))
319
+ for chunk_index, chunk in enumerate(chunks):
320
+ variant = "full" if chunk_index == 0 else "photos"
321
+ print_pages.append(
322
+ {
323
+ "page_index": page_index,
324
+ "template": template,
325
+ "photos": chunk,
326
+ "variant": variant,
327
+ "section_index": section_index,
328
+ }
329
+ )
330
+
331
+ if not print_pages:
332
+ print_pages = [
333
+ {
334
+ "page_index": 0,
335
+ "template": {},
336
+ "photos": [],
337
+ "variant": "full",
338
+ }
339
+ ]
340
+
341
+ total_pages = len(print_pages)
342
+ repo_root = Path(__file__).resolve().parents[3]
343
+ logo_candidates = [
344
+ repo_root / "frontend" / "public" / "assets" / "prosento-logo.png",
345
+ repo_root / "frontend" / "dist" / "assets" / "prosento-logo.png",
346
+ repo_root / "server" / "assets" / "prosento-logo.png",
347
+ ]
348
+ default_logo = next((path for path in logo_candidates if path.exists()), None)
349
+
350
+ for output_index, payload in enumerate(print_pages):
351
+ template = payload["template"]
352
+ photos = payload["photos"]
353
+ variant = payload["variant"]
354
+
355
+ header_y = height - margin
356
+ content_top = header_y - header_h - gap
357
+ content_bottom = margin + footer_h + gap
358
+ content_height = content_top - content_bottom
359
+
360
+ # Header
361
+ logo_x = margin
362
+ logo_y = header_y - 15 * mm
363
+ logo_w = 28 * mm
364
+ logo_h = 15 * mm
365
+ logo_drawn = False
366
+ if default_logo:
367
+ logo_drawn = _draw_image_fit(pdf, default_logo, logo_x, logo_y, logo_w, logo_h)
368
+ if not logo_drawn:
369
+ pdf.setStrokeColor(colors.red)
370
+ pdf.setLineWidth(1)
371
+ pdf.rect(logo_x, logo_y, logo_w, logo_h, stroke=1, fill=0)
372
+ pdf.setFillColor(colors.red)
373
+ pdf.setFont("Helvetica-Bold", 9)
374
+ pdf.drawString(logo_x + 2, logo_y + logo_h / 2 - 3, "LOGO MISSING")
375
+ else:
376
+ pdf.setStrokeColor(gray_200)
377
+ pdf.setLineWidth(0.5)
378
+ pdf.rect(logo_x, logo_y, logo_w, logo_h, stroke=1, fill=0)
379
+ client_logo = _resolve_logo_path(store, session, template.get("company_logo", ""))
380
+ if client_logo:
381
+ _draw_image_fit(
382
+ pdf,
383
+ client_logo,
384
+ width - margin - 32 * mm,
385
+ header_y - 15 * mm,
386
+ 32 * mm,
387
+ 15 * mm,
388
+ )
389
+ pdf.setFillColor(gray_900)
390
+ pdf.setFont("Helvetica-Bold", 13)
391
+ pdf.drawCentredString(width / 2, header_y - 7 * mm, "RepEx Inspection Job Sheet")
392
+ pdf.setFillColor(gray_600)
393
+ pdf.setFont("Helvetica", 11)
394
+ pdf.drawCentredString(width / 2, header_y - 13 * mm, f"Page {output_index + 1} of {total_pages}")
395
+ pdf.setStrokeColor(gray_200)
396
+ pdf.line(margin, header_y - 17 * mm, width - margin, header_y - 17 * mm)
397
+
398
+ y = content_top
399
+
400
+ if variant == "full":
401
+ # Observations and Findings
402
+ pdf.setFillColor(gray_800)
403
+ pdf.setFont("Helvetica-Bold", 14)
404
+ pdf.drawString(margin, y, "Observations and Findings")
405
+ pdf.setStrokeColor(gray_200)
406
+ pdf.line(margin, y - 2, width - margin, y - 2)
407
+ y -= photos_header_gap
408
+
409
+ ref = _safe_text(template.get("reference"))
410
+ area = _safe_text(template.get("area"))
411
+ location = _safe_text(template.get("functional_location"))
412
+ item_desc = _safe_text(template.get("item_description"))
413
+ condition_desc = _safe_text(template.get("condition_description"))
414
+ action_type = _safe_text(template.get("action_type"))
415
+ required_action = _safe_text(template.get("required_action"))
416
+
417
+ left_w = (width - 2 * margin) * 0.6
418
+ right_w = (width - 2 * margin) * 0.4
419
+ left_x = margin
420
+ right_x = margin + left_w + 4 * mm
421
+
422
+ label_size = 11
423
+ value_size = 12
424
+ value_gap = 2 * mm
425
+ row_gap = 4 * mm
426
+ leading = 14
427
+
428
+ row_y = y
429
+ _draw_label_value(
430
+ pdf,
431
+ "Ref",
432
+ ref,
433
+ left_x,
434
+ row_y,
435
+ "Helvetica",
436
+ "Helvetica-Bold",
437
+ label_size,
438
+ value_size,
439
+ gray_500,
440
+ gray_900,
441
+ )
442
+ _draw_label_value(
443
+ pdf,
444
+ "Area",
445
+ area,
446
+ left_x + left_w / 2,
447
+ row_y,
448
+ "Helvetica",
449
+ "Helvetica-Bold",
450
+ label_size,
451
+ value_size,
452
+ gray_500,
453
+ gray_900,
454
+ )
455
+
456
+ pdf.setFillColor(gray_500)
457
+ pdf.setFont("Helvetica", label_size)
458
+ pdf.drawString(right_x, row_y, "Location")
459
+ pdf.setFillColor(gray_900)
460
+ loc_lines = _wrap_lines(
461
+ pdf,
462
+ location or "-",
463
+ right_w - 2 * mm,
464
+ 2,
465
+ "Helvetica-Bold",
466
+ value_size,
467
+ )
468
+ _draw_wrapped(
469
+ pdf,
470
+ location or "-",
471
+ right_x,
472
+ row_y - label_size - value_gap,
473
+ right_w - 2 * mm,
474
+ leading,
475
+ 2,
476
+ "Helvetica-Bold",
477
+ value_size,
478
+ )
479
+
480
+ y = row_y - (label_size + value_gap + leading * max(1, len(loc_lines))) - row_gap
481
+
482
+ category = _safe_text(template.get("category"))
483
+ priority = _safe_text(template.get("priority"))
484
+ cat_scale = {
485
+ "0": {"label": "Excellent", "bg": green_100, "text": colors.HexColor("#166534")},
486
+ "1": {"label": "Good", "bg": green_200, "text": colors.HexColor("#166534")},
487
+ "2": {"label": "Fair", "bg": yellow_100, "text": colors.HexColor("#854d0e")},
488
+ "3": {"label": "Poor", "bg": yellow_200, "text": colors.HexColor("#854d0e")},
489
+ "4": {"label": "Worse", "bg": orange_200, "text": colors.HexColor("#9a3412")},
490
+ "5": {"label": "Severe", "bg": red_200, "text": colors.HexColor("#991b1b")},
491
+ }
492
+ pr_scale = {
493
+ "1": {"label": "Immediate", "bg": red_200, "text": colors.HexColor("#991b1b")},
494
+ "2": {"label": "1 Year", "bg": orange_200, "text": colors.HexColor("#9a3412")},
495
+ "3": {"label": "3 Years", "bg": green_200, "text": colors.HexColor("#166534")},
496
+ "X": {"label": "At Use", "bg": purple_200, "text": purple_800},
497
+ "M": {"label": "Monitor", "bg": blue_200, "text": blue_900},
498
+ }
499
+ cat_text, cat_bg, cat_text_color = _badge_style(category, cat_scale)
500
+ pr_text, pr_bg, pr_text_color = _badge_style(priority, pr_scale)
501
+
502
+ badge_w = 40 * mm
503
+ badge_h = 10 * mm
504
+ y -= 2 * mm
505
+ pdf.setFillColor(gray_500)
506
+ pdf.setFont("Helvetica", 11)
507
+ cat_label_x = margin + 20 * mm + badge_w / 2
508
+ pr_label_x = margin + 100 * mm + badge_w / 2
509
+ label_y = y
510
+ pdf.drawCentredString(cat_label_x, label_y, "Category")
511
+ pdf.drawCentredString(pr_label_x, label_y, "Priority")
512
+ y -= 12 * mm
513
+ pdf.setFillColor(cat_bg)
514
+ pdf.setStrokeColor(gray_200)
515
+ pdf.roundRect(margin + 20 * mm, y - 2, badge_w, badge_h, 2 * mm, stroke=1, fill=1)
516
+ pdf.setFillColor(cat_text_color)
517
+ pdf.setFont("Helvetica-Bold", 11)
518
+ pdf.drawCentredString(cat_label_x, y - 2 + badge_h / 2 - 4, cat_text)
519
+ pdf.setFillColor(pr_bg)
520
+ pdf.roundRect(margin + 100 * mm, y - 2, badge_w, badge_h, 2 * mm, stroke=1, fill=1)
521
+ pdf.setFillColor(pr_text_color)
522
+ pdf.drawCentredString(pr_label_x, y - 2 + badge_h / 2 - 4, pr_text)
523
+ y -= 10 * mm
524
+
525
+ condition = " - ".join([v for v in [item_desc, condition_desc] if v])
526
+ action = " - ".join([v for v in [action_type, required_action] if v])
527
+
528
+ pdf.setFillColor(gray_500)
529
+ pdf.setFont("Helvetica", 11)
530
+ pdf.drawCentredString(
531
+ margin + (width - 2 * margin) / 2, y, "Condition Description"
532
+ )
533
+ y -= 4 * mm
534
+ pdf.setFillColor(amber_50)
535
+ pdf.setStrokeColor(amber_300)
536
+ cond_lines = _wrap_lines(
537
+ pdf,
538
+ condition or "-",
539
+ width - 2 * margin - 4 * mm,
540
+ 4,
541
+ "Helvetica-Bold",
542
+ 11,
543
+ )
544
+ cond_h = max(18 * mm, (len(cond_lines) or 1) * leading + 6 * mm)
545
+ cond_bottom = y - cond_h
546
+ pdf.rect(margin, cond_bottom, width - 2 * margin, cond_h, stroke=1, fill=1)
547
+ pdf.setLineWidth(3)
548
+ pdf.line(margin, cond_bottom, margin, y)
549
+ pdf.setLineWidth(1)
550
+ pdf.setFillColor(amber_800)
551
+ pdf.setFont("Helvetica-Bold", 11)
552
+ text_center_x = margin + (width - 2 * margin) / 2
553
+ _draw_centered_block(
554
+ pdf,
555
+ cond_lines,
556
+ text_center_x,
557
+ cond_bottom,
558
+ cond_h,
559
+ leading,
560
+ "Helvetica-Bold",
561
+ 11,
562
+ )
563
+ y = cond_bottom - 6 * mm
564
+
565
+ pdf.setFillColor(gray_500)
566
+ pdf.setFont("Helvetica", 11)
567
+ pdf.drawCentredString(
568
+ margin + (width - 2 * margin) / 2, y, "Required Action"
569
+ )
570
+ y -= 4 * mm
571
+ pdf.setFillColor(blue_50)
572
+ pdf.setStrokeColor(blue_300)
573
+ action_lines = _wrap_lines(
574
+ pdf,
575
+ action or "-",
576
+ width - 2 * margin - 4 * mm,
577
+ 4,
578
+ "Helvetica-Bold",
579
+ 11,
580
+ )
581
+ action_h = max(18 * mm, (len(action_lines) or 1) * leading + 6 * mm)
582
+ action_bottom = y - action_h
583
+ pdf.rect(margin, action_bottom, width - 2 * margin, action_h, stroke=1, fill=1)
584
+ pdf.setLineWidth(3)
585
+ pdf.line(margin, action_bottom, margin, y)
586
+ pdf.setLineWidth(1)
587
+ pdf.setFillColor(blue_800)
588
+ pdf.setFont("Helvetica-Bold", 11)
589
+ text_center_x = margin + (width - 2 * margin) / 2
590
+ _draw_centered_block(
591
+ pdf,
592
+ action_lines,
593
+ text_center_x,
594
+ action_bottom,
595
+ action_h,
596
+ leading,
597
+ "Helvetica-Bold",
598
+ 11,
599
+ )
600
+ y = action_bottom - 6 * mm
601
+ else:
602
+ pdf.setFillColor(gray_800)
603
+ pdf.setFont("Helvetica-Bold", 11)
604
+ pdf.drawString(margin, y, "Photo Documentation (continued)")
605
+ pdf.setStrokeColor(gray_200)
606
+ pdf.line(margin, y - 2, width - margin, y - 2)
607
+ y -= photos_header_gap
608
+
609
+ if variant == "full":
610
+ y -= 2 * mm
611
+ pdf.setFillColor(gray_800)
612
+ pdf.setFont("Helvetica-Bold", 14)
613
+ pdf.drawString(margin, y, "Photo Documentation")
614
+ pdf.setStrokeColor(gray_200)
615
+ pdf.line(margin, y - 2, width - margin, y - 2)
616
+ y -= 8 * mm
617
+
618
+ photo_area_top = y
619
+ photo_area_height = max(0, photo_area_top - content_bottom)
620
+
621
+ if photos:
622
+ columns = 1 if len(photos) == 1 else 2
623
+ rows = math.ceil(len(photos) / columns)
624
+ cell_w = (width - 2 * margin - (columns - 1) * photo_col_gap) / columns
625
+ cell_h = (photo_area_height - (rows - 1) * photo_row_gap) / rows
626
+
627
+ for idx, photo in enumerate(photos):
628
+ photo_path = photo["path"]
629
+ label = photo.get("label") or photo_path.name
630
+ row = idx // columns
631
+ col = idx % columns
632
+ x = margin + col * (cell_w + photo_col_gap)
633
+ y = photo_area_top - (row + 1) * cell_h - row * photo_row_gap
634
+ pdf.setStrokeColor(gray_200)
635
+ pdf.setFillColor(gray_50)
636
+ pdf.roundRect(x, y, cell_w, cell_h, 3 * mm, stroke=1, fill=1)
637
+ _draw_image_fit(
638
+ pdf, photo_path, x + 2 * mm, y + 8 * mm, cell_w - 4 * mm, cell_h - 14 * mm
639
+ )
640
+ pdf.setFillColor(gray_500)
641
+ pdf.setFont("Helvetica", 11)
642
+ if label:
643
+ pdf.drawCentredString(
644
+ x + cell_w / 2, y + 3 * mm, f"Fig {idx + 1}: {label}"
645
+ )
646
+ else:
647
+ pdf.setFont("Helvetica", 11)
648
+ pdf.drawString(margin, photo_area_top - 10 * mm, "No photos selected.")
649
+
650
+ # Footer
651
+ footer_y = margin
652
+ pdf.setFillColor(gray_500)
653
+ pdf.setFont("Helvetica", 11)
654
+ pdf.drawCentredString(width / 2, footer_y + 5 * mm, "Prosento - (c) 2026 All Rights Reserved")
655
+ pdf.drawCentredString(width / 2, footer_y + 1 * mm, "Automatically generated job sheet")
656
+
657
+ pdf.showPage()
658
+
659
+ pdf.save()
660
+ return output_path
server/app/services/session_store.py CHANGED
@@ -91,8 +91,9 @@ class SessionStore:
91
  "notes": notes,
92
  "uploads": {"photos": [], "documents": [], "data_files": []},
93
  "selected_photo_ids": [],
94
- "page_count": 6,
95
  "pages": [],
 
96
  }
97
  self._save_session(session)
98
  return session
@@ -142,25 +143,79 @@ class SessionStore:
142
  def set_pages(self, session: dict, pages: List[dict]) -> dict:
143
  if not pages:
144
  pages = [{"items": []}]
145
- session["pages"] = pages
 
 
 
 
146
  session["page_count"] = len(pages)
147
  self.update_session(session)
148
  return session
149
 
150
  def ensure_pages(self, session: dict) -> List[dict]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  pages = session.get("pages") or []
152
- if pages:
153
- if session.get("page_count") != len(pages):
154
- session["page_count"] = len(pages)
155
- self.update_session(session)
156
- return pages
157
- selected_count = len(session.get("selected_photo_ids") or [])
158
- photo_count = len(session.get("uploads", {}).get("photos", []) or [])
159
- count = selected_count or photo_count or session.get("page_count", 1) or 1
160
- pages = [{"items": []} for _ in range(count)]
161
- session["pages"] = pages
162
  self.update_session(session)
163
- return pages
164
 
165
  def save_upload(self, session_id: str, upload: UploadFile) -> StoredFile:
166
  filename = _safe_name(upload.filename or "upload")
 
91
  "notes": notes,
92
  "uploads": {"photos": [], "documents": [], "data_files": []},
93
  "selected_photo_ids": [],
94
+ "page_count": 0,
95
  "pages": [],
96
+ "jobsheet_sections": [],
97
  }
98
  self._save_session(session)
99
  return session
 
143
  def set_pages(self, session: dict, pages: List[dict]) -> dict:
144
  if not pages:
145
  pages = [{"items": []}]
146
+ # Legacy compatibility: store as a single section.
147
+ session["jobsheet_sections"] = [
148
+ {"id": uuid4().hex, "title": "Section 1", "pages": pages}
149
+ ]
150
+ session["pages"] = []
151
  session["page_count"] = len(pages)
152
  self.update_session(session)
153
  return session
154
 
155
  def ensure_pages(self, session: dict) -> List[dict]:
156
+ # Legacy compatibility: flatten sections to pages.
157
+ sections = self.ensure_sections(session)
158
+ pages: List[dict] = []
159
+ for section in sections:
160
+ pages.extend(section.get("pages") or [])
161
+ session["page_count"] = len(pages)
162
+ return pages
163
+
164
+ def set_sections(self, session: dict, sections: List[dict]) -> dict:
165
+ normalized: List[dict] = []
166
+ for section in sections or []:
167
+ if hasattr(section, "model_dump"):
168
+ section = section.model_dump()
169
+ elif hasattr(section, "dict"):
170
+ section = section.dict()
171
+ pages = section.get("pages") or []
172
+ if pages:
173
+ normalized_pages = []
174
+ for page in pages:
175
+ if hasattr(page, "model_dump"):
176
+ normalized_pages.append(page.model_dump())
177
+ elif hasattr(page, "dict"):
178
+ normalized_pages.append(page.dict())
179
+ else:
180
+ normalized_pages.append(page)
181
+ pages = normalized_pages
182
+ normalized.append(
183
+ {
184
+ "id": section.get("id") or uuid4().hex,
185
+ "title": section.get("title") or "Section",
186
+ "pages": pages if pages else [{"items": []}],
187
+ }
188
+ )
189
+ if not normalized:
190
+ normalized = [{"id": uuid4().hex, "title": "Section 1", "pages": [{"items": []}]}]
191
+ session["jobsheet_sections"] = normalized
192
+ session["pages"] = []
193
+ session["page_count"] = sum(len(section.get("pages") or []) for section in normalized)
194
+ self.update_session(session)
195
+ return session
196
+
197
+ def ensure_sections(self, session: dict) -> List[dict]:
198
+ sections = session.get("jobsheet_sections") or []
199
+ if sections:
200
+ session["page_count"] = sum(
201
+ len(section.get("pages") or []) for section in sections
202
+ )
203
+ self.update_session(session)
204
+ return sections
205
+
206
  pages = session.get("pages") or []
207
+ if not pages:
208
+ selected_count = len(session.get("selected_photo_ids") or [])
209
+ photo_count = len(session.get("uploads", {}).get("photos", []) or [])
210
+ count = selected_count or photo_count or session.get("page_count", 1) or 1
211
+ pages = [{"items": []} for _ in range(count)]
212
+
213
+ sections = [{"id": uuid4().hex, "title": "Section 1", "pages": pages}]
214
+ session["jobsheet_sections"] = sections
215
+ session["pages"] = []
216
+ session["page_count"] = len(pages)
217
  self.update_session(session)
218
+ return sections
219
 
220
  def save_upload(self, session_id: str, upload: UploadFile) -> StoredFile:
221
  filename = _safe_name(upload.filename or "upload")
server/assets/prosento-logo.png ADDED
server/requirements.txt CHANGED
@@ -3,4 +3,5 @@ uvicorn[standard]>=0.30.0,<0.32.0
3
  python-multipart>=0.0.9,<0.1.0
4
  openpyxl>=3.1.2,<4.0.0
5
  xlrd>=2.0.1,<3.0.0
6
- playwright>=1.41.0,<2.0.0
 
 
3
  python-multipart>=0.0.9,<0.1.0
4
  openpyxl>=3.1.2,<4.0.0
5
  xlrd>=2.0.1,<3.0.0
6
+ reportlab>=4.2.5,<5.0.0
7
+ pillow>=10.4.0,<11.0.0