ChristopherJKoen commited on
Commit
15a4294
·
1 Parent(s): 6842961

Refresh sections UI and templates

Browse files

Normalize sections/headings storage and guard autosave to prevent headings flicker.
Improve Edit Layouts UX (collapsible sections, page counts, clearer controls).
Align job sheet templates in viewer/editor/PDF and remove the redundant 'Document No' label.
Add ImagePlacementPage scaffold.

DataInputTemplate-populated-sr.xlsx ADDED
Binary file (15.5 kB). View file
 
examples/DataInputTemplate-populated-sr.xlsx ADDED
Binary file (15.5 kB). View file
 
frontend/public/templates/job-sheet-template.html CHANGED
@@ -58,8 +58,15 @@
58
  </div>
59
 
60
  <div class="text-center leading-tight">
61
- <h1 class="text-2xl font-bold text-gray-900 whitespace-nowrap">RepEx Inspection Job Sheet</h1>
62
- <p class="text-sm text-gray-600 whitespace-nowrap">Report Express - Inspection Management</p>
 
 
 
 
 
 
 
63
  </div>
64
 
65
  <div class="flex items-center justify-end">
@@ -128,7 +135,6 @@
128
  <div class="text-xs font-medium text-gray-500">Condition Description</div>
129
  <div class="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
130
  <p class="template-field template-field-multiline text-amber-800 text-sm font-semibold leading-snug" contenteditable="true" data-placeholder="Item description"></p>
131
- <p class="template-field template-field-multiline text-amber-800 text-sm font-semibold leading-snug" contenteditable="true" data-placeholder="Condition description"></p>
132
  </div>
133
  </div>
134
 
@@ -136,7 +142,6 @@
136
  <div class="md:col-span-2 space-y-1">
137
  <div class="text-xs font-medium text-gray-500">Action Required</div>
138
  <div class="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
139
- <p class="template-field template-field-multiline text-blue-800 text-sm font-semibold leading-snug" contenteditable="true" data-placeholder="Action type"></p>
140
  <p class="template-field template-field-multiline text-blue-800 text-sm font-semibold leading-snug" contenteditable="true" data-placeholder="Required action"></p>
141
  </div>
142
  </div>
@@ -207,11 +212,28 @@
207
  </section>
208
 
209
  <!-- Footer -->
210
- <footer class="mt-2 text-[10px] text-gray-500 flex flex-wrap items-center justify-center gap-3">
211
- <span>Date: <span class="template-field" contenteditable="true" data-placeholder="YYYY-MM-DD"></span></span>
212
- <span>Inspector: <span class="template-field" contenteditable="true" data-placeholder="Inspector name"></span></span>
213
- <span>Doc: <span class="template-field" contenteditable="true" data-placeholder="Document no"></span></span>
214
- <span>Site: <span class="template-field" contenteditable="true" data-placeholder="Client or site"></span></span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  </footer>
216
  </main>
217
  </body>
 
58
  </div>
59
 
60
  <div class="text-center leading-tight">
61
+ <div class="text-base font-semibold text-gray-900 whitespace-nowrap">
62
+ <span
63
+ class="template-field"
64
+ contenteditable="true"
65
+ data-placeholder="Document No"
66
+ style="display:inline-block; min-width: 180px;"
67
+ ></span>
68
+ </div>
69
+ <div class="text-[10px] text-gray-500 whitespace-nowrap">Document No</div>
70
  </div>
71
 
72
  <div class="flex items-center justify-end">
 
135
  <div class="text-xs font-medium text-gray-500">Condition Description</div>
136
  <div class="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
137
  <p class="template-field template-field-multiline text-amber-800 text-sm font-semibold leading-snug" contenteditable="true" data-placeholder="Item description"></p>
 
138
  </div>
139
  </div>
140
 
 
142
  <div class="md:col-span-2 space-y-1">
143
  <div class="text-xs font-medium text-gray-500">Action Required</div>
144
  <div class="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
 
145
  <p class="template-field template-field-multiline text-blue-800 text-sm font-semibold leading-snug" contenteditable="true" data-placeholder="Required action"></p>
146
  </div>
147
  </div>
 
212
  </section>
213
 
214
  <!-- Footer -->
215
+ <footer class="mt-2 text-[10px] text-gray-500 flex flex-col items-center gap-1">
216
+ <div class="flex flex-wrap items-center justify-center gap-3">
217
+ <span>Date: <span class="template-field" contenteditable="true" data-placeholder="YYYY-MM-DD"></span></span>
218
+ <span>Inspector: <span class="template-field" contenteditable="true" data-placeholder="Inspector name"></span></span>
219
+ <span>Doc: <span class="template-field" contenteditable="true" data-placeholder="Document no"></span></span>
220
+ </div>
221
+ <div class="text-[10px] font-semibold text-gray-600">RepEx Inspection Job Sheet</div>
222
+ <div class="text-[10px] text-gray-500">
223
+ <span
224
+ class="template-field"
225
+ contenteditable="true"
226
+ data-placeholder="Section 1"
227
+ style="display:inline-block; min-width: 90px;"
228
+ ></span>
229
+ <span class="mx-1">-</span>
230
+ <span
231
+ class="template-field"
232
+ contenteditable="true"
233
+ data-placeholder="Page 1 of 1"
234
+ style="display:inline-block; min-width: 90px;"
235
+ ></span>
236
+ </div>
237
  </footer>
238
  </main>
239
  </body>
frontend/src/App.tsx CHANGED
@@ -4,6 +4,7 @@ import UploadPage from "./pages/UploadPage";
4
  import ProcessingPage from "./pages/ProcessingPage";
5
  import ReviewSetupPage from "./pages/ReviewSetupPage";
6
  import ReportViewerPage from "./pages/ReportViewerPage";
 
7
  import InputDataPage from "./pages/InputDataPage";
8
  import EditReportPage from "./pages/EditReportPage";
9
  import EditLayoutsPage from "./pages/EditLayoutsPage";
