ChristopherJKoen commited on
Commit
08113a7
·
2 Parent(s): 0bc4be2310edf0

Merge dev into main for Space update

Browse files
DataInputTemplate-populated-sr.xlsx DELETED
Binary file (15.5 kB)
 
README.md CHANGED
@@ -28,7 +28,6 @@ React (Vite) frontend + FastAPI backend with local session storage.
28
  frontend/
29
  public/
30
  assets/
31
- templates/
32
  src/
33
  components/
34
  pages/
 
28
  frontend/
29
  public/
30
  assets/
 
31
  src/
32
  components/
33
  pages/
examples/DataInputTemplate-populated-sr.xlsx DELETED
Binary file (15.5 kB)
 
frontend/public/templates/job-sheet-template.html DELETED
@@ -1,240 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>RepEx Inspection Job Sheet</title>
7
-
8
- <!-- Tailwind (CDN OK for prototypes; compile for production) -->
9
- <script src="https://cdn.tailwindcss.com"></script>
10
-
11
- <!-- Feather icons -->
12
- <script defer src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
13
-
14
- <style>
15
- @page { size: A4; margin: 10mm; }
16
-
17
- @media print {
18
- html, body { background: #fff !important; }
19
- .no-print { display: none !important; }
20
- main { box-shadow: none !important; border: none !important; margin: 0 !important; padding: 0 !important; }
21
- }
22
-
23
- .avoid-break { break-inside: avoid; page-break-inside: avoid; }
24
- img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
25
-
26
- .template-field {
27
- display: block;
28
- min-height: 1.1em;
29
- border-bottom: 1px dotted #d1d5db;
30
- padding-bottom: 1px;
31
- }
32
-
33
- .template-field-multiline {
34
- min-height: 2.4em;
35
- white-space: pre-wrap;
36
- }
37
-
38
- .template-field[contenteditable="true"]:empty:before {
39
- content: attr(data-placeholder);
40
- color: #9ca3af;
41
- }
42
- </style>
43
- </head>
44
-
45
- <body class="bg-gray-50 print:bg-white">
46
- <!-- A4-focused container -->
47
- <main class="mx-auto max-w-[210mm] bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-5 print:ring-0 print:shadow-none print:rounded-none">
48
- <!-- Header -->
49
- <header class="mb-4 border-b border-gray-200 pb-3">
50
- <div class="grid grid-cols-[auto,1fr,auto] items-center gap-3">
51
- <div class="flex items-center">
52
- <img
53
- src="../assets/prosento-logo.png"
54
- alt="Prosento logo"
55
- class="h-10 w-auto object-contain"
56
- loading="eager"
57
- />
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">
73
- <img
74
- src="../assets/client-logo.png"
75
- alt="Company logo"
76
- class="h-10 w-auto object-contain"
77
- loading="eager"
78
- />
79
- </div>
80
- </div>
81
- </header>
82
-
83
- <!-- Observations and Findings -->
84
- <section class="mb-4" aria-labelledby="observations-title">
85
- <h2 id="observations-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
86
- Observations and Findings
87
- </h2>
88
-
89
- <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
90
- <div class="md:col-span-2">
91
- <div class="grid grid-cols-3 gap-3">
92
- <div class="space-y-0.5">
93
- <div class="text-xs font-medium text-gray-500">Ref</div>
94
- <div class="template-field text-sm font-semibold text-gray-900" contenteditable="true" data-placeholder="Ref"></div>
95
- </div>
96
-
97
- <div class="space-y-0.5">
98
- <div class="text-xs font-medium text-gray-500">Area</div>
99
- <div class="template-field text-sm font-semibold text-gray-900" contenteditable="true" data-placeholder="Area"></div>
100
- </div>
101
-
102
- <div class="space-y-0.5">
103
- <div class="text-xs font-medium text-gray-500">Location</div>
104
- <div class="template-field text-sm font-semibold text-gray-900" contenteditable="true" data-placeholder="Location"></div>
105
- </div>
106
- </div>
107
- </div>
108
-
109
- <!-- Centered Category + Priority (must be direct child of the grid) -->
110
- <div class="md:col-span-2 flex justify-center">
111
- <div class="inline-flex items-center gap-6">
112
- <div class="text-center space-y-1">
113
- <div class="text-xs font-medium text-gray-500">Category</div>
114
- <span
115
- class="template-field inline-flex items-center justify-center rounded-md border px-3 py-1 text-sm font-semibold min-w-[120px] bg-yellow-200 text-yellow-800 border-yellow-200"
116
- contenteditable="true"
117
- data-placeholder="3 - Poor"
118
- >3 - Poor</span>
119
- </div>
120
-
121
- <div class="text-center space-y-1">
122
- <div class="text-xs font-medium text-gray-500">Priority</div>
123
- <span
124
- class="template-field inline-flex items-center justify-center rounded-md border px-3 py-1 text-sm font-semibold min-w-[120px] bg-green-200 text-green-800 border-green-200"
125
- contenteditable="true"
126
- data-placeholder="3 - 3 Years"
127
- >3 - 3 Years</span>
128
- </div>
129
-
130
- </div>
131
- </div>
132
-
133
- <!-- Full-width: Condition Description -->
134
- <div class="md:col-span-2 space-y-1">
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
-
141
- <!-- Full-width: Action Required -->
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>
148
- </div>
149
- </section>
150
-
151
- <!-- Photo Documentation -->
152
- <section class="mb-4 avoid-break" aria-labelledby="photo-doc-title">
153
- <h2 id="photo-doc-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
154
- Photo Documentation
155
- </h2>
156
-
157
- <div class="columns-2" style="column-gap:0.75rem;">
158
- <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50 overflow-hidden break-inside-avoid mb-3">
159
- <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
160
- Photo slot
161
- </div>
162
- <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
163
- Figure 1
164
- </figcaption>
165
- </figure>
166
-
167
- <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50 overflow-hidden break-inside-avoid mb-3">
168
- <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
169
- Photo slot
170
- </div>
171
- <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
172
- Figure 2
173
- </figcaption>
174
- </figure>
175
-
176
- <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50 overflow-hidden break-inside-avoid mb-3">
177
- <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
178
- Photo slot
179
- </div>
180
- <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
181
- Figure 3
182
- </figcaption>
183
- </figure>
184
-
185
- <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50 overflow-hidden break-inside-avoid mb-3">
186
- <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
187
- Photo slot
188
- </div>
189
- <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
190
- Figure 4
191
- </figcaption>
192
- </figure>
193
-
194
- <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50 overflow-hidden break-inside-avoid mb-3">
195
- <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
196
- Photo slot
197
- </div>
198
- <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
199
- Figure 5
200
- </figcaption>
201
- </figure>
202
-
203
- <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50 overflow-hidden break-inside-avoid mb-3">
204
- <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
205
- Photo slot
206
- </div>
207
- <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
208
- Figure 6
209
- </figcaption>
210
- </figure>
211
- </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>
240
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/public/templates/repex-data-input-template.xlsx ADDED
Binary file (6.09 kB). View file
 
frontend/src/App.tsx CHANGED
@@ -8,6 +8,7 @@ import ImagePlacementPage from "./pages/ImagePlacementPage";
8
  import InputDataPage from "./pages/InputDataPage";
9
  import EditReportPage from "./pages/EditReportPage";
10
  import EditLayoutsPage from "./pages/EditLayoutsPage";
 
11
  import ExportPage from "./pages/ExportPage";
12
  import RatingsInfoPage from "./pages/RatingsInfoPage";
13
 
@@ -23,6 +24,7 @@ export default function App() {
23
  <Route path="/input-data" element={<InputDataPage />} />
24
  <Route path="/edit-report" element={<EditReportPage />} />
25
  <Route path="/edit-layouts" element={<EditLayoutsPage />} />
 
26
  <Route path="/export" element={<ExportPage />} />
27
  <Route path="/info/ratings" element={<RatingsInfoPage />} />
28
  <Route path="*" element={<Navigate to="/" replace />} />
 
8
  import InputDataPage from "./pages/InputDataPage";
9
  import EditReportPage from "./pages/EditReportPage";
10
  import EditLayoutsPage from "./pages/EditLayoutsPage";
11
+ import LayoutTemplatesPage from "./pages/LayoutTemplatesPage";
12
  import ExportPage from "./pages/ExportPage";
13
  import RatingsInfoPage from "./pages/RatingsInfoPage";
14
 
 
24
  <Route path="/input-data" element={<InputDataPage />} />
25
  <Route path="/edit-report" element={<EditReportPage />} />
26
  <Route path="/edit-layouts" element={<EditLayoutsPage />} />
27
+ <Route path="/edit-layouts/templates" element={<LayoutTemplatesPage />} />
28
  <Route path="/export" element={<ExportPage />} />
29
  <Route path="/info/ratings" element={<RatingsInfoPage />} />
30
  <Route path="*" element={<Navigate to="/" replace />} />
frontend/src/components/JobSheetTemplate.tsx CHANGED
@@ -144,21 +144,24 @@ function photoUrl(photo: FileMeta) {
144
 
145
  function resolveLogoUrl(session: Session | null, rawValue?: string) {
146
  const value = (rawValue || "").trim();
147
- if (!value) return "/assets/client-logo.png";
148
  if (/^(https?:|data:|\/)/i.test(value)) return value;
149
 
150
  const uploads = session?.uploads?.photos ?? [];
151
  const key = normalizeKey(value);
152
  for (const photo of uploads) {
 
 
 
153
  const name = photo.name || "";
154
  if (!name) continue;
155
  const nameKey = normalizeKey(name);
156
  const stemKey = normalizeKey(name.replace(/\.[^/.]+$/, ""));
157
  if (key == nameKey || key == stemKey) {
158
- return photo.url || "/assets/client-logo.png";
159
  }
160
  }
161
- return value;
162
  }
163
 
164
  function computeLayout(photos: FileMeta[], ratios: Record<string, number>): LayoutEntry[] {
@@ -238,18 +241,22 @@ function PhotoSlot({ url, label, className = "", imageClassName = "" }: PhotoSlo
238
  return (
239
  <figure
240
  className={[
241
- "rounded-lg border border-gray-200 bg-gray-50 p-2 break-inside-avoid",
242
  className,
243
  ].join(" ")}
244
  style={{ breakInside: "avoid", pageBreakInside: "avoid" }}
245
  >
246
- <img
247
- src={url}
248
- alt={label}
249
- className={["w-full h-full object-contain", imageClassName].join(" ")}
250
- loading="eager"
251
- />
252
- <figcaption className="mt-1 text-[10px] text-gray-600 text-center">
 
 
 
 
253
  {label}
254
  </figcaption>
255
  </figure>
@@ -352,48 +359,54 @@ export function JobSheetTemplate({
352
  <img
353
  src="/assets/prosento-logo.png"
354
  alt="Prosento logo"
355
- className="h-9 w-auto object-contain"
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
363
- src={logoUrl}
364
- alt="Company logo"
365
- className="h-9 w-auto object-contain"
366
- />
 
 
 
 
 
 
367
  </div>
368
  </header>
369
 
370
  {variant === "full" ? (
371
- <section className="mb-3" aria-labelledby="observations-title">
372
  <h2
373
  id="observations-title"
374
- className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2"
375
  >
376
  Observations and Findings
377
  </h2>
378
 
379
- <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
380
  <div className="md:col-span-2">
381
- <div className="grid grid-cols-3 gap-3">
382
  <div className="space-y-0.5">
383
- <div className="text-[10px] font-medium text-gray-500">Ref</div>
384
- <div className="template-field text-[11px] font-semibold text-gray-900">
385
  {reference}
386
  </div>
387
  </div>
388
  <div className="space-y-0.5">
389
- <div className="text-[10px] font-medium text-gray-500">Area</div>
390
- <div className="template-field text-[11px] font-semibold text-gray-900">
391
  {area}
392
  </div>
393
  </div>
394
  <div className="space-y-0.5">
395
- <div className="text-[10px] font-medium text-gray-500">Location</div>
396
- <div className="template-field text-[11px] font-semibold text-gray-900">
397
  {functionalLocation}
398
  </div>
399
  </div>
@@ -401,14 +414,14 @@ export function JobSheetTemplate({
401
  </div>
402
 
403
  <div className="md:col-span-2 flex justify-center">
404
- <div className="inline-flex items-center gap-6">
405
  <div className="text-center space-y-1">
406
- <div className="text-[10px] font-medium text-gray-500">
407
  Category
408
  </div>
409
  <span
410
  className={[
411
- "template-field inline-flex items-center justify-center rounded-md border px-4 py-1 text-[11px] font-semibold min-w-[120px]",
412
  categoryBadge.className,
413
  ].join(" ")}
414
  >
@@ -417,12 +430,12 @@ export function JobSheetTemplate({
417
  </div>
418
 
419
  <div className="text-center space-y-1">
420
- <div className="text-[10px] font-medium text-gray-500">
421
  Priority
422
  </div>
423
  <span
424
  className={[
425
- "template-field inline-flex items-center justify-center rounded-md border px-4 py-1 text-[11px] font-semibold min-w-[120px]",
426
  priorityBadge.className,
427
  ].join(" ")}
428
  >
@@ -434,22 +447,22 @@ export function JobSheetTemplate({
434
  </div>
435
 
436
  <div className="md:col-span-2 space-y-1">
437
- <div className="text-[10px] font-medium text-gray-500">
438
  Condition Description
439
  </div>
440
- <div className="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
441
- <p className="template-field template-field-multiline text-amber-800 text-[11px] font-semibold leading-snug">
442
  {conditionText}
443
  </p>
444
  </div>
445
  </div>
446
 
447
  <div className="md:col-span-2 space-y-1">
448
- <div className="text-[10px] font-medium text-gray-500">
449
  Action Required
450
  </div>
451
- <div className="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
452
- <p className="template-field template-field-multiline text-blue-800 text-[11px] font-semibold leading-snug">
453
  {actionText}
454
  </p>
455
  </div>
@@ -459,7 +472,7 @@ export function JobSheetTemplate({
459
  ) : null}
460
 
461
  <section className="mb-3 avoid-break flex-1 min-h-0 flex flex-col">
462
- <div className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
463
  {variant === "photos" ? "Photo Documentation (continued)" : "Photo Documentation"}
464
  </div>
465
  <div className={`${photoGridClass} flex-1 items-stretch`}>
@@ -472,7 +485,6 @@ export function JobSheetTemplate({
472
  url={photo?.url}
473
  label={figureCaption || photo?.name || `Figure ${index + 1}`}
474
  className="h-full"
475
- imageClassName="h-full"
476
  />
477
  ))
478
  )}
 
144
 
145
  function resolveLogoUrl(session: Session | null, rawValue?: string) {
146
  const value = (rawValue || "").trim();
147
+ if (!value) return "";
148
  if (/^(https?:|data:|\/)/i.test(value)) return value;
149
 
150
  const uploads = session?.uploads?.photos ?? [];
151
  const key = normalizeKey(value);
152
  for (const photo of uploads) {
153
+ if (photo.id && value === photo.id) {
154
+ return photo.url || "";
155
+ }
156
  const name = photo.name || "";
157
  if (!name) continue;
158
  const nameKey = normalizeKey(name);
159
  const stemKey = normalizeKey(name.replace(/\.[^/.]+$/, ""));
160
  if (key == nameKey || key == stemKey) {
161
+ return photo.url || "";
162
  }
163
  }
164
+ return "";
165
  }
166
 
167
  function computeLayout(photos: FileMeta[], ratios: Record<string, number>): LayoutEntry[] {
 
241
  return (
242
  <figure
243
  className={[
244
+ "rounded-lg border border-gray-200 bg-gray-50 p-2 pb-3 break-inside-avoid flex flex-col gap-1 overflow-hidden",
245
  className,
246
  ].join(" ")}
247
  style={{ breakInside: "avoid", pageBreakInside: "avoid" }}
248
  >
249
+ <div className="w-full flex-1 flex items-center justify-center">
250
+ <img
251
+ src={url}
252
+ alt={label}
253
+ className={["w-full object-contain max-h-[240px]", imageClassName].join(
254
+ " ",
255
+ )}
256
+ loading="eager"
257
+ />
258
+ </div>
259
+ <figcaption className="text-[10px] text-gray-600 text-center break-all leading-tight">
260
  {label}
261
  </figcaption>
262
  </figure>
 
359
  <img
360
  src="/assets/prosento-logo.png"
361
  alt="Prosento logo"
362
+ className="h-14 w-auto object-contain"
363
  />
364
  <div className="text-center leading-tight">
365
  <div className="text-base font-semibold text-gray-900">
366
  {docNumber || "-"}
367
  </div>
368
  </div>
369
+ {logoUrl ? (
370
+ <img
371
+ src={logoUrl}
372
+ alt="Company logo"
373
+ className="h-14 w-auto object-contain"
374
+ />
375
+ ) : (
376
+ <div className="h-14 min-w-[140px] rounded-md border border-dashed border-gray-300 px-2 text-[9px] font-semibold text-gray-400 flex items-center justify-center text-center">
377
+ Company Logo not found
378
+ </div>
379
+ )}
380
  </div>
381
  </header>
382
 
383
  {variant === "full" ? (
384
+ <section className="mb-2" aria-labelledby="observations-title">
385
  <h2
386
  id="observations-title"
387
+ className="text-[10px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-1"
388
  >
389
  Observations and Findings
390
  </h2>
391
 
392
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
393
  <div className="md:col-span-2">
394
+ <div className="grid grid-cols-3 gap-2">
395
  <div className="space-y-0.5">
396
+ <div className="text-[9px] font-medium text-gray-500">Ref</div>
397
+ <div className="template-field text-[10px] font-semibold text-gray-900">
398
  {reference}
399
  </div>
400
  </div>
401
  <div className="space-y-0.5">
402
+ <div className="text-[9px] font-medium text-gray-500">Area</div>
403
+ <div className="template-field text-[10px] font-semibold text-gray-900">
404
  {area}
405
  </div>
406
  </div>
407
  <div className="space-y-0.5">
408
+ <div className="text-[9px] font-medium text-gray-500">Location</div>
409
+ <div className="template-field text-[10px] font-semibold text-gray-900">
410
  {functionalLocation}
411
  </div>
412
  </div>
 
414
  </div>
415
 
416
  <div className="md:col-span-2 flex justify-center">
417
+ <div className="inline-flex items-center gap-4">
418
  <div className="text-center space-y-1">
419
+ <div className="text-[9px] font-medium text-gray-500">
420
  Category
421
  </div>
422
  <span
423
  className={[
424
+ "template-field inline-flex items-center justify-center rounded-md border px-4 py-1 text-[10px] font-semibold min-w-[120px]",
425
  categoryBadge.className,
426
  ].join(" ")}
427
  >
 
430
  </div>
431
 
432
  <div className="text-center space-y-1">
433
+ <div className="text-[9px] font-medium text-gray-500">
434
  Priority
435
  </div>
436
  <span
437
  className={[
438
+ "template-field inline-flex items-center justify-center rounded-md border px-4 py-1 text-[10px] font-semibold min-w-[120px]",
439
  priorityBadge.className,
440
  ].join(" ")}
441
  >
 
447
  </div>
448
 
449
  <div className="md:col-span-2 space-y-1">
450
+ <div className="text-[9px] font-medium text-gray-500">
451
  Condition Description
452
  </div>
453
+ <div className="bg-gray-50 border-l-4 border-gray-300 p-1.5 rounded-sm">
454
+ <p className="template-field template-field-multiline text-gray-700 text-[9px] font-medium leading-snug">
455
  {conditionText}
456
  </p>
457
  </div>
458
  </div>
459
 
460
  <div className="md:col-span-2 space-y-1">
461
+ <div className="text-[9px] font-medium text-gray-500">
462
  Action Required
463
  </div>
464
+ <div className="bg-gray-50 border-l-4 border-gray-300 p-1.5 rounded-sm">
465
+ <p className="template-field template-field-multiline text-gray-700 text-[9px] font-medium leading-snug">
466
  {actionText}
467
  </p>
468
  </div>
 
472
  ) : null}
473
 
474
  <section className="mb-3 avoid-break flex-1 min-h-0 flex flex-col">
475
+ <div className="text-[10px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
476
  {variant === "photos" ? "Photo Documentation (continued)" : "Photo Documentation"}
477
  </div>
478
  <div className={`${photoGridClass} flex-1 items-stretch`}>
 
485
  url={photo?.url}
486
  label={figureCaption || photo?.name || `Figure ${index + 1}`}
487
  className="h-full"
 
488
  />
489
  ))
490
  )}
frontend/src/components/report-editor.js CHANGED
@@ -18,6 +18,9 @@ const PRIORITY_SCALE = {
18
  M: { label: "Monitor", className: "bg-blue-200 text-blue-800 border-blue-200" },
19
  };
20
 
 
 
 
21
  function parseScaleCode(value) {
22
  const match = String(value || "").trim().match(/^[A-Za-z0-9]+/);
23
  return match ? match[0].toUpperCase() : String(value || "").trim().toUpperCase();
@@ -409,6 +412,22 @@ class ReportEditor extends HTMLElement {
409
  </div>
410
  </div>
411
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  <div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
413
  <div class="font-semibold text-gray-800 mb-1">Keyboard shortcuts</div>
414
  <ul class="list-disc pl-4 space-y-1">
@@ -437,9 +456,11 @@ class ReportEditor extends HTMLElement {
437
  this.$propsText = this.querySelector("[data-props-text]");
438
  this.$propsRect = this.querySelector("[data-props-rect]");
439
  this.$propsImage = this.querySelector("[data-props-image]");
 
440
 
441
  this.$imgFile = this.querySelector('[data-file="image"]');
442
  this.$replaceFile = this.querySelector('[data-file="replace"]');
 
443
 
444
  if (this.$canvas && "ResizeObserver" in window) {
445
  this._resizeObserver = new ResizeObserver(() => {
@@ -500,6 +521,24 @@ class ReportEditor extends HTMLElement {
500
  this.querySelector('[data-btn="replace-image"]').addEventListener("click", () => this.$replaceFile.click());
501
  this.$replaceFile.addEventListener("change", (e) => this._handleImageUpload(e, "replace"));
502
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
  // canvas interactions
504
  this.$canvas.addEventListener("pointerdown", (e) => this.onCanvasPointerDown(e));
505
  window.addEventListener("pointermove", (e) => this.onPointerMove(e));
@@ -561,6 +600,7 @@ class ReportEditor extends HTMLElement {
561
  this.renderCanvas();
562
  this.updateCanvasScale();
563
  this.updatePropsPanel();
 
564
  this.updateUndoRedoButtons();
565
  this._refreshIcons();
566
  }
@@ -723,20 +763,92 @@ class ReportEditor extends HTMLElement {
723
 
724
  _resolveLogoUrl(session, rawValue) {
725
  const value = String(rawValue || "").trim();
726
- if (!value) return "/assets/client-logo.png";
727
  if (/^(https?:|data:|\/)/i.test(value)) return value;
728
  const uploads = (session && session.uploads && session.uploads.photos) || [];
729
  const key = this._normalizeKey(value);
730
  for (const photo of uploads) {
 
 
 
731
  const name = photo && photo.name ? photo.name : "";
732
  if (!name) continue;
733
  const nameKey = this._normalizeKey(name);
734
  const stemKey = this._normalizeKey(name.replace(/\.[^/.]+$/, ""));
735
  if (key === nameKey || key === stemKey) {
736
- return photo.url || (this.sessionId ? `${this._apiRoot()}/sessions/${this.sessionId}/uploads/${photo.id}` : value);
737
  }
738
  }
739
- return value;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
740
  }
741
 
742
  _photoKey(photo) {
@@ -861,14 +973,16 @@ class ReportEditor extends HTMLElement {
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
  }
@@ -983,48 +1097,48 @@ class ReportEditor extends HTMLElement {
983
  const observationsHtml =
984
  variant === "full"
985
  ? `
986
- <section class="mb-4" aria-labelledby="observations-title">
987
- <h2 id="observations-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
988
  Observations and Findings
989
  </h2>
990
 
991
- <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
992
  <div class="md:col-span-2">
993
- <div class="grid grid-cols-3 gap-3">
994
  <div class="space-y-0.5">
995
- <div class="text-xs font-medium text-gray-500">Ref</div>
996
- ${this._tplField("reference", reference, "Ref", "text-sm font-semibold text-gray-900")}
997
  </div>
998
  <div class="space-y-0.5">
999
- <div class="text-xs font-medium text-gray-500">Area</div>
1000
- ${this._tplField("area", area, "Area", "text-sm font-semibold text-gray-900")}
1001
  </div>
1002
  <div class="space-y-0.5">
1003
- <div class="text-xs font-medium text-gray-500">Location</div>
1004
- ${this._tplField("functional_location", functionalLocation, "Location", "text-sm font-semibold text-gray-900")}
1005
  </div>
1006
  </div>
1007
  </div>
1008
 
1009
  <div class="md:col-span-2 flex justify-center">
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
 
@@ -1032,19 +1146,19 @@ class ReportEditor extends HTMLElement {
1032
  </div>
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>
1050
  </div>
@@ -1060,7 +1174,7 @@ class ReportEditor extends HTMLElement {
1060
  <header class="mb-3 border-b border-gray-200 pb-2">
1061
  <div class="grid grid-cols-[auto,1fr,auto] items-center gap-3">
1062
  <div class="flex items-center">
1063
- <img src="/assets/prosento-logo.png" alt="Prosento logo" class="h-10 w-auto object-contain" />
1064
  </div>
1065
 
1066
  <div class="text-center leading-tight">
@@ -1073,7 +1187,13 @@ class ReportEditor extends HTMLElement {
1073
  </div>
1074
 
1075
  <div class="flex items-center justify-end">
1076
- <img src="${this._escape(this._resolveLogoUrl(session, companyLogo))}" alt="Company logo" class="h-10 w-auto object-contain" />
 
 
 
 
 
 
1077
  </div>
1078
  </div>
1079
  </header>
@@ -1289,16 +1409,14 @@ class ReportEditor extends HTMLElement {
1289
  : [];
1290
  if (!photoIds.length) return [normalized];
1291
 
1292
- const chunks = [];
1293
- for (let i = 0; i < photoIds.length; i += 2) {
1294
- chunks.push(photoIds.slice(i, i + 2));
1295
- }
1296
-
1297
- if (chunks.length <= 1) {
1298
- return [{ ...normalized, photo_ids: chunks[0] || [], variant: normalized.variant }];
1299
- }
1300
-
1301
  if (normalized.variant === "photos") {
 
 
 
 
 
 
 
1302
  return chunks.map((chunk, idx) => {
1303
  if (idx === 0) {
1304
  return { ...normalized, photo_ids: chunk, variant: "photos" };
@@ -1307,12 +1425,20 @@ class ReportEditor extends HTMLElement {
1307
  });
1308
  }
1309
 
 
 
 
 
 
 
 
1310
  const basePage = {
1311
  ...normalized,
1312
- photo_ids: chunks[0],
1313
  variant: normalized.variant || "full",
1314
  };
1315
- const extraPages = chunks.slice(1).map((chunk) =>
 
1316
  this._buildPhotoContinuation(normalized, chunk),
1317
  );
1318
  return [basePage, ...extraPages];
 
18
  M: { label: "Monitor", className: "bg-blue-200 text-blue-800 border-blue-200" },
19
  };
20
 
21
+ const MAX_PHOTOS_PRIMARY_PAGE = 2;
22
+ const MAX_PHOTOS_CONTINUATION_PAGE = 6;
23
+
24
  function parseScaleCode(value) {
25
  const match = String(value || "").trim().match(/^[A-Za-z0-9]+/);
26
  return match ? match[0].toUpperCase() : String(value || "").trim().toUpperCase();
 
412
  </div>
413
  </div>
414
 
415
+ <div class="mt-4 rounded-lg border border-gray-200 p-3">
416
+ <div class="text-xs font-semibold text-gray-600 mb-2">Company Logo (Top Right)</div>
417
+ <p class="text-[11px] text-gray-500 mb-2">
418
+ Select an uploaded image or upload a new one for this page.
419
+ </p>
420
+ <select data-prop-template-logo
421
+ class="w-full rounded-lg border border-gray-200 bg-white px-2 py-2 text-xs font-semibold text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-200">
422
+ <option value="">No logo (show placeholder)</option>
423
+ </select>
424
+ <button data-btn="upload-template-logo"
425
+ class="mt-2 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">
426
+ <i data-feather="upload" class="h-4 w-4"></i> Upload logo
427
+ </button>
428
+ <input data-file="template-logo" type="file" accept="image/*" class="hidden" />
429
+ </div>
430
+
431
  <div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
432
  <div class="font-semibold text-gray-800 mb-1">Keyboard shortcuts</div>
433
  <ul class="list-disc pl-4 space-y-1">
 
456
  this.$propsText = this.querySelector("[data-props-text]");
457
  this.$propsRect = this.querySelector("[data-props-rect]");
458
  this.$propsImage = this.querySelector("[data-props-image]");
459
+ this.$templateLogoSelect = this.querySelector("[data-prop-template-logo]");
460
 
461
  this.$imgFile = this.querySelector('[data-file="image"]');
462
  this.$replaceFile = this.querySelector('[data-file="replace"]');
463
+ this.$templateLogoFile = this.querySelector('[data-file="template-logo"]');
464
 
465
  if (this.$canvas && "ResizeObserver" in window) {
466
  this._resizeObserver = new ResizeObserver(() => {
 
521
  this.querySelector('[data-btn="replace-image"]').addEventListener("click", () => this.$replaceFile.click());
522
  this.$replaceFile.addEventListener("change", (e) => this._handleImageUpload(e, "replace"));
523
 
524
+ // company logo controls
525
+ this.querySelector('[data-btn="upload-template-logo"]').addEventListener("click", () => {
526
+ this.$templateLogoFile.click();
527
+ });
528
+ this.$templateLogoFile.addEventListener("change", (e) => {
529
+ const file = e.target.files && e.target.files[0];
530
+ if (file) {
531
+ this._uploadTemplateLogo(file);
532
+ }
533
+ e.target.value = "";
534
+ });
535
+ this.$templateLogoSelect.addEventListener("change", () => {
536
+ const template = this._getTemplate();
537
+ template.company_logo = this.$templateLogoSelect.value;
538
+ this._savePages();
539
+ this.renderCanvas();
540
+ });
541
+
542
  // canvas interactions
543
  this.$canvas.addEventListener("pointerdown", (e) => this.onCanvasPointerDown(e));
544
  window.addEventListener("pointermove", (e) => this.onPointerMove(e));
 
600
  this.renderCanvas();
601
  this.updateCanvasScale();
602
  this.updatePropsPanel();
603
+ this._syncTemplateLogoOptions();
604
  this.updateUndoRedoButtons();
605
  this._refreshIcons();
606
  }
 
763
 
764
  _resolveLogoUrl(session, rawValue) {
765
  const value = String(rawValue || "").trim();
766
+ if (!value) return "";
767
  if (/^(https?:|data:|\/)/i.test(value)) return value;
768
  const uploads = (session && session.uploads && session.uploads.photos) || [];
769
  const key = this._normalizeKey(value);
770
  for (const photo of uploads) {
771
+ if (photo && photo.id && value === photo.id) {
772
+ return photo.url || (this.sessionId ? `${this._apiRoot()}/sessions/${this.sessionId}/uploads/${photo.id}` : "");
773
+ }
774
  const name = photo && photo.name ? photo.name : "";
775
  if (!name) continue;
776
  const nameKey = this._normalizeKey(name);
777
  const stemKey = this._normalizeKey(name.replace(/\.[^/.]+$/, ""));
778
  if (key === nameKey || key === stemKey) {
779
+ return photo.url || (this.sessionId ? `${this._apiRoot()}/sessions/${this.sessionId}/uploads/${photo.id}` : "");
780
  }
781
  }
782
+ return "";
783
+ }
784
+
785
+ _syncTemplateLogoOptions() {
786
+ if (!this.$templateLogoSelect) return;
787
+ const session = this.state.payload || {};
788
+ const template = this._getTemplate();
789
+ const current = String(template.company_logo || "");
790
+ const uploads = (session && session.uploads && session.uploads.photos) || [];
791
+
792
+ const previousValue = this.$templateLogoSelect.value;
793
+ this.$templateLogoSelect.innerHTML = "";
794
+
795
+ const noneOption = document.createElement("option");
796
+ noneOption.value = "";
797
+ noneOption.textContent = "No logo (show placeholder)";
798
+ this.$templateLogoSelect.appendChild(noneOption);
799
+
800
+ uploads.forEach((photo) => {
801
+ const option = document.createElement("option");
802
+ option.value = String(photo.name || photo.id || "");
803
+ option.textContent = String(photo.name || photo.id || "Unnamed image");
804
+ if (option.value) {
805
+ this.$templateLogoSelect.appendChild(option);
806
+ }
807
+ });
808
+
809
+ const nextValue = current || previousValue || "";
810
+ this.$templateLogoSelect.value = nextValue;
811
+ }
812
+
813
+ async _uploadTemplateLogo(file) {
814
+ const base = this._apiRoot();
815
+ if (!base || !this.sessionId) {
816
+ this._toast("Missing session");
817
+ return;
818
+ }
819
+ const payload = this.state.payload || {};
820
+ const existing = new Set(
821
+ ((payload.uploads && payload.uploads.photos) || []).map((photo) => photo.id),
822
+ );
823
+
824
+ try {
825
+ this._toast("Uploading logo...");
826
+ const form = new FormData();
827
+ form.append("file", file);
828
+ const res = await fetch(`${base}/sessions/${this.sessionId}/uploads`, {
829
+ method: "POST",
830
+ body: form,
831
+ });
832
+ if (!res.ok) {
833
+ throw new Error("Upload failed");
834
+ }
835
+ const updated = await res.json();
836
+ this.state.payload = updated;
837
+ const photos = (updated.uploads && updated.uploads.photos) || [];
838
+ const uploaded =
839
+ photos.find((photo) => !existing.has(photo.id)) ||
840
+ photos.find((photo) => photo.name === file.name);
841
+ const template = this._getTemplate();
842
+ template.company_logo = uploaded
843
+ ? String(uploaded.name || uploaded.id || "")
844
+ : template.company_logo || "";
845
+ this._savePages();
846
+ this.renderCanvas();
847
+ this._syncTemplateLogoOptions();
848
+ this._toast("Logo uploaded");
849
+ } catch {
850
+ this._toast("Logo upload failed");
851
+ }
852
  }
853
 
854
  _photoKey(photo) {
 
973
  "figure_caption",
974
  fallbackLabel || photo.name || "",
975
  "Figure caption",
976
+ "text-[10px] text-gray-600 text-center w-full break-all leading-tight",
977
+ false,
978
  false,
 
979
  );
980
  return `
981
+ <figure class="rounded-lg border border-gray-200 bg-gray-50 p-2 pb-3 break-inside-avoid mb-3 flex flex-col gap-1 overflow-hidden">
982
+ <div class="w-full flex-1 flex items-center justify-center">
983
+ <img src="${safeUrl}" alt="${label}" class="w-full object-contain max-h-[240px]" />
984
+ </div>
985
+ <figcaption class="text-[10px] text-gray-600 text-center break-all leading-tight">${caption}</figcaption>
986
  </figure>
987
  `;
988
  }
 
1097
  const observationsHtml =
1098
  variant === "full"
1099
  ? `
1100
+ <section class="mb-2" aria-labelledby="observations-title">
1101
+ <h2 id="observations-title" class="text-[10px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-1">
1102
  Observations and Findings
1103
  </h2>
1104
 
1105
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
1106
  <div class="md:col-span-2">
1107
+ <div class="grid grid-cols-3 gap-2">
1108
  <div class="space-y-0.5">
1109
+ <div class="text-[9px] font-medium text-gray-500">Ref</div>
1110
+ ${this._tplField("reference", reference, "Ref", "text-[10px] font-semibold text-gray-900")}
1111
  </div>
1112
  <div class="space-y-0.5">
1113
+ <div class="text-[9px] font-medium text-gray-500">Area</div>
1114
+ ${this._tplField("area", area, "Area", "text-[10px] font-semibold text-gray-900")}
1115
  </div>
1116
  <div class="space-y-0.5">
1117
+ <div class="text-[9px] font-medium text-gray-500">Location</div>
1118
+ ${this._tplField("functional_location", functionalLocation, "Location", "text-[10px] font-semibold text-gray-900")}
1119
  </div>
1120
  </div>
1121
  </div>
1122
 
1123
  <div class="md:col-span-2 flex justify-center">
1124
+ <div class="inline-flex items-center gap-4">
1125
  <div class="text-center space-y-1">
1126
+ <div class="text-[9px] font-medium text-gray-500">Category</div>
1127
  ${this._tplSelectField(
1128
  "category",
1129
  category,
1130
  categoryOptions,
1131
+ `min-w-[140px] rounded-md border px-3 py-1 text-[10px] font-semibold text-center ${categoryBadge.className}`,
1132
  )}
1133
  </div>
1134
 
1135
  <div class="text-center space-y-1">
1136
+ <div class="text-[9px] font-medium text-gray-500">Priority</div>
1137
  ${this._tplSelectField(
1138
  "priority",
1139
  priority,
1140
  priorityOptions,
1141
+ `min-w-[140px] rounded-md border px-3 py-1 text-[10px] font-semibold text-center ${priorityBadge.className}`,
1142
  )}
1143
  </div>
1144
 
 
1146
  </div>
1147
 
1148
  <div class="md:col-span-2 space-y-1">
1149
+ <div class="text-[9px] font-medium text-gray-500">Condition Description</div>
1150
+ <div class="bg-gray-50 border-l-4 border-gray-300 p-1.5 rounded-sm">
1151
+ <div class="text-gray-700 text-[9px] font-medium leading-snug">
1152
+ ${this._tplField("item_description", itemDescription, "Item description", "template-field-multiline text-gray-700 text-[9px] font-medium leading-snug", true)}
1153
  </div>
1154
  </div>
1155
  </div>
1156
 
1157
  <div class="md:col-span-2 space-y-1">
1158
+ <div class="text-[9px] font-medium text-gray-500">Action Required</div>
1159
+ <div class="bg-gray-50 border-l-4 border-gray-300 p-1.5 rounded-sm">
1160
+ <div class="text-gray-700 text-[9px] font-medium leading-snug">
1161
+ ${this._tplField("required_action", requiredAction, "Required action", "template-field-multiline text-gray-700 text-[9px] font-medium leading-snug", true)}
1162
  </div>
1163
  </div>
1164
  </div>
 
1174
  <header class="mb-3 border-b border-gray-200 pb-2">
1175
  <div class="grid grid-cols-[auto,1fr,auto] items-center gap-3">
1176
  <div class="flex items-center">
1177
+ <img src="/assets/prosento-logo.png" alt="Prosento logo" class="h-14 w-auto object-contain" />
1178
  </div>
1179
 
1180
  <div class="text-center leading-tight">
 
1187
  </div>
1188
 
1189
  <div class="flex items-center justify-end">
1190
+ ${(() => {
1191
+ const logoUrl = this._resolveLogoUrl(session, companyLogo);
1192
+ if (logoUrl) {
1193
+ return `<img src="${this._escape(logoUrl)}" alt="Company logo" class="h-14 w-auto object-contain" />`;
1194
+ }
1195
+ return `<div class="h-14 min-w-[140px] rounded-md border border-dashed border-gray-300 px-2 text-[9px] font-semibold text-gray-400 flex items-center justify-center text-center">Company Logo not found</div>`;
1196
+ })()}
1197
  </div>
1198
  </div>
1199
  </header>
 
1409
  : [];
1410
  if (!photoIds.length) return [normalized];
1411
 
 
 
 
 
 
 
 
 
 
1412
  if (normalized.variant === "photos") {
1413
+ const chunks = [];
1414
+ for (let i = 0; i < photoIds.length; i += MAX_PHOTOS_CONTINUATION_PAGE) {
1415
+ chunks.push(photoIds.slice(i, i + MAX_PHOTOS_CONTINUATION_PAGE));
1416
+ }
1417
+ if (chunks.length <= 1) {
1418
+ return [{ ...normalized, photo_ids: chunks[0] || [], variant: "photos" }];
1419
+ }
1420
  return chunks.map((chunk, idx) => {
1421
  if (idx === 0) {
1422
  return { ...normalized, photo_ids: chunk, variant: "photos" };
 
1425
  });
1426
  }
1427
 
1428
+ const baseChunk = photoIds.slice(0, MAX_PHOTOS_PRIMARY_PAGE);
1429
+ const extraIds = photoIds.slice(MAX_PHOTOS_PRIMARY_PAGE);
1430
+ const continuationChunks = [];
1431
+ for (let i = 0; i < extraIds.length; i += MAX_PHOTOS_CONTINUATION_PAGE) {
1432
+ continuationChunks.push(extraIds.slice(i, i + MAX_PHOTOS_CONTINUATION_PAGE));
1433
+ }
1434
+
1435
  const basePage = {
1436
  ...normalized,
1437
+ photo_ids: baseChunk,
1438
  variant: normalized.variant || "full",
1439
  };
1440
+ if (!continuationChunks.length) return [basePage];
1441
+ const extraPages = continuationChunks.map((chunk) =>
1442
  this._buildPhotoContinuation(normalized, chunk),
1443
  );
1444
  return [basePage, ...extraPages];
frontend/src/index.css CHANGED
@@ -23,7 +23,7 @@ body {
23
  }
24
 
25
  .template-field-multiline {
26
- min-height: 2.4em;
27
  white-space: pre-wrap;
28
  }
29
 
 
23
  }
24
 
25
  .template-field-multiline {
26
+ min-height: 1.8em;
27
  white-space: pre-wrap;
28
  }
29
 
frontend/src/lib/pageTemplates.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Page, PageTemplateDefinition } from "../types/session";
2
+
3
+ export const BUILTIN_PAGE_TEMPLATES: PageTemplateDefinition[] = [
4
+ {
5
+ id: "repex:standard",
6
+ name: "Standard Job Sheet",
7
+ description: "Observations + up to two photos.",
8
+ blank: false,
9
+ variant: "full",
10
+ source: "builtin",
11
+ },
12
+ {
13
+ id: "repex:photos",
14
+ name: "Photo Continuation",
15
+ description: "Photo-only continuation page.",
16
+ blank: false,
17
+ variant: "photos",
18
+ source: "builtin",
19
+ },
20
+ {
21
+ id: "repex:blank",
22
+ name: "Blank Canvas",
23
+ description: "Blank white page.",
24
+ blank: true,
25
+ variant: "full",
26
+ source: "builtin",
27
+ },
28
+ ];
29
+
30
+ export function mergePageTemplates(
31
+ customTemplates?: PageTemplateDefinition[],
32
+ ): PageTemplateDefinition[] {
33
+ const byId = new Map<string, PageTemplateDefinition>();
34
+ BUILTIN_PAGE_TEMPLATES.forEach((template) => {
35
+ byId.set(template.id, template);
36
+ });
37
+ (customTemplates ?? []).forEach((template) => {
38
+ if (!template?.id) return;
39
+ byId.set(template.id, { ...template, source: "custom" });
40
+ });
41
+ return Array.from(byId.values());
42
+ }
43
+
44
+ export function inferPageTemplateId(page?: Page | null): string {
45
+ if (page?.page_template) return page.page_template;
46
+ if (page?.blank) return "repex:blank";
47
+ if (page?.variant === "photos") return "repex:photos";
48
+ return "repex:standard";
49
+ }
50
+
51
+ export function resolvePageTemplate(
52
+ page: Page | null | undefined,
53
+ templates: PageTemplateDefinition[],
54
+ ): PageTemplateDefinition {
55
+ const id = inferPageTemplateId(page);
56
+ return (
57
+ templates.find((template) => template.id === id) ??
58
+ BUILTIN_PAGE_TEMPLATES[0]
59
+ );
60
+ }
61
+
62
+ export function applyPageTemplateToPage(
63
+ page: Page,
64
+ template: PageTemplateDefinition,
65
+ ): Page {
66
+ const next: Page = {
67
+ ...page,
68
+ page_template: template.id,
69
+ blank: Boolean(template.blank),
70
+ variant: template.variant ?? "full",
71
+ };
72
+ if (template.photo_layout) {
73
+ next.photo_layout = template.photo_layout;
74
+ }
75
+ return next;
76
+ }
77
+
78
+ export function createCustomTemplateId() {
79
+ return `tpl_${crypto.randomUUID().replace(/-/g, "")}`;
80
+ }
frontend/src/lib/sections.ts CHANGED
@@ -9,7 +9,8 @@ export type FlatPage = {
9
  flatIndex: number;
10
  };
11
 
12
- const MAX_PHOTOS_PER_PAGE = 2;
 
13
 
14
  function cloneTemplate(template?: Page["template"]): Page["template"] {
15
  if (!template) return undefined;
@@ -45,36 +46,42 @@ function splitPagePhotos(page: Page): Page[] {
45
  return [normalized];
46
  }
47
 
48
- const chunks: string[][] = [];
49
- for (let i = 0; i < photoIds.length; i += MAX_PHOTOS_PER_PAGE) {
50
- chunks.push(photoIds.slice(i, i + MAX_PHOTOS_PER_PAGE));
51
- }
52
-
53
- if (chunks.length <= 1) {
54
- return [
55
- {
56
- ...normalized,
57
- photo_ids: chunks[0] ?? [],
58
- variant: normalized.variant,
59
- },
60
- ];
61
- }
62
-
63
  if (normalized.variant === "photos") {
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  return chunks.map((chunk, idx) => {
65
- if (idx === 0) {
66
- return { ...normalized, photo_ids: chunk, variant: "photos" };
67
- }
68
  return buildPhotoContinuation(normalized, chunk);
69
  });
70
  }
71
 
 
 
 
 
 
 
 
72
  const basePage: Page = {
73
  ...normalized,
74
- photo_ids: chunks[0],
75
  variant: normalized.variant ?? "full",
76
  };
77
- const extraPages = chunks.slice(1).map((chunk) =>
 
 
 
78
  buildPhotoContinuation(normalized, chunk),
79
  );
80
  return [basePage, ...extraPages];
 
9
  flatIndex: number;
10
  };
11
 
12
+ const MAX_PHOTOS_PRIMARY_PAGE = 2;
13
+ const MAX_PHOTOS_CONTINUATION_PAGE = 6;
14
 
15
  function cloneTemplate(template?: Page["template"]): Page["template"] {
16
  if (!template) return undefined;
 
46
  return [normalized];
47
  }
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  if (normalized.variant === "photos") {
50
+ const chunks: string[][] = [];
51
+ for (let i = 0; i < photoIds.length; i += MAX_PHOTOS_CONTINUATION_PAGE) {
52
+ chunks.push(photoIds.slice(i, i + MAX_PHOTOS_CONTINUATION_PAGE));
53
+ }
54
+ if (chunks.length <= 1) {
55
+ return [
56
+ {
57
+ ...normalized,
58
+ photo_ids: chunks[0] ?? [],
59
+ variant: "photos",
60
+ },
61
+ ];
62
+ }
63
  return chunks.map((chunk, idx) => {
64
+ if (idx === 0) return { ...normalized, photo_ids: chunk, variant: "photos" };
 
 
65
  return buildPhotoContinuation(normalized, chunk);
66
  });
67
  }
68
 
69
+ const baseChunk = photoIds.slice(0, MAX_PHOTOS_PRIMARY_PAGE);
70
+ const extraIds = photoIds.slice(MAX_PHOTOS_PRIMARY_PAGE);
71
+ const continuationChunks: string[][] = [];
72
+ for (let i = 0; i < extraIds.length; i += MAX_PHOTOS_CONTINUATION_PAGE) {
73
+ continuationChunks.push(extraIds.slice(i, i + MAX_PHOTOS_CONTINUATION_PAGE));
74
+ }
75
+
76
  const basePage: Page = {
77
  ...normalized,
78
+ photo_ids: baseChunk,
79
  variant: normalized.variant ?? "full",
80
  };
81
+ if (!continuationChunks.length) {
82
+ return [basePage];
83
+ }
84
+ const extraPages = continuationChunks.map((chunk) =>
85
  buildPhotoContinuation(normalized, chunk),
86
  );
87
  return [basePage, ...extraPages];
frontend/src/lib/version.ts CHANGED
@@ -1 +1 @@
1
- export const APP_VERSION = "V0.1.3";
 
1
+ export const APP_VERSION = "V0.1.4";
frontend/src/pages/EditLayoutsPage.tsx CHANGED
@@ -15,10 +15,19 @@ import {
15
  } from "react-feather";
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";
23
  import { PageHeader } from "../components/PageHeader";
24
  import { PageShell } from "../components/PageShell";
@@ -38,6 +47,12 @@ export default function EditLayoutsPage() {
38
  const [saveState, setSaveState] = useState<
39
  "saved" | "saving" | "pending" | "error"
40
  >("saved");
 
 
 
 
 
 
41
  const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>(
42
  {},
43
  );
@@ -57,11 +72,21 @@ export default function EditLayoutsPage() {
57
  try {
58
  const data = await request<Session>(`/sessions/${sessionId}`);
59
  setSession(data);
 
60
  const sectionResp = await request<{ sections: JobsheetSection[] }>(
61
  `/sessions/${sessionId}/sections`,
62
  );
63
  const normalized = ensureSections(sectionResp.sections);
64
  setSections(normalized);
 
 
 
 
 
 
 
 
 
65
  lastSavedRef.current = JSON.stringify(normalized);
66
  pendingSaveRef.current = lastSavedRef.current;
67
  setSaveState("saved");
@@ -86,26 +111,19 @@ export default function EditLayoutsPage() {
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) =>
@@ -151,6 +169,17 @@ export default function EditLayoutsPage() {
151
  }, 800);
152
  }, [sections, sessionId, isSaving]);
153
 
 
 
 
 
 
 
 
 
 
 
 
154
  async function saveLayout(
155
  next: JobsheetSection[],
156
  nextSelectedIds?: string[],
@@ -196,6 +225,7 @@ export default function EditLayoutsPage() {
196
  setSaveState("saved");
197
  if (updatedSession) {
198
  setSession(updatedSession);
 
199
  }
200
  if (!silent) {
201
  setStatus("Layout saved.");
@@ -227,12 +257,13 @@ export default function EditLayoutsPage() {
227
  }
228
 
229
  async function handleAddSection() {
 
230
  const next = [
231
  ...sections,
232
  {
233
  id: crypto.randomUUID(),
234
  title: `Section ${sections.length + 1}`,
235
- pages: [{ items: [] }],
236
  },
237
  ];
238
  await saveLayout(next);
@@ -254,11 +285,21 @@ export default function EditLayoutsPage() {
254
  }
255
 
256
  async function handleAddPage(sectionIndex: number) {
 
 
 
257
  const next = sections.map((section, idx) => {
258
  if (idx !== sectionIndex) return section;
 
 
 
 
 
 
 
259
  return {
260
  ...section,
261
- pages: [...(section.pages ?? []), { items: [], blank: true }],
262
  };
263
  });
264
  if (session) {
@@ -271,6 +312,27 @@ export default function EditLayoutsPage() {
271
  await saveLayout(next);
272
  }
273
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  async function handleRemovePage(sectionIndex: number, pageIndex: number) {
275
  const next = sections.map((section, idx) => {
276
  if (idx !== sectionIndex) return section;
@@ -308,22 +370,6 @@ export default function EditLayoutsPage() {
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);
@@ -455,6 +501,17 @@ export default function EditLayoutsPage() {
455
  Edit Page Layouts
456
  </span>
457
 
 
 
 
 
 
 
 
 
 
 
 
458
  <Link
459
  to={`/export${sessionQuery}`}
460
  onClick={(event) => {
@@ -512,53 +569,6 @@ export default function EditLayoutsPage() {
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;
@@ -613,6 +623,23 @@ export default function EditLayoutsPage() {
613
  </>
614
  )}
615
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
616
  <button
617
  type="button"
618
  onClick={() => handleAddPage(sectionIndex)}
@@ -622,16 +649,29 @@ export default function EditLayoutsPage() {
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">
@@ -672,6 +712,35 @@ export default function EditLayoutsPage() {
672
  </div>
673
  </div>
674
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
675
  <div
676
  className="mx-auto rounded-lg border border-gray-200 bg-white"
677
  style={{ width: previewWidth }}
@@ -691,29 +760,9 @@ export default function EditLayoutsPage() {
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">
 
15
  } from "react-feather";
16
 
17
  import { putJson, request } from "../lib/api";
18
+ import {
19
+ applyPageTemplateToPage,
20
+ inferPageTemplateId,
21
+ mergePageTemplates,
22
+ } from "../lib/pageTemplates";
23
  import { BASE_W } from "../lib/report";
24
  import { ensureSections, flattenSections, replacePage } from "../lib/sections";
25
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
26
+ import type {
27
+ JobsheetSection,
28
+ PageTemplateDefinition,
29
+ Session,
30
+ } from "../types/session";
31
  import { PageFooter } from "../components/PageFooter";
32
  import { PageHeader } from "../components/PageHeader";
33
  import { PageShell } from "../components/PageShell";
 
47
  const [saveState, setSaveState] = useState<
48
  "saved" | "saving" | "pending" | "error"
49
  >("saved");
50
+ const [pageTemplates, setPageTemplates] = useState<PageTemplateDefinition[]>(
51
+ mergePageTemplates(undefined),
52
+ );
53
+ const [newPageTemplateBySection, setNewPageTemplateBySection] = useState<
54
+ Record<string, string>
55
+ >({});
56
  const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>(
57
  {},
58
  );
 
72
  try {
73
  const data = await request<Session>(`/sessions/${sessionId}`);
74
  setSession(data);
75
+ setPageTemplates(mergePageTemplates(data.page_templates));
76
  const sectionResp = await request<{ sections: JobsheetSection[] }>(
77
  `/sessions/${sessionId}/sections`,
78
  );
79
  const normalized = ensureSections(sectionResp.sections);
80
  setSections(normalized);
81
+ setNewPageTemplateBySection((prev) => {
82
+ const next = { ...prev };
83
+ normalized.forEach((section) => {
84
+ if (!next[section.id]) {
85
+ next[section.id] = "repex:blank";
86
+ }
87
+ });
88
+ return next;
89
+ });
90
  lastSavedRef.current = JSON.stringify(normalized);
91
  pendingSaveRef.current = lastSavedRef.current;
92
  setSaveState("saved");
 
111
  const previewWidth = 220;
112
  const previewScale = previewWidth / BASE_W;
113
 
114
+ function findTemplate(templateId?: string) {
115
+ const fallback = mergePageTemplates(undefined)[0];
116
+ const selectedId = (templateId || "").trim();
117
+ if (selectedId) {
118
+ const match = pageTemplates.find((template) => template.id === selectedId);
119
+ if (match) return match;
120
+ }
121
+ return (
122
+ pageTemplates.find((template) => template.id === "repex:standard") ??
123
+ pageTemplates[0] ??
124
+ fallback
125
+ );
126
+ }
 
 
 
 
 
 
 
127
 
128
  function hasExplicitPhotos(source: JobsheetSection[]) {
129
  return source.some((section) =>
 
169
  }, 800);
170
  }, [sections, sessionId, isSaving]);
171
 
172
+ useEffect(() => {
173
+ if (!sections.length) return;
174
+ setNewPageTemplateBySection((prev) => {
175
+ const next: Record<string, string> = {};
176
+ sections.forEach((section) => {
177
+ next[section.id] = prev[section.id] ?? "repex:blank";
178
+ });
179
+ return next;
180
+ });
181
+ }, [sections]);
182
+
183
  async function saveLayout(
184
  next: JobsheetSection[],
185
  nextSelectedIds?: string[],
 
225
  setSaveState("saved");
226
  if (updatedSession) {
227
  setSession(updatedSession);
228
+ setPageTemplates(mergePageTemplates(updatedSession.page_templates));
229
  }
230
  if (!silent) {
231
  setStatus("Layout saved.");
 
257
  }
258
 
259
  async function handleAddSection() {
260
+ const baseTemplate = findTemplate("repex:standard");
261
  const next = [
262
  ...sections,
263
  {
264
  id: crypto.randomUUID(),
265
  title: `Section ${sections.length + 1}`,
266
+ pages: [applyPageTemplateToPage({ items: [] }, baseTemplate)],
267
  },
268
  ];
269
  await saveLayout(next);
 
285
  }
286
 
287
  async function handleAddPage(sectionIndex: number) {
288
+ const section = sections[sectionIndex];
289
+ const templateId = section ? newPageTemplateBySection[section.id] : undefined;
290
+ const selectedTemplate = findTemplate(templateId);
291
  const next = sections.map((section, idx) => {
292
  if (idx !== sectionIndex) return section;
293
+ const baseTemplate = {
294
+ ...(section.pages?.[section.pages.length - 1]?.template ?? {}),
295
+ };
296
+ const nextPage = applyPageTemplateToPage(
297
+ { items: [], template: baseTemplate },
298
+ selectedTemplate,
299
+ );
300
  return {
301
  ...section,
302
+ pages: [...(section.pages ?? []), nextPage],
303
  };
304
  });
305
  if (session) {
 
312
  await saveLayout(next);
313
  }
314
 
315
+ function handleChangePageTemplate(
316
+ sectionIndex: number,
317
+ pageIndex: number,
318
+ templateId: string,
319
+ ) {
320
+ const selectedTemplate = findTemplate(templateId);
321
+ if (!selectedTemplate) return;
322
+ setSections((prev) => {
323
+ const section = prev[sectionIndex];
324
+ const page = section?.pages?.[pageIndex];
325
+ if (!section || !page) return prev;
326
+ const nextPage = applyPageTemplateToPage(
327
+ { ...page, template: { ...(page.template ?? {}) } },
328
+ selectedTemplate,
329
+ );
330
+ return replacePage(prev, sectionIndex, pageIndex, nextPage);
331
+ });
332
+ const label = selectedTemplate.name || selectedTemplate.id;
333
+ setStatus(`Applied "${label}" template to page ${pageIndex + 1}.`);
334
+ }
335
+
336
  async function handleRemovePage(sectionIndex: number, pageIndex: number) {
337
  const next = sections.map((section, idx) => {
338
  if (idx !== sectionIndex) return section;
 
370
  await saveLayout(next);
371
  }
372
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  async function saveAndNavigate(target: string) {
374
  if (!sessionId) {
375
  navigate(target);
 
501
  Edit Page Layouts
502
  </span>
503
 
504
+ <Link
505
+ to={`/edit-layouts/templates${sessionQuery}`}
506
+ onClick={(event) => {
507
+ event.preventDefault();
508
+ void saveAndNavigate(`/edit-layouts/templates${sessionQuery}`);
509
+ }}
510
+ 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"
511
+ >
512
+ Templates
513
+ </Link>
514
+
515
  <Link
516
  to={`/export${sessionQuery}`}
517
  onClick={(event) => {
 
569
  ) : null}
570
  </section>
571
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
  <section className="space-y-6">
573
  {sections.map((section, sectionIndex) => {
574
  const isCollapsed = collapsedSections[section.id] ?? false;
 
623
  </>
624
  )}
625
  </button>
626
+ <select
627
+ value={newPageTemplateBySection[section.id] ?? "repex:blank"}
628
+ onChange={(event) =>
629
+ setNewPageTemplateBySection((prev) => ({
630
+ ...prev,
631
+ [section.id]: event.target.value,
632
+ }))
633
+ }
634
+ className="rounded-lg border border-gray-200 bg-white px-2 py-2 text-xs font-semibold text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-200"
635
+ title="Template used when adding a page to this section"
636
+ >
637
+ {pageTemplates.map((template) => (
638
+ <option key={`section-${section.id}-template-${template.id}`} value={template.id}>
639
+ {template.name}
640
+ </option>
641
+ ))}
642
+ </select>
643
  <button
644
  type="button"
645
  onClick={() => handleAddPage(sectionIndex)}
 
649
  <Plus className="h-4 w-4" />
650
  Add page
651
  </button>
652
+ <Link
653
+ to={`/edit-layouts/templates${sessionQuery}`}
654
+ onClick={(event) => {
655
+ event.preventDefault();
656
+ void saveAndNavigate(`/edit-layouts/templates${sessionQuery}`);
657
+ }}
658
+ 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"
659
+ >
660
+ Manage templates
661
+ </Link>
662
  </div>
663
  </div>
664
 
665
  {!isCollapsed ? (
666
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
667
+ {(section.pages ?? []).map((page, pageIndex) => {
668
+ const currentTemplateId = inferPageTemplateId(page);
669
+ const currentTemplate = findTemplate(currentTemplateId);
670
+ return (
671
+ <div
672
+ key={`${section.id}-page-${pageIndex}`}
673
+ className="rounded-lg border border-gray-200 bg-white p-3"
674
+ >
675
  <div className="flex items-center justify-between mb-2">
676
  <div>
677
  <div className="text-sm font-semibold text-gray-900">
 
712
  </div>
713
  </div>
714
 
715
+ <div className="mb-2 rounded-md border border-gray-200 bg-gray-50 p-2">
716
+ <label className="block text-[11px] font-semibold uppercase tracking-wide text-gray-500">
717
+ Page Template
718
+ </label>
719
+ <select
720
+ value={currentTemplateId}
721
+ onChange={(event) =>
722
+ handleChangePageTemplate(
723
+ sectionIndex,
724
+ pageIndex,
725
+ event.target.value,
726
+ )
727
+ }
728
+ className="mt-1 w-full rounded-md border border-gray-200 bg-white px-2 py-1 text-xs font-semibold text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-200"
729
+ >
730
+ {pageTemplates.map((template) => (
731
+ <option
732
+ key={`page-${section.id}-${pageIndex}-${template.id}`}
733
+ value={template.id}
734
+ >
735
+ {template.name}
736
+ </option>
737
+ ))}
738
+ </select>
739
+ <div className="mt-1 text-[11px] text-gray-500">
740
+ {currentTemplate.description || "No description."}
741
+ </div>
742
+ </div>
743
+
744
  <div
745
  className="mx-auto rounded-lg border border-gray-200 bg-white"
746
  style={{ width: previewWidth }}
 
760
  adaptive
761
  />
762
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
763
  </div>
764
+ );
765
+ })}
766
  </div>
767
  ) : (
768
  <div className="rounded-md border border-dashed border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500">
frontend/src/pages/ImagePlacementPage.tsx CHANGED
@@ -18,7 +18,7 @@ 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
 
@@ -32,15 +32,39 @@ export default function ImagePlacementPage() {
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) {
@@ -94,23 +118,6 @@ export default function ImagePlacementPage() {
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
@@ -161,6 +168,25 @@ export default function ImagePlacementPage() {
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 =
@@ -226,6 +252,34 @@ export default function ImagePlacementPage() {
226
  void persistSections(nextSections);
227
  }
228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  function addPhotoToPage(photoId: string) {
230
  if (!photoId || !pageEntry) return;
231
  const ids = [...pagePhotoIds];
@@ -296,6 +350,40 @@ export default function ImagePlacementPage() {
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">
@@ -454,6 +542,72 @@ export default function ImagePlacementPage() {
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>
@@ -527,63 +681,121 @@ export default function ImagePlacementPage() {
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>
 
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, TemplateFields } from "../types/session";
22
  import { ReportPageCanvas } from "../components/ReportPageCanvas";
23
  import { InfoMenu } from "../components/InfoMenu";
24
 
 
32
  const [scale, setScale] = useState(1);
33
  const [error, setError] = useState("");
34
  const [photoSelection, setPhotoSelection] = useState("");
35
+ const [logoSelection, setLogoSelection] = useState("");
36
  const [isSaving, setIsSaving] = useState(false);
37
  const [saveState, setSaveState] = useState<"saved" | "saving" | "error">(
38
  "saved",
39
  );
40
  const [status, setStatus] = useState("");
41
  const [isUploading, setIsUploading] = useState(false);
42
+ const [isLogoUploading, setIsLogoUploading] = useState(false);
43
 
44
  const stageRef = useRef<HTMLDivElement | null>(null);
45
  const uploadInputRef = useRef<HTMLInputElement | null>(null);
46
+ const logoInputRef = useRef<HTMLInputElement | null>(null);
47
+
48
+ const PHOTO_LAYOUT_PRESETS = [
49
+ {
50
+ id: "auto" as const,
51
+ label: "Auto fit",
52
+ description: "Automatically balance photos across rows.",
53
+ preview: "auto" as const,
54
+ },
55
+ {
56
+ id: "two-column" as const,
57
+ label: "Two column",
58
+ description: "Force two columns for photo placement.",
59
+ preview: "two-column" as const,
60
+ },
61
+ {
62
+ id: "stacked" as const,
63
+ label: "Stacked",
64
+ description: "Stack photos in one column.",
65
+ preview: "stacked" as const,
66
+ },
67
+ ];
68
 
69
  useEffect(() => {
70
  if (!sessionId) {
 
118
  setPageIndex((idx) => Math.min(Math.max(0, idx), totalPages - 1));
119
  }, [totalPages]);
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  const page = flatPages[pageIndex]?.page ?? null;
122
  const pageEntry = flatPages[pageIndex] ?? null;
123
  const sectionLabel = flatPages[pageIndex]?.sectionTitle
 
168
  if (!all.length) return [];
169
  return all.filter((photo) => !linkedPhotoIds.has(photo.id));
170
  }, [linkedPhotoIds, session?.uploads?.photos]);
171
+ const logoOptions = session?.uploads?.photos ?? [];
172
+
173
+ useEffect(() => {
174
+ setPhotoSelection("");
175
+ setLogoSelection(template?.company_logo ?? "");
176
+ }, [pageIndex, template?.company_logo]);
177
+
178
+ useEffect(() => {
179
+ const handler = (event: KeyboardEvent) => {
180
+ if (event.key === "ArrowRight") {
181
+ setPageIndex((idx) => Math.min(totalPages - 1, idx + 1));
182
+ }
183
+ if (event.key === "ArrowLeft") {
184
+ setPageIndex((idx) => Math.max(0, idx - 1));
185
+ }
186
+ };
187
+ window.addEventListener("keydown", handler);
188
+ return () => window.removeEventListener("keydown", handler);
189
+ }, [totalPages]);
190
 
191
  const saveIndicator = useMemo(() => {
192
  const base =
 
252
  void persistSections(nextSections);
253
  }
254
 
255
+ function updatePageTemplate(nextTemplate: Partial<TemplateFields>) {
256
+ if (!pageEntry) return;
257
+ const mergedTemplate = { ...(pageEntry.page.template ?? {}), ...nextTemplate };
258
+ const nextPage = { ...pageEntry.page, template: mergedTemplate };
259
+ const nextSections = replacePage(
260
+ sections,
261
+ pageEntry.sectionIndex,
262
+ pageEntry.pageIndex,
263
+ nextPage,
264
+ );
265
+ setSections(nextSections);
266
+ void persistSections(nextSections);
267
+ }
268
+
269
+ function updatePageLayout(layout: "auto" | "two-column" | "stacked") {
270
+ if (!pageEntry) return;
271
+ const nextPage = { ...pageEntry.page, photo_layout: layout };
272
+ const nextSections = replacePage(
273
+ sections,
274
+ pageEntry.sectionIndex,
275
+ pageEntry.pageIndex,
276
+ nextPage,
277
+ );
278
+ setSections(nextSections);
279
+ setStatus(`Applied ${layout.replace("-", " ")} layout.`);
280
+ void persistSections(nextSections);
281
+ }
282
+
283
  function addPhotoToPage(photoId: string) {
284
  if (!photoId || !pageEntry) return;
285
  const ids = [...pagePhotoIds];
 
350
  }
351
  }
352
 
353
+ async function uploadLogo(file: File) {
354
+ if (!sessionId) return;
355
+ setIsLogoUploading(true);
356
+ setStatus(`Uploading logo ${file.name}...`);
357
+ const existing = new Set(
358
+ (session?.uploads?.photos ?? []).map((photo) => photo.id),
359
+ );
360
+ try {
361
+ const form = new FormData();
362
+ form.append("file", file);
363
+ const updated = await postForm<Session>(
364
+ `/sessions/${sessionId}/uploads`,
365
+ form,
366
+ );
367
+ setSession(updated);
368
+ const newPhoto =
369
+ (updated.uploads?.photos ?? []).find((photo) => !existing.has(photo.id)) ??
370
+ (updated.uploads?.photos ?? []).find((photo) => photo.name === file.name);
371
+ if (newPhoto?.name) {
372
+ setLogoSelection(newPhoto.name);
373
+ updatePageTemplate({ company_logo: newPhoto.name });
374
+ setStatus(`Company logo set to ${newPhoto.name}.`);
375
+ } else {
376
+ setStatus("Uploaded logo. Select it from the list to apply.");
377
+ }
378
+ } catch (err) {
379
+ const message =
380
+ err instanceof Error ? err.message : "Failed to upload logo.";
381
+ setStatus(message);
382
+ } finally {
383
+ setIsLogoUploading(false);
384
+ }
385
+ }
386
+
387
  return (
388
  <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">
389
  <header className="mb-8 border-b border-gray-200 pb-4">
 
542
 
543
  {pageEntry ? (
544
  <section className="mt-6 no-print" aria-label="Page images">
545
+ <div className="mb-4 rounded-lg border border-gray-200 bg-white p-3">
546
+ <div className="text-xs font-semibold text-gray-600 uppercase">
547
+ Photo layout presets
548
+ </div>
549
+ <p className="mt-1 text-[11px] text-gray-500">
550
+ This controls how images are arranged for the current page.
551
+ </p>
552
+ <div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-3">
553
+ {PHOTO_LAYOUT_PRESETS.map((preset) => (
554
+ <button
555
+ key={`photo-layout-${preset.id}`}
556
+ type="button"
557
+ onClick={() => updatePageLayout(preset.id)}
558
+ draggable
559
+ onDragStart={(event) => {
560
+ event.dataTransfer.setData("text/plain", preset.id);
561
+ event.dataTransfer.effectAllowed = "copy";
562
+ }}
563
+ className={[
564
+ "rounded-lg border p-3 text-left transition",
565
+ (page?.photo_layout ?? "auto") === preset.id
566
+ ? "border-blue-300 bg-blue-50"
567
+ : "border-gray-200 bg-gray-50 hover:bg-gray-100",
568
+ ].join(" ")}
569
+ >
570
+ <div className="text-sm font-semibold text-gray-900">
571
+ {preset.label}
572
+ </div>
573
+ <div className="text-xs text-gray-600">{preset.description}</div>
574
+ <div className="mt-2">
575
+ {preset.preview === "stacked" ? (
576
+ <div className="grid grid-cols-1 gap-1">
577
+ <div className="h-6 rounded bg-gray-200" />
578
+ <div className="h-6 rounded bg-gray-200" />
579
+ </div>
580
+ ) : (
581
+ <div className="grid grid-cols-2 gap-1">
582
+ <div className="h-6 rounded bg-gray-200" />
583
+ <div className="h-6 rounded bg-gray-200" />
584
+ </div>
585
+ )}
586
+ </div>
587
+ <div className="mt-2 text-[11px] text-gray-500">
588
+ Click or drag
589
+ </div>
590
+ </button>
591
+ ))}
592
+ </div>
593
+ <div
594
+ className="mt-3 rounded-md border border-dashed border-gray-200 bg-gray-50 px-3 py-2 text-[11px] text-gray-600"
595
+ onDragOver={(event) => {
596
+ event.preventDefault();
597
+ event.dataTransfer.dropEffect = "copy";
598
+ }}
599
+ onDrop={(event) => {
600
+ event.preventDefault();
601
+ const layout = event.dataTransfer.getData("text/plain");
602
+ if (layout === "auto" || layout === "two-column" || layout === "stacked") {
603
+ updatePageLayout(layout);
604
+ }
605
+ }}
606
+ >
607
+ Drop preset here to apply to current page - Current: {page?.photo_layout ?? "auto"}
608
+ </div>
609
+ </div>
610
+
611
  <div className="flex flex-wrap items-center justify-between gap-3">
612
  <div>
613
  <h3 className="text-lg font-semibold text-gray-900">Page Images</h3>
 
681
  </span>
682
  </div>
683
  <p className="mt-2 text-[11px] text-gray-500">
684
+ First template pages show up to 2 images. Continuation pages can
685
+ include more images automatically.
686
  </p>
687
  </div>
688
 
689
+ <div className="space-y-4">
690
+ <div className="rounded-lg border border-gray-200 bg-white p-3">
691
+ <div className="text-xs font-semibold text-gray-600 uppercase">
692
+ Add image
693
+ </div>
694
+ <div className="mt-2 flex flex-col gap-2">
695
+ <select
696
+ value={photoSelection}
697
+ onChange={(event) => setPhotoSelection(event.target.value)}
698
+ 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"
699
+ >
700
+ <option value="">Select an unlinked image</option>
701
+ {availablePhotos.map((photo) => (
702
+ <option key={`viewer-photo-${photo.id}`} value={photo.id}>
703
+ {photo.name}
704
+ </option>
705
+ ))}
706
+ </select>
707
+ <button
708
+ type="button"
709
+ onClick={() => {
710
+ addPhotoToPage(photoSelection);
711
+ setPhotoSelection("");
712
+ }}
713
+ disabled={!photoSelection || isSaving}
714
+ 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"
715
+ >
716
+ Add selected image
717
+ </button>
718
+ <button
719
+ type="button"
720
+ onClick={() => uploadInputRef.current?.click()}
721
+ disabled={isUploading || isSaving}
722
+ 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"
723
+ >
724
+ Upload new image
725
+ </button>
726
+ <input
727
+ ref={uploadInputRef}
728
+ type="file"
729
+ accept="image/*"
730
+ className="hidden"
731
+ onChange={(event) => {
732
+ const file = event.target.files?.[0];
733
+ if (file) void uploadPhoto(file);
734
+ event.target.value = "";
735
+ }}
736
+ />
737
+ {availablePhotos.length === 0 ? (
738
+ <p className="text-[11px] text-gray-500">
739
+ All uploaded images are already linked.
740
+ </p>
741
+ ) : null}
742
+ </div>
743
  </div>
744
+
745
+ <div className="rounded-lg border border-gray-200 bg-white p-3">
746
+ <div className="text-xs font-semibold text-gray-600 uppercase">
747
+ Company logo (top right)
748
+ </div>
749
+ <p className="mt-1 text-[11px] text-gray-500">
750
+ Choose an uploaded image or upload a new logo for this page.
751
+ </p>
752
+ <div className="mt-2 flex flex-col gap-2">
753
+ <select
754
+ value={logoSelection}
755
+ onChange={(event) => {
756
+ const nextValue = event.target.value;
757
+ setLogoSelection(nextValue);
758
+ setStatus("Updating company logo...");
759
+ updatePageTemplate({ company_logo: nextValue });
760
+ }}
761
+ 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"
762
+ >
763
+ <option value="">No logo (show placeholder)</option>
764
+ {logoOptions.map((photo) => {
765
+ const value = photo.name || photo.id || "";
766
+ if (!value) return null;
767
+ return (
768
+ <option key={`logo-photo-${photo.id}`} value={value}>
769
+ {photo.name || photo.id}
770
+ </option>
771
+ );
772
+ })}
773
+ </select>
774
+ <button
775
+ type="button"
776
+ onClick={() => logoInputRef.current?.click()}
777
+ disabled={isLogoUploading || isSaving}
778
+ 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"
779
+ >
780
+ Upload new logo
781
+ </button>
782
+ <input
783
+ ref={logoInputRef}
784
+ type="file"
785
+ accept="image/*"
786
+ className="hidden"
787
+ onChange={(event) => {
788
+ const file = event.target.files?.[0];
789
+ if (file) void uploadLogo(file);
790
+ event.target.value = "";
791
+ }}
792
+ />
793
+ {!logoOptions.length ? (
794
+ <p className="text-[11px] text-gray-500">
795
+ Upload a logo to see it here.
796
+ </p>
797
+ ) : null}
798
+ </div>
799
  </div>
800
  </div>
801
  </div>
frontend/src/pages/LayoutTemplatesPage.tsx ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo, useState, useEffect } from "react";
2
+ import { Link, useNavigate, useSearchParams } from "react-router-dom";
3
+ import {
4
+ ArrowLeft,
5
+ Download,
6
+ Edit3,
7
+ Grid,
8
+ Image,
9
+ Layout,
10
+ Plus,
11
+ Settings,
12
+ Table,
13
+ Trash2,
14
+ } from "react-feather";
15
+
16
+ import { putJson, request } from "../lib/api";
17
+ import {
18
+ BUILTIN_PAGE_TEMPLATES,
19
+ createCustomTemplateId,
20
+ mergePageTemplates,
21
+ } from "../lib/pageTemplates";
22
+ import { ensureSections, flattenSections } from "../lib/sections";
23
+ import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
24
+ import type { JobsheetSection, PageTemplateDefinition, Session } from "../types/session";
25
+ import { InfoMenu } from "../components/InfoMenu";
26
+ import { PageFooter } from "../components/PageFooter";
27
+ import { PageHeader } from "../components/PageHeader";
28
+ import { PageShell } from "../components/PageShell";
29
+
30
+ type Variant = "full" | "photos";
31
+ type PhotoLayout = "auto" | "two-column" | "stacked";
32
+
33
+ const DEFAULT_VARIANT: Variant = "full";
34
+ const DEFAULT_PHOTO_LAYOUT: PhotoLayout = "auto";
35
+
36
+ export default function LayoutTemplatesPage() {
37
+ const [searchParams] = useSearchParams();
38
+ const sessionId = getSessionId(searchParams.toString());
39
+ const sessionQuery = buildSessionQuery(sessionId);
40
+ const navigate = useNavigate();
41
+
42
+ const [session, setSession] = useState<Session | null>(null);
43
+ const [sections, setSections] = useState<JobsheetSection[]>([]);
44
+ const [templates, setTemplates] = useState<PageTemplateDefinition[]>(
45
+ BUILTIN_PAGE_TEMPLATES,
46
+ );
47
+ const [customTemplates, setCustomTemplates] = useState<PageTemplateDefinition[]>(
48
+ [],
49
+ );
50
+
51
+ const [newName, setNewName] = useState("");
52
+ const [newDescription, setNewDescription] = useState("");
53
+ const [newVariant, setNewVariant] = useState<Variant>(DEFAULT_VARIANT);
54
+ const [newPhotoLayout, setNewPhotoLayout] =
55
+ useState<PhotoLayout>(DEFAULT_PHOTO_LAYOUT);
56
+ const [newBlank, setNewBlank] = useState(false);
57
+ const [status, setStatus] = useState("");
58
+ const [isSaving, setIsSaving] = useState(false);
59
+
60
+ useEffect(() => {
61
+ if (!sessionId) {
62
+ setStatus("No active session found.");
63
+ return;
64
+ }
65
+ setStoredSessionId(sessionId);
66
+ async function load() {
67
+ try {
68
+ const [data, sectionResp] = await Promise.all([
69
+ request<Session>(`/sessions/${sessionId}`),
70
+ request<{ sections: JobsheetSection[] }>(`/sessions/${sessionId}/sections`),
71
+ ]);
72
+ setSession(data);
73
+ setSections(ensureSections(sectionResp.sections));
74
+ const merged = mergePageTemplates(data.page_templates);
75
+ setTemplates(merged);
76
+ setCustomTemplates(merged.filter((template) => template.source === "custom"));
77
+ } catch (error) {
78
+ const message =
79
+ error instanceof Error ? error.message : "Failed to load templates.";
80
+ setStatus(message);
81
+ }
82
+ }
83
+ load();
84
+ }, [sessionId]);
85
+
86
+ const flatPages = useMemo(
87
+ () => flattenSections(ensureSections(sections)),
88
+ [sections],
89
+ );
90
+
91
+ const saveTemplates = async (nextTemplates: PageTemplateDefinition[]) => {
92
+ if (!sessionId) return;
93
+ setIsSaving(true);
94
+ setStatus("Saving templates...");
95
+ try {
96
+ const payload = nextTemplates.map((template) => ({
97
+ id: template.id,
98
+ name: template.name,
99
+ description: template.description ?? "",
100
+ blank: Boolean(template.blank),
101
+ variant: (template.variant ?? DEFAULT_VARIANT) as Variant,
102
+ photo_layout: (template.photo_layout ?? DEFAULT_PHOTO_LAYOUT) as PhotoLayout,
103
+ }));
104
+ const response = await putJson<{ page_templates: PageTemplateDefinition[] }>(
105
+ `/sessions/${sessionId}/page-templates`,
106
+ { page_templates: payload },
107
+ );
108
+ const merged = mergePageTemplates(response.page_templates);
109
+ setTemplates(merged);
110
+ setCustomTemplates(merged.filter((template) => template.source === "custom"));
111
+ setSession((prev) =>
112
+ prev
113
+ ? { ...prev, page_templates: response.page_templates ?? payload }
114
+ : prev,
115
+ );
116
+ setStatus("Templates saved.");
117
+ } catch (error) {
118
+ const message =
119
+ error instanceof Error ? error.message : "Failed to save templates.";
120
+ setStatus(message);
121
+ } finally {
122
+ setIsSaving(false);
123
+ }
124
+ };
125
+
126
+ const addTemplate = async () => {
127
+ const name = newName.trim();
128
+ if (!name) {
129
+ setStatus("Template name is required.");
130
+ return;
131
+ }
132
+ const nextTemplate: PageTemplateDefinition = {
133
+ id: createCustomTemplateId(),
134
+ name,
135
+ description: newDescription.trim(),
136
+ blank: newBlank,
137
+ variant: newVariant,
138
+ photo_layout: newPhotoLayout,
139
+ source: "custom",
140
+ };
141
+ await saveTemplates([...customTemplates, nextTemplate]);
142
+ setNewName("");
143
+ setNewDescription("");
144
+ setNewVariant(DEFAULT_VARIANT);
145
+ setNewPhotoLayout(DEFAULT_PHOTO_LAYOUT);
146
+ setNewBlank(false);
147
+ };
148
+
149
+ const updateCustomTemplate = (
150
+ templateId: string,
151
+ patch: Partial<PageTemplateDefinition>,
152
+ ) => {
153
+ setCustomTemplates((prev) =>
154
+ prev.map((template) =>
155
+ template.id === templateId ? { ...template, ...patch } : template,
156
+ ),
157
+ );
158
+ };
159
+
160
+ const removeCustomTemplate = async (templateId: string) => {
161
+ const next = customTemplates.filter((template) => template.id !== templateId);
162
+ await saveTemplates(next);
163
+ };
164
+
165
+ const persistCustomTemplateEdits = async () => {
166
+ await saveTemplates(customTemplates);
167
+ };
168
+
169
+ return (
170
+ <PageShell>
171
+ <PageHeader
172
+ title="RepEx - Report Express"
173
+ subtitle="Template Library"
174
+ right={
175
+ <div className="flex items-center gap-2">
176
+ <Link
177
+ to={`/edit-layouts${sessionQuery}`}
178
+ 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"
179
+ >
180
+ <ArrowLeft className="h-4 w-4" />
181
+ Back
182
+ </Link>
183
+ <InfoMenu sessionQuery={sessionQuery} />
184
+ </div>
185
+ }
186
+ />
187
+
188
+ <nav className="mb-6" aria-label="Report workflow navigation">
189
+ <div className="flex flex-wrap gap-2">
190
+ <Link
191
+ to={`/input-data${sessionQuery}`}
192
+ 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"
193
+ >
194
+ <Table className="h-4 w-4" />
195
+ Input Data
196
+ </Link>
197
+
198
+ <Link
199
+ to={`/report-viewer${sessionQuery}`}
200
+ 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"
201
+ >
202
+ <Layout className="h-4 w-4" />
203
+ Report Viewer
204
+ </Link>
205
+
206
+ <Link
207
+ to={`/image-placement${sessionQuery}`}
208
+ 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"
209
+ >
210
+ <Image className="h-4 w-4" />
211
+ Image Placement
212
+ </Link>
213
+
214
+ <Link
215
+ to={`/edit-report${sessionQuery}`}
216
+ 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"
217
+ >
218
+ <Edit3 className="h-4 w-4" />
219
+ Edit Report
220
+ </Link>
221
+
222
+ <Link
223
+ to={`/edit-layouts${sessionQuery}`}
224
+ 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"
225
+ >
226
+ <Grid className="h-4 w-4" />
227
+ Edit Page Layouts
228
+ </Link>
229
+
230
+ <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">
231
+ <Settings className="h-4 w-4" />
232
+ Template Library
233
+ </span>
234
+
235
+ <Link
236
+ to={`/export${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
+ <Download className="h-4 w-4" />
240
+ Export
241
+ </Link>
242
+ </div>
243
+ </nav>
244
+
245
+ <section className="mb-6 rounded-lg border border-gray-200 bg-white p-4">
246
+ <h2 className="text-lg font-semibold text-gray-900">Current Usage</h2>
247
+ <p className="text-sm text-gray-600">
248
+ {sections.length} sections and {flatPages.length} pages currently use page templates.
249
+ </p>
250
+ </section>
251
+
252
+ <section className="mb-6 rounded-lg border border-gray-200 bg-white p-4">
253
+ <h2 className="text-lg font-semibold text-gray-900">Create Custom Template</h2>
254
+ <p className="text-sm text-gray-600 mb-3">
255
+ Define a reusable page template, name it, and apply it when adding pages in
256
+ Edit Page Layouts.
257
+ </p>
258
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
259
+ <label className="text-sm text-gray-700">
260
+ Template Name
261
+ <input
262
+ type="text"
263
+ value={newName}
264
+ onChange={(event) => setNewName(event.target.value)}
265
+ className="mt-1 w-full rounded-md border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
266
+ placeholder="My Custom Template"
267
+ />
268
+ </label>
269
+ <label className="text-sm text-gray-700">
270
+ Variant
271
+ <select
272
+ value={newVariant}
273
+ onChange={(event) => setNewVariant(event.target.value as Variant)}
274
+ className="mt-1 w-full rounded-md border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
275
+ >
276
+ <option value="full">Full</option>
277
+ <option value="photos">Photos only</option>
278
+ </select>
279
+ </label>
280
+ <label className="text-sm text-gray-700">
281
+ Photo Layout
282
+ <select
283
+ value={newPhotoLayout}
284
+ onChange={(event) =>
285
+ setNewPhotoLayout(event.target.value as PhotoLayout)
286
+ }
287
+ className="mt-1 w-full rounded-md border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
288
+ >
289
+ <option value="auto">Auto</option>
290
+ <option value="two-column">Two column</option>
291
+ <option value="stacked">Stacked</option>
292
+ </select>
293
+ </label>
294
+ <label className="text-sm text-gray-700 md:col-span-2 lg:col-span-2">
295
+ Description
296
+ <input
297
+ type="text"
298
+ value={newDescription}
299
+ onChange={(event) => setNewDescription(event.target.value)}
300
+ className="mt-1 w-full rounded-md border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
301
+ placeholder="Optional notes for the template"
302
+ />
303
+ </label>
304
+ <label className="inline-flex items-center gap-2 self-end text-sm text-gray-700">
305
+ <input
306
+ type="checkbox"
307
+ checked={newBlank}
308
+ onChange={(event) => setNewBlank(event.target.checked)}
309
+ />
310
+ Blank page
311
+ </label>
312
+ </div>
313
+ <button
314
+ type="button"
315
+ onClick={() => {
316
+ void addTemplate();
317
+ }}
318
+ disabled={isSaving}
319
+ className="mt-4 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"
320
+ >
321
+ <Plus className="h-4 w-4" />
322
+ Create template
323
+ </button>
324
+ </section>
325
+
326
+ <section className="rounded-lg border border-gray-200 bg-white p-4">
327
+ <div className="flex items-center justify-between gap-3 mb-3">
328
+ <h2 className="text-lg font-semibold text-gray-900">Saved Templates</h2>
329
+ <button
330
+ type="button"
331
+ onClick={() => {
332
+ void persistCustomTemplateEdits();
333
+ }}
334
+ disabled={isSaving}
335
+ 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"
336
+ >
337
+ Save edits
338
+ </button>
339
+ </div>
340
+
341
+ <div className="space-y-3">
342
+ {templates.map((template) => {
343
+ const isCustom = template.source === "custom";
344
+ return (
345
+ <div
346
+ key={template.id}
347
+ className="rounded-lg border border-gray-200 bg-gray-50 p-3"
348
+ >
349
+ <div className="flex flex-wrap items-center justify-between gap-2">
350
+ <div className="text-xs font-semibold text-gray-500">{template.id}</div>
351
+ <span
352
+ className={[
353
+ "inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold",
354
+ isCustom
355
+ ? "bg-emerald-50 text-emerald-700 border border-emerald-200"
356
+ : "bg-gray-100 text-gray-600 border border-gray-200",
357
+ ].join(" ")}
358
+ >
359
+ {isCustom ? "Custom" : "Built-in"}
360
+ </span>
361
+ </div>
362
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-2 mt-2">
363
+ <label className="text-xs text-gray-600 lg:col-span-2">
364
+ Name
365
+ <input
366
+ type="text"
367
+ value={template.name}
368
+ disabled={!isCustom}
369
+ onChange={(event) =>
370
+ updateCustomTemplate(template.id, { name: event.target.value })
371
+ }
372
+ className="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-sm disabled:bg-gray-100 disabled:text-gray-500"
373
+ />
374
+ </label>
375
+ <label className="text-xs text-gray-600">
376
+ Variant
377
+ <select
378
+ value={template.variant ?? DEFAULT_VARIANT}
379
+ disabled={!isCustom}
380
+ onChange={(event) =>
381
+ updateCustomTemplate(template.id, {
382
+ variant: event.target.value as Variant,
383
+ })
384
+ }
385
+ className="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-sm disabled:bg-gray-100 disabled:text-gray-500"
386
+ >
387
+ <option value="full">Full</option>
388
+ <option value="photos">Photos only</option>
389
+ </select>
390
+ </label>
391
+ <label className="text-xs text-gray-600">
392
+ Photo Layout
393
+ <select
394
+ value={template.photo_layout ?? DEFAULT_PHOTO_LAYOUT}
395
+ disabled={!isCustom}
396
+ onChange={(event) =>
397
+ updateCustomTemplate(template.id, {
398
+ photo_layout: event.target.value as PhotoLayout,
399
+ })
400
+ }
401
+ className="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-sm disabled:bg-gray-100 disabled:text-gray-500"
402
+ >
403
+ <option value="auto">Auto</option>
404
+ <option value="two-column">Two column</option>
405
+ <option value="stacked">Stacked</option>
406
+ </select>
407
+ </label>
408
+ <label className="inline-flex items-center gap-2 text-xs text-gray-600 self-end">
409
+ <input
410
+ type="checkbox"
411
+ checked={Boolean(template.blank)}
412
+ disabled={!isCustom}
413
+ onChange={(event) =>
414
+ updateCustomTemplate(template.id, { blank: event.target.checked })
415
+ }
416
+ />
417
+ Blank page
418
+ </label>
419
+ </div>
420
+ <label className="text-xs text-gray-600 block mt-2">
421
+ Description
422
+ <input
423
+ type="text"
424
+ value={template.description ?? ""}
425
+ disabled={!isCustom}
426
+ onChange={(event) =>
427
+ updateCustomTemplate(template.id, {
428
+ description: event.target.value,
429
+ })
430
+ }
431
+ className="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-sm disabled:bg-gray-100 disabled:text-gray-500"
432
+ />
433
+ </label>
434
+ {isCustom ? (
435
+ <button
436
+ type="button"
437
+ onClick={() => {
438
+ void removeCustomTemplate(template.id);
439
+ }}
440
+ disabled={isSaving}
441
+ className="mt-2 inline-flex items-center gap-1 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-xs font-semibold text-red-700 hover:bg-red-100 disabled:opacity-50 disabled:cursor-not-allowed"
442
+ >
443
+ <Trash2 className="h-3.5 w-3.5" />
444
+ Delete template
445
+ </button>
446
+ ) : null}
447
+ </div>
448
+ );
449
+ })}
450
+ </div>
451
+ </section>
452
+
453
+ {status ? <p className="mt-4 text-sm text-gray-600">{status}</p> : null}
454
+
455
+ <PageFooter note="Templates are stored per session and available when adding new pages." />
456
+ </PageShell>
457
+ );
458
+ }
frontend/src/pages/UploadPage.tsx CHANGED
@@ -256,6 +256,33 @@ export default function UploadPage() {
256
  <p className="text-xs text-gray-500 mt-4">{fileSummary}</p>
257
  </div>
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">
 
256
  <p className="text-xs text-gray-500 mt-4">{fileSummary}</p>
257
  </div>
258
 
259
+ <div className="mb-6 rounded-lg border border-blue-100 bg-blue-50 p-4">
260
+ <div className="flex flex-wrap items-center justify-between gap-3">
261
+ <div>
262
+ <h3 className="text-sm font-semibold text-blue-900">
263
+ Need a data template?
264
+ </h3>
265
+ <p className="mt-1 text-xs text-blue-800">
266
+ Download the Excel template, fill the three sheets, then upload it
267
+ together with your images.
268
+ </p>
269
+ </div>
270
+ <a
271
+ href="/templates/repex-data-input-template.xlsx"
272
+ download
273
+ className="inline-flex items-center justify-center rounded-lg border border-blue-200 bg-white px-4 py-2 text-xs font-semibold text-blue-800 hover:bg-blue-100 transition"
274
+ >
275
+ Download Excel Template
276
+ </a>
277
+ </div>
278
+ <ol className="mt-3 list-decimal pl-5 text-xs text-blue-900 space-y-1">
279
+ <li>Fill <span className="font-semibold">General Information</span>.</li>
280
+ <li>Fill <span className="font-semibold">Headings</span>.</li>
281
+ <li>Fill <span className="font-semibold">Item Spesific</span> and image names.</li>
282
+ <li>Upload the Excel file and matching image files in one upload batch.</li>
283
+ </ol>
284
+ </div>
285
+
286
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
287
  <div className="space-y-1">
288
  <label className="block text-sm font-medium text-gray-700" htmlFor="documentNo">
frontend/src/types/session.ts CHANGED
@@ -30,6 +30,7 @@ export type Session = {
30
  page_count: number;
31
  jobsheet_sections?: JobsheetSection[];
32
  headings?: Heading[];
 
33
  layout?: Record<string, unknown> | null;
34
  };
35
 
@@ -88,6 +89,7 @@ export type Page = {
88
  photo_order_locked?: boolean;
89
  blank?: boolean;
90
  variant?: "full" | "photos";
 
91
  };
92
 
93
  export type JobsheetSection = {
@@ -96,6 +98,16 @@ export type JobsheetSection = {
96
  pages: Page[];
97
  };
98
 
 
 
 
 
 
 
 
 
 
 
99
  export type PagesResponse = {
100
  pages: Page[];
101
  };
 
30
  page_count: number;
31
  jobsheet_sections?: JobsheetSection[];
32
  headings?: Heading[];
33
+ page_templates?: PageTemplateDefinition[];
34
  layout?: Record<string, unknown> | null;
35
  };
36
 
 
89
  photo_order_locked?: boolean;
90
  blank?: boolean;
91
  variant?: "full" | "photos";
92
+ page_template?: string;
93
  };
94
 
95
  export type JobsheetSection = {
 
98
  pages: Page[];
99
  };
100
 
101
+ export type PageTemplateDefinition = {
102
+ id: string;
103
+ name: string;
104
+ description?: string;
105
+ blank?: boolean;
106
+ variant?: "full" | "photos";
107
+ photo_layout?: "auto" | "two-column" | "stacked";
108
+ source?: "builtin" | "custom";
109
+ };
110
+
111
  export type PagesResponse = {
112
  pages: Page[];
113
  };
server/app/api/routes/sessions.py CHANGED
@@ -12,6 +12,8 @@ from ..deps import get_session_store
12
  from ..schemas import (
13
  HeadingsRequest,
14
  HeadingsResponse,
 
 
15
  PagesRequest,
16
  PagesResponse,
17
  SectionsRequest,
@@ -206,6 +208,20 @@ def save_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,
@@ -329,6 +345,8 @@ def import_json(
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)
 
12
  from ..schemas import (
13
  HeadingsRequest,
14
  HeadingsResponse,
15
+ PageTemplatesRequest,
16
+ PageTemplatesResponse,
17
  PagesRequest,
18
  PagesResponse,
19
  SectionsRequest,
 
208
  return HeadingsResponse(headings=session.get("headings") or [])
209
 
210
 
211
+ @router.put("/{session_id}/page-templates", response_model=PageTemplatesResponse)
212
+ def save_page_templates(
213
+ session_id: str,
214
+ payload: PageTemplatesRequest,
215
+ store: SessionStore = Depends(get_session_store),
216
+ ) -> PageTemplatesResponse:
217
+ session_id = _normalize_session_id(session_id, store)
218
+ session = store.get_session(session_id)
219
+ if not session:
220
+ raise HTTPException(status_code=404, detail="Session not found.")
221
+ session = store.set_page_templates(session, payload.page_templates)
222
+ return PageTemplatesResponse(page_templates=session.get("page_templates") or [])
223
+
224
+
225
  @router.get("/{session_id}/uploads/{file_id}")
226
  def get_upload(
227
  session_id: str,
 
345
  session["page_count"] = imported_session["page_count"]
346
  if imported_session.get("headings") is not None:
347
  session["headings"] = imported_session["headings"]
348
+ if imported_session.get("page_templates") is not None:
349
+ session["page_templates"] = imported_session["page_templates"]
350
  store.update_session(session)
351
 
352
  return _attach_urls(session)
server/app/api/schemas.py CHANGED
@@ -19,6 +19,16 @@ class Heading(BaseModel):
19
  name: str = ""
20
 
21
 
 
 
 
 
 
 
 
 
 
 
22
  class SessionResponse(BaseModel):
23
  id: str
24
  status: str
@@ -31,6 +41,7 @@ class SessionResponse(BaseModel):
31
  page_count: int = 0
32
  headings: List[Heading] = Field(default_factory=list)
33
  jobsheet_sections: List["JobsheetSection"] = Field(default_factory=list)
 
34
 
35
 
36
  class SessionStatusResponse(BaseModel):
@@ -71,3 +82,11 @@ class HeadingsRequest(BaseModel):
71
 
72
  class HeadingsResponse(BaseModel):
73
  headings: List[Heading] = Field(default_factory=list)
 
 
 
 
 
 
 
 
 
19
  name: str = ""
20
 
21
 
22
+ class PageTemplateDefinition(BaseModel):
23
+ id: str
24
+ name: str
25
+ description: str = ""
26
+ blank: bool = False
27
+ variant: str = "full"
28
+ photo_layout: str = "auto"
29
+ source: str = "custom"
30
+
31
+
32
  class SessionResponse(BaseModel):
33
  id: str
34
  status: str
 
41
  page_count: int = 0
42
  headings: List[Heading] = Field(default_factory=list)
43
  jobsheet_sections: List["JobsheetSection"] = Field(default_factory=list)
44
+ page_templates: List[PageTemplateDefinition] = Field(default_factory=list)
45
 
46
 
47
  class SessionStatusResponse(BaseModel):
 
82
 
83
  class HeadingsResponse(BaseModel):
84
  headings: List[Heading] = Field(default_factory=list)
85
+
86
+
87
+ class PageTemplatesRequest(BaseModel):
88
+ page_templates: List[PageTemplateDefinition] = Field(default_factory=list)
89
+
90
+
91
+ class PageTemplatesResponse(BaseModel):
92
+ page_templates: List[PageTemplateDefinition] = Field(default_factory=list)
server/app/services/data_import.py CHANGED
@@ -467,7 +467,14 @@ def populate_session_from_data_files(
467
  for photo_id in photo_ids:
468
  if photo_id not in selected_photo_ids:
469
  selected_photo_ids.append(photo_id)
470
- page = {"items": [], "template": template, "photo_ids": photo_ids}
 
 
 
 
 
 
 
471
  title = item.get("reference") or item.get("area") or f"Section {idx + 1}"
472
  sections.append({"id": None, "title": title, "pages": [page]})
473
 
 
467
  for photo_id in photo_ids:
468
  if photo_id not in selected_photo_ids:
469
  selected_photo_ids.append(photo_id)
470
+ page = {
471
+ "items": [],
472
+ "template": template,
473
+ "photo_ids": photo_ids,
474
+ "page_template": "repex:standard",
475
+ "blank": False,
476
+ "variant": "full",
477
+ }
478
  title = item.get("reference") or item.get("area") or f"Section {idx + 1}"
479
  sections.append({"id": None, "title": title, "pages": [page]})
480
 
server/app/services/pdf_reportlab.py CHANGED
@@ -51,6 +51,22 @@ def _wrap_lines(
51
  lines: List[str] = []
52
  current: List[str] = []
53
  for word in words:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  test = " ".join(current + [word])
55
  if pdf.stringWidth(test, font, size) <= width or not current:
56
  current.append(word)
@@ -130,6 +146,37 @@ def _draw_label_value(
130
  return y
131
 
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  def _badge_style(value: str, scale: dict) -> tuple[str, colors.Color, colors.Color]:
134
  raw = (value or "").strip()
135
  key = raw.upper()
@@ -149,6 +196,10 @@ def _resolve_logo_path(store: SessionStore, session: dict, raw: str) -> Optional
149
  uploads = (session.get("uploads") or {}).get("photos") or []
150
  lower = value.lower()
151
  for item in uploads:
 
 
 
 
152
  name = (item.get("name") or "").lower()
153
  if not name:
154
  continue
@@ -220,7 +271,7 @@ def render_report_pdf(
220
  ) -> Path:
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
@@ -378,10 +429,10 @@ def render_report_pdf(
378
  content_height = content_top - content_bottom
379
 
380
  # Header
 
 
381
  logo_x = margin
382
- logo_y = header_y - 15 * mm
383
- logo_w = 28 * mm
384
- logo_h = 15 * mm
385
  logo_drawn = False
386
  if default_logo:
387
  logo_drawn = _draw_image_fit(pdf, default_logo, logo_x, logo_y, logo_w, logo_h)
@@ -397,31 +448,50 @@ def render_report_pdf(
397
  pdf.setLineWidth(0.5)
398
  pdf.rect(logo_x, logo_y, logo_w, logo_h, stroke=1, fill=0)
399
  client_logo = _resolve_logo_path(store, session, template.get("company_logo", ""))
 
 
 
 
400
  if client_logo:
401
  _draw_image_fit(
402
  pdf,
403
  client_logo,
404
- width - margin - 32 * mm,
405
- header_y - 15 * mm,
406
- 32 * mm,
407
- 15 * mm,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
419
  y = content_top
420
 
421
  if variant == "full":
422
  # Observations and Findings
423
  pdf.setFillColor(gray_800)
424
- pdf.setFont("Helvetica-Bold", 14)
425
  pdf.drawString(margin, y, "Observations and Findings")
426
  pdf.setStrokeColor(gray_200)
427
  pdf.line(margin, y - 2, width - margin, y - 2)
@@ -433,70 +503,78 @@ def render_report_pdf(
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
437
- right_w = (width - 2 * margin) * 0.4
438
- left_x = margin
439
- right_x = margin + left_w + 4 * mm
 
 
 
440
 
441
- label_size = 11
442
- value_size = 12
443
- value_gap = 2 * mm
444
- row_gap = 4 * mm
445
- leading = 14
446
 
447
  row_y = y
448
- _draw_label_value(
449
- pdf,
450
- "Ref",
451
- ref,
452
- left_x,
453
- row_y,
454
- "Helvetica",
455
- "Helvetica-Bold",
456
- label_size,
457
- value_size,
458
- gray_500,
459
- gray_900,
460
- )
461
- _draw_label_value(
462
- pdf,
463
- "Area",
464
- area,
465
- left_x + left_w / 2,
466
- row_y,
467
- "Helvetica",
468
- "Helvetica-Bold",
469
- label_size,
470
- value_size,
471
- gray_500,
472
- gray_900,
473
  )
474
-
475
- pdf.setFillColor(gray_500)
476
- pdf.setFont("Helvetica", label_size)
477
- pdf.drawString(right_x, row_y, "Location")
478
- pdf.setFillColor(gray_900)
479
- loc_lines = _wrap_lines(
480
- pdf,
481
- location or "-",
482
- right_w - 2 * mm,
483
- 2,
484
- "Helvetica-Bold",
485
- value_size,
 
 
 
 
 
486
  )
487
- _draw_wrapped(
488
- pdf,
489
- location or "-",
490
- right_x,
491
- row_y - label_size - value_gap,
492
- right_w - 2 * mm,
493
- leading,
494
- 2,
495
- "Helvetica-Bold",
496
- value_size,
 
 
 
 
 
 
 
497
  )
498
 
499
- y = row_y - (label_size + value_gap + leading * max(1, len(loc_lines))) - row_gap
500
 
501
  category = _safe_text(template.get("category"))
502
  priority = _safe_text(template.get("priority"))
@@ -520,54 +598,58 @@ def render_report_pdf(
520
 
521
  badge_w = 40 * mm
522
  badge_h = 10 * mm
523
- y -= 2 * mm
524
  pdf.setFillColor(gray_500)
525
- pdf.setFont("Helvetica", 11)
526
- cat_label_x = margin + 20 * mm + badge_w / 2
527
- pr_label_x = margin + 100 * mm + badge_w / 2
 
 
 
 
 
528
  label_y = y
529
  pdf.drawCentredString(cat_label_x, label_y, "Category")
530
  pdf.drawCentredString(pr_label_x, label_y, "Priority")
531
- y -= 12 * mm
532
  pdf.setFillColor(cat_bg)
533
  pdf.setStrokeColor(gray_200)
534
- pdf.roundRect(margin + 20 * mm, y - 2, badge_w, badge_h, 2 * mm, stroke=1, fill=1)
535
  pdf.setFillColor(cat_text_color)
536
- pdf.setFont("Helvetica-Bold", 11)
537
- pdf.drawCentredString(cat_label_x, y - 2 + badge_h / 2 - 4, cat_text)
538
  pdf.setFillColor(pr_bg)
539
- pdf.roundRect(margin + 100 * mm, y - 2, badge_w, badge_h, 2 * mm, stroke=1, fill=1)
540
  pdf.setFillColor(pr_text_color)
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)
549
  pdf.drawCentredString(
550
  margin + (width - 2 * margin) / 2, y, "Condition Description"
551
  )
552
- y -= 4 * mm
553
- pdf.setFillColor(amber_50)
554
- pdf.setStrokeColor(amber_300)
555
  cond_lines = _wrap_lines(
556
  pdf,
557
  condition or "-",
558
  width - 2 * margin - 4 * mm,
559
  4,
560
- "Helvetica-Bold",
561
- 11,
562
  )
563
- cond_h = max(18 * mm, (len(cond_lines) or 1) * leading + 6 * mm)
564
  cond_bottom = y - cond_h
565
  pdf.rect(margin, cond_bottom, width - 2 * margin, cond_h, stroke=1, fill=1)
566
- pdf.setLineWidth(3)
567
  pdf.line(margin, cond_bottom, margin, y)
568
  pdf.setLineWidth(1)
569
- pdf.setFillColor(amber_800)
570
- pdf.setFont("Helvetica-Bold", 11)
571
  text_center_x = margin + (width - 2 * margin) / 2
572
  _draw_centered_block(
573
  pdf,
@@ -577,34 +659,33 @@ def render_report_pdf(
577
  cond_h,
578
  leading,
579
  "Helvetica-Bold",
580
- 11,
581
  )
582
- y = cond_bottom - 6 * mm
583
 
584
  pdf.setFillColor(gray_500)
585
- pdf.setFont("Helvetica", 11)
586
  pdf.drawCentredString(
587
  margin + (width - 2 * margin) / 2, y, "Required Action"
588
  )
589
- y -= 4 * mm
590
- pdf.setFillColor(blue_50)
591
- pdf.setStrokeColor(blue_300)
592
  action_lines = _wrap_lines(
593
  pdf,
594
  action or "-",
595
  width - 2 * margin - 4 * mm,
596
  4,
597
- "Helvetica-Bold",
598
- 11,
599
  )
600
- action_h = max(18 * mm, (len(action_lines) or 1) * leading + 6 * mm)
601
  action_bottom = y - action_h
602
  pdf.rect(margin, action_bottom, width - 2 * margin, action_h, stroke=1, fill=1)
603
- pdf.setLineWidth(3)
604
  pdf.line(margin, action_bottom, margin, y)
605
  pdf.setLineWidth(1)
606
- pdf.setFillColor(blue_800)
607
- pdf.setFont("Helvetica-Bold", 11)
608
  text_center_x = margin + (width - 2 * margin) / 2
609
  _draw_centered_block(
610
  pdf,
@@ -614,12 +695,12 @@ def render_report_pdf(
614
  action_h,
615
  leading,
616
  "Helvetica-Bold",
617
- 11,
618
  )
619
- y = action_bottom - 6 * mm
620
  else:
621
  pdf.setFillColor(gray_800)
622
- pdf.setFont("Helvetica-Bold", 11)
623
  pdf.drawString(margin, y, "Photo Documentation (continued)")
624
  pdf.setStrokeColor(gray_200)
625
  pdf.line(margin, y - 2, width - margin, y - 2)
@@ -628,7 +709,7 @@ def render_report_pdf(
628
  if variant == "full":
629
  y -= 2 * mm
630
  pdf.setFillColor(gray_800)
631
- pdf.setFont("Helvetica-Bold", 14)
632
  pdf.drawString(margin, y, "Photo Documentation")
633
  pdf.setStrokeColor(gray_200)
634
  pdf.line(margin, y - 2, width - margin, y - 2)
@@ -653,14 +734,43 @@ def render_report_pdf(
653
  pdf.setStrokeColor(gray_200)
654
  pdf.setFillColor(gray_50)
655
  pdf.roundRect(x, y, cell_w, cell_h, 3 * mm, stroke=1, fill=1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
  _draw_image_fit(
657
- pdf, photo_path, x + 2 * mm, y + 8 * mm, cell_w - 4 * mm, cell_h - 14 * mm
 
 
 
 
 
658
  )
659
- pdf.setFillColor(gray_500)
660
- pdf.setFont("Helvetica", 11)
661
- if label:
662
- pdf.drawCentredString(
663
- x + cell_w / 2, y + 3 * mm, f"Fig {idx + 1}: {label}"
 
 
 
 
 
 
664
  )
665
  else:
666
  pdf.setFont("Helvetica", 11)
@@ -675,7 +785,7 @@ def render_report_pdf(
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:
 
51
  lines: List[str] = []
52
  current: List[str] = []
53
  for word in words:
54
+ if pdf.stringWidth(word, font, size) > width:
55
+ if current:
56
+ lines.append(" ".join(current))
57
+ current = []
58
+ if len(lines) >= max_lines:
59
+ break
60
+ remaining = word
61
+ while remaining and len(lines) < max_lines:
62
+ cut = len(remaining)
63
+ while cut > 1 and pdf.stringWidth(remaining[:cut], font, size) > width:
64
+ cut -= 1
65
+ if cut <= 1:
66
+ cut = 1
67
+ lines.append(remaining[:cut])
68
+ remaining = remaining[cut:]
69
+ continue
70
  test = " ".join(current + [word])
71
  if pdf.stringWidth(test, font, size) <= width or not current:
72
  current.append(word)
 
146
  return y
147
 
148
 
149
+ def _draw_label_value_centered(
150
+ pdf: canvas.Canvas,
151
+ label: str,
152
+ value: str,
153
+ x_center: float,
154
+ y: float,
155
+ label_font: str,
156
+ value_font: str,
157
+ label_size: int,
158
+ value_size: int,
159
+ label_color: colors.Color,
160
+ value_color: colors.Color,
161
+ max_width: float,
162
+ max_lines: int,
163
+ leading: float,
164
+ ) -> int:
165
+ pdf.setFillColor(label_color)
166
+ pdf.setFont(label_font, label_size)
167
+ pdf.drawCentredString(x_center, y, label)
168
+ y -= label_size + 1
169
+ pdf.setFillColor(value_color)
170
+ pdf.setFont(value_font, value_size)
171
+ lines = _wrap_lines(pdf, value or "-", max_width, max_lines, value_font, value_size)
172
+ if not lines:
173
+ lines = ["-"]
174
+ for line in lines:
175
+ pdf.drawCentredString(x_center, y, line)
176
+ y -= leading
177
+ return len(lines)
178
+
179
+
180
  def _badge_style(value: str, scale: dict) -> tuple[str, colors.Color, colors.Color]:
181
  raw = (value or "").strip()
182
  key = raw.upper()
 
196
  uploads = (session.get("uploads") or {}).get("photos") or []
197
  lower = value.lower()
198
  for item in uploads:
199
+ if value == (item.get("id") or ""):
200
+ path = store.resolve_upload_path(session, item.get("id"))
201
+ if path and path.exists():
202
+ return path
203
  name = (item.get("name") or "").lower()
204
  if not name:
205
  continue
 
271
  ) -> Path:
272
  width, height = A4
273
  margin = 10 * mm
274
+ header_h = 26 * mm
275
  footer_h = 12 * mm
276
  gap = 4 * mm
277
  photo_col_gap = 6 * mm
 
429
  content_height = content_top - content_bottom
430
 
431
  # Header
432
+ logo_w = 40 * mm
433
+ logo_h = 20 * mm
434
  logo_x = margin
435
+ logo_y = header_y - (header_h + logo_h) / 2
 
 
436
  logo_drawn = False
437
  if default_logo:
438
  logo_drawn = _draw_image_fit(pdf, default_logo, logo_x, logo_y, logo_w, logo_h)
 
448
  pdf.setLineWidth(0.5)
449
  pdf.rect(logo_x, logo_y, logo_w, logo_h, stroke=1, fill=0)
450
  client_logo = _resolve_logo_path(store, session, template.get("company_logo", ""))
451
+ client_logo_w = 40 * mm
452
+ client_logo_h = 20 * mm
453
+ client_logo_x = width - margin - client_logo_w
454
+ client_logo_y = header_y - (header_h + client_logo_h) / 2
455
  if client_logo:
456
  _draw_image_fit(
457
  pdf,
458
  client_logo,
459
+ client_logo_x,
460
+ client_logo_y,
461
+ client_logo_w,
462
+ client_logo_h,
463
+ )
464
+ else:
465
+ pdf.setStrokeColor(gray_200)
466
+ pdf.rect(client_logo_x, client_logo_y, client_logo_w, client_logo_h, stroke=1, fill=0)
467
+ pdf.setFillColor(gray_500)
468
+ pdf.setFont("Helvetica", 8)
469
+ pdf.drawCentredString(
470
+ client_logo_x + client_logo_w / 2,
471
+ client_logo_y + client_logo_h / 2 + 2,
472
+ "Company Logo",
473
+ )
474
+ pdf.drawCentredString(
475
+ client_logo_x + client_logo_w / 2,
476
+ client_logo_y + client_logo_h / 2 - 6,
477
+ "not found",
478
  )
479
  pdf.setFillColor(gray_900)
480
  pdf.setFont("Helvetica-Bold", 13)
481
  pdf.drawCentredString(
482
  width / 2,
483
+ header_y - header_h / 2 + 2 * mm,
484
  doc_number or "Document No",
485
  )
486
  pdf.setStrokeColor(gray_200)
487
+ pdf.line(margin, header_y - header_h + 3 * mm, width - margin, header_y - header_h + 3 * mm)
488
 
489
  y = content_top
490
 
491
  if variant == "full":
492
  # Observations and Findings
493
  pdf.setFillColor(gray_800)
494
+ pdf.setFont("Helvetica-Bold", 12)
495
  pdf.drawString(margin, y, "Observations and Findings")
496
  pdf.setStrokeColor(gray_200)
497
  pdf.line(margin, y - 2, width - margin, y - 2)
 
503
  item_desc = _safe_text(template.get("item_description"))
504
  required_action = _safe_text(template.get("required_action"))
505
 
506
+ total_w = width - 2 * margin
507
+ col_w = total_w / 3
508
+ col_centers = [
509
+ margin + col_w / 2,
510
+ margin + col_w * 1.5,
511
+ margin + col_w * 2.5,
512
+ ]
513
 
514
+ label_size = 9
515
+ value_size = 10
516
+ value_gap = 1.5 * mm
517
+ row_gap = 3 * mm
518
+ leading = 11
519
 
520
  row_y = y
521
+ line_counts = []
522
+ line_counts.append(
523
+ _draw_label_value_centered(
524
+ pdf,
525
+ "Ref",
526
+ ref,
527
+ col_centers[0],
528
+ row_y,
529
+ "Helvetica",
530
+ "Helvetica-Bold",
531
+ label_size,
532
+ value_size,
533
+ gray_500,
534
+ gray_900,
535
+ col_w - 4 * mm,
536
+ 2,
537
+ leading,
538
+ )
 
 
 
 
 
 
 
539
  )
540
+ line_counts.append(
541
+ _draw_label_value_centered(
542
+ pdf,
543
+ "Area",
544
+ area,
545
+ col_centers[1],
546
+ row_y,
547
+ "Helvetica",
548
+ "Helvetica-Bold",
549
+ label_size,
550
+ value_size,
551
+ gray_500,
552
+ gray_900,
553
+ col_w - 4 * mm,
554
+ 2,
555
+ leading,
556
+ )
557
  )
558
+ line_counts.append(
559
+ _draw_label_value_centered(
560
+ pdf,
561
+ "Location",
562
+ location,
563
+ col_centers[2],
564
+ row_y,
565
+ "Helvetica",
566
+ "Helvetica-Bold",
567
+ label_size,
568
+ value_size,
569
+ gray_500,
570
+ gray_900,
571
+ col_w - 4 * mm,
572
+ 2,
573
+ leading,
574
+ )
575
  )
576
 
577
+ y = row_y - (label_size + value_gap + leading * max(1, max(line_counts))) - row_gap
578
 
579
  category = _safe_text(template.get("category"))
580
  priority = _safe_text(template.get("priority"))
 
598
 
599
  badge_w = 40 * mm
600
  badge_h = 10 * mm
601
+ y -= 1 * mm
602
  pdf.setFillColor(gray_500)
603
+ pdf.setFont("Helvetica", 9)
604
+ badge_gap = 20 * mm
605
+ total_badge_w = badge_w * 2 + badge_gap
606
+ start_x = (width - total_badge_w) / 2
607
+ cat_x = start_x
608
+ pr_x = start_x + badge_w + badge_gap
609
+ cat_label_x = cat_x + badge_w / 2
610
+ pr_label_x = pr_x + badge_w / 2
611
  label_y = y
612
  pdf.drawCentredString(cat_label_x, label_y, "Category")
613
  pdf.drawCentredString(pr_label_x, label_y, "Priority")
614
+ y -= 10 * mm
615
  pdf.setFillColor(cat_bg)
616
  pdf.setStrokeColor(gray_200)
617
+ pdf.roundRect(cat_x, y - 2, badge_w, badge_h, 2 * mm, stroke=1, fill=1)
618
  pdf.setFillColor(cat_text_color)
619
+ pdf.setFont("Helvetica-Bold", 10)
620
+ pdf.drawCentredString(cat_label_x, y - 2 + badge_h / 2 - 3, cat_text)
621
  pdf.setFillColor(pr_bg)
622
+ pdf.roundRect(pr_x, y - 2, badge_w, badge_h, 2 * mm, stroke=1, fill=1)
623
  pdf.setFillColor(pr_text_color)
624
+ pdf.drawCentredString(pr_label_x, y - 2 + badge_h / 2 - 3, pr_text)
625
+ y -= 8 * mm
626
 
627
  condition = item_desc
628
  action = required_action
629
 
630
  pdf.setFillColor(gray_500)
631
+ pdf.setFont("Helvetica", 9)
632
  pdf.drawCentredString(
633
  margin + (width - 2 * margin) / 2, y, "Condition Description"
634
  )
635
+ y -= 3 * mm
636
+ pdf.setFillColor(gray_50)
637
+ pdf.setStrokeColor(gray_200)
638
  cond_lines = _wrap_lines(
639
  pdf,
640
  condition or "-",
641
  width - 2 * margin - 4 * mm,
642
  4,
643
+ "Helvetica",
644
+ 9,
645
  )
646
+ cond_h = max(10 * mm, (len(cond_lines) or 1) * leading + 4 * mm)
647
  cond_bottom = y - cond_h
648
  pdf.rect(margin, cond_bottom, width - 2 * margin, cond_h, stroke=1, fill=1)
649
+ pdf.setLineWidth(2)
650
  pdf.line(margin, cond_bottom, margin, y)
651
  pdf.setLineWidth(1)
652
+ pdf.setFillColor(gray_700)
 
653
  text_center_x = margin + (width - 2 * margin) / 2
654
  _draw_centered_block(
655
  pdf,
 
659
  cond_h,
660
  leading,
661
  "Helvetica-Bold",
662
+ 10,
663
  )
664
+ y = cond_bottom - 4 * mm
665
 
666
  pdf.setFillColor(gray_500)
667
+ pdf.setFont("Helvetica", 9)
668
  pdf.drawCentredString(
669
  margin + (width - 2 * margin) / 2, y, "Required Action"
670
  )
671
+ y -= 3 * mm
672
+ pdf.setFillColor(gray_50)
673
+ pdf.setStrokeColor(gray_200)
674
  action_lines = _wrap_lines(
675
  pdf,
676
  action or "-",
677
  width - 2 * margin - 4 * mm,
678
  4,
679
+ "Helvetica",
680
+ 9,
681
  )
682
+ action_h = max(10 * mm, (len(action_lines) or 1) * leading + 4 * mm)
683
  action_bottom = y - action_h
684
  pdf.rect(margin, action_bottom, width - 2 * margin, action_h, stroke=1, fill=1)
685
+ pdf.setLineWidth(2)
686
  pdf.line(margin, action_bottom, margin, y)
687
  pdf.setLineWidth(1)
688
+ pdf.setFillColor(gray_700)
 
689
  text_center_x = margin + (width - 2 * margin) / 2
690
  _draw_centered_block(
691
  pdf,
 
695
  action_h,
696
  leading,
697
  "Helvetica-Bold",
698
+ 10,
699
  )
700
+ y = action_bottom - 4 * mm
701
  else:
702
  pdf.setFillColor(gray_800)
703
+ pdf.setFont("Helvetica-Bold", 12)
704
  pdf.drawString(margin, y, "Photo Documentation (continued)")
705
  pdf.setStrokeColor(gray_200)
706
  pdf.line(margin, y - 2, width - margin, y - 2)
 
709
  if variant == "full":
710
  y -= 2 * mm
711
  pdf.setFillColor(gray_800)
712
+ pdf.setFont("Helvetica-Bold", 12)
713
  pdf.drawString(margin, y, "Photo Documentation")
714
  pdf.setStrokeColor(gray_200)
715
  pdf.line(margin, y - 2, width - margin, y - 2)
 
734
  pdf.setStrokeColor(gray_200)
735
  pdf.setFillColor(gray_50)
736
  pdf.roundRect(x, y, cell_w, cell_h, 3 * mm, stroke=1, fill=1)
737
+
738
+ caption_text = f"Fig {idx + 1}: {label}" if label else ""
739
+ caption_font = "Helvetica"
740
+ caption_size = 9
741
+ caption_leading = 10
742
+ caption_lines = _wrap_lines(
743
+ pdf,
744
+ caption_text,
745
+ cell_w - 6 * mm,
746
+ 2,
747
+ caption_font,
748
+ caption_size,
749
+ )
750
+ caption_h = max(6 * mm, max(1, len(caption_lines)) * caption_leading)
751
+ image_y = y + caption_h + 2 * mm
752
+ image_h = cell_h - caption_h - 6 * mm
753
+ if image_h < 10 * mm:
754
+ image_h = 10 * mm
755
  _draw_image_fit(
756
+ pdf,
757
+ photo_path,
758
+ x + 2 * mm,
759
+ image_y,
760
+ cell_w - 4 * mm,
761
+ image_h,
762
  )
763
+ if caption_lines:
764
+ pdf.setFillColor(gray_500)
765
+ _draw_centered_block(
766
+ pdf,
767
+ caption_lines,
768
+ x + cell_w / 2,
769
+ y + 2 * mm,
770
+ caption_h,
771
+ caption_leading,
772
+ caption_font,
773
+ caption_size,
774
  )
775
  else:
776
  pdf.setFont("Helvetica", 11)
 
785
  pdf.drawCentredString(
786
  width / 2,
787
  footer_y + 4 * mm,
788
+ "Prosento - (c) 2026 All Rights Reserved",
789
  )
790
  page_line = f"Page {output_index + 1} of {total_pages}"
791
  if section_label:
server/app/services/session_store.py CHANGED
@@ -19,6 +19,36 @@ IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
19
  DOC_EXTS = {".pdf", ".doc", ".docx"}
20
  DATA_EXTS = {".csv", ".xls", ".xlsx"}
21
  SESSION_ID_RE = re.compile(r"^[0-9a-f]{32}$")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
 
24
  @dataclass
@@ -109,6 +139,54 @@ def _normalize_template_fields(template: Optional[dict]) -> dict:
109
  return normalized
110
 
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  class SessionStore:
113
  def __init__(self, base_dir: Optional[Path] = None) -> None:
114
  settings = get_settings()
@@ -134,6 +212,32 @@ class SessionStore:
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):
@@ -161,6 +265,7 @@ class SessionStore:
161
  "pages": [],
162
  "jobsheet_sections": [],
163
  "headings": [],
 
164
  }
165
  self._save_session(session)
166
  return session
@@ -212,13 +317,15 @@ class SessionStore:
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}
@@ -238,6 +345,7 @@ class SessionStore:
238
  return pages
239
 
240
  def set_sections(self, session: dict, sections: List[dict]) -> dict:
 
241
  normalized: List[dict] = []
242
  for section in sections or []:
243
  if hasattr(section, "model_dump"):
@@ -258,10 +366,11 @@ class SessionStore:
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,
@@ -293,7 +402,40 @@ class SessionStore:
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] = []
@@ -304,10 +446,11 @@ class SessionStore:
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,
@@ -329,6 +472,11 @@ class SessionStore:
329
  count = selected_count or photo_count or session.get("page_count", 1) or 1
330
  pages = [{"items": []} for _ in range(count)]
331
 
 
 
 
 
 
332
  sections = [{"id": uuid4().hex, "title": "Section 1", "pages": pages}]
333
  session["jobsheet_sections"] = sections
334
  session["pages"] = []
@@ -370,15 +518,21 @@ class SessionStore:
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 []
@@ -391,10 +545,11 @@ class SessionStore:
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,
 
19
  DOC_EXTS = {".pdf", ".doc", ".docx"}
20
  DATA_EXTS = {".csv", ".xls", ".xlsx"}
21
  SESSION_ID_RE = re.compile(r"^[0-9a-f]{32}$")
22
+ BUILTIN_PAGE_TEMPLATES = [
23
+ {
24
+ "id": "repex:standard",
25
+ "name": "Standard Job Sheet",
26
+ "description": "Observations + up to two photos.",
27
+ "blank": False,
28
+ "variant": "full",
29
+ "photo_layout": "auto",
30
+ "source": "builtin",
31
+ },
32
+ {
33
+ "id": "repex:photos",
34
+ "name": "Photo Continuation",
35
+ "description": "Photo-only continuation page.",
36
+ "blank": False,
37
+ "variant": "photos",
38
+ "photo_layout": "auto",
39
+ "source": "builtin",
40
+ },
41
+ {
42
+ "id": "repex:blank",
43
+ "name": "Blank Canvas",
44
+ "description": "Blank white page.",
45
+ "blank": True,
46
+ "variant": "full",
47
+ "photo_layout": "auto",
48
+ "source": "builtin",
49
+ },
50
+ ]
51
+ BUILTIN_PAGE_TEMPLATE_MAP = {item["id"]: item for item in BUILTIN_PAGE_TEMPLATES}
52
 
53
 
54
  @dataclass
 
139
  return normalized
140
 
141
 
142
+ def _infer_template_id(page: dict) -> str:
143
+ template_id = str(page.get("page_template") or "").strip()
144
+ if template_id:
145
+ return template_id
146
+ if page.get("blank"):
147
+ return "repex:blank"
148
+ if str(page.get("variant") or "").strip().lower() == "photos":
149
+ return "repex:photos"
150
+ return "repex:standard"
151
+
152
+
153
+ def _normalize_page_templates(templates: Optional[List[dict]]) -> List[dict]:
154
+ normalized: List[dict] = []
155
+ seen: set[str] = set()
156
+ for template in templates or []:
157
+ if hasattr(template, "model_dump"):
158
+ template = template.model_dump()
159
+ elif hasattr(template, "dict"):
160
+ template = template.dict()
161
+ if not isinstance(template, dict):
162
+ continue
163
+ template_id = str(template.get("id") or "").strip()
164
+ if not template_id or template_id in BUILTIN_PAGE_TEMPLATE_MAP:
165
+ continue
166
+ if template_id in seen:
167
+ continue
168
+ seen.add(template_id)
169
+ name = str(template.get("name") or template_id).strip() or template_id
170
+ variant = str(template.get("variant") or "full").strip().lower()
171
+ if variant not in {"full", "photos"}:
172
+ variant = "full"
173
+ photo_layout = str(template.get("photo_layout") or "auto").strip().lower()
174
+ if photo_layout not in {"auto", "two-column", "stacked"}:
175
+ photo_layout = "auto"
176
+ normalized.append(
177
+ {
178
+ "id": template_id,
179
+ "name": name,
180
+ "description": str(template.get("description") or "").strip(),
181
+ "blank": bool(template.get("blank")),
182
+ "variant": variant,
183
+ "photo_layout": photo_layout,
184
+ "source": "custom",
185
+ }
186
+ )
187
+ return normalized
188
+
189
+
190
  class SessionStore:
191
  def __init__(self, base_dir: Optional[Path] = None) -> None:
192
  settings = get_settings()
 
212
  except Exception:
213
  continue
214
 
215
+ def _template_index(self, session: dict) -> dict:
216
+ custom_templates = _normalize_page_templates(session.get("page_templates") or [])
217
+ session["page_templates"] = custom_templates
218
+ merged = {key: dict(value) for key, value in BUILTIN_PAGE_TEMPLATE_MAP.items()}
219
+ for template in custom_templates:
220
+ merged[template["id"]] = template
221
+ return merged
222
+
223
+ def _normalize_page(self, page: dict, template_index: dict) -> dict:
224
+ template = _normalize_template_fields(page.get("template"))
225
+ normalized = {**page, "template": template}
226
+ template_id = _infer_template_id(normalized)
227
+ definition = template_index.get(template_id) or BUILTIN_PAGE_TEMPLATE_MAP["repex:standard"]
228
+ normalized["page_template"] = definition["id"]
229
+ normalized["blank"] = bool(definition.get("blank"))
230
+ normalized["variant"] = (
231
+ str(definition.get("variant") or normalized.get("variant") or "full")
232
+ .strip()
233
+ .lower()
234
+ )
235
+ if normalized["variant"] not in {"full", "photos"}:
236
+ normalized["variant"] = "full"
237
+ if normalized.get("photo_layout") is None and definition.get("photo_layout"):
238
+ normalized["photo_layout"] = definition["photo_layout"]
239
+ return normalized
240
+
241
  def list_sessions(self) -> List[dict]:
242
  sessions: List[dict] = []
243
  for session_file in sorted(self.sessions_dir.glob("*/session.json"), reverse=True):
 
265
  "pages": [],
266
  "jobsheet_sections": [],
267
  "headings": [],
268
+ "page_templates": [],
269
  }
270
  self._save_session(session)
271
  return session
 
317
  def set_pages(self, session: dict, pages: List[dict]) -> dict:
318
  if not pages:
319
  pages = [{"items": []}]
320
+ template_index = self._template_index(session)
321
  normalized_pages = []
322
  for page in pages:
323
  if not isinstance(page, dict):
324
+ normalized_pages.append(
325
+ self._normalize_page({"items": []}, template_index)
326
+ )
327
  continue
328
+ normalized_pages.append(self._normalize_page(page, template_index))
 
329
  # Legacy compatibility: store as a single section.
330
  session["jobsheet_sections"] = [
331
  {"id": uuid4().hex, "title": "Section 1", "pages": normalized_pages}
 
345
  return pages
346
 
347
  def set_sections(self, session: dict, sections: List[dict]) -> dict:
348
+ template_index = self._template_index(session)
349
  normalized: List[dict] = []
350
  for section in sections or []:
351
  if hasattr(section, "model_dump"):
 
366
  normalized_pages = []
367
  for page in pages:
368
  if not isinstance(page, dict):
369
+ normalized_pages.append(
370
+ self._normalize_page({"items": []}, template_index)
371
+ )
372
  continue
373
+ normalized_pages.append(self._normalize_page(page, template_index))
 
374
  normalized.append(
375
  {
376
  "id": section.get("id") or uuid4().hex,
 
402
  self.update_session(session)
403
  return session
404
 
405
+ def set_page_templates(self, session: dict, templates: List[dict]) -> dict:
406
+ session["page_templates"] = _normalize_page_templates(templates)
407
+ template_index = self._template_index(session)
408
+ sections = session.get("jobsheet_sections") or []
409
+ normalized_sections = []
410
+ for section in sections:
411
+ if not isinstance(section, dict):
412
+ continue
413
+ pages = section.get("pages") or []
414
+ normalized_pages = []
415
+ for page in pages:
416
+ if not isinstance(page, dict):
417
+ normalized_pages.append(
418
+ self._normalize_page({"items": []}, template_index)
419
+ )
420
+ continue
421
+ normalized_pages.append(self._normalize_page(page, template_index))
422
+ normalized_sections.append(
423
+ {
424
+ "id": section.get("id") or uuid4().hex,
425
+ "title": section.get("title") or "Section",
426
+ "pages": normalized_pages if normalized_pages else [{"items": []}],
427
+ }
428
+ )
429
+ if normalized_sections:
430
+ session["jobsheet_sections"] = normalized_sections
431
+ session["page_count"] = sum(
432
+ len(section.get("pages") or []) for section in normalized_sections
433
+ )
434
+ self.update_session(session)
435
+ return session
436
+
437
  def ensure_sections(self, session: dict) -> List[dict]:
438
+ template_index = self._template_index(session)
439
  sections = session.get("jobsheet_sections") or []
440
  if sections:
441
  normalized_sections: List[dict] = []
 
446
  normalized_pages = []
447
  for page in pages:
448
  if not isinstance(page, dict):
449
+ normalized_pages.append(
450
+ self._normalize_page({"items": []}, template_index)
451
+ )
452
  continue
453
+ normalized_pages.append(self._normalize_page(page, template_index))
 
454
  normalized_sections.append(
455
  {
456
  "id": section.get("id") or uuid4().hex,
 
472
  count = selected_count or photo_count or session.get("page_count", 1) or 1
473
  pages = [{"items": []} for _ in range(count)]
474
 
475
+ pages = [
476
+ self._normalize_page(page if isinstance(page, dict) else {"items": []}, template_index)
477
+ for page in pages
478
+ ]
479
+
480
  sections = [{"id": uuid4().hex, "title": "Section 1", "pages": pages}]
481
  session["jobsheet_sections"] = sections
482
  session["pages"] = []
 
518
  else:
519
  session["headings"] = []
520
 
521
+ session["page_templates"] = _normalize_page_templates(
522
+ session.get("page_templates") or []
523
+ )
524
+ template_index = self._template_index(session)
525
+
526
  pages = session.get("pages") or []
527
  if pages:
528
  normalized_pages = []
529
  for page in pages:
530
  if not isinstance(page, dict):
531
+ normalized_pages.append(
532
+ self._normalize_page({"items": []}, template_index)
533
+ )
534
  continue
535
+ normalized_pages.append(self._normalize_page(page, template_index))
 
536
  session["pages"] = normalized_pages
537
 
538
  sections = session.get("jobsheet_sections") or []
 
545
  normalized_pages = []
546
  for page in pages:
547
  if not isinstance(page, dict):
548
+ normalized_pages.append(
549
+ self._normalize_page({"items": []}, template_index)
550
+ )
551
  continue
552
+ normalized_pages.append(self._normalize_page(page, template_index))
 
553
  normalized_sections.append(
554
  {
555
  "id": section.get("id") or uuid4().hex,