ChristopherJKoen commited on
Commit
ed33547
·
1 Parent(s): bcb6323

Multi-Image Web Fixed

Browse files
frontend/src/App.tsx CHANGED
@@ -8,6 +8,7 @@ import InputDataPage from "./pages/InputDataPage";
8
  import EditReportPage from "./pages/EditReportPage";
9
  import EditLayoutsPage from "./pages/EditLayoutsPage";
10
  import ExportPage from "./pages/ExportPage";
 
11
 
12
  export default function App() {
13
  return (
@@ -21,6 +22,7 @@ export default function App() {
21
  <Route path="/edit-report" element={<EditReportPage />} />
22
  <Route path="/edit-layouts" element={<EditLayoutsPage />} />
23
  <Route path="/export" element={<ExportPage />} />
 
24
  <Route path="*" element={<Navigate to="/" replace />} />
25
  </Routes>
26
  </BrowserRouter>
 
8
  import EditReportPage from "./pages/EditReportPage";
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 (
 
22
  <Route path="/edit-report" element={<EditReportPage />} />
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,6 +26,109 @@ type LayoutEntry = {
25
  span: boolean;
26
  };
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  function normalizeKey(value: string) {
29
  return value.toLowerCase().replace(/[^a-z0-9]/g, "");
30
  }
@@ -132,14 +236,14 @@ function PhotoSlot({ url, label, className = "", imageClassName = "" }: PhotoSlo
132
  return (
133
  <figure
134
  className={[
135
- "rounded-lg border border-gray-200 bg-gray-50 p-2 break-inside-avoid mb-3",
136
  className,
137
  ].join(" ")}
138
  >
139
  <img
140
  src={url}
141
  alt={label}
142
- className={["w-full h-auto object-contain", imageClassName].join(" ")}
143
  loading="eager"
144
  />
145
  <figcaption className="mt-1 text-[10px] text-gray-600 text-center">
@@ -157,6 +261,7 @@ export function JobSheetTemplate({
157
  photos,
158
  orderLocked = false,
159
  variant = "full",
 
160
  }: JobSheetTemplateProps) {
161
  const inspectionDate =
162
  template?.inspection_date ?? session?.inspection_date ?? "";
@@ -183,6 +288,8 @@ export function JobSheetTemplate({
183
  const actionText = [actionType, requiredAction]
184
  .filter((value) => value && value.trim())
185
  .join(" - ");
 
 
186
 
187
  const resolvedPhotos =
188
  photos && photos.length ? photos : getPhotosForPage(session, pageIndex, 1);
@@ -223,10 +330,16 @@ export function JobSheetTemplate({
223
  return layout.map((entry) => entry.photo);
224
  }, [limitedPhotos, ratios, orderLocked]);
225
 
226
- const photoColumnsClass = orderedPhotos.length <= 1 ? "columns-1" : "columns-2";
 
 
 
 
 
 
227
 
228
  return (
229
- <div className="w-full h-full p-5 text-[11px] text-gray-700">
230
  <header className="mb-3 border-b border-gray-200 pb-2">
231
  <div className="grid grid-cols-[auto,1fr,auto] items-center gap-3">
232
  <img
@@ -289,8 +402,13 @@ export function JobSheetTemplate({
289
  <div className="text-[10px] font-medium text-gray-500">
290
  Category
291
  </div>
292
- <span className="template-field inline-flex items-center justify-center rounded-md border border-gray-200 px-4 py-1 text-[11px] font-semibold text-gray-900 min-w-[96px]">
293
- {category}
 
 
 
 
 
294
  </span>
295
  </div>
296
 
@@ -298,8 +416,13 @@ export function JobSheetTemplate({
298
  <div className="text-[10px] font-medium text-gray-500">
299
  Priority
300
  </div>
301
- <span className="template-field inline-flex items-center justify-center rounded-md border border-gray-200 px-4 py-1 text-[11px] font-semibold text-gray-900 min-w-[96px]">
302
- {priority}
 
 
 
 
 
303
  </span>
304
  </div>
305
  </div>
@@ -330,24 +453,21 @@ export function JobSheetTemplate({
330
  </section>
331
  ) : null}
332
 
333
- <section className="mb-3 avoid-break">
334
  <div className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
335
  {variant === "photos" ? "Photo Documentation (continued)" : "Photo Documentation"}
336
  </div>
337
- <div
338
- className={`${photoColumnsClass}`}
339
- style={{ columnGap: "0.75rem" }}
340
- >
341
- {orderedPhotos.length === 0 ? (
342
- <PhotoSlot url={undefined} label="No photo selected" className="break-inside-avoid mb-3" />
343
  ) : (
344
- orderedPhotos.map((photo, index) => (
345
  <PhotoSlot
346
  key={photo?.id || `${index}`}
347
  url={photo?.url}
348
  label={photo?.name || `Figure ${index + 1}`}
349
- className="break-inside-avoid mb-3"
350
- imageClassName=""
351
  />
352
  ))
353
  )}
@@ -359,6 +479,7 @@ export function JobSheetTemplate({
359
  <span>Inspector: {inspector || "-"}</span>
360
  <span>Doc: {docNumber || "-"}</span>
361
  <span>Site: {clientSite || "-"}</span>
 
362
  </footer>
363
  </div>
364
  );
 
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) {
133
  return value.toLowerCase().replace(/[^a-z0-9]/g, "");
134
  }
 
236
  return (
237
  <figure
238
  className={[
239
+ "rounded-lg border border-gray-200 bg-gray-50 p-2 break-inside-avoid",
240
  className,
241
  ].join(" ")}
242
  >
243
  <img
244
  src={url}
245
  alt={label}
246
+ className={["w-full h-full object-contain", imageClassName].join(" ")}
247
  loading="eager"
248
  />
249
  <figcaption className="mt-1 text-[10px] text-gray-600 text-center">
 
261
  photos,
262
  orderLocked = false,
263
  variant = "full",
264
+ sectionLabel,
265
  }: JobSheetTemplateProps) {
266
  const inspectionDate =
267
  template?.inspection_date ?? session?.inspection_date ?? "";
 
288
  const actionText = [actionType, requiredAction]
289
  .filter((value) => value && value.trim())
290
  .join(" - ");
291
+ const categoryBadge = formatRating(category, CATEGORY_SCALE);
292
+ const priorityBadge = formatRating(priority, PRIORITY_SCALE);
293
 
294
  const resolvedPhotos =
295
  photos && photos.length ? photos : getPhotosForPage(session, pageIndex, 1);
 
330
  return layout.map((entry) => entry.photo);
331
  }, [limitedPhotos, ratios, orderLocked]);
332
 
333
+ const displayedPhotos =
334
+ variant === "full" ? orderedPhotos.slice(0, 2) : orderedPhotos;
335
+
336
+ const photoGridClass =
337
+ displayedPhotos.length <= 1
338
+ ? "grid grid-cols-1 gap-3"
339
+ : "grid grid-cols-2 gap-3";
340
 
341
  return (
342
+ <div className="w-full h-full p-5 text-[11px] text-gray-700 flex flex-col">
343
  <header className="mb-3 border-b border-gray-200 pb-2">
344
  <div className="grid grid-cols-[auto,1fr,auto] items-center gap-3">
345
  <img
 
402
  <div className="text-[10px] font-medium text-gray-500">
403
  Category
404
  </div>
405
+ <span
406
+ className={[
407
+ "template-field inline-flex items-center justify-center rounded-md border px-4 py-1 text-[11px] font-semibold min-w-[120px]",
408
+ categoryBadge.className,
409
+ ].join(" ")}
410
+ >
411
+ {categoryBadge.text}
412
  </span>
413
  </div>
414
 
 
416
  <div className="text-[10px] font-medium text-gray-500">
417
  Priority
418
  </div>
419
+ <span
420
+ className={[
421
+ "template-field inline-flex items-center justify-center rounded-md border px-4 py-1 text-[11px] font-semibold min-w-[120px]",
422
+ priorityBadge.className,
423
+ ].join(" ")}
424
+ >
425
+ {priorityBadge.text}
426
  </span>
427
  </div>
428
  </div>
 
453
  </section>
454
  ) : null}
455
 
456
+ <section className="mb-3 avoid-break flex-1 min-h-0 flex flex-col">
457
  <div className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
458
  {variant === "photos" ? "Photo Documentation (continued)" : "Photo Documentation"}
459
  </div>
460
+ <div className={`${photoGridClass} flex-1 items-stretch`}>
461
+ {displayedPhotos.length === 0 ? (
462
+ <PhotoSlot url={undefined} label="No photo selected" className="h-full" />
 
 
 
463
  ) : (
464
+ displayedPhotos.map((photo, index) => (
465
  <PhotoSlot
466
  key={photo?.id || `${index}`}
467
  url={photo?.url}
468
  label={photo?.name || `Figure ${index + 1}`}
469
+ className="h-full"
470
+ imageClassName="h-full"
471
  />
472
  ))
473
  )}
 
479
  <span>Inspector: {inspector || "-"}</span>
480
  <span>Doc: {docNumber || "-"}</span>
481
  <span>Site: {clientSite || "-"}</span>
482
+ {sectionLabel ? <span>{sectionLabel}</span> : null}
483
  </footer>
484
  </div>
485
  );
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
@@ -13,7 +13,8 @@ class ReportEditor extends HTMLElement {
13
  isOpen: false,
14
  zoom: 1,
15
  activePage: 0,
16
- pages: [], // [{ items: [...] }]
 
17
  selectedId: null,
18
  tool: "select", // select | text | rect
19
  dragging: null, // { id, startX, startY, origX, origY }
@@ -27,6 +28,7 @@ class ReportEditor extends HTMLElement {
27
  this.apiBase = null;
28
  this._saveTimer = null;
29
  this._photoRatios = new Map();
 
30
  }
31
 
32
  connectedCallback() {
@@ -70,12 +72,22 @@ class ReportEditor extends HTMLElement {
70
 
71
  const initialCount = Math.max(Number(totalPages) || 1, 1);
72
 
73
- // Load existing editor pages from storage, else initialize
74
  const stored = this._loadPages();
75
- if (stored && Array.isArray(stored.pages) && stored.pages.length) {
76
- this.state.pages = stored.pages;
 
 
 
 
77
  } else {
78
- this.state.pages = Array.from({ length: initialCount }, () => ({ items: [] }));
 
 
 
 
 
 
79
  this._savePages();
80
  }
81
  this._ensurePageCount(initialCount);
@@ -97,10 +109,10 @@ class ReportEditor extends HTMLElement {
97
  setTimeout(() => this.updateAll(), 0);
98
 
99
  if (this.sessionId) {
100
- this._loadPagesFromServer().then((pages) => {
101
- if (pages && pages.length) {
102
- this.state.pages = pages;
103
- this._ensurePageCount(Math.max(initialCount, pages.length));
104
  this.state.activePage = Math.min(
105
  Math.max(0, pageIndex),
106
  this.state.pages.length - 1
@@ -750,6 +762,7 @@ class ReportEditor extends HTMLElement {
750
  _templateMarkup() {
751
  const session = this.state.payload || {};
752
  const template = this._getTemplate();
 
753
 
754
  const inspectionDate =
755
  template.inspection_date || session.inspection_date || "";
@@ -771,40 +784,46 @@ class ReportEditor extends HTMLElement {
771
  template.condition_description || session.notes || "";
772
  const requiredAction = template.required_action || "";
773
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
774
  const photos = this._photosForActivePage(session).slice(0, 6);
775
  this._ensurePhotoRatios(photos);
776
  const orderLocked = !!(this.activePage && this.activePage.photo_order_locked);
777
  const orderedPhotos = orderLocked
778
  ? photos
779
  : this._computePhotoLayout(photos).map((entry) => entry.photo);
780
- const photoColumnsClass = orderedPhotos.length <= 1 ? "columns-1" : "columns-2";
781
- const photoSlots = orderedPhotos.length
782
- ? orderedPhotos
 
 
783
  .map((photo, idx) => this._photoSlot(photo, `Figure ${idx + 1}`))
784
  .join("")
785
  : this._photoSlot(null, "No photo selected");
786
  const pageNum = this.state.activePage + 1;
787
  const pageCount = this.state.pages.length || 1;
788
 
789
- return `
790
- <div class="w-full h-full p-5 text-[11px] text-gray-700" style="color:#374151; font-size:11px;">
791
- <header class="mb-3 border-b border-gray-200 pb-2">
792
- <div class="grid grid-cols-[auto,1fr,auto] items-center gap-3">
793
- <div class="flex items-center">
794
- <img src="/assets/prosento-logo.png" alt="Prosento logo" class="h-10 w-auto object-contain" />
795
- </div>
796
-
797
- <div class="text-center leading-tight">
798
- <h1 class="text-2xl font-bold text-gray-900 whitespace-nowrap">RepEx Inspection Job Sheet</h1>
799
- <p class="text-sm text-gray-600 whitespace-nowrap">Report Express - Inspection Management</p>
800
- </div>
801
-
802
- <div class="flex items-center justify-end">
803
- <img src="${this._escape(this._resolveLogoUrl(session, companyLogo))}" alt="Company logo" class="h-10 w-auto object-contain" />
804
- </div>
805
- </div>
806
- </header>
807
-
808
  <section class="mb-4" aria-labelledby="observations-title">
809
  <h2 id="observations-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
810
  Observations and Findings
@@ -832,12 +851,22 @@ class ReportEditor extends HTMLElement {
832
  <div class="inline-flex items-center gap-10">
833
  <div class="text-center space-y-1">
834
  <div class="text-xs font-medium text-gray-500">Category</div>
835
- ${this._tplField("category", category, "Category", "inline-flex items-center justify-center rounded-md border border-gray-200 px-4 py-1 text-sm font-semibold text-gray-900 min-w-[96px]")}
 
 
 
 
 
836
  </div>
837
 
838
  <div class="text-center space-y-1">
839
  <div class="text-xs font-medium text-gray-500">Priority</div>
840
- ${this._tplField("priority", priority, "Priority", "inline-flex items-center justify-center rounded-md border border-gray-200 px-4 py-1 text-sm font-semibold text-gray-900 min-w-[96px]")}
 
 
 
 
 
841
  </div>
842
  </div>
843
  </div>
@@ -863,10 +892,35 @@ class ReportEditor extends HTMLElement {
863
  </div>
864
  </div>
865
  </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
866
 
867
  <section class="mb-4 avoid-break" aria-labelledby="photo-doc-title">
868
  <h2 id="photo-doc-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
869
- Photo Documentation
870
  </h2>
871
 
872
  <div class="${photoColumnsClass}" style="column-gap:0.75rem;">
@@ -879,6 +933,7 @@ class ReportEditor extends HTMLElement {
879
  <span>Inspector: ${this._escape(inspector || "-")}</span>
880
  <span>Doc: ${this._escape(docNumber || "-")}</span>
881
  <span>Site: ${this._escape(clientSite || "-")}</span>
 
882
  </footer>
883
  </div>
884
  `;
@@ -887,9 +942,9 @@ class ReportEditor extends HTMLElement {
887
  // ---------- Storage ----------
888
  _storageKey() {
889
  if (this.sessionId) {
890
- return `repex_report_pages_v1_${this.sessionId}`;
891
  }
892
- return "repex_report_pages_v1";
893
  }
894
 
895
  _loadPages() {
@@ -903,7 +958,11 @@ class ReportEditor extends HTMLElement {
903
 
904
  _savePages(showToast = false) {
905
  try {
906
- localStorage.setItem(this._storageKey(), JSON.stringify({ pages: this.state.pages }));
 
 
 
 
907
  this._scheduleServerSave();
908
  if (showToast) this._toast("Saved");
909
  } catch {
@@ -921,11 +980,11 @@ class ReportEditor extends HTMLElement {
921
  const base = this._apiRoot();
922
  if (!base || !this.sessionId) return null;
923
  try {
924
- const res = await fetch(`${base}/sessions/${this.sessionId}/pages`);
925
  if (!res.ok) return null;
926
  const data = await res.json();
927
- if (data && Array.isArray(data.pages)) {
928
- return data.pages;
929
  }
930
  } catch {}
931
  return null;
@@ -943,10 +1002,11 @@ class ReportEditor extends HTMLElement {
943
  const base = this._apiRoot();
944
  if (!base || !this.sessionId) return;
945
  try {
946
- const res = await fetch(`${base}/sessions/${this.sessionId}/pages`, {
 
947
  method: "PUT",
948
  headers: { "Content-Type": "application/json" },
949
- body: JSON.stringify({ pages: this.state.pages }),
950
  });
951
  if (!res.ok) {
952
  throw new Error("Failed");
@@ -964,11 +1024,160 @@ class ReportEditor extends HTMLElement {
964
  setTimeout(() => el.remove(), 1200);
965
  }
966
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
967
  // ---------- Page list ----------
968
  renderPageList() {
969
  this.$pageList.innerHTML = "";
970
 
971
  this.state.pages.forEach((_, idx) => {
 
 
 
 
 
972
  const active = idx === this.state.activePage;
973
 
974
  const row = document.createElement("div");
@@ -983,7 +1192,10 @@ class ReportEditor extends HTMLElement {
983
  : "border-gray-200 bg-white text-gray-800 hover:bg-gray-50");
984
  btn.innerHTML = `
985
  <div class="flex items-center justify-between">
986
- <div class="text-sm font-semibold">Page ${idx + 1}</div>
 
 
 
987
  <div class="text-xs ${active ? "text-white/80" : "text-gray-500"}">${this.state.pages[idx].items.length} items</div>
988
  </div>
989
  `;
@@ -1015,8 +1227,26 @@ class ReportEditor extends HTMLElement {
1015
 
1016
  addPage() {
1017
  this._pushUndoSnapshot();
1018
- this.state.pages.push({ items: [] });
1019
- this.state.activePage = this.state.pages.length - 1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1020
  this.state.selectedId = null;
1021
  this._savePages();
1022
  this.updateAll();
@@ -1027,7 +1257,14 @@ class ReportEditor extends HTMLElement {
1027
  const idx = typeof index === "number" ? index : this.state.activePage;
1028
  if (idx < 0 || idx >= this.state.pages.length) return;
1029
 
1030
- this.state.pages.splice(idx, 1);
 
 
 
 
 
 
 
1031
  if (this.state.activePage >= this.state.pages.length) {
1032
  this.state.activePage = this.state.pages.length - 1;
1033
  } else if (this.state.activePage > idx) {
@@ -1042,8 +1279,16 @@ class ReportEditor extends HTMLElement {
1042
 
1043
  _ensurePageCount(count) {
1044
  const target = Math.max(Number(count) || 1, 1);
 
 
 
 
 
1045
  while (this.state.pages.length < target) {
1046
- this.state.pages.push({ items: [] });
 
 
 
1047
  }
1048
  }
1049
 
@@ -1068,16 +1313,21 @@ class ReportEditor extends HTMLElement {
1068
 
1069
  const template = document.createElement("div");
1070
  template.className = "absolute inset-0";
 
1071
  let templateHtml = "";
1072
- try {
1073
- templateHtml = this._templateMarkup();
1074
- } catch (err) {
1075
- console.error("Template render failed", err);
1076
- templateHtml = `
1077
- <div class="p-4 text-sm text-red-600">
1078
- Template failed to render. Check console for details.
1079
- </div>
1080
- `;
 
 
 
 
1081
  }
1082
  template.innerHTML = `
1083
  <div style="width:${this.BASE_W}px; height:${this.BASE_H}px; transform: scale(${scale}); transform-origin: top left;">
@@ -1085,7 +1335,9 @@ class ReportEditor extends HTMLElement {
1085
  </div>
1086
  `;
1087
  this.$canvas.appendChild(template);
1088
- this._bindTemplateFields();
 
 
1089
 
1090
  const items = this.activePage.items;
1091
  const selectedId = this.state.selectedId;
 
13
  isOpen: false,
14
  zoom: 1,
15
  activePage: 0,
16
+ pages: [], // flattened pages
17
+ sections: [], // [{ id, title?, pages: [...] }]
18
  selectedId: null,
19
  tool: "select", // select | text | rect
20
  dragging: null, // { id, startX, startY, origX, origY }
 
28
  this.apiBase = null;
29
  this._saveTimer = null;
30
  this._photoRatios = new Map();
31
+ this._indexMap = [];
32
  }
33
 
34
  connectedCallback() {
 
72
 
73
  const initialCount = Math.max(Number(totalPages) || 1, 1);
74
 
75
+ // Load existing editor sections from storage, else initialize
76
  const stored = this._loadPages();
77
+ if (stored && Array.isArray(stored.sections) && stored.sections.length) {
78
+ this._setSections(stored.sections);
79
+ } else if (stored && Array.isArray(stored.pages) && stored.pages.length) {
80
+ this._setSections([
81
+ { id: this._sectionId(), title: "Section 1", pages: stored.pages },
82
+ ]);
83
  } else {
84
+ this._setSections([
85
+ {
86
+ id: this._sectionId(),
87
+ title: "Section 1",
88
+ pages: Array.from({ length: initialCount }, () => ({ items: [] })),
89
+ },
90
+ ]);
91
  this._savePages();
92
  }
93
  this._ensurePageCount(initialCount);
 
109
  setTimeout(() => this.updateAll(), 0);
110
 
111
  if (this.sessionId) {
112
+ this._loadPagesFromServer().then((sections) => {
113
+ if (sections && sections.length) {
114
+ this._setSections(sections);
115
+ this._ensurePageCount(Math.max(initialCount, this.state.pages.length));
116
  this.state.activePage = Math.min(
117
  Math.max(0, pageIndex),
118
  this.state.pages.length - 1
 
762
  _templateMarkup() {
763
  const session = this.state.payload || {};
764
  const template = this._getTemplate();
765
+ const sectionLabel = this._getActiveSectionLabel();
766
 
767
  const inspectionDate =
768
  template.inspection_date || session.inspection_date || "";
 
784
  template.condition_description || session.notes || "";
785
  const requiredAction = template.required_action || "";
786
 
787
+ const categoryScale = {
788
+ "0": { label: "Excellent", bg: "bg-green-100", text: "text-green-800", border: "border-green-200" },
789
+ "1": { label: "Good", bg: "bg-green-200", text: "text-green-800", border: "border-green-200" },
790
+ "2": { label: "Fair", bg: "bg-yellow-100", text: "text-yellow-800", border: "border-yellow-200" },
791
+ "3": { label: "Poor", bg: "bg-yellow-200", text: "text-yellow-800", border: "border-yellow-200" },
792
+ "4": { label: "Worse", bg: "bg-orange-200", text: "text-orange-800", border: "border-orange-200" },
793
+ "5": { label: "Severe", bg: "bg-red-200", text: "text-red-800", border: "border-red-200" },
794
+ };
795
+ const priorityScale = {
796
+ "1": { label: "Immediate", bg: "bg-red-200", text: "text-red-800", border: "border-red-200" },
797
+ "2": { label: "1 Year", bg: "bg-orange-200", text: "text-orange-800", border: "border-orange-200" },
798
+ "3": { label: "3 Years", bg: "bg-green-200", text: "text-green-800", border: "border-green-200" },
799
+ X: { label: "At Use", bg: "bg-purple-200", text: "text-purple-800", border: "border-purple-200" },
800
+ M: { label: "Monitor", bg: "bg-blue-200", text: "text-blue-800", border: "border-blue-200" },
801
+ };
802
+ const categoryBadge = this._ratingBadge(category, categoryScale);
803
+ const priorityBadge = this._ratingBadge(priority, priorityScale);
804
+
805
+ const variant =
806
+ (this.activePage && this.activePage.variant) || "full";
807
  const photos = this._photosForActivePage(session).slice(0, 6);
808
  this._ensurePhotoRatios(photos);
809
  const orderLocked = !!(this.activePage && this.activePage.photo_order_locked);
810
  const orderedPhotos = orderLocked
811
  ? photos
812
  : this._computePhotoLayout(photos).map((entry) => entry.photo);
813
+ const displayedPhotos =
814
+ variant === "full" ? orderedPhotos.slice(0, 2) : orderedPhotos;
815
+ const photoColumnsClass = displayedPhotos.length <= 1 ? "columns-1" : "columns-2";
816
+ const photoSlots = displayedPhotos.length
817
+ ? displayedPhotos
818
  .map((photo, idx) => this._photoSlot(photo, `Figure ${idx + 1}`))
819
  .join("")
820
  : this._photoSlot(null, "No photo selected");
821
  const pageNum = this.state.activePage + 1;
822
  const pageCount = this.state.pages.length || 1;
823
 
824
+ const observationsHtml =
825
+ variant === "full"
826
+ ? `
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
827
  <section class="mb-4" aria-labelledby="observations-title">
828
  <h2 id="observations-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
829
  Observations and Findings
 
851
  <div class="inline-flex items-center gap-10">
852
  <div class="text-center space-y-1">
853
  <div class="text-xs font-medium text-gray-500">Category</div>
854
+ ${this._tplField(
855
+ "category",
856
+ categoryBadge.text,
857
+ "Category",
858
+ `inline-flex items-center justify-center rounded-md border px-4 py-1 text-sm font-semibold min-w-[120px] ${categoryBadge.className}`,
859
+ )}
860
  </div>
861
 
862
  <div class="text-center space-y-1">
863
  <div class="text-xs font-medium text-gray-500">Priority</div>
864
+ ${this._tplField(
865
+ "priority",
866
+ priorityBadge.text,
867
+ "Priority",
868
+ `inline-flex items-center justify-center rounded-md border px-4 py-1 text-sm font-semibold min-w-[120px] ${priorityBadge.className}`,
869
+ )}
870
  </div>
871
  </div>
872
  </div>
 
892
  </div>
893
  </div>
894
  </section>
895
+ `
896
+ : "";
897
+ const photoTitle =
898
+ variant === "photos" ? "Photo Documentation (continued)" : "Photo Documentation";
899
+
900
+ return `
901
+ <div class="w-full h-full p-5 text-[11px] text-gray-700" style="color:#374151; font-size:11px;">
902
+ <header class="mb-3 border-b border-gray-200 pb-2">
903
+ <div class="grid grid-cols-[auto,1fr,auto] items-center gap-3">
904
+ <div class="flex items-center">
905
+ <img src="/assets/prosento-logo.png" alt="Prosento logo" class="h-10 w-auto object-contain" />
906
+ </div>
907
+
908
+ <div class="text-center leading-tight">
909
+ <h1 class="text-2xl font-bold text-gray-900 whitespace-nowrap">RepEx Inspection Job Sheet</h1>
910
+ <p class="text-sm text-gray-600 whitespace-nowrap">Report Express - Inspection Management</p>
911
+ </div>
912
+
913
+ <div class="flex items-center justify-end">
914
+ <img src="${this._escape(this._resolveLogoUrl(session, companyLogo))}" alt="Company logo" class="h-10 w-auto object-contain" />
915
+ </div>
916
+ </div>
917
+ </header>
918
+
919
+ ${observationsHtml}
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
+ ${photoTitle}
924
  </h2>
925
 
926
  <div class="${photoColumnsClass}" style="column-gap:0.75rem;">
 
933
  <span>Inspector: ${this._escape(inspector || "-")}</span>
934
  <span>Doc: ${this._escape(docNumber || "-")}</span>
935
  <span>Site: ${this._escape(clientSite || "-")}</span>
936
+ ${sectionLabel ? `<span>${this._escape(sectionLabel)}</span>` : ""}
937
  </footer>
938
  </div>
939
  `;
 
942
  // ---------- Storage ----------
943
  _storageKey() {
944
  if (this.sessionId) {
945
+ return `repex_report_sections_v1_${this.sessionId}`;
946
  }
947
+ return "repex_report_sections_v1";
948
  }
949
 
950
  _loadPages() {
 
958
 
959
  _savePages(showToast = false) {
960
  try {
961
+ this._syncSectionsFromPages();
962
+ localStorage.setItem(
963
+ this._storageKey(),
964
+ JSON.stringify({ sections: this.state.sections }),
965
+ );
966
  this._scheduleServerSave();
967
  if (showToast) this._toast("Saved");
968
  } catch {
 
980
  const base = this._apiRoot();
981
  if (!base || !this.sessionId) return null;
982
  try {
983
+ const res = await fetch(`${base}/sessions/${this.sessionId}/sections`);
984
  if (!res.ok) return null;
985
  const data = await res.json();
986
+ if (data && Array.isArray(data.sections)) {
987
+ return data.sections;
988
  }
989
  } catch {}
990
  return null;
 
1002
  const base = this._apiRoot();
1003
  if (!base || !this.sessionId) return;
1004
  try {
1005
+ this._syncSectionsFromPages();
1006
+ const res = await fetch(`${base}/sessions/${this.sessionId}/sections`, {
1007
  method: "PUT",
1008
  headers: { "Content-Type": "application/json" },
1009
+ body: JSON.stringify({ sections: this.state.sections }),
1010
  });
1011
  if (!res.ok) {
1012
  throw new Error("Failed");
 
1024
  setTimeout(() => el.remove(), 1200);
1025
  }
1026
 
1027
+ _sectionId() {
1028
+ return `sec_${Math.random().toString(16).slice(2)}_${Date.now().toString(16)}`;
1029
+ }
1030
+
1031
+ _ratingBadge(value, scale) {
1032
+ const raw = String(value || "").trim();
1033
+ if (!raw) {
1034
+ return { text: "-", className: "bg-gray-50 text-gray-700 border-gray-200" };
1035
+ }
1036
+ const match = raw.match(/^([0-9]|[xXmM])/);
1037
+ const key = match ? match[1].toUpperCase() : raw.split("-")[0].trim().toUpperCase();
1038
+ const tone = scale[key];
1039
+ if (!tone) {
1040
+ return { text: raw, className: "bg-gray-50 text-gray-700 border-gray-200" };
1041
+ }
1042
+ return {
1043
+ text: `${key} - ${tone.label}`,
1044
+ className: `${tone.bg} ${tone.text} ${tone.border}`,
1045
+ };
1046
+ }
1047
+
1048
+ _buildPhotoContinuation(source, photoIds) {
1049
+ return {
1050
+ items: [],
1051
+ template: source.template ? { ...source.template } : undefined,
1052
+ photo_ids: photoIds,
1053
+ photo_order_locked: source.photo_order_locked,
1054
+ variant: "photos",
1055
+ };
1056
+ }
1057
+
1058
+ _splitPagePhotos(page) {
1059
+ const normalized = {
1060
+ ...page,
1061
+ items: Array.isArray(page.items) ? page.items : [],
1062
+ };
1063
+ if (normalized.blank) return [normalized];
1064
+
1065
+ const photoIds = Array.isArray(normalized.photo_ids)
1066
+ ? normalized.photo_ids.filter(Boolean)
1067
+ : [];
1068
+ if (!photoIds.length) return [normalized];
1069
+
1070
+ const chunks = [];
1071
+ for (let i = 0; i < photoIds.length; i += 2) {
1072
+ chunks.push(photoIds.slice(i, i + 2));
1073
+ }
1074
+
1075
+ if (chunks.length <= 1) {
1076
+ return [{ ...normalized, photo_ids: chunks[0] || [], variant: normalized.variant }];
1077
+ }
1078
+
1079
+ if (normalized.variant === "photos") {
1080
+ return chunks.map((chunk, idx) => {
1081
+ if (idx === 0) {
1082
+ return { ...normalized, photo_ids: chunk, variant: "photos" };
1083
+ }
1084
+ return this._buildPhotoContinuation(normalized, chunk);
1085
+ });
1086
+ }
1087
+
1088
+ const basePage = {
1089
+ ...normalized,
1090
+ photo_ids: chunks[0],
1091
+ variant: normalized.variant || "full",
1092
+ };
1093
+ const extraPages = chunks.slice(1).map((chunk) =>
1094
+ this._buildPhotoContinuation(normalized, chunk),
1095
+ );
1096
+ return [basePage, ...extraPages];
1097
+ }
1098
+
1099
+ _normalizeSections(sections) {
1100
+ const source = Array.isArray(sections) ? sections : [];
1101
+ if (!source.length) {
1102
+ return [{ id: this._sectionId(), title: "Section 1", pages: [{ items: [] }] }];
1103
+ }
1104
+ return source.map((section) => {
1105
+ const basePages =
1106
+ Array.isArray(section.pages) && section.pages.length
1107
+ ? section.pages
1108
+ : [{ items: [] }];
1109
+ const normalizedPages = basePages.flatMap((page) => this._splitPagePhotos(page));
1110
+ return {
1111
+ id: section.id || this._sectionId(),
1112
+ title: section.title ?? "Section",
1113
+ pages: normalizedPages.length ? normalizedPages : [{ items: [] }],
1114
+ };
1115
+ });
1116
+ }
1117
+
1118
+ _rebuildFlatPages() {
1119
+ this._indexMap = [];
1120
+ this.state.pages = [];
1121
+ const sections = Array.isArray(this.state.sections) ? this.state.sections : [];
1122
+ sections.forEach((section, sectionIndex) => {
1123
+ const pages = Array.isArray(section.pages) && section.pages.length
1124
+ ? section.pages
1125
+ : [{ items: [] }];
1126
+ section.pages = pages;
1127
+ pages.forEach((page, pageIndex) => {
1128
+ this.state.pages.push(page);
1129
+ this._indexMap.push({ sectionIndex, pageIndex });
1130
+ });
1131
+ });
1132
+ if (!this.state.pages.length) {
1133
+ this.state.sections = [
1134
+ { id: this._sectionId(), title: "Section 1", pages: [{ items: [] }] },
1135
+ ];
1136
+ this._rebuildFlatPages();
1137
+ }
1138
+ }
1139
+
1140
+ _setSections(sections) {
1141
+ this.state.sections = this._normalizeSections(sections);
1142
+ this._rebuildFlatPages();
1143
+ }
1144
+
1145
+ _syncSectionsFromPages() {
1146
+ if (!this._indexMap || this._indexMap.length !== this.state.pages.length) {
1147
+ this._rebuildFlatPages();
1148
+ }
1149
+ const sections = this.state.sections.map((section) => ({
1150
+ ...section,
1151
+ pages: Array.isArray(section.pages) ? [...section.pages] : [{ items: [] }],
1152
+ }));
1153
+ this.state.pages.forEach((page, idx) => {
1154
+ const map = this._indexMap[idx];
1155
+ if (!map) return;
1156
+ if (!sections[map.sectionIndex]) return;
1157
+ sections[map.sectionIndex].pages[map.pageIndex] = page;
1158
+ });
1159
+ this.state.sections = sections;
1160
+ }
1161
+
1162
+ _getActiveSectionLabel() {
1163
+ const map = this._indexMap?.[this.state.activePage];
1164
+ if (!map || !this.state.sections?.[map.sectionIndex]) return "";
1165
+ const section = this.state.sections[map.sectionIndex] || {};
1166
+ const title = section.title || "";
1167
+ if (title) return `Section ${map.sectionIndex + 1} - ${title}`;
1168
+ return `Section ${map.sectionIndex + 1}`;
1169
+ }
1170
+
1171
  // ---------- Page list ----------
1172
  renderPageList() {
1173
  this.$pageList.innerHTML = "";
1174
 
1175
  this.state.pages.forEach((_, idx) => {
1176
+ const map = this._indexMap?.[idx] || { sectionIndex: 0, pageIndex: idx };
1177
+ const section =
1178
+ (this.state.sections && this.state.sections[map.sectionIndex]) || {};
1179
+ const sectionLabel =
1180
+ section.title || `Section ${map.sectionIndex + 1}`;
1181
  const active = idx === this.state.activePage;
1182
 
1183
  const row = document.createElement("div");
 
1192
  : "border-gray-200 bg-white text-gray-800 hover:bg-gray-50");
1193
  btn.innerHTML = `
1194
  <div class="flex items-center justify-between">
1195
+ <div>
1196
+ <div class="text-[11px] ${active ? "text-white/80" : "text-gray-500"}">${sectionLabel}</div>
1197
+ <div class="text-sm font-semibold">Page ${map.pageIndex + 1}</div>
1198
+ </div>
1199
  <div class="text-xs ${active ? "text-white/80" : "text-gray-500"}">${this.state.pages[idx].items.length} items</div>
1200
  </div>
1201
  `;
 
1227
 
1228
  addPage() {
1229
  this._pushUndoSnapshot();
1230
+ const map = this._indexMap?.[this.state.activePage] || {
1231
+ sectionIndex: 0,
1232
+ pageIndex: this.state.activePage,
1233
+ };
1234
+ if (!this.state.sections[map.sectionIndex]) {
1235
+ this.state.sections = [
1236
+ { id: this._sectionId(), title: "Section 1", pages: [{ items: [] }] },
1237
+ ];
1238
+ }
1239
+ const section = this.state.sections[map.sectionIndex];
1240
+ section.pages = section.pages || [];
1241
+ section.pages.splice(map.pageIndex + 1, 0, { items: [] });
1242
+ this._setSections(this.state.sections);
1243
+ const newIndex = this._indexMap.findIndex(
1244
+ (entry) =>
1245
+ entry.sectionIndex === map.sectionIndex &&
1246
+ entry.pageIndex === map.pageIndex + 1
1247
+ );
1248
+ this.state.activePage =
1249
+ newIndex >= 0 ? newIndex : this.state.pages.length - 1;
1250
  this.state.selectedId = null;
1251
  this._savePages();
1252
  this.updateAll();
 
1257
  const idx = typeof index === "number" ? index : this.state.activePage;
1258
  if (idx < 0 || idx >= this.state.pages.length) return;
1259
 
1260
+ const map = this._indexMap?.[idx];
1261
+ if (!map || !this.state.sections[map.sectionIndex]) return;
1262
+ const section = this.state.sections[map.sectionIndex];
1263
+ const pages = section.pages || [];
1264
+ if (pages.length <= 1) return;
1265
+ pages.splice(map.pageIndex, 1);
1266
+ section.pages = pages.length ? pages : [{ items: [] }];
1267
+ this._setSections(this.state.sections);
1268
  if (this.state.activePage >= this.state.pages.length) {
1269
  this.state.activePage = this.state.pages.length - 1;
1270
  } else if (this.state.activePage > idx) {
 
1279
 
1280
  _ensurePageCount(count) {
1281
  const target = Math.max(Number(count) || 1, 1);
1282
+ if (!this.state.sections || !this.state.sections.length) {
1283
+ this.state.sections = [
1284
+ { id: this._sectionId(), title: "Section 1", pages: [{ items: [] }] },
1285
+ ];
1286
+ }
1287
  while (this.state.pages.length < target) {
1288
+ const lastSection = this.state.sections[this.state.sections.length - 1];
1289
+ lastSection.pages = lastSection.pages || [];
1290
+ lastSection.pages.push({ items: [] });
1291
+ this._setSections(this.state.sections);
1292
  }
1293
  }
1294
 
 
1313
 
1314
  const template = document.createElement("div");
1315
  template.className = "absolute inset-0";
1316
+ const active = this.activePage || {};
1317
  let templateHtml = "";
1318
+ if (active.blank) {
1319
+ templateHtml = `<div class="w-full h-full bg-white"></div>`;
1320
+ } else {
1321
+ try {
1322
+ templateHtml = this._templateMarkup();
1323
+ } catch (err) {
1324
+ console.error("Template render failed", err);
1325
+ templateHtml = `
1326
+ <div class="p-4 text-sm text-red-600">
1327
+ Template failed to render. Check console for details.
1328
+ </div>
1329
+ `;
1330
+ }
1331
  }
1332
  template.innerHTML = `
1333
  <div style="width:${this.BASE_W}px; height:${this.BASE_H}px; transform: scale(${scale}); transform-origin: top left;">
 
1335
  </div>
1336
  `;
1337
  this.$canvas.appendChild(template);
1338
+ if (!active.blank) {
1339
+ this._bindTemplateFields();
1340
+ }
1341
 
1342
  const items = this.activePage.items;
1343
  const selectedId = this.state.selectedId;
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
@@ -304,7 +313,7 @@ export default function ExportPage() {
304
  <div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
305
  <div className="text-xs font-semibold text-gray-600">Pages</div>
306
  <div className="text-sm font-semibold text-gray-900">
307
- {pages.length
308
  ? `${totalPages} pages - ${totals.items} total items`
309
  : "No saved pages yet"}
310
  </div>
@@ -356,16 +365,23 @@ export default function ExportPage() {
356
  >
357
  <div
358
  ref={index === 0 ? previewRef : undefined}
359
- className="relative overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm print:border-0 print:rounded-none print:shadow-none"
360
- style={{ aspectRatio: "210 / 297" }}
361
  >
362
  <ReportPageCanvas
363
  session={session}
364
- page={pages[index] ?? { items: [] }}
365
  pageIndex={index}
366
  pageCount={totalPages}
367
  scale={previewScale}
368
- template={pages[index]?.template}
 
 
 
 
 
 
 
 
369
  />
370
  </div>
371
  </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
 
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 ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BarChart2, ArrowLeft } from "react-feather";
2
+ import { Link, useSearchParams } from "react-router-dom";
3
+
4
+ import { PageShell } from "../components/PageShell";
5
+ import { PageHeader } from "../components/PageHeader";
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
+ return (
13
+ <PageShell className="max-w-6xl">
14
+ <PageHeader
15
+ title="Assessment Rating Scales"
16
+ subtitle="Interactive rating system for condition evaluation"
17
+ right={
18
+ <Link
19
+ to={`/report-viewer${sessionQuery}`}
20
+ 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"
21
+ >
22
+ <ArrowLeft className="h-4 w-4" />
23
+ Back
24
+ </Link>
25
+ }
26
+ />
27
+
28
+ <section className="mt-4 bg-white rounded-xl shadow-sm p-6">
29
+ <h2 className="text-xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-4 flex items-center gap-2">
30
+ <BarChart2 className="h-5 w-5" />
31
+ Rating Scales
32
+ </h2>
33
+
34
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-4 mb-6">
35
+ <p className="text-sm text-gray-700">
36
+ Use the tables below to interpret{" "}
37
+ <span className="font-semibold">Category</span> and{" "}
38
+ <span className="font-semibold">Priority</span> ratings used
39
+ throughout this report. Ratings determine severity and recommended
40
+ response time.
41
+ </p>
42
+ </div>
43
+
44
+ <div className="mt-4">
45
+ <label className="block text-sm font-medium text-gray-500 mb-2">
46
+ Condition Scale
47
+ </label>
48
+
49
+ <div className="grid grid-cols-6 gap-1 text-center mb-2">
50
+ <div className="text-xs font-bold bg-green-100 text-green-800 p-1 rounded">
51
+ 0
52
+ </div>
53
+ <div className="text-xs font-bold bg-green-200 text-green-800 p-1 rounded">
54
+ 1
55
+ </div>
56
+ <div className="text-xs font-bold bg-yellow-100 text-yellow-800 p-1 rounded">
57
+ 2
58
+ </div>
59
+ <div className="text-xs font-bold bg-yellow-200 text-yellow-800 p-1 rounded">
60
+ 3
61
+ </div>
62
+ <div className="text-xs font-bold bg-orange-200 text-orange-800 p-1 rounded">
63
+ 4
64
+ </div>
65
+ <div className="text-xs font-bold bg-red-200 text-red-800 p-1 rounded">
66
+ 5
67
+ </div>
68
+ </div>
69
+
70
+ <div className="grid grid-cols-6 gap-1 text-center">
71
+ <div
72
+ className="text-xs font-bold bg-green-100 text-green-800 p-1 rounded"
73
+ title="Excellent Condition: No Deterioration. Structure is Safe for use. 100% Original Strength of Structure. Remedial Action: None"
74
+ >
75
+ Excellent
76
+ </div>
77
+ <div
78
+ className="text-xs font-bold bg-green-200 text-green-800 p-1 rounded"
79
+ title="Good Condition: Slight Surface Deterioration. Structure is safe for use. 100% Original Strength of Structure. Remedial Action: None"
80
+ >
81
+ Good
82
+ </div>
83
+ <div
84
+ className="text-xs font-bold bg-yellow-100 text-yellow-800 p-1 rounded"
85
+ title="Fair Condition: Some Deterioration deeper than surface level. Structure is safe for use. 95-100% Original Strength of Structure. Remedial Action: Minor Works"
86
+ >
87
+ Fair
88
+ </div>
89
+ <div
90
+ className="text-xs font-bold bg-yellow-200 text-yellow-800 p-1 rounded"
91
+ title="Poor Condition: Discernible Deterioration. Some compromise in safety. 75-95% Original Strength of Structure. Remedial Action: Minor Works"
92
+ >
93
+ Poor
94
+ </div>
95
+ <div
96
+ className="text-xs font-bold bg-orange-200 text-orange-800 p-1 rounded"
97
+ title="Worse Condition: Severe Deterioration. Severe compromise in safety. 50-75% Original Strength of Structure. Remedial Action: Major Works"
98
+ >
99
+ Worse
100
+ </div>
101
+ <div
102
+ className="text-xs font-bold bg-red-200 text-red-800 p-1 rounded"
103
+ title="Severe Condition: Severe Deterioration. Total and complete compromise in safety. <50% Original Strength of Structure. Remedial Action: Major Works Urgently"
104
+ >
105
+ Severe
106
+ </div>
107
+ </div>
108
+
109
+ <div className="mt-3 overflow-x-auto rounded-lg border border-gray-200">
110
+ <table className="min-w-full divide-y divide-gray-200 text-sm">
111
+ <thead className="bg-gray-50">
112
+ <tr>
113
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
114
+ Category
115
+ </th>
116
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
117
+ Label
118
+ </th>
119
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
120
+ Definition
121
+ </th>
122
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
123
+ Strength
124
+ </th>
125
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
126
+ Remedial Action
127
+ </th>
128
+ </tr>
129
+ </thead>
130
+ <tbody className="bg-white divide-y divide-gray-200">
131
+ <tr>
132
+ <td className="px-3 py-2 font-semibold text-gray-900">0</td>
133
+ <td className="px-3 py-2">Excellent</td>
134
+ <td className="px-3 py-2">
135
+ No deterioration. Structure is safe for use.
136
+ </td>
137
+ <td className="px-3 py-2">100% Original Strength</td>
138
+ <td className="px-3 py-2">None</td>
139
+ </tr>
140
+ <tr>
141
+ <td className="px-3 py-2 font-semibold text-gray-900">1</td>
142
+ <td className="px-3 py-2">Good</td>
143
+ <td className="px-3 py-2">
144
+ Slight surface deterioration. Structure is safe for use.
145
+ </td>
146
+ <td className="px-3 py-2">100% Original Strength</td>
147
+ <td className="px-3 py-2">None</td>
148
+ </tr>
149
+ <tr>
150
+ <td className="px-3 py-2 font-semibold text-gray-900">2</td>
151
+ <td className="px-3 py-2">Fair</td>
152
+ <td className="px-3 py-2">
153
+ Some deterioration deeper than surface level. Structure is
154
+ safe for use.
155
+ </td>
156
+ <td className="px-3 py-2">95–100% Original Strength</td>
157
+ <td className="px-3 py-2">Minor Works</td>
158
+ </tr>
159
+ <tr>
160
+ <td className="px-3 py-2 font-semibold text-gray-900">3</td>
161
+ <td className="px-3 py-2">Poor</td>
162
+ <td className="px-3 py-2">
163
+ Discernible deterioration. Some compromise in safety.
164
+ </td>
165
+ <td className="px-3 py-2">75–95% Original Strength</td>
166
+ <td className="px-3 py-2">Minor Works</td>
167
+ </tr>
168
+ <tr>
169
+ <td className="px-3 py-2 font-semibold text-gray-900">4</td>
170
+ <td className="px-3 py-2">Worse</td>
171
+ <td className="px-3 py-2">
172
+ Severe deterioration. Severe compromise in safety.
173
+ </td>
174
+ <td className="px-3 py-2">50–75% Original Strength</td>
175
+ <td className="px-3 py-2">Major Works</td>
176
+ </tr>
177
+ <tr>
178
+ <td className="px-3 py-2 font-semibold text-gray-900">5</td>
179
+ <td className="px-3 py-2">Severe</td>
180
+ <td className="px-3 py-2">
181
+ Severe deterioration. Total and complete compromise in
182
+ safety.
183
+ </td>
184
+ <td className="px-3 py-2">&lt;50% Original Strength</td>
185
+ <td className="px-3 py-2">Major Works (Urgently)</td>
186
+ </tr>
187
+ </tbody>
188
+ </table>
189
+ </div>
190
+ </div>
191
+
192
+ <div className="mt-6">
193
+ <label className="block text-sm font-medium text-gray-500 mb-2">
194
+ Priority Scale
195
+ </label>
196
+
197
+ <div className="grid grid-cols-5 gap-1 text-center mb-2">
198
+ <div className="text-xs font-bold bg-red-200 text-red-800 p-1 rounded">
199
+ 1
200
+ </div>
201
+ <div className="text-xs font-bold bg-orange-200 text-orange-800 p-1 rounded">
202
+ 2
203
+ </div>
204
+ <div className="text-xs font-bold bg-green-200 text-green-800 p-1 rounded">
205
+ 3
206
+ </div>
207
+ <div className="text-xs font-bold bg-purple-200 text-purple-800 p-1 rounded">
208
+ X
209
+ </div>
210
+ <div className="text-xs font-bold bg-blue-200 text-blue-800 p-1 rounded">
211
+ M
212
+ </div>
213
+ </div>
214
+
215
+ <div className="grid grid-cols-5 gap-1 text-center">
216
+ <div className="text-xs font-bold bg-red-200 text-red-800 p-1 rounded">
217
+ Immediate
218
+ </div>
219
+ <div className="text-xs font-bold bg-orange-200 text-orange-800 p-1 rounded">
220
+ 1 Year
221
+ </div>
222
+ <div className="text-xs font-bold bg-green-200 text-green-800 p-1 rounded">
223
+ 3 Years
224
+ </div>
225
+ <div className="text-xs font-bold bg-purple-200 text-purple-800 p-1 rounded">
226
+ At Use
227
+ </div>
228
+ <div className="text-xs font-bold bg-blue-200 text-blue-800 p-1 rounded">
229
+ Monitor
230
+ </div>
231
+ </div>
232
+
233
+ <div className="mt-3 overflow-x-auto rounded-lg border border-gray-200">
234
+ <table className="min-w-full divide-y divide-gray-200 text-sm">
235
+ <thead className="bg-gray-50">
236
+ <tr>
237
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
238
+ Priority
239
+ </th>
240
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
241
+ Label
242
+ </th>
243
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
244
+ Definition
245
+ </th>
246
+ </tr>
247
+ </thead>
248
+ <tbody className="bg-white divide-y divide-gray-200">
249
+ <tr>
250
+ <td className="px-3 py-2 font-semibold text-gray-900">1</td>
251
+ <td className="px-3 py-2">Immediate</td>
252
+ <td className="px-3 py-2">
253
+ Action requires urgent, immediate attention.
254
+ </td>
255
+ </tr>
256
+ <tr>
257
+ <td className="px-3 py-2 font-semibold text-gray-900">2</td>
258
+ <td className="px-3 py-2">1 Year</td>
259
+ <td className="px-3 py-2">
260
+ Action to be done ASAP, and no later than 1 year from report
261
+ date.
262
+ </td>
263
+ </tr>
264
+ <tr>
265
+ <td className="px-3 py-2 font-semibold text-gray-900">3</td>
266
+ <td className="px-3 py-2">3 Years</td>
267
+ <td className="px-3 py-2">
268
+ Action required within the next 3 years from report date.
269
+ </td>
270
+ </tr>
271
+ <tr>
272
+ <td className="px-3 py-2 font-semibold text-gray-900">X</td>
273
+ <td className="px-3 py-2">At Use</td>
274
+ <td className="px-3 py-2">
275
+ Action must be completed before use of the non-critical
276
+ service structure.
277
+ </td>
278
+ </tr>
279
+ <tr>
280
+ <td className="px-3 py-2 font-semibold text-gray-900">M</td>
281
+ <td className="px-3 py-2">Monitor</td>
282
+ <td className="px-3 py-2">
283
+ Monitoring required to evaluate the extent of
284
+ damage/deterioration with record keeping.
285
+ </td>
286
+ </tr>
287
+ </tbody>
288
+ </table>
289
+ </div>
290
+ </div>
291
+ </section>
292
+ </PageShell>
293
+ );
294
+ }
frontend/src/pages/ReportViewerPage.tsx CHANGED
@@ -13,16 +13,18 @@ import {
13
 
14
  import { request } from "../lib/api";
15
  import { BASE_W } from "../lib/report";
 
16
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
17
- import type { Page, Session } from "../types/session";
18
  import { ReportPageCanvas } from "../components/ReportPageCanvas";
 
19
 
20
  export default function ReportViewerPage() {
21
  const [searchParams] = useSearchParams();
22
  const sessionId = getSessionId(searchParams.toString());
23
 
24
  const [session, setSession] = useState<Session | null>(null);
25
- const [pages, setPages] = useState<Page[]>([]);
26
  const [pageIndex, setPageIndex] = useState(0);
27
  const [scale, setScale] = useState(1);
28
  const [error, setError] = useState("");
@@ -54,11 +56,11 @@ export default function ReportViewerPage() {
54
  try {
55
  const data = await request<Session>(`/sessions/${sessionId}`);
56
  setSession(data);
57
- const pageResp = await request<{ pages: Page[] }>(
58
- `/sessions/${sessionId}/pages`,
59
  );
60
- const loaded = Array.isArray(pageResp.pages) ? pageResp.pages : [];
61
- setPages(loaded);
62
  } catch (err) {
63
  const message =
64
  err instanceof Error ? err.message : "Failed to load session.";
@@ -68,10 +70,14 @@ export default function ReportViewerPage() {
68
  load();
69
  }, [sessionId]);
70
 
 
 
 
 
71
  const totalPages = useMemo(() => {
72
- if (pages.length > 0) return pages.length;
73
  return Math.max(1, session?.page_count ?? 0);
74
- }, [pages.length, session?.page_count]);
75
 
76
  useEffect(() => {
77
  setPageIndex((idx) => Math.min(Math.max(0, idx), totalPages - 1));
@@ -90,7 +96,12 @@ export default function ReportViewerPage() {
90
  return () => window.removeEventListener("keydown", handler);
91
  }, [totalPages]);
92
 
93
- const page = pages[pageIndex] ?? null;
 
 
 
 
 
94
  const template = page?.template;
95
  const sessionQuery = buildSessionQuery(sessionId || "");
96
  const editReportQuery = useMemo(() => {
@@ -106,12 +117,12 @@ export default function ReportViewerPage() {
106
  const selected = session.selected_photo_ids?.length ?? 0;
107
  const docs = session.uploads?.documents?.length ?? 0;
108
  const dataFiles = session.uploads?.data_files?.length ?? 0;
109
- const hasEdits = pages.length > 0;
110
  return (
111
  `Selected example photos: ${selected} - Documents: ${docs} - Data files: ${dataFiles}` +
112
  (hasEdits ? " - Edited pages loaded" : " - No saved edits yet")
113
  );
114
- }, [pages.length, session]);
115
 
116
  return (
117
  <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">
@@ -141,6 +152,7 @@ export default function ReportViewerPage() {
141
  <ArrowLeft className="h-4 w-4" />
142
  Back
143
  </Link>
 
144
  </div>
145
  </div>
146
  </header>
@@ -240,6 +252,7 @@ export default function ReportViewerPage() {
240
  pageCount={totalPages}
241
  scale={scale}
242
  template={template}
 
243
  adaptive
244
  />
245
  </div>
 
13
 
14
  import { request } from "../lib/api";
15
  import { BASE_W } from "../lib/report";
16
+ import { ensureSections, flattenSections } from "../lib/sections";
17
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
18
+ import type { JobsheetSection, Session } from "../types/session";
19
  import { ReportPageCanvas } from "../components/ReportPageCanvas";
20
+ import { InfoMenu } from "../components/InfoMenu";
21
 
22
  export default function ReportViewerPage() {
23
  const [searchParams] = useSearchParams();
24
  const sessionId = getSessionId(searchParams.toString());
25
 
26
  const [session, setSession] = useState<Session | null>(null);
27
+ const [sections, setSections] = useState<JobsheetSection[]>([]);
28
  const [pageIndex, setPageIndex] = useState(0);
29
  const [scale, setScale] = useState(1);
30
  const [error, setError] = useState("");
 
56
  try {
57
  const data = await request<Session>(`/sessions/${sessionId}`);
58
  setSession(data);
59
+ const sectionResp = await request<{ sections: JobsheetSection[] }>(
60
+ `/sessions/${sessionId}/sections`,
61
  );
62
+ const loaded = ensureSections(sectionResp.sections);
63
+ setSections(loaded);
64
  } catch (err) {
65
  const message =
66
  err instanceof Error ? err.message : "Failed to load session.";
 
70
  load();
71
  }, [sessionId]);
72
 
73
+ const flatPages = useMemo(
74
+ () => flattenSections(ensureSections(sections)),
75
+ [sections],
76
+ );
77
  const totalPages = useMemo(() => {
78
+ if (flatPages.length > 0) return flatPages.length;
79
  return Math.max(1, session?.page_count ?? 0);
80
+ }, [flatPages.length, session?.page_count]);
81
 
82
  useEffect(() => {
83
  setPageIndex((idx) => Math.min(Math.max(0, idx), totalPages - 1));
 
96
  return () => window.removeEventListener("keydown", handler);
97
  }, [totalPages]);
98
 
99
+ const page = flatPages[pageIndex]?.page ?? null;
100
+ const sectionLabel = flatPages[pageIndex]?.sectionTitle
101
+ ? `Section ${flatPages[pageIndex].sectionIndex + 1} - ${flatPages[pageIndex].sectionTitle}`
102
+ : flatPages[pageIndex]
103
+ ? `Section ${flatPages[pageIndex].sectionIndex + 1}`
104
+ : "";
105
  const template = page?.template;
106
  const sessionQuery = buildSessionQuery(sessionId || "");
107
  const editReportQuery = useMemo(() => {
 
117
  const selected = session.selected_photo_ids?.length ?? 0;
118
  const docs = session.uploads?.documents?.length ?? 0;
119
  const dataFiles = session.uploads?.data_files?.length ?? 0;
120
+ const hasEdits = flatPages.length > 0;
121
  return (
122
  `Selected example photos: ${selected} - Documents: ${docs} - Data files: ${dataFiles}` +
123
  (hasEdits ? " - Edited pages loaded" : " - No saved edits yet")
124
  );
125
+ }, [flatPages.length, session]);
126
 
127
  return (
128
  <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">
 
152
  <ArrowLeft className="h-4 w-4" />
153
  Back
154
  </Link>
155
+ <InfoMenu sessionQuery={sessionQuery} />
156
  </div>
157
  </div>
158
  </header>
 
252
  pageCount={totalPages}
253
  scale={scale}
254
  template={template}
255
+ sectionLabel={sectionLabel}
256
  adaptive
257
  />
258
  </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
+ };
server/app/api/routes/sessions.py CHANGED
@@ -12,6 +12,8 @@ from ..deps import get_session_store
12
  from ..schemas import (
13
  PagesRequest,
14
  PagesResponse,
 
 
15
  SelectionRequest,
16
  SessionResponse,
17
  SessionStatusResponse,
@@ -95,6 +97,7 @@ def get_session(session_id: str, store: SessionStore = Depends(get_session_store
95
  session = store.get_session(session_id)
96
  if not session:
97
  raise HTTPException(status_code=404, detail="Session not found.")
 
98
  return _attach_urls(session)
99
 
100
 
@@ -149,6 +152,32 @@ def save_pages(
149
  return PagesResponse(pages=session.get("pages") or [])
150
 
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  @router.get("/{session_id}/uploads/{file_id}")
153
  def get_upload(
154
  session_id: str,
@@ -247,8 +276,13 @@ def import_json(
247
 
248
  imported_session = payload.get("session") if isinstance(payload, dict) else None
249
  pages = payload.get("pages") if isinstance(payload, dict) else None
 
 
 
250
  if not pages and isinstance(imported_session, dict):
251
  pages = imported_session.get("pages")
 
 
252
  if pages:
253
  session = store.set_pages(session, pages)
254
 
@@ -276,10 +310,13 @@ def export_package(
276
  session = store.get_session(session_id)
277
  if not session:
278
  raise HTTPException(status_code=404, detail="Session not found.")
 
 
279
  export_path = Path(store.session_dir(session_id)) / "export.json"
280
  payload = {
281
  "session": session,
282
- "pages": session.get("pages") or [],
 
283
  "exported_at": session.get("updated_at"),
284
  }
285
  export_path.write_text(
 
12
  from ..schemas import (
13
  PagesRequest,
14
  PagesResponse,
15
+ SectionsRequest,
16
+ SectionsResponse,
17
  SelectionRequest,
18
  SessionResponse,
19
  SessionStatusResponse,
 
97
  session = store.get_session(session_id)
98
  if not session:
99
  raise HTTPException(status_code=404, detail="Session not found.")
100
+ store.ensure_sections(session)
101
  return _attach_urls(session)
102
 
103
 
 
152
  return PagesResponse(pages=session.get("pages") or [])
153
 
154
 
155
+ @router.get("/{session_id}/sections", response_model=SectionsResponse)
156
+ def get_sections(
157
+ session_id: str, store: SessionStore = Depends(get_session_store)
158
+ ) -> SectionsResponse:
159
+ session_id = _normalize_session_id(session_id, store)
160
+ session = store.get_session(session_id)
161
+ if not session:
162
+ raise HTTPException(status_code=404, detail="Session not found.")
163
+ sections = store.ensure_sections(session)
164
+ return SectionsResponse(sections=sections)
165
+
166
+
167
+ @router.put("/{session_id}/sections", response_model=SectionsResponse)
168
+ def save_sections(
169
+ session_id: str,
170
+ payload: SectionsRequest,
171
+ store: SessionStore = Depends(get_session_store),
172
+ ) -> SectionsResponse:
173
+ session_id = _normalize_session_id(session_id, store)
174
+ session = store.get_session(session_id)
175
+ if not session:
176
+ raise HTTPException(status_code=404, detail="Session not found.")
177
+ session = store.set_sections(session, payload.sections)
178
+ return SectionsResponse(sections=session.get("jobsheet_sections") or [])
179
+
180
+
181
  @router.get("/{session_id}/uploads/{file_id}")
182
  def get_upload(
183
  session_id: str,
 
276
 
277
  imported_session = payload.get("session") if isinstance(payload, dict) else None
278
  pages = payload.get("pages") if isinstance(payload, dict) else None
279
+ sections = payload.get("sections") if isinstance(payload, dict) else None
280
+ if not sections and isinstance(imported_session, dict):
281
+ sections = imported_session.get("jobsheet_sections")
282
  if not pages and isinstance(imported_session, dict):
283
  pages = imported_session.get("pages")
284
+ if sections:
285
+ session = store.set_sections(session, sections)
286
  if pages:
287
  session = store.set_pages(session, pages)
288
 
 
310
  session = store.get_session(session_id)
311
  if not session:
312
  raise HTTPException(status_code=404, detail="Session not found.")
313
+ sections = store.ensure_sections(session)
314
+ pages = store.ensure_pages(session)
315
  export_path = Path(store.session_dir(session_id)) / "export.json"
316
  payload = {
317
  "session": session,
318
+ "pages": pages,
319
+ "sections": sections,
320
  "exported_at": session.get("updated_at"),
321
  }
322
  export_path.write_text(
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 CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
 
3
  import math
4
  from pathlib import Path
 
5
  from typing import Iterable, List, Optional
6
 
7
  from reportlab.lib import colors
@@ -130,10 +131,14 @@ def _draw_label_value(
130
 
131
 
132
  def _badge_style(value: str, scale: dict) -> tuple[str, colors.Color, colors.Color]:
133
- key = (value or "").strip().upper()
 
 
 
 
134
  tone = scale.get(key)
135
  if not tone:
136
- return (value or "-"), colors.HexColor("#f9fafb"), colors.HexColor("#374151")
137
  return f"{key} - {tone['label']}", tone["bg"], tone["text"]
138
 
139
 
 
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
 
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
 
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")