@@ -18,6 +19,7 @@ export default function App() {
18
  <Route path="/processing" element={<ProcessingPage />} />
19
  <Route path="/review-setup" element={<ReviewSetupPage />} />
20
  <Route path="/report-viewer" element={<ReportViewerPage />} />
 
21
  <Route path="/input-data" element={<InputDataPage />} />
22
  <Route path="/edit-report" element={<EditReportPage />} />
23
  <Route path="/edit-layouts" element={<EditLayoutsPage />} />
 
4
  import ProcessingPage from "./pages/ProcessingPage";
5
  import ReviewSetupPage from "./pages/ReviewSetupPage";
6
  import ReportViewerPage from "./pages/ReportViewerPage";
7
+ import ImagePlacementPage from "./pages/ImagePlacementPage";
8
  import InputDataPage from "./pages/InputDataPage";
9
  import EditReportPage from "./pages/EditReportPage";
10
  import EditLayoutsPage from "./pages/EditLayoutsPage";
 
19
  <Route path="/processing" element={<ProcessingPage />} />
20
  <Route path="/review-setup" element={<ReviewSetupPage />} />
21
  <Route path="/report-viewer" element={<ReportViewerPage />} />
22
+ <Route path="/image-placement" element={<ImagePlacementPage />} />
23
  <Route path="/input-data" element={<InputDataPage />} />
24
  <Route path="/edit-report" element={<EditReportPage />} />
25
  <Route path="/edit-layouts" element={<EditLayoutsPage />} />
frontend/src/components/JobSheetTemplate.tsx CHANGED
@@ -1,7 +1,7 @@
1
  import { useEffect, useMemo, useState } from "react";
2
 
3
  import type { FileMeta, Session, TemplateFields } from "../types/session";
4
- import { formatDocNumber, getPhotosForPage } from "../lib/report";
5
 
6
  type JobSheetTemplateProps = {
7
  session: Session | null;
@@ -12,6 +12,7 @@ type JobSheetTemplateProps = {
12
  orderLocked?: boolean;
13
  variant?: "full" | "photos";
14
  sectionLabel?: string;
 
15
  };
16
 
17
  type PhotoSlotProps = {
@@ -264,37 +265,32 @@ export function JobSheetTemplate({
264
  orderLocked = false,
265
  variant = "full",
266
  sectionLabel,
 
267
  }: JobSheetTemplateProps) {
268
  const inspectionDate =
269
  template?.inspection_date ?? session?.inspection_date ?? "";
270
  const inspector = template?.inspector ?? "";
271
  const docNumber =
272
- template?.document_no ?? (session?.id ? formatDocNumber(session) : "");
273
- const clientSite = template?.client_site ?? "";
 
274
  const companyLogo = template?.company_logo ?? "";
 
275
 
276
  const reference = template?.reference ?? "";
277
  const area = template?.area ?? "";
278
- const actionType = template?.action_type ?? "";
279
  const itemDescription = template?.item_description ?? "";
280
  const functionalLocation = template?.functional_location ?? "";
281
  const category = template?.category ?? "";
282
  const priority = template?.priority ?? "";
283
- const conditionDescription =
284
- template?.condition_description ?? session?.notes ?? "";
285
  const requiredAction = template?.required_action ?? "";
286
 
287
- const conditionText = [itemDescription, conditionDescription]
288
- .filter((value) => value && value.trim())
289
- .join(" - ");
290
- const actionText = [actionType, requiredAction]
291
- .filter((value) => value && value.trim())
292
- .join(" - ");
293
  const categoryBadge = formatRating(category, CATEGORY_SCALE);
294
  const priorityBadge = formatRating(priority, PRIORITY_SCALE);
295
 
296
- const resolvedPhotos =
297
- photos && photos.length ? photos : getPhotosForPage(session, pageIndex, 1);
298
  const limitedPhotos = resolvedPhotos.slice(0, 6);
299
  const logoUrl = resolveLogoUrl(session, companyLogo);
300
  const [ratios, setRatios] = useState<Record<string, number>>({});
@@ -335,10 +331,19 @@ export function JobSheetTemplate({
335
  const displayedPhotos =
336
  variant === "full" ? orderedPhotos.slice(0, 2) : orderedPhotos;
337
 
 
 
 
 
 
338
  const photoGridClass =
339
- displayedPhotos.length <= 1
340
  ? "grid grid-cols-1 gap-3"
341
- : "grid grid-cols-2 gap-3";
 
 
 
 
342
 
343
  return (
344
  <div className="w-full h-full p-5 text-[11px] text-gray-700 flex flex-col">
@@ -351,10 +356,7 @@ export function JobSheetTemplate({
351
  />
352
  <div className="text-center leading-tight">
353
  <div className="text-base font-semibold text-gray-900">
354
- RepEx Inspection Job Sheet
355
- </div>
356
- <div className="text-[11px] text-gray-500">
357
- Page {pageIndex + 1} of {pageCount}
358
  </div>
359
  </div>
360
  <img
@@ -468,7 +470,7 @@ export function JobSheetTemplate({
468
  <PhotoSlot
469
  key={photo?.id || `${index}`}
470
  url={photo?.url}
471
- label={photo?.name || `Figure ${index + 1}`}
472
  className="h-full"
473
  imageClassName="h-full"
474
  />
@@ -477,12 +479,19 @@ export function JobSheetTemplate({
477
  </div>
478
  </section>
479
 
480
- <footer className="mt-2 text-[10px] text-gray-500 flex flex-wrap items-center justify-center gap-3">
481
- <span>Date: {inspectionDate || "-"}</span>
482
- <span>Inspector: {inspector || "-"}</span>
483
- <span>Doc: {docNumber || "-"}</span>
484
- <span>Site: {clientSite || "-"}</span>
485
- {sectionLabel ? <span>{sectionLabel}</span> : null}
 
 
 
 
 
 
 
486
  </footer>
487
  </div>
488
  );
 
1
  import { useEffect, useMemo, useState } from "react";
2
 
3
  import type { FileMeta, Session, TemplateFields } from "../types/session";
4
+ import { formatDocNumber } from "../lib/report";
5
 
6
  type JobSheetTemplateProps = {
7
  session: Session | null;
 
12
  orderLocked?: boolean;
13
  variant?: "full" | "photos";
14
  sectionLabel?: string;
15
+ photoLayout?: "auto" | "two-column" | "stacked";
16
  };
17
 
18
  type PhotoSlotProps = {
 
265
  orderLocked = false,
266
  variant = "full",
267
  sectionLabel,
268
+ photoLayout = "auto",
269
  }: JobSheetTemplateProps) {
270
  const inspectionDate =
271
  template?.inspection_date ?? session?.inspection_date ?? "";
272
  const inspector = template?.inspector ?? "";
273
  const docNumber =
274
+ template?.document_no ??
275
+ session?.document_no ??
276
+ (session?.id ? formatDocNumber(session) : "");
277
  const companyLogo = template?.company_logo ?? "";
278
+ const figureCaption = template?.figure_caption ?? "";
279
 
280
  const reference = template?.reference ?? "";
281
  const area = template?.area ?? "";
 
282
  const itemDescription = template?.item_description ?? "";
283
  const functionalLocation = template?.functional_location ?? "";
284
  const category = template?.category ?? "";
285
  const priority = template?.priority ?? "";
 
 
286
  const requiredAction = template?.required_action ?? "";
287
 
288
+ const conditionText = itemDescription;
289
+ const actionText = requiredAction;
 
 
 
 
290
  const categoryBadge = formatRating(category, CATEGORY_SCALE);
291
  const priorityBadge = formatRating(priority, PRIORITY_SCALE);
292
 
293
+ const resolvedPhotos = photos && photos.length ? photos : [];
 
294
  const limitedPhotos = resolvedPhotos.slice(0, 6);
295
  const logoUrl = resolveLogoUrl(session, companyLogo);
296
  const [ratios, setRatios] = useState<Record<string, number>>({});
 
331
  const displayedPhotos =
332
  variant === "full" ? orderedPhotos.slice(0, 2) : orderedPhotos;
333
 
334
+ const normalizedLayout = (photoLayout || "auto").toLowerCase();
335
+ const layoutMode =
336
+ normalizedLayout === "stacked" || normalizedLayout === "two-column"
337
+ ? normalizedLayout
338
+ : "auto";
339
  const photoGridClass =
340
+ layoutMode === "stacked"
341
  ? "grid grid-cols-1 gap-3"
342
+ : layoutMode === "two-column"
343
+ ? "grid grid-cols-2 gap-3"
344
+ : displayedPhotos.length <= 1
345
+ ? "grid grid-cols-1 gap-3"
346
+ : "grid grid-cols-2 gap-3";
347
 
348
  return (
349
  <div className="w-full h-full p-5 text-[11px] text-gray-700 flex flex-col">
 
356
  />
357
  <div className="text-center leading-tight">
358
  <div className="text-base font-semibold text-gray-900">
359
+ {docNumber || "-"}
 
 
 
360
  </div>
361
  </div>
362
  <img
 
470
  <PhotoSlot
471
  key={photo?.id || `${index}`}
472
  url={photo?.url}
473
+ label={figureCaption || photo?.name || `Figure ${index + 1}`}
474
  className="h-full"
475
  imageClassName="h-full"
476
  />
 
479
  </div>
480
  </section>
481
 
482
+ <footer className="mt-2 text-[10px] text-gray-500 flex flex-col items-center gap-1">
483
+ <div className="flex flex-wrap items-center justify-center gap-3">
484
+ <span>Date: {inspectionDate || "-"}</span>
485
+ <span>Inspector: {inspector || "-"}</span>
486
+ <span>Doc: {docNumber || "-"}</span>
487
+ </div>
488
+ <div className="text-[10px] font-semibold text-gray-600">
489
+ RepEx Inspection Job Sheet
490
+ </div>
491
+ <div className="text-[10px] text-gray-500">
492
+ {sectionLabel ? `${sectionLabel} - ` : ""}
493
+ Page {pageIndex + 1} of {pageCount}
494
+ </div>
495
  </footer>
496
  </div>
497
  );
frontend/src/components/ReportPageCanvas.tsx CHANGED
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
  import type { CSSProperties } from "react";
3
 
4
  import type { FileMeta, Page, Session, TemplateFields } from "../types/session";
5
- import { BASE_H, BASE_W, getPhotosForPage } from "../lib/report";
6
  import { JobSheetTemplate } from "./JobSheetTemplate";
7
 
8
  type ReportPageCanvasProps = {
@@ -116,16 +116,17 @@ export function ReportPageCanvas({
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>
@@ -209,5 +210,5 @@ function resolvePagePhotos(
209
  if (explicit.length) {
210
  return explicit.map((id) => byId.get(id)).filter(Boolean) as FileMeta[];
211
  }
212
- return getPhotosForPage(session, pageIndex, 1);
213
  }
 
2
  import type { CSSProperties } from "react";
3
 
4
  import type { FileMeta, Page, Session, TemplateFields } from "../types/session";
5
+ import { BASE_H, BASE_W } from "../lib/report";
6
  import { JobSheetTemplate } from "./JobSheetTemplate";
7
 
8
  type ReportPageCanvasProps = {
 
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
+ photoLayout={page?.photo_layout}
129
+ />
130
  )}
131
  </div>
132
  </div>
 
210
  if (explicit.length) {
211
  return explicit.map((id) => byId.get(id)).filter(Boolean) as FileMeta[];
212
  }
213
+ return [];
214
  }
frontend/src/components/report-editor.js CHANGED
@@ -62,6 +62,7 @@ class ReportEditor extends HTMLElement {
62
  this.sessionId = null;
63
  this.apiBase = null;
64
  this._saveTimer = null;
 
65
  this._photoRatios = new Map();
66
  this._indexMap = [];
67
  }
@@ -164,6 +165,14 @@ class ReportEditor extends HTMLElement {
164
  this.dispatchEvent(new CustomEvent("editor-closed", { bubbles: true }));
165
  }
166
 
 
 
 
 
 
 
 
 
167
  // ---------- Rendering ----------
168
  render() {
169
  this.innerHTML = `
@@ -194,10 +203,9 @@ class ReportEditor extends HTMLElement {
194
  <i data-feather="rotate-cw" class="h-4 w-4"></i> Redo
195
  </button>
196
 
197
- <button data-btn="save"
198
- class="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-3 py-2 text-sm font-semibold text-white hover:bg-emerald-700 transition">
199
- <i data-feather="save" class="h-4 w-4"></i> Save
200
- </button>
201
 
202
  <button data-btn="close"
203
  class="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">
@@ -445,7 +453,6 @@ class ReportEditor extends HTMLElement {
445
 
446
  // header buttons
447
  this.querySelector('[data-btn="close"]').addEventListener("click", () => this.close());
448
- this.querySelector('[data-btn="save"]').addEventListener("click", () => this._savePages(true));
449
  this.querySelector('[data-btn="undo"]').addEventListener("click", () => this.undo());
450
  this.querySelector('[data-btn="redo"]').addEventListener("click", () => this.redo());
451
 
@@ -605,14 +612,61 @@ class ReportEditor extends HTMLElement {
605
  this.$canvas.querySelectorAll("[data-template-field]").forEach((el) => {
606
  const key = el.dataset.templateField;
607
  if (!key) return;
608
- const value = template[key] || "";
609
- if (document.activeElement !== el && el.textContent !== value) {
610
- el.textContent = value;
 
 
 
 
 
 
 
 
 
 
 
 
611
  }
612
- el.oninput = () => {
613
- template[key] = el.textContent || "";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
614
  this._savePages();
615
  };
 
 
 
 
 
 
 
 
 
 
616
  el.onpointerdown = (e) => {
617
  e.stopPropagation();
618
  };
@@ -622,6 +676,28 @@ class ReportEditor extends HTMLElement {
622
  }
623
  };
624
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
625
  }
626
 
627
  _escape(value) {
@@ -767,10 +843,7 @@ class ReportEditor extends HTMLElement {
767
  if (explicit.length) {
768
  return explicit.map((id) => byId.get(id)).filter(Boolean);
769
  }
770
- const selected = this._selectedPhotos(session);
771
- const perPage = 1;
772
- const start = this.state.activePage * perPage;
773
- return selected.slice(start, start + perPage);
774
  }
775
 
776
  _photoSlot(photo, fallbackLabel) {
@@ -782,21 +855,43 @@ class ReportEditor extends HTMLElement {
782
  </div>
783
  `;
784
  }
785
- const label = this._escape(photo.name || fallbackLabel);
786
  const safeUrl = this._escape(url);
 
 
 
 
 
 
 
 
787
  return `
788
  <figure class="rounded-lg border border-gray-200 bg-gray-50 p-2 break-inside-avoid mb-3">
789
  <img src="${safeUrl}" alt="${label}" class="w-full h-auto object-contain" />
790
- <figcaption class="mt-1 text-[10px] text-gray-600 text-center">${label}</figcaption>
791
  </figure>
792
  `;
793
  }
794
 
795
- _tplField(key, value, placeholder, className = "", multiline = false) {
796
  const safeValue = this._escape(value || "");
797
  const safePlaceholder = this._escape(placeholder || "");
798
  const multiAttr = multiline ? ' data-multiline="true"' : "";
799
- return `<div class="template-field ${className}" data-template-field="${key}" contenteditable="true" data-placeholder="${safePlaceholder}"${multiAttr}>${safeValue}</div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
800
  }
801
 
802
  _templateMarkup() {
@@ -809,19 +904,19 @@ class ReportEditor extends HTMLElement {
809
  const inspector = template.inspector || "";
810
  const docNumber =
811
  template.document_no ||
 
812
  (session.id ? `REP-${String(session.id).slice(0, 8).toUpperCase()}` : "");
813
- const clientSite = template.client_site || "";
814
  const companyLogo = template.company_logo || "";
 
815
 
816
  const reference = template.reference || "";
817
  const area = template.area || "";
818
- const actionType = template.action_type || "";
819
  const itemDescription = template.item_description || "";
820
  const functionalLocation = template.functional_location || "";
821
- const category = template.category || "";
822
- const priority = template.priority || "";
823
- const conditionDescription =
824
- template.condition_description || session.notes || "";
825
  const requiredAction = template.required_action || "";
826
 
827
  const categoryScale = {
@@ -841,6 +936,14 @@ class ReportEditor extends HTMLElement {
841
  };
842
  const categoryBadge = this._ratingBadge(category, categoryScale);
843
  const priorityBadge = this._ratingBadge(priority, priorityScale);
 
 
 
 
 
 
 
 
844
 
845
  const variant =
846
  (this.activePage && this.activePage.variant) || "full";
@@ -852,10 +955,26 @@ class ReportEditor extends HTMLElement {
852
  : this._computePhotoLayout(photos).map((entry) => entry.photo);
853
  const displayedPhotos =
854
  variant === "full" ? orderedPhotos.slice(0, 2) : orderedPhotos;
855
- const photoColumnsClass = displayedPhotos.length <= 1 ? "columns-1" : "columns-2";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
856
  const photoSlots = displayedPhotos.length
857
  ? displayedPhotos
858
- .map((photo, idx) => this._photoSlot(photo, `Figure ${idx + 1}`))
 
 
859
  .join("")
860
  : this._photoSlot(null, "No photo selected");
861
  const pageNum = this.state.activePage + 1;
@@ -891,21 +1010,21 @@ class ReportEditor extends HTMLElement {
891
  <div class="inline-flex items-center gap-6">
892
  <div class="text-center space-y-1">
893
  <div class="text-xs font-medium text-gray-500">Category</div>
894
- ${this._tplField(
895
  "category",
896
- categoryBadge.text,
897
- "Category",
898
- `inline-flex items-center justify-center rounded-md border px-4 py-1 text-sm font-semibold min-w-[120px] ${categoryBadge.className}`,
899
  )}
900
  </div>
901
 
902
  <div class="text-center space-y-1">
903
  <div class="text-xs font-medium text-gray-500">Priority</div>
904
- ${this._tplField(
905
  "priority",
906
- priorityBadge.text,
907
- "Priority",
908
- `inline-flex items-center justify-center rounded-md border px-4 py-1 text-sm font-semibold min-w-[120px] ${priorityBadge.className}`,
909
  )}
910
  </div>
911
 
@@ -914,19 +1033,17 @@ class ReportEditor extends HTMLElement {
914
 
915
  <div class="md:col-span-2 space-y-1">
916
  <div class="text-xs font-medium text-gray-500">Condition Description</div>
917
- <div class="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
918
- <div class="text-amber-800 text-sm font-semibold leading-snug">
919
- ${this._tplField("item_description", itemDescription, "Item description", "template-field-multiline text-amber-800 text-sm font-semibold leading-snug", true)}
920
- ${this._tplField("condition_description", conditionDescription, "Condition description", "template-field-multiline text-amber-800 text-sm font-semibold leading-snug", true)}
921
  </div>
922
- </div>
923
  </div>
924
 
925
  <div class="md:col-span-2 space-y-1">
926
  <div class="text-xs font-medium text-gray-500">Action Required</div>
927
  <div class="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
928
  <div class="text-blue-800 text-sm font-semibold leading-snug">
929
- ${this._tplField("action_type", actionType, "Action type", "template-field-multiline text-blue-800 text-sm font-semibold leading-snug", true)}
930
  ${this._tplField("required_action", requiredAction, "Required action", "template-field-multiline text-blue-800 text-sm font-semibold leading-snug", true)}
931
  </div>
932
  </div>
@@ -947,8 +1064,12 @@ class ReportEditor extends HTMLElement {
947
  </div>
948
 
949
  <div class="text-center leading-tight">
950
- <h1 class="text-2xl font-bold text-gray-900 whitespace-nowrap">RepEx Inspection Job Sheet</h1>
951
- <p class="text-sm text-gray-600 whitespace-nowrap">Report Express - Inspection Management</p>
 
 
 
 
952
  </div>
953
 
954
  <div class="flex items-center justify-end">
@@ -969,12 +1090,48 @@ class ReportEditor extends HTMLElement {
969
  </div>
970
  </section>
971
 
972
- <footer class="mt-2 text-[10px] text-gray-500 flex flex-wrap items-center justify-center gap-3">
973
- <span>Date: ${this._escape(inspectionDate || "-")}</span>
974
- <span>Inspector: ${this._escape(inspector || "-")}</span>
975
- <span>Doc: ${this._escape(docNumber || "-")}</span>
976
- <span>Site: ${this._escape(clientSite || "-")}</span>
977
- ${sectionLabel ? `<span>${this._escape(sectionLabel)}</span>` : ""}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
978
  </footer>
979
  </div>
980
  `;
@@ -1037,23 +1194,46 @@ class ReportEditor extends HTMLElement {
1037
  this._saveTimer = setTimeout(() => {
1038
  this._savePagesToServer();
1039
  }, 800);
 
1040
  }
1041
 
1042
  async _savePagesToServer() {
1043
  const base = this._apiRoot();
1044
  if (!base || !this.sessionId) return;
1045
- try {
1046
- this._syncSectionsFromPages();
1047
- const res = await fetch(`${base}/sessions/${this.sessionId}/sections`, {
1048
- method: "PUT",
1049
- headers: { "Content-Type": "application/json" },
1050
- body: JSON.stringify({ sections: this.state.sections }),
1051
- });
1052
- if (!res.ok) {
1053
- throw new Error("Failed");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1054
  }
1055
- } catch {
1056
- this._toast("Sync failed");
 
 
 
 
 
1057
  }
1058
  }
1059
 
@@ -1091,6 +1271,7 @@ class ReportEditor extends HTMLElement {
1091
  items: [],
1092
  template: source.template ? { ...source.template } : undefined,
1093
  photo_ids: photoIds,
 
1094
  photo_order_locked: source.photo_order_locked,
1095
  variant: "photos",
1096
  };
 
62
  this.sessionId = null;
63
  this.apiBase = null;
64
  this._saveTimer = null;
65
+ this._savingPromise = null;
66
  this._photoRatios = new Map();
67
  this._indexMap = [];
68
  }
 
165
  this.dispatchEvent(new CustomEvent("editor-closed", { bubbles: true }));
166
  }
167
 
168
+ async flushSave() {
169
+ if (this._saveTimer) {
170
+ clearTimeout(this._saveTimer);
171
+ this._saveTimer = null;
172
+ }
173
+ return this._savePagesToServer();
174
+ }
175
+
176
  // ---------- Rendering ----------
177
  render() {
178
  this.innerHTML = `
 
203
  <i data-feather="rotate-cw" class="h-4 w-4"></i> Redo
204
  </button>
205
 
206
+ <div class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm font-semibold text-gray-600">
207
+ <i data-feather="check-circle" class="h-4 w-4"></i> Auto-saved
208
+ </div>
 
209
 
210
  <button data-btn="close"
211
  class="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">
 
453
 
454
  // header buttons
455
  this.querySelector('[data-btn="close"]').addEventListener("click", () => this.close());
 
456
  this.querySelector('[data-btn="undo"]').addEventListener("click", () => this.undo());
457
  this.querySelector('[data-btn="redo"]').addEventListener("click", () => this.redo());
458
 
 
612
  this.$canvas.querySelectorAll("[data-template-field]").forEach((el) => {
613
  const key = el.dataset.templateField;
614
  if (!key) return;
615
+ const isScale = key === "category" || key === "priority";
616
+ const rawValue = template[key] || "";
617
+ const displayValue = isScale
618
+ ? buildScaleBadge(
619
+ rawValue,
620
+ key === "category" ? CATEGORY_SCALE : PRIORITY_SCALE,
621
+ ).text
622
+ : rawValue;
623
+
624
+ if (document.activeElement !== el) {
625
+ if (displayValue) {
626
+ if (el.textContent !== displayValue) {
627
+ el.textContent = displayValue;
628
+ }
629
+ }
630
  }
631
+
632
+ const commitValue = () => {
633
+ const nextText = el.textContent || "";
634
+ if (isScale) {
635
+ const code = parseScaleCode(nextText);
636
+ template[key] = code || nextText;
637
+ } else {
638
+ template[key] = nextText;
639
+ }
640
+ if (this.$canvas) {
641
+ const nextDisplay = isScale
642
+ ? buildScaleBadge(
643
+ template[key],
644
+ key === "category" ? CATEGORY_SCALE : PRIORITY_SCALE,
645
+ ).text
646
+ : template[key] || "";
647
+ if (nextDisplay) {
648
+ this.$canvas
649
+ .querySelectorAll(`[data-template-field="${key}"]`)
650
+ .forEach((node) => {
651
+ if (node === el || document.activeElement === node) return;
652
+ if (node.textContent !== nextDisplay) {
653
+ node.textContent = nextDisplay;
654
+ }
655
+ });
656
+ }
657
+ }
658
  this._savePages();
659
  };
660
+
661
+ el.oninput = () => {
662
+ commitValue();
663
+ };
664
+ el.onblur = () => {
665
+ commitValue();
666
+ if (isScale) {
667
+ this.renderCanvas();
668
+ }
669
+ };
670
  el.onpointerdown = (e) => {
671
  e.stopPropagation();
672
  };
 
676
  }
677
  };
678
  });
679
+ this._bindTemplateSelects();
680
+ }
681
+
682
+ _bindTemplateSelects() {
683
+ if (!this.$canvas) return;
684
+ const template = this._getTemplate();
685
+ this.$canvas.querySelectorAll("[data-template-select]").forEach((el) => {
686
+ const key = el.dataset.templateSelect;
687
+ if (!key) return;
688
+ const current = template[key] || "";
689
+ if (el.value !== String(current)) {
690
+ el.value = String(current);
691
+ }
692
+ el.onchange = () => {
693
+ template[key] = el.value;
694
+ this._savePages();
695
+ this.renderCanvas();
696
+ };
697
+ el.onpointerdown = (e) => {
698
+ e.stopPropagation();
699
+ };
700
+ });
701
  }
702
 
703
  _escape(value) {
 
843
  if (explicit.length) {
844
  return explicit.map((id) => byId.get(id)).filter(Boolean);
845
  }
846
+ return [];
 
 
 
847
  }
848
 
849
  _photoSlot(photo, fallbackLabel) {
 
855
  </div>
856
  `;
857
  }
858
+ const label = this._escape(fallbackLabel || photo.name || "");
859
  const safeUrl = this._escape(url);
860
+ const caption = this._tplField(
861
+ "figure_caption",
862
+ fallbackLabel || photo.name || "",
863
+ "Figure caption",
864
+ "text-[10px] text-gray-600 text-center w-full",
865
+ false,
866
+ true,
867
+ );
868
  return `
869
  <figure class="rounded-lg border border-gray-200 bg-gray-50 p-2 break-inside-avoid mb-3">
870
  <img src="${safeUrl}" alt="${label}" class="w-full h-auto object-contain" />
871
+ <figcaption class="mt-1 text-[10px] text-gray-600 text-center">${caption}</figcaption>
872
  </figure>
873
  `;
874
  }
875
 
876
+ _tplField(key, value, placeholder, className = "", multiline = false, inline = false) {
877
  const safeValue = this._escape(value || "");
878
  const safePlaceholder = this._escape(placeholder || "");
879
  const multiAttr = multiline ? ' data-multiline="true"' : "";
880
+ const inlineAttr = inline ? ' style="display:inline-block;"' : "";
881
+ return `<div class="template-field ${className}" data-template-field="${key}" contenteditable="true" data-placeholder="${safePlaceholder}"${multiAttr}${inlineAttr}>${safeValue}</div>`;
882
+ }
883
+
884
+ _tplSelectField(key, value, options, className = "") {
885
+ const safeValue = this._escape(value || "");
886
+ const optionHtml = options
887
+ .map((option) => {
888
+ const optValue = this._escape(option.value);
889
+ const optLabel = this._escape(option.label);
890
+ const selected = optValue === safeValue ? " selected" : "";
891
+ return `<option value="${optValue}"${selected}>${optLabel}</option>`;
892
+ })
893
+ .join("");
894
+ return `<select class="template-select ${className}" data-template-select="${key}"><option value="">Select</option>${optionHtml}</select>`;
895
  }
896
 
897
  _templateMarkup() {
 
904
  const inspector = template.inspector || "";
905
  const docNumber =
906
  template.document_no ||
907
+ session.document_no ||
908
  (session.id ? `REP-${String(session.id).slice(0, 8).toUpperCase()}` : "");
 
909
  const companyLogo = template.company_logo || "";
910
+ const figureCaption = template.figure_caption || "";
911
 
912
  const reference = template.reference || "";
913
  const area = template.area || "";
 
914
  const itemDescription = template.item_description || "";
915
  const functionalLocation = template.functional_location || "";
916
+ const categoryRaw = template.category || "";
917
+ const priorityRaw = template.priority || "";
918
+ const category = parseScaleCode(categoryRaw) || categoryRaw;
919
+ const priority = parseScaleCode(priorityRaw) || priorityRaw;
920
  const requiredAction = template.required_action || "";
921
 
922
  const categoryScale = {
 
936
  };
937
  const categoryBadge = this._ratingBadge(category, categoryScale);
938
  const priorityBadge = this._ratingBadge(priority, priorityScale);
939
+ const categoryOptions = ["0", "1", "2", "3", "4", "5"].map((key) => ({
940
+ value: key,
941
+ label: `${key} - ${categoryScale[key].label}`,
942
+ }));
943
+ const priorityOptions = ["1", "2", "3", "X", "M"].map((key) => ({
944
+ value: key,
945
+ label: `${key} - ${priorityScale[key].label}`,
946
+ }));
947
 
948
  const variant =
949
  (this.activePage && this.activePage.variant) || "full";
 
955
  : this._computePhotoLayout(photos).map((entry) => entry.photo);
956
  const displayedPhotos =
957
  variant === "full" ? orderedPhotos.slice(0, 2) : orderedPhotos;
958
+ const photoLayout =
959
+ (this.activePage && this.activePage.photo_layout) || "auto";
960
+ const normalizedLayout = String(photoLayout).toLowerCase();
961
+ const layoutMode =
962
+ normalizedLayout === "stacked" || normalizedLayout === "two-column"
963
+ ? normalizedLayout
964
+ : "auto";
965
+ const photoColumnsClass =
966
+ layoutMode === "stacked"
967
+ ? "columns-1"
968
+ : layoutMode === "two-column"
969
+ ? "columns-2"
970
+ : displayedPhotos.length <= 1
971
+ ? "columns-1"
972
+ : "columns-2";
973
  const photoSlots = displayedPhotos.length
974
  ? displayedPhotos
975
+ .map((photo, idx) =>
976
+ this._photoSlot(photo, figureCaption || `Figure ${idx + 1}`),
977
+ )
978
  .join("")
979
  : this._photoSlot(null, "No photo selected");
980
  const pageNum = this.state.activePage + 1;
 
1010
  <div class="inline-flex items-center gap-6">
1011
  <div class="text-center space-y-1">
1012
  <div class="text-xs font-medium text-gray-500">Category</div>
1013
+ ${this._tplSelectField(
1014
  "category",
1015
+ category,
1016
+ categoryOptions,
1017
+ `min-w-[140px] rounded-md border px-3 py-1 text-sm font-semibold text-center ${categoryBadge.className}`,
1018
  )}
1019
  </div>
1020
 
1021
  <div class="text-center space-y-1">
1022
  <div class="text-xs font-medium text-gray-500">Priority</div>
1023
+ ${this._tplSelectField(
1024
  "priority",
1025
+ priority,
1026
+ priorityOptions,
1027
+ `min-w-[140px] rounded-md border px-3 py-1 text-sm font-semibold text-center ${priorityBadge.className}`,
1028
  )}
1029
  </div>
1030
 
 
1033
 
1034
  <div class="md:col-span-2 space-y-1">
1035
  <div class="text-xs font-medium text-gray-500">Condition Description</div>
1036
+ <div class="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
1037
+ <div class="text-amber-800 text-sm font-semibold leading-snug">
1038
+ ${this._tplField("item_description", itemDescription, "Item description", "template-field-multiline text-amber-800 text-sm font-semibold leading-snug", true)}
1039
+ </div>
1040
  </div>
 
1041
  </div>
1042
 
1043
  <div class="md:col-span-2 space-y-1">
1044
  <div class="text-xs font-medium text-gray-500">Action Required</div>
1045
  <div class="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
1046
  <div class="text-blue-800 text-sm font-semibold leading-snug">
 
1047
  ${this._tplField("required_action", requiredAction, "Required action", "template-field-multiline text-blue-800 text-sm font-semibold leading-snug", true)}
1048
  </div>
1049
  </div>
 
1064
  </div>
1065
 
1066
  <div class="text-center leading-tight">
1067
+ ${this._tplField(
1068
+ "document_no",
1069
+ docNumber,
1070
+ "Document No",
1071
+ "text-base font-semibold text-gray-900 whitespace-nowrap",
1072
+ )}
1073
  </div>
1074
 
1075
  <div class="flex items-center justify-end">
 
1090
  </div>
1091
  </section>
1092
 
1093
+ <footer class="mt-2 text-[10px] text-gray-500 flex flex-col items-center gap-1">
1094
+ <div class="flex flex-wrap items-center justify-center gap-3">
1095
+ <div class="flex items-center gap-1">
1096
+ <span>Date:</span>
1097
+ ${this._tplField(
1098
+ "inspection_date",
1099
+ inspectionDate,
1100
+ "Date",
1101
+ "text-[10px] text-gray-500",
1102
+ false,
1103
+ true,
1104
+ )}
1105
+ </div>
1106
+ <div class="flex items-center gap-1">
1107
+ <span>Inspector:</span>
1108
+ ${this._tplField(
1109
+ "inspector",
1110
+ inspector,
1111
+ "Inspector",
1112
+ "text-[10px] text-gray-500",
1113
+ false,
1114
+ true,
1115
+ )}
1116
+ </div>
1117
+ <div class="flex items-center gap-1">
1118
+ <span>Doc:</span>
1119
+ ${this._tplField(
1120
+ "document_no",
1121
+ docNumber,
1122
+ "Document No",
1123
+ "text-[10px] text-gray-500",
1124
+ false,
1125
+ true,
1126
+ )}
1127
+ </div>
1128
+ </div>
1129
+ <div class="text-[10px] font-semibold text-gray-600">
1130
+ RepEx Inspection Job Sheet
1131
+ </div>
1132
+ <div class="text-[10px] text-gray-500">
1133
+ ${sectionLabel ? `${this._escape(sectionLabel)} - ` : ""}Page ${pageNum} of ${pageCount}
1134
+ </div>
1135
  </footer>
1136
  </div>
1137
  `;
 
1194
  this._saveTimer = setTimeout(() => {
1195
  this._savePagesToServer();
1196
  }, 800);
1197
+ this.dispatchEvent(new CustomEvent("editor-save-queued", { bubbles: true }));
1198
  }
1199
 
1200
  async _savePagesToServer() {
1201
  const base = this._apiRoot();
1202
  if (!base || !this.sessionId) return;
1203
+ if (this._savingPromise) {
1204
+ return this._savingPromise;
1205
+ }
1206
+ const promise = (async () => {
1207
+ this.dispatchEvent(new CustomEvent("editor-save-start", { bubbles: true }));
1208
+ let ok = false;
1209
+ try {
1210
+ this._syncSectionsFromPages();
1211
+ const res = await fetch(`${base}/sessions/${this.sessionId}/sections`, {
1212
+ method: "PUT",
1213
+ headers: { "Content-Type": "application/json" },
1214
+ body: JSON.stringify({ sections: this.state.sections }),
1215
+ });
1216
+ if (!res.ok) {
1217
+ throw new Error("Failed");
1218
+ }
1219
+ ok = true;
1220
+ } catch {
1221
+ this._toast("Sync failed");
1222
+ } finally {
1223
+ this.dispatchEvent(
1224
+ new CustomEvent("editor-save-end", {
1225
+ bubbles: true,
1226
+ detail: { ok },
1227
+ }),
1228
+ );
1229
  }
1230
+ return ok;
1231
+ })();
1232
+ this._savingPromise = promise;
1233
+ try {
1234
+ return await promise;
1235
+ } finally {
1236
+ this._savingPromise = null;
1237
  }
1238
  }
1239
 
 
1271
  items: [],
1272
  template: source.template ? { ...source.template } : undefined,
1273
  photo_ids: photoIds,
1274
+ photo_layout: source.photo_layout,
1275
  photo_order_locked: source.photo_order_locked,
1276
  variant: "photos",
1277
  };
frontend/src/lib/sections.ts CHANGED
@@ -26,6 +26,7 @@ function buildPhotoContinuation(source: Page, photoIds: string[]): Page {
26
  items: [],
27
  template: cloneTemplate(source.template),
28
  photo_ids: photoIds,
 
29
  photo_order_locked: source.photo_order_locked,
30
  variant: "photos",
31
  };
 
26
  items: [],
27
  template: cloneTemplate(source.template),
28
  photo_ids: photoIds,
29
+ photo_layout: source.photo_layout,
30
  photo_order_locked: source.photo_order_locked,
31
  variant: "photos",
32
  };
frontend/src/pages/EditLayoutsPage.tsx CHANGED
@@ -1,5 +1,5 @@
1
- import { useEffect, useMemo, useState } from "react";
2
- import { Link, useSearchParams } from "react-router-dom";
3
  import {
4
  ArrowLeft,
5
  ChevronDown,
@@ -8,6 +8,7 @@ import {
8
  Edit3,
9
  Grid,
10
  Layout,
 
11
  Plus,
12
  Table,
13
  Trash2,
@@ -15,7 +16,7 @@ import {
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";
@@ -28,12 +29,23 @@ export default function EditLayoutsPage() {
28
  const [searchParams] = useSearchParams();
29
  const sessionId = getSessionId(searchParams.toString());
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;
 
 
 
 
37
 
38
  useEffect(() => {
39
  if (!sessionId) {
@@ -48,7 +60,11 @@ export default function EditLayoutsPage() {
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.";
@@ -66,9 +82,31 @@ export default function EditLayoutsPage() {
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),
@@ -91,40 +129,100 @@ export default function EditLayoutsPage() {
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(
107
- putJson<Session>(`/sessions/${sessionId}/selection`, {
108
- selected_photo_ids: nextSelectedIds,
109
- }),
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
  }
121
- setStatus("Layout saved.");
122
- } catch (err) {
123
- const message =
124
- err instanceof Error ? err.message : "Failed to save layout.";
125
- setStatus(message);
126
  } finally {
127
- setIsSaving(false);
128
  }
129
  }
130
 
@@ -140,6 +238,21 @@ export default function EditLayoutsPage() {
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;
@@ -195,15 +308,88 @@ export default function EditLayoutsPage() {
195
  await saveLayout(next);
196
  }
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  return (
199
  <PageShell>
200
  <PageHeader
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" />
@@ -218,6 +404,10 @@ export default function EditLayoutsPage() {
218
  <div className="flex flex-wrap gap-2">
219
  <Link
220
  to={`/input-data${sessionQuery}`}
 
 
 
 
221
  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"
222
  >
223
  <Table className="h-4 w-4" />
@@ -226,14 +416,34 @@ export default function EditLayoutsPage() {
226
 
227
  <Link
228
  to={`/report-viewer${sessionQuery}`}
 
 
 
 
229
  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"
230
  >
231
  <Layout className="h-4 w-4" />
232
  Report Viewer
233
  </Link>
234
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  <Link
236
  to={`/edit-report${sessionQuery}`}
 
 
 
 
237
  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"
238
  >
239
  <Edit3 className="h-4 w-4" />
@@ -247,6 +457,10 @@ export default function EditLayoutsPage() {
247
 
248
  <Link
249
  to={`/export${sessionQuery}`}
 
 
 
 
250
  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"
251
  >
252
  <Download className="h-4 w-4" />
@@ -262,8 +476,26 @@ export default function EditLayoutsPage() {
262
  <p className="text-sm text-gray-600">
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}
@@ -280,110 +512,217 @@ export default function EditLayoutsPage() {
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
- ))}
387
  </section>
388
 
389
  <PageFooter note="Tip: reorder pages here and remove empty pages before export." />
 
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { Link, useNavigate, useSearchParams } from "react-router-dom";
3
  import {
4
  ArrowLeft,
5
  ChevronDown,
 
8
  Edit3,
9
  Grid,
10
  Layout,
11
+ Image,
12
  Plus,
13
  Table,
14
  Trash2,
 
16
 
17
  import { putJson, request } from "../lib/api";
18
  import { BASE_W } from "../lib/report";
19
+ import { ensureSections, flattenSections, replacePage } from "../lib/sections";
20
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
21
  import type { JobsheetSection, Session } from "../types/session";
22
  import { PageFooter } from "../components/PageFooter";
 
29
  const [searchParams] = useSearchParams();
30
  const sessionId = getSessionId(searchParams.toString());
31
  const sessionQuery = buildSessionQuery(sessionId);
32
+ const navigate = useNavigate();
33
 
34
  const [session, setSession] = useState<Session | null>(null);
35
  const [sections, setSections] = useState<JobsheetSection[]>([]);
36
  const [status, setStatus] = useState("");
37
  const [isSaving, setIsSaving] = useState(false);
38
+ const [saveState, setSaveState] = useState<
39
+ "saved" | "saving" | "pending" | "error"
40
+ >("saved");
41
+ const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>(
42
+ {},
43
+ );
44
  const canModify = Boolean(sessionId) && !isSaving;
45
+ const saveTimerRef = useRef<number | null>(null);
46
+ const savePromiseRef = useRef<Promise<void> | null>(null);
47
+ const lastSavedRef = useRef<string>("");
48
+ const pendingSaveRef = useRef<string>("");
49
 
50
  useEffect(() => {
51
  if (!sessionId) {
 
60
  const sectionResp = await request<{ sections: JobsheetSection[] }>(
61
  `/sessions/${sessionId}/sections`,
62
  );
63
+ const normalized = ensureSections(sectionResp.sections);
64
+ setSections(normalized);
65
+ lastSavedRef.current = JSON.stringify(normalized);
66
+ pendingSaveRef.current = lastSavedRef.current;
67
+ setSaveState("saved");
68
  } catch (err) {
69
  const message =
70
  err instanceof Error ? err.message : "Failed to load session.";
 
82
  () => Math.max(1, flatPages.length),
83
  [flatPages.length],
84
  );
85
+ const totalSections = sections.length;
86
  const previewWidth = 220;
87
  const previewScale = previewWidth / BASE_W;
88
 
89
+ const PHOTO_LAYOUT_PRESETS = [
90
+ {
91
+ id: "auto",
92
+ label: "Auto fit",
93
+ description: "Let the system choose 1 or 2 columns per page.",
94
+ preview: "auto",
95
+ },
96
+ {
97
+ id: "two-column",
98
+ label: "Two column",
99
+ description: "Force a two-column photo grid.",
100
+ preview: "two-column",
101
+ },
102
+ {
103
+ id: "stacked",
104
+ label: "Stacked",
105
+ description: "Stack images vertically for tall photos.",
106
+ preview: "stacked",
107
+ },
108
+ ] as const;
109
+
110
  function hasExplicitPhotos(source: JobsheetSection[]) {
111
  return source.some((section) =>
112
  (section.pages ?? []).some((page) => (page.photo_ids ?? []).length > 0),
 
129
  return result;
130
  }
131
 
132
+ async function triggerAutoSave() {
133
+ if (!sessionId || savePromiseRef.current) return;
134
+ const snapshot = pendingSaveRef.current;
135
+ if (!snapshot || snapshot === lastSavedRef.current) return;
136
+ await saveLayout(sections, undefined, true);
137
+ }
138
+
139
+ useEffect(() => {
140
  if (!sessionId) return;
141
+ const snapshot = JSON.stringify(sections);
142
+ if (snapshot === lastSavedRef.current) {
143
+ if (!isSaving) setSaveState("saved");
144
+ return;
145
+ }
146
+ pendingSaveRef.current = snapshot;
147
+ if (!isSaving) setSaveState("pending");
148
+ if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
149
+ saveTimerRef.current = window.setTimeout(() => {
150
+ void triggerAutoSave();
151
+ }, 800);
152
+ }, [sections, sessionId, isSaving]);
153
+
154
+ async function saveLayout(
155
+ next: JobsheetSection[],
156
+ nextSelectedIds?: string[],
157
+ silent = false,
158
+ ) {
159
+ if (!sessionId) return;
160
+ if (savePromiseRef.current) {
161
+ await savePromiseRef.current;
162
+ }
163
+ const snapshot = JSON.stringify(next);
164
+ if (snapshot === lastSavedRef.current && nextSelectedIds === undefined) {
165
+ if (!isSaving) setSaveState("saved");
166
+ return;
167
+ }
168
+ const promise = (async () => {
169
+ setIsSaving(true);
170
+ setSaveState("saving");
171
+ if (!silent) {
172
+ setStatus("Saving layout changes...");
173
  }
174
+ try {
175
+ const requests: Promise<unknown>[] = [
176
+ putJson<{ sections: JobsheetSection[] }>(
177
+ `/sessions/${sessionId}/sections`,
178
+ { sections: next },
179
+ ),
180
+ ];
181
+ if (nextSelectedIds !== undefined) {
182
+ requests.push(
183
+ putJson<Session>(`/sessions/${sessionId}/selection`, {
184
+ selected_photo_ids: nextSelectedIds,
185
+ }),
186
+ );
187
+ }
188
+ const [pagesResp, sessionResp] = await Promise.all(requests);
189
+ const updatedSections =
190
+ (pagesResp as { sections?: JobsheetSection[] }).sections ?? next;
191
+ const updatedSession = sessionResp as Session | undefined;
192
+ const updated = ensureSections(updatedSections);
193
+ setSections(updated);
194
+ lastSavedRef.current = JSON.stringify(updated);
195
+ pendingSaveRef.current = lastSavedRef.current;
196
+ setSaveState("saved");
197
+ if (updatedSession) {
198
+ setSession(updatedSession);
199
+ }
200
+ if (!silent) {
201
+ setStatus("Layout saved.");
202
+ }
203
+ } catch (err) {
204
+ const message =
205
+ err instanceof Error ? err.message : "Failed to save layout.";
206
+ setStatus(message);
207
+ setSaveState("error");
208
+ } finally {
209
+ setIsSaving(false);
210
+ if (
211
+ pendingSaveRef.current &&
212
+ pendingSaveRef.current !== lastSavedRef.current
213
+ ) {
214
+ if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
215
+ saveTimerRef.current = window.setTimeout(() => {
216
+ void triggerAutoSave();
217
+ }, 200);
218
+ }
219
  }
220
+ })();
221
+ savePromiseRef.current = promise;
222
+ try {
223
+ await promise;
 
224
  } finally {
225
+ savePromiseRef.current = null;
226
  }
227
  }
228
 
 
238
  await saveLayout(next);
239
  }
240
 
241
+ function toggleSection(sectionId: string) {
242
+ setCollapsedSections((prev) => ({
243
+ ...prev,
244
+ [sectionId]: !prev[sectionId],
245
+ }));
246
+ }
247
+
248
+ function setAllSectionsCollapsed(value: boolean) {
249
+ const next: Record<string, boolean> = {};
250
+ sections.forEach((section) => {
251
+ next[section.id] = value;
252
+ });
253
+ setCollapsedSections(next);
254
+ }
255
+
256
  async function handleAddPage(sectionIndex: number) {
257
  const next = sections.map((section, idx) => {
258
  if (idx !== sectionIndex) return section;
 
308
  await saveLayout(next);
309
  }
310
 
311
+ function applyPhotoLayout(
312
+ sectionIndex: number,
313
+ pageIndex: number,
314
+ layout: "auto" | "two-column" | "stacked",
315
+ ) {
316
+ if (!canModify) return;
317
+ setSections((prev) => {
318
+ const section = prev[sectionIndex];
319
+ const page = section?.pages?.[pageIndex];
320
+ if (!section || !page) return prev;
321
+ const nextPage = { ...page, photo_layout: layout };
322
+ return replacePage(prev, sectionIndex, pageIndex, nextPage);
323
+ });
324
+ setStatus(`Applied ${layout.replace("-", " ")} layout to page ${pageIndex + 1}.`);
325
+ }
326
+
327
+ async function saveAndNavigate(target: string) {
328
+ if (!sessionId) {
329
+ navigate(target);
330
+ return;
331
+ }
332
+ if (saveTimerRef.current) {
333
+ window.clearTimeout(saveTimerRef.current);
334
+ saveTimerRef.current = null;
335
+ }
336
+ const snapshot = JSON.stringify(sections);
337
+ pendingSaveRef.current = snapshot;
338
+ if (snapshot !== lastSavedRef.current || savePromiseRef.current) {
339
+ await saveLayout(sections, undefined, true);
340
+ }
341
+ navigate(target);
342
+ }
343
+
344
+ const saveIndicator = useMemo(() => {
345
+ const base =
346
+ "inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold";
347
+ if (saveState === "saving") {
348
+ return (
349
+ <div className={`${base} border-amber-200 bg-amber-50 text-amber-700`}>
350
+ <span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
351
+ Saving...
352
+ </div>
353
+ );
354
+ }
355
+ if (saveState === "pending") {
356
+ return (
357
+ <div className={`${base} border-amber-200 bg-amber-50 text-amber-700`}>
358
+ <span className="h-2 w-2 rounded-full bg-amber-500" />
359
+ Unsaved changes
360
+ </div>
361
+ );
362
+ }
363
+ if (saveState === "error") {
364
+ return (
365
+ <div className={`${base} border-red-200 bg-red-50 text-red-700`}>
366
+ <span className="h-2 w-2 rounded-full bg-red-500" />
367
+ Save failed
368
+ </div>
369
+ );
370
+ }
371
+ return (
372
+ <div className={`${base} border-emerald-200 bg-emerald-50 text-emerald-700`}>
373
+ <span className="h-2 w-2 rounded-full bg-emerald-500" />
374
+ All changes saved
375
+ </div>
376
+ );
377
+ }, [saveState]);
378
+
379
  return (
380
  <PageShell>
381
  <PageHeader
382
  title="RepEx - Report Express"
383
  subtitle="Edit Page Layouts"
384
  right={
385
+ <div className="flex flex-col items-end gap-2 sm:flex-row sm:items-center">
386
+ {saveIndicator}
387
  <Link
388
  to={`/report-viewer${sessionQuery}`}
389
+ onClick={(event) => {
390
+ event.preventDefault();
391
+ void saveAndNavigate(`/report-viewer${sessionQuery}`);
392
+ }}
393
  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"
394
  >
395
  <ArrowLeft className="h-4 w-4" />
 
404
  <div className="flex flex-wrap gap-2">
405
  <Link
406
  to={`/input-data${sessionQuery}`}
407
+ onClick={(event) => {
408
+ event.preventDefault();
409
+ void saveAndNavigate(`/input-data${sessionQuery}`);
410
+ }}
411
  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"
412
  >
413
  <Table className="h-4 w-4" />
 
416
 
417
  <Link
418
  to={`/report-viewer${sessionQuery}`}
419
+ onClick={(event) => {
420
+ event.preventDefault();
421
+ void saveAndNavigate(`/report-viewer${sessionQuery}`);
422
+ }}
423
  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"
424
  >
425
  <Layout className="h-4 w-4" />
426
  Report Viewer
427
  </Link>
428
 
429
+ <Link
430
+ to={`/image-placement${sessionQuery}`}
431
+ onClick={(event) => {
432
+ event.preventDefault();
433
+ void saveAndNavigate(`/image-placement${sessionQuery}`);
434
+ }}
435
+ 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"
436
+ >
437
+ <Image className="h-4 w-4" />
438
+ Image Placement
439
+ </Link>
440
+
441
  <Link
442
  to={`/edit-report${sessionQuery}`}
443
+ onClick={(event) => {
444
+ event.preventDefault();
445
+ void saveAndNavigate(`/edit-report${sessionQuery}`);
446
+ }}
447
  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"
448
  >
449
  <Edit3 className="h-4 w-4" />
 
457
 
458
  <Link
459
  to={`/export${sessionQuery}`}
460
+ onClick={(event) => {
461
+ event.preventDefault();
462
+ void saveAndNavigate(`/export${sessionQuery}`);
463
+ }}
464
  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"
465
  >
466
  <Download className="h-4 w-4" />
 
476
  <p className="text-sm text-gray-600">
477
  Add, remove, or reorder pages, then return to the report viewer to edit content.
478
  </p>
479
+ <div className="mt-2 text-xs text-gray-500">
480
+ {totalSections} section{totalSections === 1 ? "" : "s"} -{" "}
481
+ {totalPages} page{totalPages === 1 ? "" : "s"}
482
+ </div>
483
  </div>
484
  <div className="flex flex-wrap gap-2">
485
+ <button
486
+ type="button"
487
+ onClick={() => setAllSectionsCollapsed(true)}
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-700 hover:bg-gray-50 transition"
489
+ >
490
+ Collapse all
491
+ </button>
492
+ <button
493
+ type="button"
494
+ onClick={() => setAllSectionsCollapsed(false)}
495
+ 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-700 hover:bg-gray-50 transition"
496
+ >
497
+ Expand all
498
+ </button>
499
  <button
500
  type="button"
501
  onClick={handleAddSection}
 
512
  ) : null}
513
  </section>
514
 
515
+ <section className="mb-6 rounded-lg border border-gray-200 bg-white p-4">
516
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
517
+ <div>
518
+ <h2 className="text-lg font-semibold text-gray-900">
519
+ Image Layout Presets
520
+ </h2>
521
+ <p className="text-sm text-gray-600">
522
+ Drag a preset onto a page to set how photos are arranged.
523
+ </p>
524
+ </div>
525
+ </div>
526
+ <div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-3">
527
+ {PHOTO_LAYOUT_PRESETS.map((preset) => (
528
+ <div
529
+ key={preset.id}
530
+ draggable
531
+ onDragStart={(event) => {
532
+ event.dataTransfer.setData("text/plain", preset.id);
533
+ event.dataTransfer.effectAllowed = "copy";
534
+ }}
535
+ className="rounded-lg border border-gray-200 bg-gray-50 p-3 cursor-grab active:cursor-grabbing"
536
+ >
537
+ <div className="text-sm font-semibold text-gray-900">
538
+ {preset.label}
539
+ </div>
540
+ <div className="text-xs text-gray-600">{preset.description}</div>
541
+ <div className="mt-2">
542
+ {preset.preview === "stacked" ? (
543
+ <div className="grid grid-cols-1 gap-1">
544
+ <div className="h-6 rounded bg-gray-200" />
545
+ <div className="h-6 rounded bg-gray-200" />
546
+ </div>
547
+ ) : (
548
+ <div className="grid grid-cols-2 gap-1">
549
+ <div className="h-6 rounded bg-gray-200" />
550
+ <div className="h-6 rounded bg-gray-200" />
551
+ </div>
552
+ )}
553
+ </div>
554
+ <div className="mt-2 text-[11px] text-gray-500">
555
+ Drag to apply
556
  </div>
557
  </div>
558
+ ))}
559
+ </div>
560
+ </section>
 
 
 
 
 
 
 
561
 
562
+ <section className="space-y-6">
563
+ {sections.map((section, sectionIndex) => {
564
+ const isCollapsed = collapsedSections[section.id] ?? false;
565
+ const pageCount = section.pages?.length ?? 0;
566
+ const start =
567
+ sections
568
+ .slice(0, sectionIndex)
569
+ .reduce((sum, item) => sum + (item.pages?.length ?? 0), 0) + 1;
570
+ const end = Math.max(start, start + pageCount - 1);
571
+ return (
572
+ <div key={section.id} className="rounded-lg border border-gray-200 bg-white p-4">
573
+ <div className="flex flex-wrap items-center justify-between gap-3 mb-4">
574
+ <div>
575
+ <div className="text-xs font-semibold text-gray-500">
576
+ Section {sectionIndex + 1}
577
+ </div>
578
+ <input
579
+ type="text"
580
+ value={section.title ?? ""}
581
+ onChange={(event) =>
582
+ setSections((prev) =>
583
+ prev.map((item, idx) =>
584
+ idx === sectionIndex
585
+ ? { ...item, title: event.target.value }
586
+ : item,
587
+ ),
588
+ )
589
+ }
590
+ placeholder={`Section ${sectionIndex + 1}`}
591
+ 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"
592
+ />
593
+ <div className="text-xs text-gray-500">
594
+ {pageCount} page{pageCount === 1 ? "" : "s"} - Pages {start}
595
+ {pageCount > 1 ? `-${end}` : ""}
596
+ </div>
597
+ </div>
598
+ <div className="flex items-center gap-2">
599
+ <button
600
+ type="button"
601
+ onClick={() => toggleSection(section.id)}
602
+ 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"
603
+ >
604
+ {isCollapsed ? (
605
+ <>
606
+ <ChevronDown className="h-4 w-4" />
607
+ Expand
608
+ </>
609
+ ) : (
610
+ <>
611
+ <ChevronUp className="h-4 w-4" />
612
+ Collapse
613
+ </>
614
+ )}
615
+ </button>
616
+ <button
617
+ type="button"
618
+ onClick={() => handleAddPage(sectionIndex)}
619
+ disabled={!canModify}
620
+ 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"
621
+ >
622
+ <Plus className="h-4 w-4" />
623
+ Add page
624
+ </button>
625
+ </div>
626
+ </div>
627
+
628
+ {!isCollapsed ? (
629
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
630
+ {(section.pages ?? []).map((page, pageIndex) => (
631
+ <div
632
+ key={`${section.id}-page-${pageIndex}`}
633
+ className="rounded-lg border border-gray-200 bg-white p-3"
634
+ >
635
+ <div className="flex items-center justify-between mb-2">
636
+ <div>
637
+ <div className="text-sm font-semibold text-gray-900">
638
+ Page {pageIndex + 1}
639
+ </div>
640
+ <div className="text-xs text-gray-500">
641
+ {page.items?.length ?? 0} items - Global page {start + pageIndex} of {totalPages}
642
+ </div>
643
+ </div>
644
+ <div className="flex items-center gap-2">
645
+ <button
646
+ type="button"
647
+ onClick={() => handleMovePage(sectionIndex, pageIndex, -1)}
648
+ disabled={pageIndex === 0 || !canModify}
649
+ 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"
650
+ aria-label="Move page up"
651
+ >
652
+ <ChevronUp className="h-3.5 w-3.5" />
653
+ </button>
654
+ <button
655
+ type="button"
656
+ onClick={() => handleMovePage(sectionIndex, pageIndex, 1)}
657
+ disabled={pageIndex === (section.pages?.length ?? 1) - 1 || !canModify}
658
+ 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"
659
+ aria-label="Move page down"
660
+ >
661
+ <ChevronDown className="h-3.5 w-3.5" />
662
+ </button>
663
+ <button
664
+ type="button"
665
+ onClick={() => handleRemovePage(sectionIndex, pageIndex)}
666
+ disabled={(section.pages?.length ?? 1) <= 1 || !canModify}
667
+ 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"
668
+ >
669
+ <Trash2 className="h-3.5 w-3.5" />
670
+ Remove
671
+ </button>
672
+ </div>
673
  </div>
674
+
675
+ <div
676
+ className="mx-auto rounded-lg border border-gray-200 bg-white"
677
+ style={{ width: previewWidth }}
 
 
 
 
 
 
 
 
 
 
 
 
 
678
  >
679
+ <ReportPageCanvas
680
+ session={session}
681
+ page={page}
682
+ pageIndex={pageIndex}
683
+ pageCount={totalPages}
684
+ scale={previewScale}
685
+ template={page?.template}
686
+ sectionLabel={
687
+ section.title
688
+ ? `Section ${sectionIndex + 1} - ${section.title}`
689
+ : `Section ${sectionIndex + 1}`
690
+ }
691
+ adaptive
692
+ />
693
+ </div>
694
+
695
+ <div
696
+ className="mt-2 rounded-md border border-dashed border-gray-200 bg-gray-50 px-2 py-2 text-[11px] text-gray-600"
697
+ onDragOver={(event) => {
698
+ event.preventDefault();
699
+ event.dataTransfer.dropEffect = "copy";
700
+ }}
701
+ onDrop={(event) => {
702
+ event.preventDefault();
703
+ const layout = event.dataTransfer.getData("text/plain");
704
+ if (
705
+ layout === "auto" ||
706
+ layout === "two-column" ||
707
+ layout === "stacked"
708
+ ) {
709
+ applyPhotoLayout(sectionIndex, pageIndex, layout);
710
+ }
711
+ }}
712
  >
713
+ Drop image layout here - Current: {page.photo_layout ?? "auto"}
714
+ </div>
 
715
  </div>
716
+ ))}
717
+ </div>
718
+ ) : (
719
+ <div className="rounded-md border border-dashed border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500">
720
+ Section collapsed. Expand to edit pages and apply layout presets.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
721
  </div>
722
+ )}
723
  </div>
724
+ );
725
+ })}
726
  </section>
727
 
728
  <PageFooter note="Tip: reorder pages here and remove empty pages before export." />
frontend/src/pages/EditReportPage.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
  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 { ensureSections, flattenSections } from "../lib/sections";
@@ -20,6 +20,9 @@ export default function EditReportPage() {
20
  const [session, setSession] = useState<Session | null>(null);
21
  const [pageCount, setPageCount] = useState<number | null>(null);
22
  const [error, setError] = useState("");
 
 
 
23
 
24
  const [editorEl, setEditorEl] = useState<ReportEditorElement | null>(null);
25
  const editorRef = useCallback((node: ReportEditorElement | null) => {
@@ -32,6 +35,16 @@ export default function EditReportPage() {
32
  return Math.max(0, Math.floor(raw) - 1);
33
  }, [searchParams]);
34
 
 
 
 
 
 
 
 
 
 
 
35
  useEffect(() => {
36
  if (!sessionId) {
37
  setError("No active session found. Return to upload to continue.");
@@ -80,11 +93,70 @@ export default function EditReportPage() {
80
  useEffect(() => {
81
  if (!editorEl) return;
82
  const handleClose = () => {
83
- navigate(`/report-viewer${sessionQuery}`);
84
  };
85
  editorEl.addEventListener("editor-closed", handleClose);
86
  return () => editorEl.removeEventListener("editor-closed", handleClose);
87
- }, [editorEl, navigate, sessionQuery]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
  return (
90
  <PageShell className="max-w-6xl">
@@ -92,9 +164,14 @@ export default function EditReportPage() {
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" />
@@ -109,6 +186,10 @@ export default function EditReportPage() {
109
  <div className="flex flex-wrap gap-2">
110
  <Link
111
  to={`/input-data${sessionQuery}`}
 
 
 
 
112
  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"
113
  >
114
  <Table className="h-4 w-4" />
@@ -117,12 +198,28 @@ export default function EditReportPage() {
117
 
118
  <Link
119
  to={`/report-viewer${sessionQuery}`}
 
 
 
 
120
  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"
121
  >
122
  <Layout className="h-4 w-4" />
123
  Report Viewer
124
  </Link>
125
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  <span className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-900 px-4 py-2 text-sm font-semibold text-white">
127
  <Edit3 className="h-4 w-4" />
128
  Edit Report
@@ -130,6 +227,10 @@ export default function EditReportPage() {
130
 
131
  <Link
132
  to={`/edit-layouts${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"
134
  >
135
  <Grid className="h-4 w-4" />
@@ -138,6 +239,10 @@ export default function EditReportPage() {
138
 
139
  <Link
140
  to={`/export${sessionQuery}`}
 
 
 
 
141
  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"
142
  >
143
  <Download className="h-4 w-4" />
 
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
  import { Link, useNavigate, useSearchParams } from "react-router-dom";
3
+ import { ArrowLeft, Download, Edit3, Grid, Layout, Table, Image } from "react-feather";
4
 
5
  import { API_BASE, request } from "../lib/api";
6
  import { ensureSections, flattenSections } from "../lib/sections";
 
20
  const [session, setSession] = useState<Session | null>(null);
21
  const [pageCount, setPageCount] = useState<number | null>(null);
22
  const [error, setError] = useState("");
23
+ const [saveState, setSaveState] = useState<
24
+ "saved" | "saving" | "pending" | "error"
25
+ >("saved");
26
 
27
  const [editorEl, setEditorEl] = useState<ReportEditorElement | null>(null);
28
  const editorRef = useCallback((node: ReportEditorElement | null) => {
 
35
  return Math.max(0, Math.floor(raw) - 1);
36
  }, [searchParams]);
37
 
38
+ const saveAndNavigate = useCallback(
39
+ async (target: string) => {
40
+ if (editorEl?.flushSave) {
41
+ await editorEl.flushSave();
42
+ }
43
+ navigate(target);
44
+ },
45
+ [editorEl, navigate],
46
+ );
47
+
48
  useEffect(() => {
49
  if (!sessionId) {
50
  setError("No active session found. Return to upload to continue.");
 
93
  useEffect(() => {
94
  if (!editorEl) return;
95
  const handleClose = () => {
96
+ void saveAndNavigate(`/report-viewer${sessionQuery}`);
97
  };
98
  editorEl.addEventListener("editor-closed", handleClose);
99
  return () => editorEl.removeEventListener("editor-closed", handleClose);
100
+ }, [editorEl, saveAndNavigate, sessionQuery]);
101
+
102
+ useEffect(() => {
103
+ if (!editorEl) return;
104
+ const handleQueued = () => {
105
+ setSaveState((prev) => (prev === "saving" ? prev : "pending"));
106
+ };
107
+ const handleStart = () => setSaveState("saving");
108
+ const handleEnd = (event: Event) => {
109
+ const custom = event as CustomEvent<{ ok?: boolean }>;
110
+ if (custom.detail && custom.detail.ok === false) {
111
+ setSaveState("error");
112
+ } else {
113
+ setSaveState("saved");
114
+ }
115
+ };
116
+ editorEl.addEventListener("editor-save-queued", handleQueued);
117
+ editorEl.addEventListener("editor-save-start", handleStart);
118
+ editorEl.addEventListener("editor-save-end", handleEnd);
119
+ return () => {
120
+ editorEl.removeEventListener("editor-save-queued", handleQueued);
121
+ editorEl.removeEventListener("editor-save-start", handleStart);
122
+ editorEl.removeEventListener("editor-save-end", handleEnd);
123
+ };
124
+ }, [editorEl]);
125
+
126
+ const saveIndicator = useMemo(() => {
127
+ const base =
128
+ "inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold";
129
+ if (saveState === "saving") {
130
+ return (
131
+ <div className={`${base} border-amber-200 bg-amber-50 text-amber-700`}>
132
+ <span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
133
+ Saving...
134
+ </div>
135
+ );
136
+ }
137
+ if (saveState === "pending") {
138
+ return (
139
+ <div className={`${base} border-amber-200 bg-amber-50 text-amber-700`}>
140
+ <span className="h-2 w-2 rounded-full bg-amber-500" />
141
+ Unsaved changes
142
+ </div>
143
+ );
144
+ }
145
+ if (saveState === "error") {
146
+ return (
147
+ <div className={`${base} border-red-200 bg-red-50 text-red-700`}>
148
+ <span className="h-2 w-2 rounded-full bg-red-500" />
149
+ Save failed
150
+ </div>
151
+ );
152
+ }
153
+ return (
154
+ <div className={`${base} border-emerald-200 bg-emerald-50 text-emerald-700`}>
155
+ <span className="h-2 w-2 rounded-full bg-emerald-500" />
156
+ All changes saved
157
+ </div>
158
+ );
159
+ }, [saveState]);
160
 
161
  return (
162
  <PageShell className="max-w-6xl">
 
164
  title="RepEx - Report Express"
165
  subtitle="Edit Report"
166
  right={
167
+ <div className="flex flex-col items-end gap-2 sm:flex-row sm:items-center">
168
+ {saveIndicator}
169
  <Link
170
  to={`/report-viewer${sessionQuery}`}
171
+ onClick={(event) => {
172
+ event.preventDefault();
173
+ void saveAndNavigate(`/report-viewer${sessionQuery}`);
174
+ }}
175
  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"
176
  >
177
  <ArrowLeft className="h-4 w-4" />
 
186
  <div className="flex flex-wrap gap-2">
187
  <Link
188
  to={`/input-data${sessionQuery}`}
189
+ onClick={(event) => {
190
+ event.preventDefault();
191
+ void saveAndNavigate(`/input-data${sessionQuery}`);
192
+ }}
193
  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"
194
  >
195
  <Table className="h-4 w-4" />
 
198
 
199
  <Link
200
  to={`/report-viewer${sessionQuery}`}
201
+ onClick={(event) => {
202
+ event.preventDefault();
203
+ void saveAndNavigate(`/report-viewer${sessionQuery}`);
204
+ }}
205
  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"
206
  >
207
  <Layout className="h-4 w-4" />
208
  Report Viewer
209
  </Link>
210
 
211
+ <Link
212
+ to={`/image-placement${sessionQuery}`}
213
+ onClick={(event) => {
214
+ event.preventDefault();
215
+ void saveAndNavigate(`/image-placement${sessionQuery}`);
216
+ }}
217
+ 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"
218
+ >
219
+ <Image className="h-4 w-4" />
220
+ Image Placement
221
+ </Link>
222
+
223
  <span className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-900 px-4 py-2 text-sm font-semibold text-white">
224
  <Edit3 className="h-4 w-4" />
225
  Edit Report
 
227
 
228
  <Link
229
  to={`/edit-layouts${sessionQuery}`}
230
+ onClick={(event) => {
231
+ event.preventDefault();
232
+ void saveAndNavigate(`/edit-layouts${sessionQuery}`);
233
+ }}
234
  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"
235
  >
236
  <Grid className="h-4 w-4" />
 
239
 
240
  <Link
241
  to={`/export${sessionQuery}`}
242
+ onClick={(event) => {
243
+ event.preventDefault();
244
+ void saveAndNavigate(`/export${sessionQuery}`);
245
+ }}
246
  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"
247
  >
248
  <Download className="h-4 w-4" />
frontend/src/pages/ExportPage.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
  import { Link, 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 { BASE_W } from "../lib/report";
@@ -159,6 +159,14 @@ export default function ExportPage() {
159
  Report Viewer
160
  </Link>
161
 
 
 
 
 
 
 
 
 
162
  <Link
163
  to={`/edit-report${sessionQuery}`}
164
  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"
 
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
  import { Link, useSearchParams } from "react-router-dom";
3
+ import { ArrowLeft, Download, Edit3, Grid, Layout, Table, Image } from "react-feather";
4
 
5
  import { API_BASE, request } from "../lib/api";
6
  import { BASE_W } from "../lib/report";
 
159
  Report Viewer
160
  </Link>
161
 
162
+ <Link
163
+ to={`/image-placement${sessionQuery}`}
164
+ 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"
165
+ >
166
+ <Image className="h-4 w-4" />
167
+ Image Placement
168
+ </Link>
169
+
170
  <Link
171
  to={`/edit-report${sessionQuery}`}
172
  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"
frontend/src/pages/ImagePlacementPage.tsx ADDED
@@ -0,0 +1,605 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { Link, useSearchParams } from "react-router-dom";
3
+ import {
4
+ ArrowLeft,
5
+ ChevronLeft,
6
+ ChevronRight,
7
+ Layout,
8
+ Edit3,
9
+ Grid,
10
+ Download,
11
+ Table,
12
+ Info,
13
+ Image,
14
+ } from "react-feather";
15
+
16
+ import { postForm, putJson, request } from "../lib/api";
17
+ import { BASE_W } from "../lib/report";
18
+ import { ensureSections, flattenSections, replacePage } from "../lib/sections";
19
+ import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
20
+ import { APP_VERSION } from "../lib/version";
21
+ import type { JobsheetSection, Session } from "../types/session";
22
+ import { ReportPageCanvas } from "../components/ReportPageCanvas";
23
+ import { InfoMenu } from "../components/InfoMenu";
24
+
25
+ export default function ImagePlacementPage() {
26
+ const [searchParams] = useSearchParams();
27
+ const sessionId = getSessionId(searchParams.toString());
28
+
29
+ const [session, setSession] = useState<Session | null>(null);
30
+ const [sections, setSections] = useState<JobsheetSection[]>([]);
31
+ const [pageIndex, setPageIndex] = useState(0);
32
+ const [scale, setScale] = useState(1);
33
+ const [error, setError] = useState("");
34
+ const [photoSelection, setPhotoSelection] = useState("");
35
+ const [isSaving, setIsSaving] = useState(false);
36
+ const [saveState, setSaveState] = useState<"saved" | "saving" | "error">(
37
+ "saved",
38
+ );
39
+ const [status, setStatus] = useState("");
40
+ const [isUploading, setIsUploading] = useState(false);
41
+
42
+ const stageRef = useRef<HTMLDivElement | null>(null);
43
+ const uploadInputRef = useRef<HTMLInputElement | null>(null);
44
+
45
+ useEffect(() => {
46
+ if (!sessionId) {
47
+ setError("No active session found. Return to upload to continue.");
48
+ return;
49
+ }
50
+ setStoredSessionId(sessionId);
51
+ }, [sessionId]);
52
+
53
+ useEffect(() => {
54
+ const handleResize = () => {
55
+ if (!stageRef.current) return;
56
+ const width = stageRef.current.clientWidth;
57
+ if (width > 0) setScale(width / BASE_W);
58
+ };
59
+ handleResize();
60
+ window.addEventListener("resize", handleResize);
61
+ return () => window.removeEventListener("resize", handleResize);
62
+ }, []);
63
+
64
+ useEffect(() => {
65
+ if (!sessionId) return;
66
+ async function load() {
67
+ try {
68
+ const data = await request<Session>(`/sessions/${sessionId}`);
69
+ setSession(data);
70
+ const sectionResp = await request<{ sections: JobsheetSection[] }>(
71
+ `/sessions/${sessionId}/sections`,
72
+ );
73
+ const loaded = ensureSections(sectionResp.sections);
74
+ setSections(loaded);
75
+ } catch (err) {
76
+ const message =
77
+ err instanceof Error ? err.message : "Failed to load session.";
78
+ setError(message);
79
+ }
80
+ }
81
+ load();
82
+ }, [sessionId]);
83
+
84
+ const flatPages = useMemo(
85
+ () => flattenSections(ensureSections(sections)),
86
+ [sections],
87
+ );
88
+ const totalPages = useMemo(() => {
89
+ if (flatPages.length > 0) return flatPages.length;
90
+ return Math.max(1, session?.page_count ?? 0);
91
+ }, [flatPages.length, session?.page_count]);
92
+
93
+ useEffect(() => {
94
+ setPageIndex((idx) => Math.min(Math.max(0, idx), totalPages - 1));
95
+ }, [totalPages]);
96
+
97
+ useEffect(() => {
98
+ setPhotoSelection("");
99
+ }, [pageIndex]);
100
+
101
+ useEffect(() => {
102
+ const handler = (event: KeyboardEvent) => {
103
+ if (event.key === "ArrowRight") {
104
+ setPageIndex((idx) => Math.min(totalPages - 1, idx + 1));
105
+ }
106
+ if (event.key === "ArrowLeft") {
107
+ setPageIndex((idx) => Math.max(0, idx - 1));
108
+ }
109
+ };
110
+ window.addEventListener("keydown", handler);
111
+ return () => window.removeEventListener("keydown", handler);
112
+ }, [totalPages]);
113
+
114
+ const page = flatPages[pageIndex]?.page ?? null;
115
+ const pageEntry = flatPages[pageIndex] ?? null;
116
+ const sectionLabel = flatPages[pageIndex]?.sectionTitle
117
+ ? `Section ${flatPages[pageIndex].sectionIndex + 1} - ${flatPages[pageIndex].sectionTitle}`
118
+ : flatPages[pageIndex]
119
+ ? `Section ${flatPages[pageIndex].sectionIndex + 1}`
120
+ : "";
121
+ const template = page?.template;
122
+ const orderLocked = page?.photo_order_locked ?? false;
123
+ const sessionQuery = buildSessionQuery(sessionId || "");
124
+ const editReportQuery = useMemo(() => {
125
+ if (!sessionId) return "";
126
+ const params = new URLSearchParams();
127
+ params.set("session", sessionId);
128
+ params.set("page", String(pageIndex + 1));
129
+ return `?${params.toString()}`;
130
+ }, [sessionId, pageIndex]);
131
+
132
+ const viewerMeta = useMemo(() => {
133
+ if (!session) return "Loading...";
134
+ const selected = session.selected_photo_ids?.length ?? 0;
135
+ const docs = session.uploads?.documents?.length ?? 0;
136
+ const dataFiles = session.uploads?.data_files?.length ?? 0;
137
+ const hasEdits = flatPages.length > 0;
138
+ return (
139
+ `Selected example photos: ${selected} - Documents: ${docs} - Data files: ${dataFiles}` +
140
+ (hasEdits ? " - Edited pages loaded" : " - No saved edits yet")
141
+ );
142
+ }, [flatPages.length, session]);
143
+
144
+ const linkedPhotoIds = useMemo(() => {
145
+ const ids = new Set<string>();
146
+ sections.forEach((section) => {
147
+ (section.pages ?? []).forEach((sectionPage) => {
148
+ (sectionPage.photo_ids ?? []).forEach((photoId) => ids.add(photoId));
149
+ });
150
+ });
151
+ return ids;
152
+ }, [sections]);
153
+
154
+ const pagePhotoIds = page?.photo_ids ?? [];
155
+ const photoLookup = useMemo(
156
+ () => new Map((session?.uploads?.photos ?? []).map((photo) => [photo.id, photo])),
157
+ [session?.uploads?.photos],
158
+ );
159
+ const availablePhotos = useMemo(() => {
160
+ const all = session?.uploads?.photos ?? [];
161
+ if (!all.length) return [];
162
+ return all.filter((photo) => !linkedPhotoIds.has(photo.id));
163
+ }, [linkedPhotoIds, session?.uploads?.photos]);
164
+
165
+ const saveIndicator = useMemo(() => {
166
+ const base =
167
+ "inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold";
168
+ if (saveState === "saving") {
169
+ return (
170
+ <div className={`${base} border-amber-200 bg-amber-50 text-amber-700`}>
171
+ <span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
172
+ Saving...
173
+ </div>
174
+ );
175
+ }
176
+ if (saveState === "error") {
177
+ return (
178
+ <div className={`${base} border-red-200 bg-red-50 text-red-700`}>
179
+ <span className="h-2 w-2 rounded-full bg-red-500" />
180
+ Save failed
181
+ </div>
182
+ );
183
+ }
184
+ return (
185
+ <div className={`${base} border-emerald-200 bg-emerald-50 text-emerald-700`}>
186
+ <span className="h-2 w-2 rounded-full bg-emerald-500" />
187
+ All changes saved
188
+ </div>
189
+ );
190
+ }, [saveState]);
191
+
192
+ async function persistSections(next: JobsheetSection[]) {
193
+ if (!sessionId) return;
194
+ setIsSaving(true);
195
+ setSaveState("saving");
196
+ setStatus("Saving image changes...");
197
+ try {
198
+ const resp = await putJson<{ sections: JobsheetSection[] }>(
199
+ `/sessions/${sessionId}/sections`,
200
+ { sections: next },
201
+ );
202
+ const normalized = ensureSections(resp.sections ?? next);
203
+ setSections(normalized);
204
+ setSaveState("saved");
205
+ setStatus("Image changes saved.");
206
+ } catch (err) {
207
+ const message =
208
+ err instanceof Error ? err.message : "Failed to save image changes.";
209
+ setStatus(message);
210
+ setSaveState("error");
211
+ } finally {
212
+ setIsSaving(false);
213
+ }
214
+ }
215
+
216
+ function updatePagePhotos(nextIds: string[]) {
217
+ if (!pageEntry) return;
218
+ const nextPage = { ...pageEntry.page, photo_ids: nextIds };
219
+ const nextSections = replacePage(
220
+ sections,
221
+ pageEntry.sectionIndex,
222
+ pageEntry.pageIndex,
223
+ nextPage,
224
+ );
225
+ setSections(nextSections);
226
+ void persistSections(nextSections);
227
+ }
228
+
229
+ function addPhotoToPage(photoId: string) {
230
+ if (!photoId || !pageEntry) return;
231
+ const ids = [...pagePhotoIds];
232
+ if (!ids.includes(photoId)) ids.push(photoId);
233
+ updatePagePhotos(ids);
234
+ }
235
+
236
+ function removePhotoAt(index: number) {
237
+ const ids = [...pagePhotoIds];
238
+ ids.splice(index, 1);
239
+ updatePagePhotos(ids);
240
+ }
241
+
242
+ function movePhoto(from: number, to: number) {
243
+ const ids = [...pagePhotoIds];
244
+ if (from < 0 || from >= ids.length || to < 0 || to >= ids.length) return;
245
+ const [moved] = ids.splice(from, 1);
246
+ ids.splice(to, 0, moved);
247
+ updatePagePhotos(ids);
248
+ }
249
+
250
+ function toggleManualOrder() {
251
+ if (!pageEntry) return;
252
+ const nextPage = {
253
+ ...pageEntry.page,
254
+ photo_order_locked: !pageEntry.page.photo_order_locked,
255
+ };
256
+ const nextSections = replacePage(
257
+ sections,
258
+ pageEntry.sectionIndex,
259
+ pageEntry.pageIndex,
260
+ nextPage,
261
+ );
262
+ setSections(nextSections);
263
+ void persistSections(nextSections);
264
+ }
265
+
266
+ async function uploadPhoto(file: File) {
267
+ if (!sessionId) return;
268
+ setIsUploading(true);
269
+ setStatus(`Uploading ${file.name}...`);
270
+ const existing = new Set(
271
+ (session?.uploads?.photos ?? []).map((photo) => photo.id),
272
+ );
273
+ try {
274
+ const form = new FormData();
275
+ form.append("file", file);
276
+ const updated = await postForm<Session>(
277
+ `/sessions/${sessionId}/uploads`,
278
+ form,
279
+ );
280
+ setSession(updated);
281
+ const newPhoto =
282
+ (updated.uploads?.photos ?? []).find((photo) => !existing.has(photo.id)) ??
283
+ (updated.uploads?.photos ?? []).find((photo) => photo.name === file.name);
284
+ if (newPhoto) {
285
+ addPhotoToPage(newPhoto.id);
286
+ setStatus(`Added ${newPhoto.name} to this page.`);
287
+ } else {
288
+ setStatus("Uploaded image. Select it from the list to add.");
289
+ }
290
+ } catch (err) {
291
+ const message =
292
+ err instanceof Error ? err.message : "Failed to upload image.";
293
+ setStatus(message);
294
+ } finally {
295
+ setIsUploading(false);
296
+ }
297
+ }
298
+
299
+ return (
300
+ <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">
301
+ <header className="mb-8 border-b border-gray-200 pb-4">
302
+ <div className="grid grid-cols-[auto,1fr,auto] items-center gap-4">
303
+ <div className="flex items-center">
304
+ <img
305
+ src="/assets/prosento-logo.png"
306
+ alt="Company logo"
307
+ className="h-12 w-auto object-contain"
308
+ loading="eager"
309
+ />
310
+ </div>
311
+
312
+ <div className="text-center">
313
+ <h1 className="text-2xl md:text-3xl font-bold text-gray-900 whitespace-nowrap">
314
+ RepEx - Report Express
315
+ </h1>
316
+ <p className="text-gray-600 whitespace-nowrap">Image Placement</p>
317
+ </div>
318
+
319
+ <div className="flex justify-end gap-2 no-print">
320
+ <Link
321
+ to={`/report-viewer${sessionQuery}`}
322
+ 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"
323
+ >
324
+ <ArrowLeft className="h-4 w-4" />
325
+ Back
326
+ </Link>
327
+ <InfoMenu sessionQuery={sessionQuery} />
328
+ </div>
329
+ </div>
330
+ </header>
331
+
332
+ <nav className="mb-6 no-print" aria-label="Report workflow navigation">
333
+ <div className="flex flex-wrap gap-2">
334
+ <Link
335
+ to={`/input-data${sessionQuery}`}
336
+ 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"
337
+ >
338
+ <Table className="h-4 w-4" />
339
+ Input Data
340
+ </Link>
341
+
342
+ <Link
343
+ to={`/report-viewer${sessionQuery}`}
344
+ 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"
345
+ >
346
+ <Layout className="h-4 w-4" />
347
+ Report Viewer
348
+ </Link>
349
+
350
+ <span className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-900 px-4 py-2 text-sm font-semibold text-white">
351
+ <Image className="h-4 w-4" />
352
+ Image Placement
353
+ </span>
354
+
355
+ <Link
356
+ to={`/edit-report${editReportQuery}`}
357
+ 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"
358
+ >
359
+ <Edit3 className="h-4 w-4" />
360
+ Edit Report
361
+ </Link>
362
+
363
+ <Link
364
+ to={`/edit-layouts${sessionQuery}`}
365
+ 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"
366
+ >
367
+ <Grid className="h-4 w-4" />
368
+ Edit Page Layouts
369
+ </Link>
370
+
371
+ <Link
372
+ to={`/export${sessionQuery}`}
373
+ 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"
374
+ >
375
+ <Download className="h-4 w-4" />
376
+ Export
377
+ </Link>
378
+
379
+ <Link
380
+ to="/info/ratings"
381
+ 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"
382
+ >
383
+ <Info className="h-4 w-4" />
384
+ Rating Scales
385
+ </Link>
386
+ </div>
387
+ </nav>
388
+
389
+ <section
390
+ id="viewerSection"
391
+ aria-label="Image placement preview"
392
+ >
393
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
394
+ <div>
395
+ <h2 className="text-xl font-semibold text-gray-800">Page Preview</h2>
396
+ <p className="text-sm text-gray-600">{viewerMeta}</p>
397
+ </div>
398
+
399
+ <div className="flex items-center gap-2 no-print">
400
+ <button
401
+ type="button"
402
+ onClick={() => setPageIndex((idx) => Math.max(0, idx - 1))}
403
+ disabled={pageIndex === 0}
404
+ 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"
405
+ >
406
+ <ChevronLeft className="h-4 w-4" />
407
+ Prev
408
+ </button>
409
+
410
+ <div className="text-sm font-semibold text-gray-700">
411
+ Page <span>{pageIndex + 1}</span> / <span>{totalPages}</span>
412
+ </div>
413
+
414
+ <button
415
+ type="button"
416
+ onClick={() =>
417
+ setPageIndex((idx) => Math.min(totalPages - 1, idx + 1))
418
+ }
419
+ disabled={pageIndex >= totalPages - 1}
420
+ 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"
421
+ >
422
+ Next
423
+ <ChevronRight className="h-4 w-4" />
424
+ </button>
425
+ </div>
426
+ </div>
427
+
428
+ <div className="flex justify-center">
429
+ <div
430
+ ref={stageRef}
431
+ className="relative shadow-sm rounded-xl bg-white border border-gray-200"
432
+ style={{
433
+ width: "min(100%, 560px)",
434
+ }}
435
+ >
436
+ <ReportPageCanvas
437
+ session={session}
438
+ page={page}
439
+ pageIndex={pageIndex}
440
+ pageCount={totalPages}
441
+ scale={scale}
442
+ template={template}
443
+ sectionLabel={sectionLabel}
444
+ adaptive
445
+ />
446
+ </div>
447
+ </div>
448
+
449
+ <p className="mt-4 text-xs text-gray-500 no-print">
450
+ Tip: Use keyboard arrows (left / right) to change pages.
451
+ </p>
452
+ {error ? <p className="text-sm text-red-600 mt-2">{error}</p> : null}
453
+ </section>
454
+
455
+ {pageEntry ? (
456
+ <section className="mt-6 no-print" aria-label="Page images">
457
+ <div className="flex flex-wrap items-center justify-between gap-3">
458
+ <div>
459
+ <h3 className="text-lg font-semibold text-gray-900">Page Images</h3>
460
+ <p className="text-sm text-gray-600">
461
+ Images only appear once they are linked to a page. Unlinked uploads
462
+ stay hidden until you place them.
463
+ </p>
464
+ </div>
465
+ {saveIndicator}
466
+ </div>
467
+
468
+ <div className="mt-3 grid gap-4 lg:grid-cols-[1.6fr,1fr]">
469
+ <div className="rounded-lg border border-gray-200 bg-white p-3">
470
+ <div className="text-xs font-semibold text-gray-600 uppercase">
471
+ Linked images
472
+ </div>
473
+ <div className="mt-2 space-y-2">
474
+ {pagePhotoIds.length ? (
475
+ pagePhotoIds.map((photoId, idx) => {
476
+ const photo = photoLookup.get(photoId);
477
+ return (
478
+ <div
479
+ key={`${photoId}-${idx}`}
480
+ className="flex flex-wrap items-center gap-2 text-xs"
481
+ >
482
+ <span className="font-semibold text-gray-800">
483
+ {photo?.name || photoId}
484
+ </span>
485
+ <button
486
+ type="button"
487
+ onClick={() => movePhoto(idx, idx - 1)}
488
+ disabled={idx === 0 || isSaving}
489
+ className="rounded border border-gray-200 px-2 py-0.5 text-[11px] text-gray-600 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
490
+ >
491
+ Up
492
+ </button>
493
+ <button
494
+ type="button"
495
+ onClick={() => movePhoto(idx, idx + 1)}
496
+ disabled={idx === pagePhotoIds.length - 1 || isSaving}
497
+ className="rounded border border-gray-200 px-2 py-0.5 text-[11px] text-gray-600 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
498
+ >
499
+ Down
500
+ </button>
501
+ <button
502
+ type="button"
503
+ onClick={() => removePhotoAt(idx)}
504
+ disabled={isSaving}
505
+ className="rounded border border-red-200 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700 hover:bg-red-100 disabled:opacity-50 disabled:cursor-not-allowed"
506
+ >
507
+ Remove
508
+ </button>
509
+ </div>
510
+ );
511
+ })
512
+ ) : (
513
+ <div className="text-xs text-gray-500">No images linked.</div>
514
+ )}
515
+ </div>
516
+ <div className="mt-3 flex flex-wrap items-center gap-2">
517
+ <button
518
+ type="button"
519
+ onClick={toggleManualOrder}
520
+ disabled={!pagePhotoIds.length || isSaving}
521
+ className="rounded border border-gray-200 px-2.5 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
522
+ >
523
+ {orderLocked ? "Use auto order" : "Apply order"}
524
+ </button>
525
+ <span className="text-[11px] text-gray-500">
526
+ {orderLocked ? "Manual order locked" : "Auto ordering enabled"}
527
+ </span>
528
+ </div>
529
+ <p className="mt-2 text-[11px] text-gray-500">
530
+ Adding more than 2 images will automatically create a continuation
531
+ page for the overflow.
532
+ </p>
533
+ </div>
534
+
535
+ <div className="rounded-lg border border-gray-200 bg-white p-3">
536
+ <div className="text-xs font-semibold text-gray-600 uppercase">
537
+ Add image
538
+ </div>
539
+ <div className="mt-2 flex flex-col gap-2">
540
+ <select
541
+ value={photoSelection}
542
+ onChange={(event) => setPhotoSelection(event.target.value)}
543
+ className="w-full rounded-md border border-gray-200 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
544
+ >
545
+ <option value="">Select an unlinked image</option>
546
+ {availablePhotos.map((photo) => (
547
+ <option key={`viewer-photo-${photo.id}`} value={photo.id}>
548
+ {photo.name}
549
+ </option>
550
+ ))}
551
+ </select>
552
+ <button
553
+ type="button"
554
+ onClick={() => {
555
+ addPhotoToPage(photoSelection);
556
+ setPhotoSelection("");
557
+ }}
558
+ disabled={!photoSelection || isSaving}
559
+ className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
560
+ >
561
+ Add selected image
562
+ </button>
563
+ <button
564
+ type="button"
565
+ onClick={() => uploadInputRef.current?.click()}
566
+ disabled={isUploading || isSaving}
567
+ className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
568
+ >
569
+ Upload new image
570
+ </button>
571
+ <input
572
+ ref={uploadInputRef}
573
+ type="file"
574
+ accept="image/*"
575
+ className="hidden"
576
+ onChange={(event) => {
577
+ const file = event.target.files?.[0];
578
+ if (file) void uploadPhoto(file);
579
+ event.target.value = "";
580
+ }}
581
+ />
582
+ {availablePhotos.length === 0 ? (
583
+ <p className="text-[11px] text-gray-500">
584
+ All uploaded images are already linked.
585
+ </p>
586
+ ) : null}
587
+ </div>
588
+ </div>
589
+ </div>
590
+
591
+ {status ? (
592
+ <p className="mt-2 text-xs text-gray-500">{status}</p>
593
+ ) : null}
594
+ </section>
595
+ ) : null}
596
+
597
+ <footer className="mt-12 text-center text-xs text-gray-500 no-print">
598
+ <p>Prosento - (c) 2026 All Rights Reserved</p>
599
+ <p className="mt-1">Images linked here appear in the report output.</p>
600
+ <p className="mt-1">Version {APP_VERSION}</p>
601
+ </footer>
602
+
603
+ </main>
604
+ );
605
+ }
frontend/src/pages/InputDataPage.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
- import { Link, useSearchParams } from "react-router-dom";
3
- import { ArrowLeft, Download, Edit3, Grid, Layout, Save, Table } from "react-feather";
4
 
5
  import { API_BASE, postForm, putJson, request } from "../lib/api";
6
  import { formatDocNumber } from "../lib/report";
@@ -12,7 +12,7 @@ import {
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";
@@ -24,49 +24,75 @@ type FieldDef = {
24
  multiline?: boolean;
25
  };
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  const GENERAL_FIELDS: FieldDef[] = [
28
  { key: "inspection_date", label: "Inspection Date" },
29
  { key: "inspector", label: "Inspector" },
30
- { key: "accompanied_by", label: "Accompanied By" },
31
  { key: "document_no", label: "Document No" },
32
- { key: "project", label: "Project" },
33
- { key: "client_site", label: "Client / Site" },
34
  { key: "company_logo", label: "Company Logo" },
35
  ];
36
 
37
  const ITEM_FIELDS: FieldDef[] = [
38
  { key: "area", label: "Area" },
39
  { key: "reference", label: "Reference" },
40
- { key: "action_type", label: "Action Type" },
41
  { key: "item_description", label: "Item Description", multiline: true },
42
- { key: "functional_location", label: "Functional Location" },
43
  { key: "category", label: "Category" },
44
  { key: "priority", label: "Priority" },
45
- { key: "condition_description", label: "Condition Description", multiline: true },
46
  { key: "required_action", label: "Required Action", multiline: true },
 
47
  ];
48
 
49
  export default function InputDataPage() {
50
  const [searchParams] = useSearchParams();
51
  const sessionId = getSessionId(searchParams.toString());
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>({});
66
  const [photoSelections, setPhotoSelections] = useState<Record<number, string>>(
67
  {},
68
  );
69
- const canSave = Boolean(sessionId) && !isSaving;
 
 
 
 
 
 
 
70
  const excelInputRef = useRef<HTMLInputElement | null>(null);
71
  const jsonInputRef = useRef<HTMLInputElement | null>(null);
72
  const uploadInputRefs = useRef<Record<number, HTMLInputElement | null>>({});
@@ -84,7 +110,15 @@ export default function InputDataPage() {
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.";
@@ -101,7 +135,15 @@ export default function InputDataPage() {
101
  const sectionResp = await request<{ sections: JobsheetSection[] }>(
102
  `/sessions/${sessionId}/sections`,
103
  );
104
- setSections(ensureSections(sectionResp.sections));
 
 
 
 
 
 
 
 
105
  }
106
 
107
  const flatPages = useMemo(
@@ -158,6 +200,41 @@ export default function InputDataPage() {
158
  setGeneralDirty(true);
159
  }
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  function applyRowToAll(pageIndex: number) {
162
  const entry = flatPages[pageIndex];
163
  if (!entry) return;
@@ -173,25 +250,13 @@ export default function InputDataPage() {
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) {
@@ -380,34 +445,147 @@ export default function InputDataPage() {
380
  case "inspection_date":
381
  return session.inspection_date || "";
382
  case "document_no":
383
- return formatDocNumber(session);
384
- case "project":
385
- return session.project_name || "";
386
- case "condition_description":
387
- return session.notes || "";
388
  default:
389
  return "";
390
  }
391
  }
392
 
393
- async function saveAll() {
 
 
 
394
  if (!sessionId) return;
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 =
407
- err instanceof Error ? err.message : "Failed to save input data.";
408
  setStatus(message);
409
- } finally {
410
- setIsSaving(false);
411
  }
412
  }
413
 
@@ -484,15 +662,82 @@ export default function InputDataPage() {
484
  }
485
  }
486
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487
  return (
488
  <PageShell className="max-w-6xl">
489
  <PageHeader
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" />
@@ -512,14 +757,34 @@ export default function InputDataPage() {
512
 
513
  <Link
514
  to={`/report-viewer${sessionQuery}`}
 
 
 
 
515
  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"
516
  >
517
  <Layout className="h-4 w-4" />
518
  Report Viewer
519
  </Link>
520
 
 
 
 
 
 
 
 
 
 
 
 
 
521
  <Link
522
  to={`/edit-report${sessionQuery}`}
 
 
 
 
523
  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"
524
  >
525
  <Edit3 className="h-4 w-4" />
@@ -528,6 +793,10 @@ export default function InputDataPage() {
528
 
529
  <Link
530
  to={`/edit-layouts${sessionQuery}`}
 
 
 
 
531
  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"
532
  >
533
  <Grid className="h-4 w-4" />
@@ -536,6 +805,10 @@ export default function InputDataPage() {
536
 
537
  <Link
538
  to={`/export${sessionQuery}`}
 
 
 
 
539
  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"
540
  >
541
  <Download className="h-4 w-4" />
@@ -584,15 +857,6 @@ export default function InputDataPage() {
584
  >
585
  Add section
586
  </button>
587
- <button
588
- type="button"
589
- onClick={saveAll}
590
- disabled={!canSave}
591
- 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"
592
- >
593
- <Save className="h-4 w-4" />
594
- Save changes
595
- </button>
596
  </div>
597
  </div>
598
  {status ? <p className="text-sm text-gray-600 mt-3">{status}</p> : null}
@@ -777,12 +1041,23 @@ export default function InputDataPage() {
777
  </div>
778
 
779
  <div className="w-full lg:w-[320px] shrink-0">
780
- <h3 className="text-base font-semibold text-gray-900">Headings</h3>
781
- <p className="text-sm text-gray-600">
782
- Imported heading numbers from the Excel sheet.
783
- </p>
 
 
 
 
 
 
 
 
 
 
 
784
  <div className="mt-3 rounded-lg border border-gray-200 bg-white overflow-x-auto">
785
- <table className="min-w-[280px] w-full text-sm">
786
  <thead className="bg-gray-50 border-b border-gray-200">
787
  <tr>
788
  <th className="px-3 py-2 text-left text-xs font-semibold text-gray-600">
@@ -791,24 +1066,50 @@ export default function InputDataPage() {
791
  <th className="px-3 py-2 text-left text-xs font-semibold text-gray-600">
792
  Heading
793
  </th>
 
 
 
794
  </tr>
795
  </thead>
796
  <tbody>
797
- {session?.headings?.length ? (
798
- session.headings.map((heading, idx) => (
799
  <tr key={`heading-${idx}`} className="border-b border-gray-100">
800
  <td className="px-3 py-2 text-xs font-semibold text-gray-700">
801
- {heading.number}
 
 
 
 
 
 
 
802
  </td>
803
  <td className="px-3 py-2 text-sm text-gray-700">
804
- {heading.name}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
805
  </td>
806
  </tr>
807
  ))
808
  ) : (
809
  <tr>
810
  <td
811
- colSpan={2}
812
  className="px-3 py-4 text-sm text-gray-500 text-center"
813
  >
814
  No headings found.
@@ -1180,7 +1481,7 @@ export default function InputDataPage() {
1180
  </button>
1181
  </div>
1182
 
1183
- <PageFooter note="Tip: edit fields per page and save once. Use apply row to keep pages consistent." />
1184
  </PageShell>
1185
  );
1186
  }
 
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { Link, useNavigate, useSearchParams } from "react-router-dom";
3
+ import { ArrowLeft, Download, Edit3, Grid, Layout, Table, Image } from "react-feather";
4
 
5
  import { API_BASE, postForm, putJson, request } from "../lib/api";
6
  import { formatDocNumber } from "../lib/report";
 
12
  replacePage,
13
  } from "../lib/sections";
14
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
15
+ import type { Heading, JobsheetSection, Session, TemplateFields } from "../types/session";
16
  import { PageFooter } from "../components/PageFooter";
17
  import { PageHeader } from "../components/PageHeader";
18
  import { PageShell } from "../components/PageShell";
 
24
  multiline?: boolean;
25
  };
26
 
27
+ const normalizeHeadings = (raw?: Heading[] | Record<string, string>): Heading[] => {
28
+ if (Array.isArray(raw)) {
29
+ return raw
30
+ .filter(Boolean)
31
+ .map((heading) => ({
32
+ number: String(heading.number ?? "").trim(),
33
+ name: String(heading.name ?? "").trim(),
34
+ }));
35
+ }
36
+ if (raw && typeof raw === "object") {
37
+ return Object.entries(raw).map(([number, name]) => ({
38
+ number: String(number).trim(),
39
+ name: String(name ?? "").trim(),
40
+ }));
41
+ }
42
+ return [];
43
+ };
44
+
45
  const GENERAL_FIELDS: FieldDef[] = [
46
  { key: "inspection_date", label: "Inspection Date" },
47
  { key: "inspector", label: "Inspector" },
 
48
  { key: "document_no", label: "Document No" },
 
 
49
  { key: "company_logo", label: "Company Logo" },
50
  ];
51
 
52
  const ITEM_FIELDS: FieldDef[] = [
53
  { key: "area", label: "Area" },
54
  { key: "reference", label: "Reference" },
55
+ { key: "functional_location", label: "Location" },
56
  { key: "item_description", label: "Item Description", multiline: true },
 
57
  { key: "category", label: "Category" },
58
  { key: "priority", label: "Priority" },
 
59
  { key: "required_action", label: "Required Action", multiline: true },
60
+ { key: "figure_caption", label: "Figure Caption" },
61
  ];
62
 
63
  export default function InputDataPage() {
64
  const [searchParams] = useSearchParams();
65
  const sessionId = getSessionId(searchParams.toString());
66
  const sessionQuery = buildSessionQuery(sessionId);
67
+ const navigate = useNavigate();
68
 
69
  const [session, setSession] = useState<Session | null>(null);
70
  const [sections, setSections] = useState<JobsheetSection[]>([]);
71
  const [status, setStatus] = useState("");
72
  const [isSaving, setIsSaving] = useState(false);
73
+ const [saveState, setSaveState] = useState<
74
+ "saved" | "saving" | "pending" | "error"
75
+ >("saved");
76
  const [isUploading, setIsUploading] = useState(false);
77
  const [copySourceIndex, setCopySourceIndex] = useState(0);
78
  const [copyTargets, setCopyTargets] = useState("");
79
  const [addSectionId, setAddSectionId] = useState<string>("");
80
  const [sectionsCollapsed, setSectionsCollapsed] = useState(true);
81
+ const [headings, setHeadings] = useState<Heading[]>([]);
82
  const [showGeneralColumns, setShowGeneralColumns] = useState(false);
83
  const [generalDirty, setGeneralDirty] = useState(false);
84
  const [generalTemplate, setGeneralTemplate] = useState<TemplateFields>({});
85
  const [photoSelections, setPhotoSelections] = useState<Record<number, string>>(
86
  {},
87
  );
88
+ const saveTimerRef = useRef<number | null>(null);
89
+ const generalApplyTimerRef = useRef<number | null>(null);
90
+ const headingsSaveTimerRef = useRef<number | null>(null);
91
+ const lastSavedHeadingsRef = useRef<string>(JSON.stringify([]));
92
+ const headingsLoadedRef = useRef(false);
93
+ const lastSavedRef = useRef<string>("");
94
+ const pendingSaveRef = useRef<string>("");
95
+ const savePromiseRef = useRef<Promise<void> | null>(null);
96
  const excelInputRef = useRef<HTMLInputElement | null>(null);
97
  const jsonInputRef = useRef<HTMLInputElement | null>(null);
98
  const uploadInputRefs = useRef<Record<number, HTMLInputElement | null>>({});
 
110
  const sectionResp = await request<{ sections: JobsheetSection[] }>(
111
  `/sessions/${sessionId}/sections`,
112
  );
113
+ const normalized = ensureSections(sectionResp.sections);
114
+ setSections(normalized);
115
+ const initialHeadings = normalizeHeadings(data.headings);
116
+ setHeadings(initialHeadings);
117
+ headingsLoadedRef.current = true;
118
+ lastSavedHeadingsRef.current = JSON.stringify(initialHeadings);
119
+ lastSavedRef.current = JSON.stringify(normalized);
120
+ pendingSaveRef.current = lastSavedRef.current;
121
+ setSaveState("saved");
122
  } catch (err) {
123
  const message =
124
  err instanceof Error ? err.message : "Failed to load session.";
 
135
  const sectionResp = await request<{ sections: JobsheetSection[] }>(
136
  `/sessions/${sessionId}/sections`,
137
  );
138
+ const normalized = ensureSections(sectionResp.sections);
139
+ setSections(normalized);
140
+ const nextHeadings = normalizeHeadings(data.headings);
141
+ setHeadings(nextHeadings);
142
+ headingsLoadedRef.current = true;
143
+ lastSavedHeadingsRef.current = JSON.stringify(nextHeadings);
144
+ lastSavedRef.current = JSON.stringify(normalized);
145
+ pendingSaveRef.current = lastSavedRef.current;
146
+ setSaveState("saved");
147
  }
148
 
149
  const flatPages = useMemo(
 
200
  setGeneralDirty(true);
201
  }
202
 
203
+ function updateHeadingField(index: number, key: keyof Heading, value: string) {
204
+ setHeadings((prev) =>
205
+ prev.map((heading, idx) =>
206
+ idx === index ? { ...heading, [key]: value } : heading,
207
+ ),
208
+ );
209
+ }
210
+
211
+ function addHeadingRow() {
212
+ setHeadings((prev) => [...prev, { number: "", name: "" }]);
213
+ }
214
+
215
+ function removeHeadingRow(index: number) {
216
+ setHeadings((prev) => prev.filter((_, idx) => idx !== index));
217
+ }
218
+
219
+ function applyGeneralToSections(
220
+ source: JobsheetSection[],
221
+ template: TemplateFields,
222
+ ): JobsheetSection[] {
223
+ return source.map((section) => ({
224
+ ...section,
225
+ pages: (section.pages ?? []).map((page) => {
226
+ const nextTemplate = { ...(page.template ?? {}) };
227
+ GENERAL_FIELDS.forEach((field) => {
228
+ const value = template[field.key];
229
+ if (value !== undefined) {
230
+ nextTemplate[field.key] = value;
231
+ }
232
+ });
233
+ return { ...page, template: nextTemplate };
234
+ }),
235
+ }));
236
+ }
237
+
238
  function applyRowToAll(pageIndex: number) {
239
  const entry = flatPages[pageIndex];
240
  if (!entry) return;
 
250
  );
251
  }
252
 
253
+ function applyGeneralToAll(silent = false) {
254
  if (!flatPages.length) return;
255
+ setSections((prev) => applyGeneralToSections(prev, generalTemplate));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  setGeneralDirty(false);
257
+ if (!silent) {
258
+ setStatus("Applied general info to all pages.");
259
+ }
260
  }
261
 
262
  function insertPageAt(index: number, templateSource?: TemplateFields) {
 
445
  case "inspection_date":
446
  return session.inspection_date || "";
447
  case "document_no":
448
+ return session.document_no || formatDocNumber(session);
 
 
 
 
449
  default:
450
  return "";
451
  }
452
  }
453
 
454
+ async function saveAll(
455
+ silent = false,
456
+ overrideSections?: JobsheetSection[],
457
+ ) {
458
  if (!sessionId) return;
459
+ if (savePromiseRef.current) {
460
+ await savePromiseRef.current;
461
+ }
462
+ const toSave = overrideSections ?? sections;
463
+ const snapshot = JSON.stringify(toSave);
464
+ if (!overrideSections && snapshot === lastSavedRef.current) {
465
+ if (!isSaving) setSaveState("saved");
466
+ return;
467
+ }
468
+ const promise = (async () => {
469
+ setIsSaving(true);
470
+ setSaveState("saving");
471
+ if (!silent) {
472
+ setStatus("Saving input data...");
473
+ }
474
+ try {
475
+ const resp = await putJson<{ sections: JobsheetSection[] }>(
476
+ `/sessions/${sessionId}/sections`,
477
+ { sections: toSave },
478
+ );
479
+ const updated = ensureSections(resp.sections ?? toSave);
480
+ setSections(updated);
481
+ lastSavedRef.current = JSON.stringify(updated);
482
+ pendingSaveRef.current = lastSavedRef.current;
483
+ setSaveState("saved");
484
+ if (!silent) {
485
+ setStatus("Input data saved.");
486
+ } else {
487
+ setStatus("All changes saved.");
488
+ }
489
+ } catch (err) {
490
+ const message =
491
+ err instanceof Error ? err.message : "Failed to save input data.";
492
+ setStatus(message);
493
+ setSaveState("error");
494
+ } finally {
495
+ setIsSaving(false);
496
+ if (
497
+ pendingSaveRef.current &&
498
+ pendingSaveRef.current !== lastSavedRef.current
499
+ ) {
500
+ if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
501
+ saveTimerRef.current = window.setTimeout(() => {
502
+ void triggerAutoSave();
503
+ }, 200);
504
+ }
505
+ }
506
+ })();
507
+ savePromiseRef.current = promise;
508
  try {
509
+ await promise;
510
+ } finally {
511
+ savePromiseRef.current = null;
512
+ }
513
+ }
514
+
515
+ async function triggerAutoSave() {
516
+ if (!sessionId || savePromiseRef.current) return;
517
+ const snapshot = pendingSaveRef.current;
518
+ if (!snapshot || snapshot === lastSavedRef.current) return;
519
+ await saveAll(true);
520
+ }
521
+
522
+ useEffect(() => {
523
+ if (!sessionId) return;
524
+ const snapshot = JSON.stringify(sections);
525
+ if (snapshot === lastSavedRef.current) {
526
+ if (!generalDirty && !isSaving) {
527
+ setSaveState("saved");
528
+ }
529
+ return;
530
+ }
531
+ pendingSaveRef.current = snapshot;
532
+ if (!isSaving) {
533
+ setSaveState("pending");
534
+ }
535
+ if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
536
+ saveTimerRef.current = window.setTimeout(() => {
537
+ void triggerAutoSave();
538
+ }, 800);
539
+ }, [sections, sessionId, generalDirty, isSaving]);
540
+
541
+ useEffect(() => {
542
+ if (!generalDirty) return;
543
+ if (!isSaving) {
544
+ setSaveState("pending");
545
+ }
546
+ if (generalApplyTimerRef.current) {
547
+ window.clearTimeout(generalApplyTimerRef.current);
548
+ }
549
+ generalApplyTimerRef.current = window.setTimeout(() => {
550
+ applyGeneralToAll(true);
551
+ }, 800);
552
+ }, [generalDirty, generalTemplate, isSaving]);
553
+
554
+ useEffect(() => {
555
+ if (!sessionId) return;
556
+ if (!headingsLoadedRef.current) return;
557
+ const snapshot = JSON.stringify(headings);
558
+ if (snapshot === lastSavedHeadingsRef.current) return;
559
+ if (headingsSaveTimerRef.current) {
560
+ window.clearTimeout(headingsSaveTimerRef.current);
561
+ }
562
+ headingsSaveTimerRef.current = window.setTimeout(() => {
563
+ void saveHeadings(true);
564
+ }, 800);
565
+ }, [headings, sessionId]);
566
+
567
+ async function saveHeadings(silent = false) {
568
+ if (!sessionId) return;
569
+ const snapshot = JSON.stringify(headings);
570
+ if (snapshot === lastSavedHeadingsRef.current) return;
571
+ try {
572
+ if (!silent) {
573
+ setStatus("Saving headings...");
574
+ }
575
+ const resp = await putJson<{ headings: Heading[] }>(
576
+ `/sessions/${sessionId}/headings`,
577
+ { headings },
578
  );
579
+ const normalized = resp.headings ?? headings;
580
+ setHeadings(normalized);
581
+ lastSavedHeadingsRef.current = JSON.stringify(normalized);
582
+ if (!silent) {
583
+ setStatus("Headings saved.");
584
+ }
585
  } catch (err) {
586
  const message =
587
+ err instanceof Error ? err.message : "Failed to save headings.";
588
  setStatus(message);
 
 
589
  }
590
  }
591
 
 
662
  }
663
  }
664
 
665
+ async function saveAndNavigate(target: string) {
666
+ if (!sessionId) {
667
+ navigate(target);
668
+ return;
669
+ }
670
+ if (generalApplyTimerRef.current) {
671
+ window.clearTimeout(generalApplyTimerRef.current);
672
+ generalApplyTimerRef.current = null;
673
+ }
674
+ let overrideSections: JobsheetSection[] | undefined;
675
+ if (generalDirty) {
676
+ overrideSections = applyGeneralToSections(sections, generalTemplate);
677
+ setSections(overrideSections);
678
+ setGeneralDirty(false);
679
+ }
680
+ if (saveTimerRef.current) {
681
+ window.clearTimeout(saveTimerRef.current);
682
+ saveTimerRef.current = null;
683
+ }
684
+ const snapshot = JSON.stringify(overrideSections ?? sections);
685
+ pendingSaveRef.current = snapshot;
686
+ if (snapshot !== lastSavedRef.current || savePromiseRef.current) {
687
+ await saveAll(true, overrideSections);
688
+ }
689
+ navigate(target);
690
+ }
691
+
692
+ const saveIndicator = useMemo(() => {
693
+ const base =
694
+ "inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold";
695
+ if (saveState === "saving") {
696
+ return (
697
+ <div className={`${base} border-amber-200 bg-amber-50 text-amber-700`}>
698
+ <span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
699
+ Saving...
700
+ </div>
701
+ );
702
+ }
703
+ if (saveState === "pending") {
704
+ return (
705
+ <div className={`${base} border-amber-200 bg-amber-50 text-amber-700`}>
706
+ <span className="h-2 w-2 rounded-full bg-amber-500" />
707
+ Unsaved changes
708
+ </div>
709
+ );
710
+ }
711
+ if (saveState === "error") {
712
+ return (
713
+ <div className={`${base} border-red-200 bg-red-50 text-red-700`}>
714
+ <span className="h-2 w-2 rounded-full bg-red-500" />
715
+ Save failed
716
+ </div>
717
+ );
718
+ }
719
+ return (
720
+ <div className={`${base} border-emerald-200 bg-emerald-50 text-emerald-700`}>
721
+ <span className="h-2 w-2 rounded-full bg-emerald-500" />
722
+ All changes saved
723
+ </div>
724
+ );
725
+ }, [saveState]);
726
+
727
  return (
728
  <PageShell className="max-w-6xl">
729
  <PageHeader
730
  title="RepEx - Report Express"
731
  subtitle="Input Data"
732
  right={
733
+ <div className="flex flex-col items-end gap-2 sm:flex-row sm:items-center">
734
+ {saveIndicator}
735
  <Link
736
  to={`/report-viewer${sessionQuery}`}
737
+ onClick={(event) => {
738
+ event.preventDefault();
739
+ void saveAndNavigate(`/report-viewer${sessionQuery}`);
740
+ }}
741
  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"
742
  >
743
  <ArrowLeft className="h-4 w-4" />
 
757
 
758
  <Link
759
  to={`/report-viewer${sessionQuery}`}
760
+ onClick={(event) => {
761
+ event.preventDefault();
762
+ void saveAndNavigate(`/report-viewer${sessionQuery}`);
763
+ }}
764
  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"
765
  >
766
  <Layout className="h-4 w-4" />
767
  Report Viewer
768
  </Link>
769
 
770
+ <Link
771
+ to={`/image-placement${sessionQuery}`}
772
+ onClick={(event) => {
773
+ event.preventDefault();
774
+ void saveAndNavigate(`/image-placement${sessionQuery}`);
775
+ }}
776
+ 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"
777
+ >
778
+ <Image className="h-4 w-4" />
779
+ Image Placement
780
+ </Link>
781
+
782
  <Link
783
  to={`/edit-report${sessionQuery}`}
784
+ onClick={(event) => {
785
+ event.preventDefault();
786
+ void saveAndNavigate(`/edit-report${sessionQuery}`);
787
+ }}
788
  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"
789
  >
790
  <Edit3 className="h-4 w-4" />
 
793
 
794
  <Link
795
  to={`/edit-layouts${sessionQuery}`}
796
+ onClick={(event) => {
797
+ event.preventDefault();
798
+ void saveAndNavigate(`/edit-layouts${sessionQuery}`);
799
+ }}
800
  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"
801
  >
802
  <Grid className="h-4 w-4" />
 
805
 
806
  <Link
807
  to={`/export${sessionQuery}`}
808
+ onClick={(event) => {
809
+ event.preventDefault();
810
+ void saveAndNavigate(`/export${sessionQuery}`);
811
+ }}
812
  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"
813
  >
814
  <Download className="h-4 w-4" />
 
857
  >
858
  Add section
859
  </button>
 
 
 
 
 
 
 
 
 
860
  </div>
861
  </div>
862
  {status ? <p className="text-sm text-gray-600 mt-3">{status}</p> : null}
 
1041
  </div>
1042
 
1043
  <div className="w-full lg:w-[320px] shrink-0">
1044
+ <div className="flex flex-wrap items-center justify-between gap-2">
1045
+ <div>
1046
+ <h3 className="text-base font-semibold text-gray-900">Headings</h3>
1047
+ <p className="text-sm text-gray-600">
1048
+ Edit heading numbers or add new rows.
1049
+ </p>
1050
+ </div>
1051
+ <button
1052
+ type="button"
1053
+ onClick={addHeadingRow}
1054
+ 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"
1055
+ >
1056
+ Add
1057
+ </button>
1058
+ </div>
1059
  <div className="mt-3 rounded-lg border border-gray-200 bg-white overflow-x-auto">
1060
+ <table className="min-w-[360px] w-full text-sm">
1061
  <thead className="bg-gray-50 border-b border-gray-200">
1062
  <tr>
1063
  <th className="px-3 py-2 text-left text-xs font-semibold text-gray-600">
 
1066
  <th className="px-3 py-2 text-left text-xs font-semibold text-gray-600">
1067
  Heading
1068
  </th>
1069
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-600 w-20">
1070
+ Actions
1071
+ </th>
1072
  </tr>
1073
  </thead>
1074
  <tbody>
1075
+ {headings.length ? (
1076
+ headings.map((heading, idx) => (
1077
  <tr key={`heading-${idx}`} className="border-b border-gray-100">
1078
  <td className="px-3 py-2 text-xs font-semibold text-gray-700">
1079
+ <input
1080
+ type="text"
1081
+ className="w-full rounded-md border border-gray-200 px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-200"
1082
+ value={heading.number}
1083
+ onChange={(event) =>
1084
+ updateHeadingField(idx, "number", event.target.value)
1085
+ }
1086
+ />
1087
  </td>
1088
  <td className="px-3 py-2 text-sm text-gray-700">
1089
+ <input
1090
+ type="text"
1091
+ className="w-full rounded-md border border-gray-200 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
1092
+ value={heading.name}
1093
+ onChange={(event) =>
1094
+ updateHeadingField(idx, "name", event.target.value)
1095
+ }
1096
+ />
1097
+ </td>
1098
+ <td className="px-3 py-2 text-xs">
1099
+ <button
1100
+ type="button"
1101
+ onClick={() => removeHeadingRow(idx)}
1102
+ className="inline-flex items-center gap-1 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-[11px] font-semibold text-red-700 hover:bg-red-100"
1103
+ >
1104
+ Remove
1105
+ </button>
1106
  </td>
1107
  </tr>
1108
  ))
1109
  ) : (
1110
  <tr>
1111
  <td
1112
+ colSpan={3}
1113
  className="px-3 py-4 text-sm text-gray-500 text-center"
1114
  >
1115
  No headings found.
 
1481
  </button>
1482
  </div>
1483
 
1484
+ <PageFooter note="Tip: changes save automatically. Use apply row to keep pages consistent." />
1485
  </PageShell>
1486
  );
1487
  }
frontend/src/pages/PrintReportPage.tsx CHANGED
@@ -3,7 +3,7 @@ import type { CSSProperties } from "react";
3
  import { useSearchParams } from "react-router-dom";
4
 
5
  import { request } from "../lib/api";
6
- import { BASE_W, getPhotosForPage, getSelectedPhotos } from "../lib/report";
7
  import { getSessionId } from "../lib/session";
8
  import type { FileMeta, Page, PageItem, Session, TemplateFields } from "../types/session";
9
  import { JobSheetTemplate } from "../components/JobSheetTemplate";
@@ -38,7 +38,7 @@ function resolvePagePhotos(
38
  if (explicit.length) {
39
  return explicit.map((id) => byId.get(id)).filter(Boolean) as FileMeta[];
40
  }
41
- return getPhotosForPage(session, pageIndex, 1);
42
  }
43
 
44
  function chunkPhotos(photos: FileMeta[], perSheet: number) {
@@ -207,11 +207,8 @@ export default function PrintReportPage() {
207
 
208
  const basePages = useMemo(() => {
209
  if (pages.length) return pages;
210
- if (!session) return [];
211
- const selected = getSelectedPhotos(session);
212
- const count = Math.max(1, selected.length || session.page_count || 1);
213
- return Array.from({ length: count }, () => ({ items: [] as PageItem[] }));
214
- }, [pages, session]);
215
 
216
  const totalPages = basePages.length || 1;
217
 
@@ -259,6 +256,7 @@ export default function PrintReportPage() {
259
  photos={sheet.photos}
260
  orderLocked={sheet.page.photo_order_locked ?? false}
261
  variant={sheet.variant}
 
262
  />
263
  {sheet.sheetIndex === 0
264
  ? renderItems(sheet.page.items ?? [], scale)
 
3
  import { useSearchParams } from "react-router-dom";
4
 
5
  import { request } from "../lib/api";
6
+ import { BASE_W } from "../lib/report";
7
  import { getSessionId } from "../lib/session";
8
  import type { FileMeta, Page, PageItem, Session, TemplateFields } from "../types/session";
9
  import { JobSheetTemplate } from "../components/JobSheetTemplate";
 
38
  if (explicit.length) {
39
  return explicit.map((id) => byId.get(id)).filter(Boolean) as FileMeta[];
40
  }
41
+ return [];
42
  }
43
 
44
  function chunkPhotos(photos: FileMeta[], perSheet: number) {
 
207
 
208
  const basePages = useMemo(() => {
209
  if (pages.length) return pages;
210
+ return [];
211
+ }, [pages]);
 
 
 
212
 
213
  const totalPages = basePages.length || 1;
214
 
 
256
  photos={sheet.photos}
257
  orderLocked={sheet.page.photo_order_locked ?? false}
258
  variant={sheet.variant}
259
+ photoLayout={sheet.page.photo_layout}
260
  />
261
  {sheet.sheetIndex === 0
262
  ? renderItems(sheet.page.items ?? [], scale)
frontend/src/pages/ReportViewerPage.tsx CHANGED
@@ -10,6 +10,7 @@ import {
10
  Download,
11
  Table,
12
  Info,
 
13
  } from "react-feather";
14
 
15
  import { request } from "../lib/api";
@@ -174,6 +175,14 @@ export default function ReportViewerPage() {
174
  Report Viewer
175
  </span>
176
 
 
 
 
 
 
 
 
 
177
  <Link
178
  to={`/edit-report${editReportQuery}`}
179
  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"
 
10
  Download,
11
  Table,
12
  Info,
13
+ Image,
14
  } from "react-feather";
15
 
16
  import { request } from "../lib/api";
 
175
  Report Viewer
176
  </span>
177
 
178
+ <Link
179
+ to={`/image-placement${sessionQuery}`}
180
+ 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"
181
+ >
182
+ <Image className="h-4 w-4" />
183
+ Image Placement
184
+ </Link>
185
+
186
  <Link
187
  to={`/edit-report${editReportQuery}`}
188
  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"
frontend/src/pages/ReviewSetupPage.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useState } from "react";
2
  import { useNavigate, useSearchParams } from "react-router-dom";
3
  import {
4
  CheckCircle,
@@ -8,9 +8,10 @@ import {
8
  CheckSquare,
9
  Square,
10
  ArrowRight,
 
11
  } from "react-feather";
12
 
13
- import { putJson, request } from "../lib/api";
14
  import { getSessionId, setStoredSessionId } from "../lib/session";
15
  import type { Session } from "../types/session";
16
  import { PageFooter } from "../components/PageFooter";
@@ -22,12 +23,16 @@ export default function ReviewSetupPage() {
22
  const [searchParams] = useSearchParams();
23
  const sessionId = getSessionId(searchParams.toString());
24
 
 
 
25
  const [session, setSession] = useState<Session | null>(null);
26
  const [selectedPhotoIds, setSelectedPhotoIds] = useState<Set<string>>(
27
  new Set(),
28
  );
29
  const [showAllPhotos, setShowAllPhotos] = useState(false);
30
  const [statusMessage, setStatusMessage] = useState("");
 
 
31
 
32
  useEffect(() => {
33
  if (!sessionId) {
@@ -40,6 +45,11 @@ export default function ReviewSetupPage() {
40
  const data = await request<Session>(`/sessions/${sessionId}`);
41
  setSession(data);
42
  const initial = new Set(data.selected_photo_ids || []);
 
 
 
 
 
43
  setSelectedPhotoIds(initial);
44
  } catch (err) {
45
  const message =
@@ -68,6 +78,69 @@ export default function ReviewSetupPage() {
68
  return "Ready. Continue to report viewer.";
69
  }, [canContinue, selectedPhotoIds.size, session?.uploads?.data_files?.length, sessionId]);
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  async function handleContinue() {
72
  if (!sessionId) return;
73
  if (selectedPhotoIds.size === 0 && dataFiles.length === 0) return;
@@ -151,11 +224,30 @@ export default function ReviewSetupPage() {
151
 
152
  <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
153
  <div className="lg:col-span-2">
154
- <div className="flex items-center justify-between mb-3">
155
  <h3 className="text-lg font-semibold text-gray-900">Photos</h3>
156
- <span className="text-sm font-semibold text-gray-600">
157
- {photos.length} file{photos.length === 1 ? "" : "s"}
158
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  </div>
160
 
161
  <div className="rounded-lg border border-gray-200 bg-white p-4">
@@ -317,11 +409,29 @@ export default function ReviewSetupPage() {
317
  </div>
318
 
319
  <div>
320
- <div className="flex items-center justify-between mb-3">
321
  <h3 className="text-lg font-semibold text-gray-900">Data files</h3>
322
- <span className="text-sm font-semibold text-gray-600">
323
- {dataFiles.length} file{dataFiles.length === 1 ? "" : "s"}
324
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  </div>
326
 
327
  <div className="rounded-lg border border-gray-200 bg-white p-4">
 
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
  import { useNavigate, useSearchParams } from "react-router-dom";
3
  import {
4
  CheckCircle,
 
8
  CheckSquare,
9
  Square,
10
  ArrowRight,
11
+ UploadCloud,
12
  } from "react-feather";
13
 
14
+ import { postForm, putJson, request } from "../lib/api";
15
  import { getSessionId, setStoredSessionId } from "../lib/session";
16
  import type { Session } from "../types/session";
17
  import { PageFooter } from "../components/PageFooter";
 
23
  const [searchParams] = useSearchParams();
24
  const sessionId = getSessionId(searchParams.toString());
25
 
26
+ const photoUploadRef = useRef<HTMLInputElement | null>(null);
27
+ const dataUploadRef = useRef<HTMLInputElement | null>(null);
28
  const [session, setSession] = useState<Session | null>(null);
29
  const [selectedPhotoIds, setSelectedPhotoIds] = useState<Set<string>>(
30
  new Set(),
31
  );
32
  const [showAllPhotos, setShowAllPhotos] = useState(false);
33
  const [statusMessage, setStatusMessage] = useState("");
34
+ const [isUploadingPhotos, setIsUploadingPhotos] = useState(false);
35
+ const [isUploadingData, setIsUploadingData] = useState(false);
36
 
37
  useEffect(() => {
38
  if (!sessionId) {
 
45
  const data = await request<Session>(`/sessions/${sessionId}`);
46
  setSession(data);
47
  const initial = new Set(data.selected_photo_ids || []);
48
+ const photoIds = (data.uploads?.photos ?? []).map((photo) => photo.id);
49
+ const hasDataFiles = (data.uploads?.data_files ?? []).length > 0;
50
+ if (!hasDataFiles && photoIds.length > 0 && initial.size < photoIds.length) {
51
+ photoIds.forEach((photoId) => initial.add(photoId));
52
+ }
53
  setSelectedPhotoIds(initial);
54
  } catch (err) {
55
  const message =
 
78
  return "Ready. Continue to report viewer.";
79
  }, [canContinue, selectedPhotoIds.size, session?.uploads?.data_files?.length, sessionId]);
80
 
81
+ async function handleUploadPhotos(files: FileList | null) {
82
+ if (!sessionId || !files || files.length === 0) return;
83
+ setIsUploadingPhotos(true);
84
+ setStatusMessage("");
85
+ const priorPhotoIds = new Set(
86
+ (session?.uploads?.photos ?? []).map((photo) => photo.id),
87
+ );
88
+ try {
89
+ let updatedSession: Session | null = session;
90
+ for (const file of Array.from(files)) {
91
+ const formData = new FormData();
92
+ formData.append("file", file);
93
+ updatedSession = await postForm<Session>(
94
+ `/sessions/${sessionId}/uploads`,
95
+ formData,
96
+ );
97
+ }
98
+ if (updatedSession) {
99
+ setSession(updatedSession);
100
+ const next = new Set(selectedPhotoIds);
101
+ for (const photo of updatedSession.uploads?.photos ?? []) {
102
+ if (!priorPhotoIds.has(photo.id)) {
103
+ next.add(photo.id);
104
+ }
105
+ }
106
+ setSelectedPhotoIds(next);
107
+ }
108
+ } catch (err) {
109
+ const message =
110
+ err instanceof Error ? err.message : "Failed to upload photos.";
111
+ setStatusMessage(message);
112
+ } finally {
113
+ setIsUploadingPhotos(false);
114
+ if (photoUploadRef.current) photoUploadRef.current.value = "";
115
+ }
116
+ }
117
+
118
+ async function handleUploadDataFile(files: FileList | null) {
119
+ if (!sessionId || !files || files.length === 0) return;
120
+ setIsUploadingData(true);
121
+ setStatusMessage("");
122
+ try {
123
+ const file = files[0];
124
+ const formData = new FormData();
125
+ formData.append("file", file);
126
+ const updated = await postForm<Session>(
127
+ `/sessions/${sessionId}/data-files`,
128
+ formData,
129
+ );
130
+ setSession(updated);
131
+ if (updated.selected_photo_ids) {
132
+ setSelectedPhotoIds(new Set(updated.selected_photo_ids));
133
+ }
134
+ } catch (err) {
135
+ const message =
136
+ err instanceof Error ? err.message : "Failed to upload data file.";
137
+ setStatusMessage(message);
138
+ } finally {
139
+ setIsUploadingData(false);
140
+ if (dataUploadRef.current) dataUploadRef.current.value = "";
141
+ }
142
+ }
143
+
144
  async function handleContinue() {
145
  if (!sessionId) return;
146
  if (selectedPhotoIds.size === 0 && dataFiles.length === 0) return;
 
224
 
225
  <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
226
  <div className="lg:col-span-2">
227
+ <div className="flex flex-wrap items-center justify-between gap-3 mb-3">
228
  <h3 className="text-lg font-semibold text-gray-900">Photos</h3>
229
+ <div className="flex flex-wrap items-center gap-2">
230
+ <span className="text-sm font-semibold text-gray-600">
231
+ {photos.length} file{photos.length === 1 ? "" : "s"}
232
+ </span>
233
+ <button
234
+ type="button"
235
+ onClick={() => photoUploadRef.current?.click()}
236
+ disabled={!sessionId || isUploadingPhotos}
237
+ 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"
238
+ >
239
+ <UploadCloud className="h-4 w-4" />
240
+ {isUploadingPhotos ? "Uploading..." : "Add photos"}
241
+ </button>
242
+ <input
243
+ ref={photoUploadRef}
244
+ type="file"
245
+ className="hidden"
246
+ multiple
247
+ accept=".jpg,.jpeg,.png,.webp"
248
+ onChange={(event) => handleUploadPhotos(event.target.files)}
249
+ />
250
+ </div>
251
  </div>
252
 
253
  <div className="rounded-lg border border-gray-200 bg-white p-4">
 
409
  </div>
410
 
411
  <div>
412
+ <div className="flex flex-wrap items-center justify-between gap-3 mb-3">
413
  <h3 className="text-lg font-semibold text-gray-900">Data files</h3>
414
+ <div className="flex flex-wrap items-center gap-2">
415
+ <span className="text-sm font-semibold text-gray-600">
416
+ {dataFiles.length} file{dataFiles.length === 1 ? "" : "s"}
417
+ </span>
418
+ <button
419
+ type="button"
420
+ onClick={() => dataUploadRef.current?.click()}
421
+ disabled={!sessionId || isUploadingData}
422
+ 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"
423
+ >
424
+ <UploadCloud className="h-4 w-4" />
425
+ {isUploadingData ? "Uploading..." : "Upload data file"}
426
+ </button>
427
+ <input
428
+ ref={dataUploadRef}
429
+ type="file"
430
+ className="hidden"
431
+ accept=".csv,.xls,.xlsx"
432
+ onChange={(event) => handleUploadDataFile(event.target.files)}
433
+ />
434
+ </div>
435
  </div>
436
 
437
  <div className="rounded-lg border border-gray-200 bg-white p-4">
frontend/src/pages/UploadPage.tsx CHANGED
@@ -13,6 +13,7 @@ import {
13
 
14
  import { postForm, request } from "../lib/api";
15
  import { formatBytes } from "../lib/format";
 
16
  import { setStoredSessionId } from "../lib/session";
17
  import { APP_VERSION } from "../lib/version";
18
  import type { Session } from "../types/session";
@@ -23,9 +24,8 @@ export default function UploadPage() {
23
  const navigate = useNavigate();
24
  const inputRef = useRef<HTMLInputElement | null>(null);
25
  const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
26
- const [projectName, setProjectName] = useState("");
27
  const [inspectionDate, setInspectionDate] = useState("");
28
- const [notes, setNotes] = useState("");
29
  const [uploadStatus, setUploadStatus] = useState("");
30
  const [statusTone, setStatusTone] = useState<StatusTone>("idle");
31
  const [recentSessions, setRecentSessions] = useState<Session[]>([]);
@@ -86,9 +86,8 @@ export default function UploadPage() {
86
  setStatusTone("idle");
87
 
88
  const formData = new FormData();
89
- formData.append("project_name", projectName.trim());
90
  formData.append("inspection_date", inspectionDate);
91
- formData.append("notes", notes.trim());
92
  selectedFiles.forEach((file) => formData.append("files", file));
93
 
94
  try {
@@ -187,7 +186,7 @@ export default function UploadPage() {
187
  </div>
188
  <h3 className="text-base font-semibold text-gray-900 mb-1">Upload documents</h3>
189
  <p className="text-sm text-gray-600">
190
- Add notes, measurements, and supporting PDFs/DOCX to complete the context.
191
  </p>
192
  </article>
193
 
@@ -259,15 +258,15 @@ export default function UploadPage() {
259
 
260
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
261
  <div className="space-y-1">
262
- <label className="block text-sm font-medium text-gray-700" htmlFor="projectName">
263
- Project Name
264
  </label>
265
  <input
266
- id="projectName"
267
  type="text"
268
- value={projectName}
269
- onChange={(event) => setProjectName(event.target.value)}
270
- placeholder="e.g., North Pit Conveyor Support"
271
  className="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500"
272
  />
273
  </div>
@@ -286,20 +285,6 @@ export default function UploadPage() {
286
  </div>
287
  </div>
288
 
289
- <div className="space-y-1 mb-6">
290
- <label className="block text-sm font-medium text-gray-700" htmlFor="notes">
291
- Additional Notes
292
- </label>
293
- <textarea
294
- id="notes"
295
- rows={4}
296
- value={notes}
297
- onChange={(event) => setNotes(event.target.value)}
298
- placeholder="Add any context you'd like included in the report..."
299
- className="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500"
300
- />
301
- </div>
302
-
303
  <button
304
  type="button"
305
  onClick={handleSubmit}
@@ -340,7 +325,7 @@ export default function UploadPage() {
340
  Report ID
341
  </th>
342
  <th className="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
343
- Project
344
  </th>
345
  <th className="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
346
  Date
@@ -368,7 +353,7 @@ export default function UploadPage() {
368
  #{session.id.slice(0, 8)}
369
  </td>
370
  <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
371
- {session.project_name || "Untitled project"}
372
  </td>
373
  <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
374
  {session.created_at
 
13
 
14
  import { postForm, request } from "../lib/api";
15
  import { formatBytes } from "../lib/format";
16
+ import { formatDocNumber } from "../lib/report";
17
  import { setStoredSessionId } from "../lib/session";
18
  import { APP_VERSION } from "../lib/version";
19
  import type { Session } from "../types/session";
 
24
  const navigate = useNavigate();
25
  const inputRef = useRef<HTMLInputElement | null>(null);
26
  const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
27
+ const [documentNo, setDocumentNo] = useState("");
28
  const [inspectionDate, setInspectionDate] = useState("");
 
29
  const [uploadStatus, setUploadStatus] = useState("");
30
  const [statusTone, setStatusTone] = useState<StatusTone>("idle");
31
  const [recentSessions, setRecentSessions] = useState<Session[]>([]);
 
86
  setStatusTone("idle");
87
 
88
  const formData = new FormData();
89
+ formData.append("document_no", documentNo.trim());
90
  formData.append("inspection_date", inspectionDate);
 
91
  selectedFiles.forEach((file) => formData.append("files", file));
92
 
93
  try {
 
186
  </div>
187
  <h3 className="text-base font-semibold text-gray-900 mb-1">Upload documents</h3>
188
  <p className="text-sm text-gray-600">
189
+ Add supporting PDFs/DOCX to complete the context.
190
  </p>
191
  </article>
192
 
 
258
 
259
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
260
  <div className="space-y-1">
261
+ <label className="block text-sm font-medium text-gray-700" htmlFor="documentNo">
262
+ Document No
263
  </label>
264
  <input
265
+ id="documentNo"
266
  type="text"
267
+ value={documentNo}
268
+ onChange={(event) => setDocumentNo(event.target.value)}
269
+ placeholder="e.g., SIMM-JS-2025-001"
270
  className="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500"
271
  />
272
  </div>
 
285
  </div>
286
  </div>
287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  <button
289
  type="button"
290
  onClick={handleSubmit}
 
325
  Report ID
326
  </th>
327
  <th className="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
328
+ Document No
329
  </th>
330
  <th className="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
331
  Date
 
353
  #{session.id.slice(0, 8)}
354
  </td>
355
  <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
356
+ {session.document_no || formatDocNumber(session)}
357
  </td>
358
  <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
359
  {session.created_at
frontend/src/types/custom-elements.d.ts CHANGED
@@ -13,6 +13,7 @@ declare global {
13
 
14
  interface ReportEditorElement extends HTMLElement {
15
  open: (options?: ReportEditorOpenOptions) => void;
 
16
  }
17
 
18
  namespace JSX {
 
13
 
14
  interface ReportEditorElement extends HTMLElement {
15
  open: (options?: ReportEditorOpenOptions) => void;
16
+ flushSave?: () => Promise<void>;
17
  }
18
 
19
  namespace JSX {
frontend/src/types/session.ts CHANGED
@@ -23,9 +23,8 @@ export type Session = {
23
  status: string;
24
  created_at: string;
25
  updated_at: string;
26
- project_name: string;
27
  inspection_date: string;
28
- notes: string;
29
  uploads: SessionUploads;
30
  selected_photo_ids: string[];
31
  page_count: number;
@@ -69,26 +68,23 @@ export type PageItem = {
69
  export type TemplateFields = {
70
  inspection_date?: string;
71
  inspector?: string;
72
- accompanied_by?: string;
73
  document_no?: string;
74
- project?: string;
75
- client_site?: string;
76
  company_logo?: string;
77
  area?: string;
78
  reference?: string;
79
- action_type?: string;
80
  item_description?: string;
81
  functional_location?: string;
82
  category?: string;
83
  priority?: string;
84
- condition_description?: string;
85
  required_action?: string;
 
86
  };
87
 
88
  export type Page = {
89
  items: PageItem[];
90
  template?: TemplateFields;
91
  photo_ids?: string[];
 
92
  photo_order_locked?: boolean;
93
  blank?: boolean;
94
  variant?: "full" | "photos";
 
23
  status: string;
24
  created_at: string;
25
  updated_at: string;
26
+ document_no: string;
27
  inspection_date: string;
 
28
  uploads: SessionUploads;
29
  selected_photo_ids: string[];
30
  page_count: number;
 
68
  export type TemplateFields = {
69
  inspection_date?: string;
70
  inspector?: string;
 
71
  document_no?: string;
 
 
72
  company_logo?: string;
73
  area?: string;
74
  reference?: string;
 
75
  item_description?: string;
76
  functional_location?: string;
77
  category?: string;
78
  priority?: string;
 
79
  required_action?: string;
80
+ figure_caption?: string;
81
  };
82
 
83
  export type Page = {
84
  items: PageItem[];
85
  template?: TemplateFields;
86
  photo_ids?: string[];
87
+ photo_layout?: "auto" | "two-column" | "stacked";
88
  photo_order_locked?: boolean;
89
  blank?: boolean;
90
  variant?: "full" | "photos";
server/app/api/routes/sessions.py CHANGED
@@ -10,6 +10,8 @@ from openpyxl import Workbook
10
 
11
  from ..deps import get_session_store
12
  from ..schemas import (
 
 
13
  PagesRequest,
14
  PagesResponse,
15
  SectionsRequest,
@@ -52,6 +54,18 @@ def _attach_urls(session: dict) -> dict:
52
  return session
53
 
54
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  @router.get("", response_model=List[SessionResponse])
56
  def list_sessions(store: SessionStore = Depends(get_session_store)) -> List[SessionResponse]:
57
  sessions = store.list_sessions()
@@ -61,16 +75,15 @@ def list_sessions(store: SessionStore = Depends(get_session_store)) -> List[Sess
61
 
62
  @router.post("", response_model=SessionResponse, status_code=status.HTTP_201_CREATED)
63
  def create_session(
64
- project_name: str = Form(""),
65
  inspection_date: str = Form(""),
66
- notes: str = Form(""),
67
  files: List[UploadFile] = File(...),
68
  store: SessionStore = Depends(get_session_store),
69
  ) -> SessionResponse:
70
  if not files:
71
  raise HTTPException(status_code=400, detail="At least one file is required.")
72
 
73
- session = store.create_session(project_name, inspection_date, notes)
74
  saved_files = []
75
  for upload in files:
76
  try:
@@ -179,6 +192,20 @@ def save_sections(
179
  return SectionsResponse(sections=session.get("jobsheet_sections") or [])
180
 
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  @router.get("/{session_id}/uploads/{file_id}")
183
  def get_upload(
184
  session_id: str,
@@ -288,16 +315,20 @@ def import_json(
288
  session = store.set_pages(session, pages)
289
 
290
  if isinstance(imported_session, dict):
291
- for key in (
292
- "project_name",
293
- "inspection_date",
294
- "notes",
295
- "selected_photo_ids",
296
- "page_count",
297
- "headings",
298
- ):
299
- if key in imported_session and imported_session[key] is not None:
300
- session[key] = imported_session[key]
 
 
 
 
301
  store.update_session(session)
302
 
303
  return _attach_urls(session)
@@ -362,15 +393,10 @@ def export_excel(
362
  wb = Workbook()
363
  ws_general = wb.active
364
  ws_general.title = "General Information"
365
- ws_general.append(["Project Name", session.get("project_name", "")])
366
  ws_general.append(["Inspection Date", session.get("inspection_date", "")])
367
  ws_general.append(["Inspector", first_template.get("inspector", "")])
368
- ws_general.append(["Accompanied by", first_template.get("accompanied_by", "")])
369
- ws_general.append(["Document No", first_template.get("document_no", "")])
370
- ws_general.append(["Client / Site", first_template.get("client_site", "")])
371
- ws_general.append(
372
- ["Client Logo Image Name", first_template.get("company_logo", "")]
373
- )
374
 
375
  ws_headings = wb.create_sheet("Headings")
376
  ws_headings.append(["Heading Number", "Heading Name"])
@@ -389,16 +415,12 @@ def export_excel(
389
  [
390
  "REF",
391
  "Area",
392
- "Functional Location",
393
  "Item Description",
394
  "Category",
395
  "Priority",
396
- "Item Description",
397
- "Condition Description",
398
- "Action Type",
399
  "Required Action",
400
  "Figure Caption",
401
- "Figure Description",
402
  "Image Name 1",
403
  "Image Name 2",
404
  "Image Name 3",
@@ -430,12 +452,8 @@ def export_excel(
430
  template.get("item_description", ""),
431
  template.get("category", ""),
432
  template.get("priority", ""),
433
- template.get("item_description", ""),
434
- template.get("condition_description", ""),
435
- template.get("action_type", ""),
436
  template.get("required_action", ""),
437
- "",
438
- "",
439
  *photo_names[:6],
440
  ]
441
  )
 
10
 
11
  from ..deps import get_session_store
12
  from ..schemas import (
13
+ HeadingsRequest,
14
+ HeadingsResponse,
15
  PagesRequest,
16
  PagesResponse,
17
  SectionsRequest,
 
54
  return session
55
 
56
 
57
+ def _merge_text(primary: str, secondary: str) -> str:
58
+ primary = (primary or "").strip()
59
+ secondary = (secondary or "").strip()
60
+ if not secondary:
61
+ return primary
62
+ if not primary:
63
+ return secondary
64
+ if secondary in primary:
65
+ return primary
66
+ return f"{primary} - {secondary}"
67
+
68
+
69
  @router.get("", response_model=List[SessionResponse])
70
  def list_sessions(store: SessionStore = Depends(get_session_store)) -> List[SessionResponse]:
71
  sessions = store.list_sessions()
 
75
 
76
  @router.post("", response_model=SessionResponse, status_code=status.HTTP_201_CREATED)
77
  def create_session(
78
+ document_no: str = Form(""),
79
  inspection_date: str = Form(""),
 
80
  files: List[UploadFile] = File(...),
81
  store: SessionStore = Depends(get_session_store),
82
  ) -> SessionResponse:
83
  if not files:
84
  raise HTTPException(status_code=400, detail="At least one file is required.")
85
 
86
+ session = store.create_session(document_no, inspection_date)
87
  saved_files = []
88
  for upload in files:
89
  try:
 
192
  return SectionsResponse(sections=session.get("jobsheet_sections") or [])
193
 
194
 
195
+ @router.put("/{session_id}/headings", response_model=HeadingsResponse)
196
+ def save_headings(
197
+ session_id: str,
198
+ payload: HeadingsRequest,
199
+ store: SessionStore = Depends(get_session_store),
200
+ ) -> HeadingsResponse:
201
+ session_id = _normalize_session_id(session_id, store)
202
+ session = store.get_session(session_id)
203
+ if not session:
204
+ raise HTTPException(status_code=404, detail="Session not found.")
205
+ session = store.set_headings(session, payload.headings)
206
+ return HeadingsResponse(headings=session.get("headings") or [])
207
+
208
+
209
  @router.get("/{session_id}/uploads/{file_id}")
210
  def get_upload(
211
  session_id: str,
 
315
  session = store.set_pages(session, pages)
316
 
317
  if isinstance(imported_session, dict):
318
+ document_no = _merge_text(
319
+ imported_session.get("document_no", ""),
320
+ imported_session.get("project_name", ""),
321
+ )
322
+ if document_no:
323
+ session["document_no"] = document_no
324
+ if imported_session.get("inspection_date") is not None:
325
+ session["inspection_date"] = imported_session["inspection_date"]
326
+ if imported_session.get("selected_photo_ids") is not None:
327
+ session["selected_photo_ids"] = imported_session["selected_photo_ids"]
328
+ if imported_session.get("page_count") is not None:
329
+ session["page_count"] = imported_session["page_count"]
330
+ if imported_session.get("headings") is not None:
331
+ session["headings"] = imported_session["headings"]
332
  store.update_session(session)
333
 
334
  return _attach_urls(session)
 
393
  wb = Workbook()
394
  ws_general = wb.active
395
  ws_general.title = "General Information"
396
+ ws_general.append(["Document No", session.get("document_no", "")])
397
  ws_general.append(["Inspection Date", session.get("inspection_date", "")])
398
  ws_general.append(["Inspector", first_template.get("inspector", "")])
399
+ ws_general.append(["Company Logo Image Name", first_template.get("company_logo", "")])
 
 
 
 
 
400
 
401
  ws_headings = wb.create_sheet("Headings")
402
  ws_headings.append(["Heading Number", "Heading Name"])
 
415
  [
416
  "REF",
417
  "Area",
418
+ "Location",
419
  "Item Description",
420
  "Category",
421
  "Priority",
 
 
 
422
  "Required Action",
423
  "Figure Caption",
 
424
  "Image Name 1",
425
  "Image Name 2",
426
  "Image Name 3",
 
452
  template.get("item_description", ""),
453
  template.get("category", ""),
454
  template.get("priority", ""),
 
 
 
455
  template.get("required_action", ""),
456
+ template.get("figure_caption", ""),
 
457
  *photo_names[:6],
458
  ]
459
  )
server/app/api/schemas.py CHANGED
@@ -24,9 +24,8 @@ class SessionResponse(BaseModel):
24
  status: str
25
  created_at: str
26
  updated_at: str
27
- project_name: str
28
  inspection_date: str
29
- notes: str
30
  uploads: Dict[str, List[FileMeta]] = Field(default_factory=dict)
31
  selected_photo_ids: List[str] = Field(default_factory=list)
32
  page_count: int = 0
@@ -64,3 +63,11 @@ class SectionsResponse(BaseModel):
64
 
65
  class SectionsRequest(BaseModel):
66
  sections: List[JobsheetSection] = Field(default_factory=list)
 
 
 
 
 
 
 
 
 
24
  status: str
25
  created_at: str
26
  updated_at: str
27
+ document_no: str
28
  inspection_date: str
 
29
  uploads: Dict[str, List[FileMeta]] = Field(default_factory=dict)
30
  selected_photo_ids: List[str] = Field(default_factory=list)
31
  page_count: int = 0
 
63
 
64
  class SectionsRequest(BaseModel):
65
  sections: List[JobsheetSection] = Field(default_factory=list)
66
+
67
+
68
+ class HeadingsRequest(BaseModel):
69
+ headings: List[Heading] = Field(default_factory=list)
70
+
71
+
72
+ class HeadingsResponse(BaseModel):
73
+ headings: List[Heading] = Field(default_factory=list)
server/app/services/data_import.py CHANGED
@@ -34,6 +34,18 @@ def _cell_to_str(value: object) -> str:
34
  return str(value).strip()
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  def _parse_general_info(rows: Iterable[Iterable[object]]) -> Dict[str, str]:
38
  info: Dict[str, str] = {}
39
  for row in rows:
@@ -91,6 +103,9 @@ def _parse_headings(rows: Iterable[Iterable[object]]) -> List[Dict[str, str]]:
91
  name = _cell_to_str(row[name_idx]) if name_idx is not None and name_idx < len(row) else ""
92
 
93
  if not number and not name:
 
 
 
94
  combined = _cell_to_str(row[0] if row else "")
95
  match = re.match(r"^(\\d+)\\s*[-–.]?\\s*(.+)$", combined)
96
  if match:
@@ -110,6 +125,9 @@ def _header_map(headers: List[str]) -> Dict[str, List[int]]:
110
  if not name:
111
  continue
112
  mapping.setdefault(name, []).append(idx)
 
 
 
113
  return mapping
114
 
115
 
@@ -172,14 +190,13 @@ def _parse_items(rows: Iterable[Iterable[object]]) -> List[Dict[str, str | List[
172
  mapping = _header_map(headers)
173
  image_indices = _image_column_indices(headers)
174
 
 
 
 
175
  def first_index(name: str) -> Optional[int]:
176
- values = mapping.get(_normalize_text(name)) or []
177
  return values[0] if values else None
178
 
179
- def second_index(name: str) -> Optional[int]:
180
- values = mapping.get(_normalize_text(name)) or []
181
- return values[1] if len(values) > 1 else None
182
-
183
  def image_index(n: int) -> Optional[int]:
184
  return image_indices.get(n) or first_index(f"image name {n}") or first_index(
185
  f"image {n}"
@@ -196,12 +213,44 @@ def _parse_items(rows: Iterable[Iterable[object]]) -> List[Dict[str, str | List[
196
  cells = list(row)
197
  if not any(_cell_to_str(cell) for cell in cells):
198
  continue
199
- item_desc = _row_value(cells, second_index("item description")) or _row_value(
200
- cells, first_index("item description")
201
- )
 
 
 
 
 
 
202
  reference = _row_value(cells, ref_index)
203
  if not reference:
204
  reference = _find_reference_value(cells)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  image_names = [
206
  _row_value(cells, image_index(1)),
207
  _row_value(cells, image_index(2)),
@@ -230,16 +279,13 @@ def _parse_items(rows: Iterable[Iterable[object]]) -> List[Dict[str, str | List[
230
  "reference": reference,
231
  "area": _row_value(cells, area_index),
232
  "functional_location": _row_value(
233
- cells, first_index("functional location")
234
  ),
235
  "item_description": item_desc,
236
  "category": _row_value(cells, first_index("category")),
237
  "priority": _row_value(cells, first_index("priority")),
238
- "condition_description": _row_value(
239
- cells, first_index("condition description")
240
- ),
241
- "action_type": _row_value(cells, first_index("action type")),
242
- "required_action": _row_value(cells, first_index("required action")),
243
  "image_names": [name for name in image_names if name],
244
  }
245
  )
@@ -378,9 +424,9 @@ def populate_session_from_data_files(
378
  items = parsed.get("items") or []
379
 
380
  # Update session-wide fields if provided
381
- project_name = general.get("project name")
382
- if project_name:
383
- session["project_name"] = project_name
384
  inspection_date = general.get("inspection date")
385
  if inspection_date:
386
  session["inspection_date"] = inspection_date
@@ -390,11 +436,8 @@ def populate_session_from_data_files(
390
  )
391
 
392
  if isinstance(headings, dict):
393
- headings = [
394
- {"number": key, "name": value} for key, value in headings.items()
395
- ]
396
- if headings:
397
- session["headings"] = headings
398
 
399
  sections: List[dict] = []
400
  selected_photo_ids: List[str] = []
@@ -408,10 +451,7 @@ def populate_session_from_data_files(
408
  template = {
409
  "inspection_date": inspection_date or session.get("inspection_date", ""),
410
  "inspector": general.get("inspector", ""),
411
- "accompanied_by": general.get("accompanied by", ""),
412
- "document_no": general.get("document no", ""),
413
- "project": general.get("project name", session.get("project_name", "")),
414
- "client_site": general.get("client / site", ""),
415
  "company_logo": company_logo,
416
  "reference": item.get("reference", ""),
417
  "area": item.get("area", ""),
@@ -419,9 +459,8 @@ def populate_session_from_data_files(
419
  "item_description": item.get("item_description", ""),
420
  "category": item.get("category", ""),
421
  "priority": item.get("priority", ""),
422
- "condition_description": item.get("condition_description", ""),
423
- "action_type": item.get("action_type", ""),
424
  "required_action": item.get("required_action", ""),
 
425
  }
426
  image_names = item.get("image_names", []) or []
427
  photo_ids = _photo_ids_for_names(image_names, photo_lookup)
 
34
  return str(value).strip()
35
 
36
 
37
+ def _merge_text(primary: str, secondary: str) -> str:
38
+ primary = (primary or "").strip()
39
+ secondary = (secondary or "").strip()
40
+ if not secondary:
41
+ return primary
42
+ if not primary:
43
+ return secondary
44
+ if secondary in primary:
45
+ return primary
46
+ return f"{primary} - {secondary}"
47
+
48
+
49
  def _parse_general_info(rows: Iterable[Iterable[object]]) -> Dict[str, str]:
50
  info: Dict[str, str] = {}
51
  for row in rows:
 
103
  name = _cell_to_str(row[name_idx]) if name_idx is not None and name_idx < len(row) else ""
104
 
105
  if not number and not name:
106
+ if len(row) >= 2:
107
+ number = _cell_to_str(row[0])
108
+ name = _cell_to_str(row[1])
109
  combined = _cell_to_str(row[0] if row else "")
110
  match = re.match(r"^(\\d+)\\s*[-–.]?\\s*(.+)$", combined)
111
  if match:
 
125
  if not name:
126
  continue
127
  mapping.setdefault(name, []).append(idx)
128
+ compact = re.sub(r"[^a-z0-9]", "", name)
129
+ if compact and compact != name:
130
+ mapping.setdefault(compact, []).append(idx)
131
  return mapping
132
 
133
 
 
190
  mapping = _header_map(headers)
191
  image_indices = _image_column_indices(headers)
192
 
193
+ def indices_for(name: str) -> List[int]:
194
+ return mapping.get(_normalize_text(name)) or []
195
+
196
  def first_index(name: str) -> Optional[int]:
197
+ values = indices_for(name)
198
  return values[0] if values else None
199
 
 
 
 
 
200
  def image_index(n: int) -> Optional[int]:
201
  return image_indices.get(n) or first_index(f"image name {n}") or first_index(
202
  f"image {n}"
 
213
  cells = list(row)
214
  if not any(_cell_to_str(cell) for cell in cells):
215
  continue
216
+ item_desc_candidates = [
217
+ _row_value(cells, idx) for idx in indices_for("item description")
218
+ ]
219
+ item_desc = max(item_desc_candidates, key=len) if item_desc_candidates else ""
220
+ condition_desc = _row_value(cells, first_index("condition description"))
221
+ if condition_desc and condition_desc not in item_desc:
222
+ item_desc = " - ".join(
223
+ [value for value in [item_desc, condition_desc] if value]
224
+ )
225
  reference = _row_value(cells, ref_index)
226
  if not reference:
227
  reference = _find_reference_value(cells)
228
+ action_type = _row_value(cells, first_index("action type"))
229
+ required_action_candidates = [
230
+ _row_value(cells, idx) for idx in indices_for("required action")
231
+ ]
232
+ required_action = (
233
+ max(required_action_candidates, key=len)
234
+ if required_action_candidates
235
+ else ""
236
+ )
237
+ if action_type and action_type not in required_action:
238
+ required_action = " - ".join(
239
+ [value for value in [action_type, required_action] if value]
240
+ )
241
+ figure_caption_candidates = [
242
+ _row_value(cells, idx) for idx in indices_for("figure caption")
243
+ ]
244
+ figure_caption = (
245
+ max(figure_caption_candidates, key=len)
246
+ if figure_caption_candidates
247
+ else ""
248
+ )
249
+ figure_description = _row_value(cells, first_index("figure description"))
250
+ if figure_description and figure_description not in figure_caption:
251
+ figure_caption = " - ".join(
252
+ [value for value in [figure_caption, figure_description] if value]
253
+ )
254
  image_names = [
255
  _row_value(cells, image_index(1)),
256
  _row_value(cells, image_index(2)),
 
279
  "reference": reference,
280
  "area": _row_value(cells, area_index),
281
  "functional_location": _row_value(
282
+ cells, first_index("functional location") or first_index("location")
283
  ),
284
  "item_description": item_desc,
285
  "category": _row_value(cells, first_index("category")),
286
  "priority": _row_value(cells, first_index("priority")),
287
+ "required_action": required_action,
288
+ "figure_caption": figure_caption,
 
 
 
289
  "image_names": [name for name in image_names if name],
290
  }
291
  )
 
424
  items = parsed.get("items") or []
425
 
426
  # Update session-wide fields if provided
427
+ document_no = general.get("document no") or general.get("document number") or ""
428
+ if document_no:
429
+ session["document_no"] = document_no
430
  inspection_date = general.get("inspection date")
431
  if inspection_date:
432
  session["inspection_date"] = inspection_date
 
436
  )
437
 
438
  if isinstance(headings, dict):
439
+ headings = [{"number": key, "name": value} for key, value in headings.items()]
440
+ session["headings"] = headings if isinstance(headings, list) else []
 
 
 
441
 
442
  sections: List[dict] = []
443
  selected_photo_ids: List[str] = []
 
451
  template = {
452
  "inspection_date": inspection_date or session.get("inspection_date", ""),
453
  "inspector": general.get("inspector", ""),
454
+ "document_no": document_no or session.get("document_no", ""),
 
 
 
455
  "company_logo": company_logo,
456
  "reference": item.get("reference", ""),
457
  "area": item.get("area", ""),
 
459
  "item_description": item.get("item_description", ""),
460
  "category": item.get("category", ""),
461
  "priority": item.get("priority", ""),
 
 
462
  "required_action": item.get("required_action", ""),
463
+ "figure_caption": item.get("figure_caption", ""),
464
  }
465
  image_names = item.get("image_names", []) or []
466
  photo_ids = _photo_ids_for_names(image_names, photo_lookup)
server/app/services/pdf_reportlab.py CHANGED
@@ -221,7 +221,7 @@ def render_report_pdf(
221
  width, height = A4
222
  margin = 10 * mm
223
  header_h = 20 * mm
224
- footer_h = 8 * mm
225
  gap = 4 * mm
226
  photo_col_gap = 6 * mm
227
  photo_row_gap = 6 * mm
@@ -280,9 +280,11 @@ def render_report_pdf(
280
 
281
  print_pages: List[dict] = []
282
  for section_index, section in enumerate(sections):
 
283
  section_pages = section.get("pages") or []
284
  for page_index, page in enumerate(section_pages):
285
  template = page.get("template") or {}
 
286
  base_variant = (
287
  (page.get("variant") or "").strip().lower() if isinstance(page, dict) else ""
288
  )
@@ -296,7 +298,7 @@ def render_report_pdf(
296
  continue
297
  path = store.resolve_upload_path(session, pid)
298
  if path and path.exists():
299
- label = _safe_text(item.get("name") or path.name)
300
  photo_entries.append({"path": path, "label": label})
301
  if base_variant == "photos":
302
  chunks = _chunk(photo_entries, max_photos_photos) or [[]]
@@ -308,6 +310,7 @@ def render_report_pdf(
308
  "photos": chunk,
309
  "variant": "photos",
310
  "section_index": section_index,
 
311
  }
312
  )
313
  else:
@@ -325,6 +328,7 @@ def render_report_pdf(
325
  "photos": chunk,
326
  "variant": variant,
327
  "section_index": section_index,
 
328
  }
329
  )
330
 
@@ -335,6 +339,8 @@ def render_report_pdf(
335
  "template": {},
336
  "photos": [],
337
  "variant": "full",
 
 
338
  }
339
  ]
340
 
@@ -351,6 +357,20 @@ def render_report_pdf(
351
  template = payload["template"]
352
  photos = payload["photos"]
353
  variant = payload["variant"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
 
355
  header_y = height - margin
356
  content_top = header_y - header_h - gap
@@ -388,10 +408,11 @@ def render_report_pdf(
388
  )
389
  pdf.setFillColor(gray_900)
390
  pdf.setFont("Helvetica-Bold", 13)
391
- pdf.drawCentredString(width / 2, header_y - 7 * mm, "RepEx Inspection Job Sheet")
392
- pdf.setFillColor(gray_600)
393
- pdf.setFont("Helvetica", 11)
394
- pdf.drawCentredString(width / 2, header_y - 13 * mm, f"Page {output_index + 1} of {total_pages}")
 
395
  pdf.setStrokeColor(gray_200)
396
  pdf.line(margin, header_y - 17 * mm, width - margin, header_y - 17 * mm)
397
 
@@ -410,8 +431,6 @@ def render_report_pdf(
410
  area = _safe_text(template.get("area"))
411
  location = _safe_text(template.get("functional_location"))
412
  item_desc = _safe_text(template.get("item_description"))
413
- condition_desc = _safe_text(template.get("condition_description"))
414
- action_type = _safe_text(template.get("action_type"))
415
  required_action = _safe_text(template.get("required_action"))
416
 
417
  left_w = (width - 2 * margin) * 0.6
@@ -522,8 +541,8 @@ def render_report_pdf(
522
  pdf.drawCentredString(pr_label_x, y - 2 + badge_h / 2 - 4, pr_text)
523
  y -= 10 * mm
524
 
525
- condition = " - ".join([v for v in [item_desc, condition_desc] if v])
526
- action = " - ".join([v for v in [action_type, required_action] if v])
527
 
528
  pdf.setFillColor(gray_500)
529
  pdf.setFont("Helvetica", 11)
@@ -650,9 +669,18 @@ def render_report_pdf(
650
  # Footer
651
  footer_y = margin
652
  pdf.setFillColor(gray_500)
653
- pdf.setFont("Helvetica", 11)
654
- pdf.drawCentredString(width / 2, footer_y + 5 * mm, "Prosento - (c) 2026 All Rights Reserved")
655
- pdf.drawCentredString(width / 2, footer_y + 1 * mm, "Automatically generated job sheet")
 
 
 
 
 
 
 
 
 
656
 
657
  pdf.showPage()
658
 
 
221
  width, height = A4
222
  margin = 10 * mm
223
  header_h = 20 * mm
224
+ footer_h = 12 * mm
225
  gap = 4 * mm
226
  photo_col_gap = 6 * mm
227
  photo_row_gap = 6 * mm
 
280
 
281
  print_pages: List[dict] = []
282
  for section_index, section in enumerate(sections):
283
+ section_title = _safe_text(section.get("title")) or f"Section {section_index + 1}"
284
  section_pages = section.get("pages") or []
285
  for page_index, page in enumerate(section_pages):
286
  template = page.get("template") or {}
287
+ figure_caption = _safe_text(template.get("figure_caption"))
288
  base_variant = (
289
  (page.get("variant") or "").strip().lower() if isinstance(page, dict) else ""
290
  )
 
298
  continue
299
  path = store.resolve_upload_path(session, pid)
300
  if path and path.exists():
301
+ label = figure_caption or _safe_text(item.get("name") or path.name)
302
  photo_entries.append({"path": path, "label": label})
303
  if base_variant == "photos":
304
  chunks = _chunk(photo_entries, max_photos_photos) or [[]]
 
310
  "photos": chunk,
311
  "variant": "photos",
312
  "section_index": section_index,
313
+ "section_title": section_title,
314
  }
315
  )
316
  else:
 
328
  "photos": chunk,
329
  "variant": variant,
330
  "section_index": section_index,
331
+ "section_title": section_title,
332
  }
333
  )
334
 
 
339
  "template": {},
340
  "photos": [],
341
  "variant": "full",
342
+ "section_index": 0,
343
+ "section_title": "Section 1",
344
  }
345
  ]
346
 
 
357
  template = payload["template"]
358
  photos = payload["photos"]
359
  variant = payload["variant"]
360
+ doc_number = _safe_text(template.get("document_no")) or _safe_text(
361
+ session.get("document_no")
362
+ )
363
+ if not doc_number and session.get("id"):
364
+ doc_number = f"REP-{session['id'][:8].upper()}"
365
+ section_index = payload.get("section_index")
366
+ section_title = _safe_text(payload.get("section_title"))
367
+ section_label = ""
368
+ if isinstance(section_index, int):
369
+ base_label = f"Section {section_index + 1}"
370
+ if section_title and section_title != base_label:
371
+ section_label = f"{base_label} - {section_title}"
372
+ else:
373
+ section_label = base_label
374
 
375
  header_y = height - margin
376
  content_top = header_y - header_h - gap
 
408
  )
409
  pdf.setFillColor(gray_900)
410
  pdf.setFont("Helvetica-Bold", 13)
411
+ pdf.drawCentredString(
412
+ width / 2,
413
+ header_y - 7 * mm,
414
+ doc_number or "Document No",
415
+ )
416
  pdf.setStrokeColor(gray_200)
417
  pdf.line(margin, header_y - 17 * mm, width - margin, header_y - 17 * mm)
418
 
 
431
  area = _safe_text(template.get("area"))
432
  location = _safe_text(template.get("functional_location"))
433
  item_desc = _safe_text(template.get("item_description"))
 
 
434
  required_action = _safe_text(template.get("required_action"))
435
 
436
  left_w = (width - 2 * margin) * 0.6
 
541
  pdf.drawCentredString(pr_label_x, y - 2 + badge_h / 2 - 4, pr_text)
542
  y -= 10 * mm
543
 
544
+ condition = item_desc
545
+ action = required_action
546
 
547
  pdf.setFillColor(gray_500)
548
  pdf.setFont("Helvetica", 11)
 
669
  # Footer
670
  footer_y = margin
671
  pdf.setFillColor(gray_500)
672
+ pdf.setFont("Helvetica-Bold", 10)
673
+ pdf.drawCentredString(width / 2, footer_y + 8 * mm, "RepEx Inspection Job Sheet")
674
+ pdf.setFont("Helvetica", 9)
675
+ pdf.drawCentredString(
676
+ width / 2,
677
+ footer_y + 4 * mm,
678
+ "Prosento - (c) 2026 All Rights Reserved - Automatically generated job sheet",
679
+ )
680
+ page_line = f"Page {output_index + 1} of {total_pages}"
681
+ if section_label:
682
+ page_line = f"{section_label} - {page_line}"
683
+ pdf.drawCentredString(width / 2, footer_y + 1 * mm, page_line)
684
 
685
  pdf.showPage()
686
 
server/app/services/session_store.py CHANGED
@@ -1,5 +1,6 @@
1
  from __future__ import annotations
2
 
 
3
  import json
4
  import re
5
  from dataclasses import dataclass
@@ -60,6 +61,54 @@ def _validate_session_id(session_id: str) -> str:
60
  return normalized
61
 
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  class SessionStore:
64
  def __init__(self, base_dir: Optional[Path] = None) -> None:
65
  settings = get_settings()
@@ -68,17 +117,35 @@ class SessionStore:
68
  self.sessions_dir.mkdir(parents=True, exist_ok=True)
69
  self.max_upload_bytes = settings.max_upload_mb * 1024 * 1024
70
  self._lock = Lock()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
  def list_sessions(self) -> List[dict]:
73
  sessions: List[dict] = []
74
  for session_file in sorted(self.sessions_dir.glob("*/session.json"), reverse=True):
75
  try:
76
- sessions.append(json.loads(session_file.read_text(encoding="utf-8")))
 
 
77
  except Exception:
78
  continue
79
  return sessions
80
 
81
- def create_session(self, project_name: str, inspection_date: str, notes: str) -> dict:
82
  session_id = uuid4().hex
83
  now = _now_iso()
84
  session = {
@@ -86,14 +153,14 @@ class SessionStore:
86
  "status": "ready",
87
  "created_at": now,
88
  "updated_at": now,
89
- "project_name": project_name,
90
  "inspection_date": inspection_date,
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
@@ -106,11 +173,13 @@ class SessionStore:
106
  if not session_path.exists():
107
  return None
108
  try:
109
- return json.loads(session_path.read_text(encoding="utf-8"))
 
110
  except Exception:
111
  return None
112
 
113
  def update_session(self, session: dict) -> None:
 
114
  session["updated_at"] = _now_iso()
115
  self._save_session(session)
116
 
@@ -143,12 +212,19 @@ class SessionStore:
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
 
@@ -179,11 +255,18 @@ class SessionStore:
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:
@@ -194,14 +277,50 @@ class SessionStore:
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:
@@ -217,6 +336,76 @@ class SessionStore:
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")
222
  ext = Path(filename).suffix
 
1
  from __future__ import annotations
2
 
3
+ import copy
4
  import json
5
  import re
6
  from dataclasses import dataclass
 
61
  return normalized
62
 
63
 
64
+ def _merge_text(primary: str, secondary: str) -> str:
65
+ primary = (primary or "").strip()
66
+ secondary = (secondary or "").strip()
67
+ if not secondary:
68
+ return primary
69
+ if not primary:
70
+ return secondary
71
+ if secondary in primary:
72
+ return primary
73
+ return f"{primary} - {secondary}"
74
+
75
+
76
+ def _normalize_template_fields(template: Optional[dict]) -> dict:
77
+ if not isinstance(template, dict):
78
+ return {}
79
+ normalized = dict(template)
80
+
81
+ item_description = _merge_text(
82
+ normalized.get("item_description", ""),
83
+ normalized.pop("condition_description", ""),
84
+ )
85
+ if item_description:
86
+ normalized["item_description"] = item_description
87
+ else:
88
+ normalized.pop("item_description", None)
89
+
90
+ action_type = normalized.pop("action_type", "")
91
+ required_action = _merge_text(action_type, normalized.get("required_action", ""))
92
+ if required_action:
93
+ normalized["required_action"] = required_action
94
+ else:
95
+ normalized.pop("required_action", None)
96
+
97
+ figure_caption = _merge_text(
98
+ normalized.get("figure_caption", ""),
99
+ normalized.pop("figure_description", ""),
100
+ )
101
+ if figure_caption:
102
+ normalized["figure_caption"] = figure_caption
103
+ else:
104
+ normalized.pop("figure_caption", None)
105
+
106
+ for legacy_key in ("accompanied_by", "project", "client_site"):
107
+ normalized.pop(legacy_key, None)
108
+
109
+ return normalized
110
+
111
+
112
  class SessionStore:
113
  def __init__(self, base_dir: Optional[Path] = None) -> None:
114
  settings = get_settings()
 
117
  self.sessions_dir.mkdir(parents=True, exist_ok=True)
118
  self.max_upload_bytes = settings.max_upload_mb * 1024 * 1024
119
  self._lock = Lock()
120
+ self._migrate_storage()
121
+
122
+ def _migrate_storage(self) -> None:
123
+ for session_file in self.sessions_dir.glob("*/session.json"):
124
+ try:
125
+ raw = json.loads(session_file.read_text(encoding="utf-8"))
126
+ except Exception:
127
+ continue
128
+ normalized = self._normalize_session(copy.deepcopy(raw))
129
+ if normalized != raw:
130
+ try:
131
+ session_file.write_text(
132
+ json.dumps(normalized, indent=2), encoding="utf-8"
133
+ )
134
+ except Exception:
135
+ continue
136
 
137
  def list_sessions(self) -> List[dict]:
138
  sessions: List[dict] = []
139
  for session_file in sorted(self.sessions_dir.glob("*/session.json"), reverse=True):
140
  try:
141
+ session = json.loads(session_file.read_text(encoding="utf-8"))
142
+ session = self._normalize_session(session)
143
+ sessions.append(session)
144
  except Exception:
145
  continue
146
  return sessions
147
 
148
+ def create_session(self, document_no: str, inspection_date: str) -> dict:
149
  session_id = uuid4().hex
150
  now = _now_iso()
151
  session = {
 
153
  "status": "ready",
154
  "created_at": now,
155
  "updated_at": now,
156
+ "document_no": document_no,
157
  "inspection_date": inspection_date,
 
158
  "uploads": {"photos": [], "documents": [], "data_files": []},
159
  "selected_photo_ids": [],
160
  "page_count": 0,
161
  "pages": [],
162
  "jobsheet_sections": [],
163
+ "headings": [],
164
  }
165
  self._save_session(session)
166
  return session
 
173
  if not session_path.exists():
174
  return None
175
  try:
176
+ session = json.loads(session_path.read_text(encoding="utf-8"))
177
+ return self._normalize_session(session)
178
  except Exception:
179
  return None
180
 
181
  def update_session(self, session: dict) -> None:
182
+ session = self._normalize_session(session)
183
  session["updated_at"] = _now_iso()
184
  self._save_session(session)
185
 
 
212
  def set_pages(self, session: dict, pages: List[dict]) -> dict:
213
  if not pages:
214
  pages = [{"items": []}]
215
+ normalized_pages = []
216
+ for page in pages:
217
+ if not isinstance(page, dict):
218
+ normalized_pages.append({"items": []})
219
+ continue
220
+ template = _normalize_template_fields(page.get("template"))
221
+ normalized_pages.append({**page, "template": template})
222
  # Legacy compatibility: store as a single section.
223
  session["jobsheet_sections"] = [
224
+ {"id": uuid4().hex, "title": "Section 1", "pages": normalized_pages}
225
  ]
226
  session["pages"] = []
227
+ session["page_count"] = len(normalized_pages)
228
  self.update_session(session)
229
  return session
230
 
 
255
  else:
256
  normalized_pages.append(page)
257
  pages = normalized_pages
258
+ normalized_pages = []
259
+ for page in pages:
260
+ if not isinstance(page, dict):
261
+ normalized_pages.append({"items": []})
262
+ continue
263
+ template = _normalize_template_fields(page.get("template"))
264
+ normalized_pages.append({**page, "template": template})
265
  normalized.append(
266
  {
267
  "id": section.get("id") or uuid4().hex,
268
  "title": section.get("title") or "Section",
269
+ "pages": normalized_pages if normalized_pages else [{"items": []}],
270
  }
271
  )
272
  if not normalized:
 
277
  self.update_session(session)
278
  return session
279
 
280
+ def set_headings(self, session: dict, headings: List[dict]) -> dict:
281
+ normalized: List[dict] = []
282
+ for heading in headings or []:
283
+ if hasattr(heading, "model_dump"):
284
+ heading = heading.model_dump()
285
+ elif hasattr(heading, "dict"):
286
+ heading = heading.dict()
287
+ if not isinstance(heading, dict):
288
+ continue
289
+ number = str(heading.get("number") or "").strip()
290
+ name = str(heading.get("name") or "").strip()
291
+ normalized.append({"number": number, "name": name})
292
+ session["headings"] = normalized
293
+ self.update_session(session)
294
+ return session
295
+
296
  def ensure_sections(self, session: dict) -> List[dict]:
297
  sections = session.get("jobsheet_sections") or []
298
  if sections:
299
+ normalized_sections: List[dict] = []
300
+ for section in sections:
301
+ if not isinstance(section, dict):
302
+ continue
303
+ pages = section.get("pages") or []
304
+ normalized_pages = []
305
+ for page in pages:
306
+ if not isinstance(page, dict):
307
+ normalized_pages.append({"items": []})
308
+ continue
309
+ template = _normalize_template_fields(page.get("template"))
310
+ normalized_pages.append({**page, "template": template})
311
+ normalized_sections.append(
312
+ {
313
+ "id": section.get("id") or uuid4().hex,
314
+ "title": section.get("title") or "Section",
315
+ "pages": normalized_pages if normalized_pages else [{"items": []}],
316
+ }
317
+ )
318
+ session["jobsheet_sections"] = normalized_sections
319
  session["page_count"] = sum(
320
+ len(section.get("pages") or []) for section in normalized_sections
321
  )
322
  self.update_session(session)
323
+ return normalized_sections
324
 
325
  pages = session.get("pages") or []
326
  if not pages:
 
336
  self.update_session(session)
337
  return sections
338
 
339
+ def _normalize_session(self, session: dict) -> dict:
340
+ if not isinstance(session, dict):
341
+ return session
342
+ document_no = _merge_text(
343
+ session.get("document_no", ""),
344
+ session.get("project_name", ""),
345
+ )
346
+ if document_no:
347
+ session["document_no"] = document_no
348
+ session.pop("project_name", None)
349
+ session.pop("notes", None)
350
+
351
+ headings = session.get("headings")
352
+ if isinstance(headings, dict):
353
+ session["headings"] = [
354
+ {"number": str(key).strip(), "name": str(value).strip()}
355
+ for key, value in headings.items()
356
+ ]
357
+ elif isinstance(headings, list):
358
+ normalized_headings = []
359
+ for heading in headings:
360
+ if hasattr(heading, "model_dump"):
361
+ heading = heading.model_dump()
362
+ elif hasattr(heading, "dict"):
363
+ heading = heading.dict()
364
+ if not isinstance(heading, dict):
365
+ continue
366
+ number = str(heading.get("number") or "").strip()
367
+ name = str(heading.get("name") or "").strip()
368
+ normalized_headings.append({"number": number, "name": name})
369
+ session["headings"] = normalized_headings
370
+ else:
371
+ session["headings"] = []
372
+
373
+ pages = session.get("pages") or []
374
+ if pages:
375
+ normalized_pages = []
376
+ for page in pages:
377
+ if not isinstance(page, dict):
378
+ normalized_pages.append({"items": []})
379
+ continue
380
+ template = _normalize_template_fields(page.get("template"))
381
+ normalized_pages.append({**page, "template": template})
382
+ session["pages"] = normalized_pages
383
+
384
+ sections = session.get("jobsheet_sections") or []
385
+ if sections:
386
+ normalized_sections = []
387
+ for section in sections:
388
+ if not isinstance(section, dict):
389
+ continue
390
+ pages = section.get("pages") or []
391
+ normalized_pages = []
392
+ for page in pages:
393
+ if not isinstance(page, dict):
394
+ normalized_pages.append({"items": []})
395
+ continue
396
+ template = _normalize_template_fields(page.get("template"))
397
+ normalized_pages.append({**page, "template": template})
398
+ normalized_sections.append(
399
+ {
400
+ "id": section.get("id") or uuid4().hex,
401
+ "title": section.get("title") or "Section",
402
+ "pages": normalized_pages if normalized_pages else [{"items": []}],
403
+ }
404
+ )
405
+ session["jobsheet_sections"] = normalized_sections
406
+
407
+ return session
408
+
409
  def save_upload(self, session_id: str, upload: UploadFile) -> StoredFile:
410
  filename = _safe_name(upload.filename or "upload")
411
  ext = Path(filename).suffix