ChristopherJKoen commited on
Commit
41178f4
·
1 Parent(s): 58c8c26

Data Input Rework

Browse files
frontend/public/templates/job-sheet-template.html CHANGED
@@ -194,7 +194,7 @@
194
  Photo Documentation
195
  </h2>
196
 
197
- <div class="grid grid-cols-2 gap-3">
198
  <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50">
199
  <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
200
  Photo slot
@@ -212,29 +212,42 @@
212
  Figure 2
213
  </figcaption>
214
  </figure>
215
- </div>
216
- </section>
217
 
218
- <!-- Signatures -->
219
- <section class="mt-6" aria-label="Signatures">
220
- <div class="grid grid-cols-3 gap-4">
221
- <div class="border-t pt-2">
222
- <div class="text-xs font-medium text-gray-500">Inspected By</div>
223
- <div class="h-10 mt-1 border-b border-gray-300"></div>
224
- <p class="text-[11px] text-gray-500 mt-1">Name / Signature / Date</p>
225
- </div>
226
 
227
- <div class="border-t pt-2">
228
- <div class="text-xs font-medium text-gray-500">Approved By</div>
229
- <div class="h-10 mt-1 border-b border-gray-300"></div>
230
- <p class="text-[11px] text-gray-500 mt-1">Name / Signature / Date</p>
231
- </div>
 
 
 
232
 
233
- <div class="border-t pt-2">
234
- <div class="text-xs font-medium text-gray-500">Completed By</div>
235
- <div class="h-10 mt-1 border-b border-gray-300"></div>
236
- <p class="text-[11px] text-gray-500 mt-1">Name / Signature / Date</p>
237
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
238
  </div>
239
  </section>
240
 
 
194
  Photo Documentation
195
  </h2>
196
 
197
+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
198
  <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50">
199
  <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
200
  Photo slot
 
212
  Figure 2
213
  </figcaption>
214
  </figure>
 
 
215
 
216
+ <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50">
217
+ <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
218
+ Photo slot
219
+ </div>
220
+ <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
221
+ Figure 3
222
+ </figcaption>
223
+ </figure>
224
 
225
+ <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50">
226
+ <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
227
+ Photo slot
228
+ </div>
229
+ <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
230
+ Figure 4
231
+ </figcaption>
232
+ </figure>
233
 
234
+ <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50">
235
+ <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
236
+ Photo slot
237
+ </div>
238
+ <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
239
+ Figure 5
240
+ </figcaption>
241
+ </figure>
242
+
243
+ <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50">
244
+ <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
245
+ Photo slot
246
+ </div>
247
+ <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
248
+ Figure 6
249
+ </figcaption>
250
+ </figure>
251
  </div>
252
  </section>
253
 
frontend/src/App.tsx CHANGED
@@ -4,6 +4,7 @@ import UploadPage from "./pages/UploadPage";
4
  import ProcessingPage from "./pages/ProcessingPage";
5
  import ReviewSetupPage from "./pages/ReviewSetupPage";
6
  import ReportViewerPage from "./pages/ReportViewerPage";
 
7
  import EditReportPage from "./pages/EditReportPage";
8
  import EditLayoutsPage from "./pages/EditLayoutsPage";
9
  import ExportPage from "./pages/ExportPage";
@@ -16,6 +17,7 @@ export default function App() {
16
  <Route path="/processing" element={<ProcessingPage />} />
17
  <Route path="/review-setup" element={<ReviewSetupPage />} />
18
  <Route path="/report-viewer" element={<ReportViewerPage />} />
 
19
  <Route path="/edit-report" element={<EditReportPage />} />
20
  <Route path="/edit-layouts" element={<EditLayoutsPage />} />
21
  <Route path="/export" element={<ExportPage />} />
 
4
  import ProcessingPage from "./pages/ProcessingPage";
5
  import ReviewSetupPage from "./pages/ReviewSetupPage";
6
  import ReportViewerPage from "./pages/ReportViewerPage";
7
+ import InputDataPage from "./pages/InputDataPage";
8
  import EditReportPage from "./pages/EditReportPage";
9
  import EditLayoutsPage from "./pages/EditLayoutsPage";
10
  import ExportPage from "./pages/ExportPage";
 
17
  <Route path="/processing" element={<ProcessingPage />} />
18
  <Route path="/review-setup" element={<ReviewSetupPage />} />
19
  <Route path="/report-viewer" element={<ReportViewerPage />} />
20
+ <Route path="/input-data" element={<InputDataPage />} />
21
  <Route path="/edit-report" element={<EditReportPage />} />
22
  <Route path="/edit-layouts" element={<EditLayoutsPage />} />
23
  <Route path="/export" element={<ExportPage />} />
frontend/src/components/JobSheetTemplate.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { Session, TemplateFields } from "../types/session";
2
  import { formatDocNumber, getPhotosForPage } from "../lib/report";
3
 
4
  type JobSheetTemplateProps = {
@@ -6,6 +6,8 @@ type JobSheetTemplateProps = {
6
  pageIndex: number;
7
  pageCount: number;
8
  template?: TemplateFields;
 
 
9
  };
10
 
11
  type PhotoSlotProps = {
@@ -41,6 +43,8 @@ export function JobSheetTemplate({
41
  pageIndex,
42
  pageCount,
43
  template,
 
 
44
  }: JobSheetTemplateProps) {
45
  const inspectionDate =
46
  template?.inspection_date ?? session?.inspection_date ?? "";
@@ -60,7 +64,9 @@ export function JobSheetTemplate({
60
  const conditionDescription =
61
  template?.condition_description ?? session?.notes ?? "";
62
  const requiredAction = template?.required_action ?? "";
63
- const photos = getPhotosForPage(session, pageIndex, 2);
 
 
64
 
65
  return (
66
  <div className="w-full h-full p-5 text-[11px] text-gray-700">
@@ -87,199 +93,191 @@ export function JobSheetTemplate({
87
  </div>
88
  </header>
89
 
90
- <section className="mb-4" aria-labelledby="inspection-details-title">
91
- <h2
92
- id="inspection-details-title"
93
- className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2"
94
- >
95
- Inspection Details
96
- </h2>
 
 
97
 
98
- <dl className="grid grid-cols-2 md:grid-cols-4 gap-2 bg-gray-50 border border-gray-200 rounded-lg p-3">
99
- <div className="space-y-0.5">
100
- <dt className="text-[10px] font-medium text-gray-500">
101
- Inspection Date
102
- </dt>
103
- <dd className="template-field text-[11px] font-semibold text-gray-900">
104
- {inspectionDate}
105
- </dd>
106
- </div>
107
 
108
- <div className="space-y-0.5">
109
- <dt className="text-[10px] font-medium text-gray-500">Inspector</dt>
110
- <dd className="template-field text-[11px] font-semibold text-gray-900">
111
- {inspector}
112
- </dd>
113
- </div>
114
 
115
- <div className="space-y-0.5">
116
- <dt className="text-[10px] font-medium text-gray-500">
117
- Accompanied By
118
- </dt>
119
- <dd className="template-field text-[11px] font-semibold text-gray-900">
120
- {accompaniedBy}
121
- </dd>
122
- </div>
123
 
124
- <div className="space-y-0.5">
125
- <dt className="text-[10px] font-medium text-gray-500">Document No</dt>
126
- <dd className="template-field text-[11px] font-mono font-semibold text-gray-900">
127
- {docNumber}
128
- </dd>
129
- </div>
130
 
131
- <div className="space-y-0.5 md:col-span-2">
132
- <dt className="text-[10px] font-medium text-gray-500">Project</dt>
133
- <dd className="template-field text-[11px] font-semibold text-gray-900">
134
- {projectName}
135
- </dd>
136
- </div>
137
 
138
- <div className="space-y-0.5 md:col-span-2">
139
- <dt className="text-[10px] font-medium text-gray-500">
140
- Client / Site
141
- </dt>
142
- <dd className="template-field text-[11px] font-semibold text-gray-900">
143
- {clientSite}
144
- </dd>
145
- </div>
146
- </dl>
147
- </section>
148
 
149
- <section className="mb-4" aria-labelledby="observations-title">
150
- <h2
151
- id="observations-title"
152
- className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2"
153
- >
154
- Observations and Findings
155
- </h2>
156
 
157
- <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
158
- <div className="space-y-2">
159
- <div className="grid grid-cols-2 gap-2">
160
- <div className="space-y-0.5">
161
- <div className="text-[10px] font-medium text-gray-500">
162
- Reference
163
- </div>
164
- <div className="template-field text-[11px] font-semibold text-gray-900">
165
- {reference}
166
- </div>
167
- </div>
168
 
169
- <div className="space-y-0.5">
170
- <div className="text-[10px] font-medium text-gray-500">
171
- Action Type
172
- </div>
173
- <div className="template-field text-[11px] font-semibold text-gray-900">
174
- {actionType}
 
 
 
 
 
 
 
 
 
 
 
175
  </div>
176
  </div>
177
 
178
- <div className="space-y-0.5 col-span-2">
179
- <div className="text-[10px] font-medium text-gray-500">
180
- Item Description
181
- </div>
182
- <div className="template-field text-[11px] font-semibold text-gray-900">
183
- {itemDescription}
 
 
184
  </div>
185
  </div>
186
- </div>
187
- </div>
188
 
189
- <div className="space-y-2">
190
- <div className="space-y-0.5">
191
- <div className="text-[10px] font-medium text-gray-500">
192
- Functional Location
193
- </div>
194
- <div className="template-field text-[11px] font-semibold text-gray-900">
195
- {functionalLocation}
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  </div>
197
- </div>
198
- </div>
199
 
200
- <div className="md:col-span-2 flex justify-center">
201
- <div className="inline-flex items-center gap-10">
202
- <div className="text-center space-y-1">
203
  <div className="text-[10px] font-medium text-gray-500">
204
- Category
 
 
 
 
 
205
  </div>
206
- <span className="template-field inline-flex items-center justify-center rounded-md border border-gray-200 px-4 py-1 text-[11px] font-semibold text-gray-900 min-w-[96px]">
207
- {category}
208
- </span>
209
  </div>
210
 
211
- <div className="text-center space-y-1">
212
  <div className="text-[10px] font-medium text-gray-500">
213
- Priority
 
 
 
 
 
214
  </div>
215
- <span className="template-field inline-flex items-center justify-center rounded-md border border-gray-200 px-4 py-1 text-[11px] font-semibold text-gray-900 min-w-[96px]">
216
- {priority}
217
- </span>
218
  </div>
219
  </div>
220
- </div>
221
-
222
- <div className="md:col-span-2 space-y-1">
223
- <div className="text-[10px] font-medium text-gray-500">
224
- Condition Description
225
- </div>
226
- <div className="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
227
- <p className="template-field template-field-multiline text-amber-800 text-[11px] font-semibold leading-snug">
228
- {conditionDescription}
229
- </p>
230
- </div>
231
- </div>
232
-
233
- <div className="md:col-span-2 space-y-1">
234
- <div className="text-[10px] font-medium text-gray-500">
235
- Required Action
236
- </div>
237
- <div className="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
238
- <p className="template-field template-field-multiline text-blue-800 text-[11px] font-semibold leading-snug">
239
- {requiredAction}
240
- </p>
241
- </div>
242
- </div>
243
- </div>
244
- </section>
245
 
246
  <section className="mb-4 avoid-break">
247
  <div className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
248
- Photo Documentation
249
- </div>
250
- <div className="grid grid-cols-2 gap-3">
251
- <PhotoSlot
252
- url={photos[0]?.url}
253
- label={photos[0]?.name || "Figure 1"}
254
- />
255
- <PhotoSlot
256
- url={photos[1]?.url}
257
- label={photos[1]?.name || "Figure 2"}
258
- />
259
  </div>
260
- </section>
261
-
262
- <section className="mt-4">
263
- <div className="grid grid-cols-3 gap-3 text-[10px] text-gray-600">
264
- <div>
265
- <div className="border-t border-gray-300 pt-1">Inspected by</div>
266
- <div className="h-5 border-b border-gray-200" />
267
- </div>
268
- <div>
269
- <div className="border-t border-gray-300 pt-1">Approved by</div>
270
- <div className="h-5 border-b border-gray-200" />
271
- </div>
272
- <div>
273
- <div className="border-t border-gray-300 pt-1">Completed by</div>
274
- <div className="h-5 border-b border-gray-200" />
275
- </div>
276
  </div>
277
  </section>
278
 
279
- <footer className="mt-4 text-center text-[10px] text-gray-500">
280
- <p>RepEx - (c) 2026 All Rights Reserved</p>
281
- <p className="mt-0.5">Generated by RepEx</p>
282
- </footer>
 
 
283
  </div>
284
  );
285
  }
 
1
+ import type { FileMeta, Session, TemplateFields } from "../types/session";
2
  import { formatDocNumber, getPhotosForPage } from "../lib/report";
3
 
4
  type JobSheetTemplateProps = {
 
6
  pageIndex: number;
7
  pageCount: number;
8
  template?: TemplateFields;
9
+ photos?: FileMeta[];
10
+ variant?: "full" | "photos";
11
  };
12
 
13
  type PhotoSlotProps = {
 
43
  pageIndex,
44
  pageCount,
45
  template,
46
+ photos,
47
+ variant = "full",
48
  }: JobSheetTemplateProps) {
49
  const inspectionDate =
50
  template?.inspection_date ?? session?.inspection_date ?? "";
 
64
  const conditionDescription =
65
  template?.condition_description ?? session?.notes ?? "";
66
  const requiredAction = template?.required_action ?? "";
67
+ const resolvedPhotos = photos && photos.length ? photos : getPhotosForPage(session, pageIndex, 1);
68
+ const photoGridClass = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3";
69
+ const limitedPhotos = resolvedPhotos.slice(0, 6);
70
 
71
  return (
72
  <div className="w-full h-full p-5 text-[11px] text-gray-700">
 
93
  </div>
94
  </header>
95
 
96
+ {variant === "full" ? (
97
+ <>
98
+ <section className="mb-4" aria-labelledby="inspection-details-title">
99
+ <h2
100
+ id="inspection-details-title"
101
+ className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2"
102
+ >
103
+ Inspection Details
104
+ </h2>
105
 
106
+ <dl className="grid grid-cols-2 md:grid-cols-4 gap-2 bg-gray-50 border border-gray-200 rounded-lg p-3">
107
+ <div className="space-y-0.5">
108
+ <dt className="text-[10px] font-medium text-gray-500">
109
+ Inspection Date
110
+ </dt>
111
+ <dd className="template-field text-[11px] font-semibold text-gray-900">
112
+ {inspectionDate}
113
+ </dd>
114
+ </div>
115
 
116
+ <div className="space-y-0.5">
117
+ <dt className="text-[10px] font-medium text-gray-500">Inspector</dt>
118
+ <dd className="template-field text-[11px] font-semibold text-gray-900">
119
+ {inspector}
120
+ </dd>
121
+ </div>
122
 
123
+ <div className="space-y-0.5">
124
+ <dt className="text-[10px] font-medium text-gray-500">
125
+ Accompanied By
126
+ </dt>
127
+ <dd className="template-field text-[11px] font-semibold text-gray-900">
128
+ {accompaniedBy}
129
+ </dd>
130
+ </div>
131
 
132
+ <div className="space-y-0.5">
133
+ <dt className="text-[10px] font-medium text-gray-500">Document No</dt>
134
+ <dd className="template-field text-[11px] font-mono font-semibold text-gray-900">
135
+ {docNumber}
136
+ </dd>
137
+ </div>
138
 
139
+ <div className="space-y-0.5 md:col-span-2">
140
+ <dt className="text-[10px] font-medium text-gray-500">Project</dt>
141
+ <dd className="template-field text-[11px] font-semibold text-gray-900">
142
+ {projectName}
143
+ </dd>
144
+ </div>
145
 
146
+ <div className="space-y-0.5 md:col-span-2">
147
+ <dt className="text-[10px] font-medium text-gray-500">
148
+ Client / Site
149
+ </dt>
150
+ <dd className="template-field text-[11px] font-semibold text-gray-900">
151
+ {clientSite}
152
+ </dd>
153
+ </div>
154
+ </dl>
155
+ </section>
156
 
157
+ <section className="mb-4" aria-labelledby="observations-title">
158
+ <h2
159
+ id="observations-title"
160
+ className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2"
161
+ >
162
+ Observations and Findings
163
+ </h2>
164
 
165
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
166
+ <div className="space-y-2">
167
+ <div className="grid grid-cols-2 gap-2">
168
+ <div className="space-y-0.5">
169
+ <div className="text-[10px] font-medium text-gray-500">
170
+ Reference
171
+ </div>
172
+ <div className="template-field text-[11px] font-semibold text-gray-900">
173
+ {reference}
174
+ </div>
175
+ </div>
176
 
177
+ <div className="space-y-0.5">
178
+ <div className="text-[10px] font-medium text-gray-500">
179
+ Action Type
180
+ </div>
181
+ <div className="template-field text-[11px] font-semibold text-gray-900">
182
+ {actionType}
183
+ </div>
184
+ </div>
185
+
186
+ <div className="space-y-0.5 col-span-2">
187
+ <div className="text-[10px] font-medium text-gray-500">
188
+ Item Description
189
+ </div>
190
+ <div className="template-field text-[11px] font-semibold text-gray-900">
191
+ {itemDescription}
192
+ </div>
193
+ </div>
194
  </div>
195
  </div>
196
 
197
+ <div className="space-y-2">
198
+ <div className="space-y-0.5">
199
+ <div className="text-[10px] font-medium text-gray-500">
200
+ Functional Location
201
+ </div>
202
+ <div className="template-field text-[11px] font-semibold text-gray-900">
203
+ {functionalLocation}
204
+ </div>
205
  </div>
206
  </div>
 
 
207
 
208
+ <div className="md:col-span-2 flex justify-center">
209
+ <div className="inline-flex items-center gap-10">
210
+ <div className="text-center space-y-1">
211
+ <div className="text-[10px] font-medium text-gray-500">
212
+ Category
213
+ </div>
214
+ <span className="template-field inline-flex items-center justify-center rounded-md border border-gray-200 px-4 py-1 text-[11px] font-semibold text-gray-900 min-w-[96px]">
215
+ {category}
216
+ </span>
217
+ </div>
218
+
219
+ <div className="text-center space-y-1">
220
+ <div className="text-[10px] font-medium text-gray-500">
221
+ Priority
222
+ </div>
223
+ <span className="template-field inline-flex items-center justify-center rounded-md border border-gray-200 px-4 py-1 text-[11px] font-semibold text-gray-900 min-w-[96px]">
224
+ {priority}
225
+ </span>
226
+ </div>
227
+ </div>
228
  </div>
 
 
229
 
230
+ <div className="md:col-span-2 space-y-1">
 
 
231
  <div className="text-[10px] font-medium text-gray-500">
232
+ Condition Description
233
+ </div>
234
+ <div className="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
235
+ <p className="template-field template-field-multiline text-amber-800 text-[11px] font-semibold leading-snug">
236
+ {conditionDescription}
237
+ </p>
238
  </div>
 
 
 
239
  </div>
240
 
241
+ <div className="md:col-span-2 space-y-1">
242
  <div className="text-[10px] font-medium text-gray-500">
243
+ Required Action
244
+ </div>
245
+ <div className="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
246
+ <p className="template-field template-field-multiline text-blue-800 text-[11px] font-semibold leading-snug">
247
+ {requiredAction}
248
+ </p>
249
  </div>
 
 
 
250
  </div>
251
  </div>
252
+ </section>
253
+ </>
254
+ ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
 
256
  <section className="mb-4 avoid-break">
257
  <div className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
258
+ {variant === "photos" ? "Photo Documentation (continued)" : "Photo Documentation"}
 
 
 
 
 
 
 
 
 
 
259
  </div>
260
+ <div className={photoGridClass}>
261
+ {limitedPhotos.length === 0 ? (
262
+ <PhotoSlot url={undefined} label="No photo selected" />
263
+ ) : (
264
+ limitedPhotos.map((photo, index) => (
265
+ <PhotoSlot
266
+ key={photo?.id || `${index}`}
267
+ url={photo?.url}
268
+ label={photo?.name || `Figure ${index + 1}`}
269
+ />
270
+ ))
271
+ )}
 
 
 
 
272
  </div>
273
  </section>
274
 
275
+ {variant === "full" ? (
276
+ <footer className="mt-4 text-center text-[10px] text-gray-500">
277
+ <p>RepEx - (c) 2026 All Rights Reserved</p>
278
+ <p className="mt-0.5">Generated by RepEx</p>
279
+ </footer>
280
+ ) : null}
281
  </div>
282
  );
283
  }
frontend/src/components/ReportPageCanvas.tsx CHANGED
@@ -1,7 +1,7 @@
1
  import type { CSSProperties } from "react";
2
 
3
- import type { Page, Session, TemplateFields } from "../types/session";
4
- import { BASE_H, BASE_W } from "../lib/report";
5
  import { JobSheetTemplate } from "./JobSheetTemplate";
6
 
7
  type ReportPageCanvasProps = {
@@ -12,6 +12,7 @@ type ReportPageCanvasProps = {
12
  scale: number;
13
  template?: TemplateFields;
14
  className?: string;
 
15
  };
16
 
17
  export function ReportPageCanvas({
@@ -22,28 +23,53 @@ export function ReportPageCanvas({
22
  scale,
23
  template,
24
  className = "",
 
25
  }: ReportPageCanvasProps) {
26
  const items = page?.items ?? [];
27
  const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
 
 
 
 
 
 
28
 
29
  return (
30
- <div className={["relative w-full h-full overflow-hidden", className].join(" ")}>
 
 
 
31
  <div className="absolute inset-0 pointer-events-none">
32
- <div
33
- style={{
34
- width: `${BASE_W}px`,
35
- height: `${BASE_H}px`,
36
- transform: `scale(${safeScale})`,
37
- transformOrigin: "top left",
38
- }}
39
- >
40
- <JobSheetTemplate
41
- session={session}
42
- pageIndex={pageIndex}
43
- pageCount={pageCount}
44
- template={template}
45
- />
46
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  </div>
48
 
49
  {items
@@ -110,3 +136,27 @@ export function ReportPageCanvas({
110
  </div>
111
  );
112
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import type { CSSProperties } from "react";
2
 
3
+ import type { FileMeta, Page, Session, TemplateFields } from "../types/session";
4
+ import { BASE_H, BASE_W, getPhotosForPage } from "../lib/report";
5
  import { JobSheetTemplate } from "./JobSheetTemplate";
6
 
7
  type ReportPageCanvasProps = {
 
12
  scale: number;
13
  template?: TemplateFields;
14
  className?: string;
15
+ adaptive?: boolean;
16
  };
17
 
18
  export function ReportPageCanvas({
 
23
  scale,
24
  template,
25
  className = "",
26
+ adaptive = false,
27
  }: ReportPageCanvasProps) {
28
  const items = page?.items ?? [];
29
  const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
30
+ const photos = resolvePagePhotos(session, page, pageIndex);
31
+ const photosPerSheet = 3;
32
+ const photoSheets = chunkPhotos(photos, photosPerSheet);
33
+ const sheets = adaptive && photoSheets.length > 1 ? photoSheets : [photos];
34
+ const sheetHeight = BASE_H * safeScale;
35
+ const containerHeight = sheetHeight * sheets.length;
36
 
37
  return (
38
+ <div
39
+ className={["relative w-full", className].join(" ")}
40
+ style={{ height: `${containerHeight}px` }}
41
+ >
42
  <div className="absolute inset-0 pointer-events-none">
43
+ {sheets.map((sheetPhotos, sheetIndex) => (
44
+ <div
45
+ key={`sheet-${sheetIndex}`}
46
+ style={{
47
+ position: "absolute",
48
+ top: `${sheetIndex * sheetHeight}px`,
49
+ left: 0,
50
+ width: `${BASE_W * safeScale}px`,
51
+ height: `${sheetHeight}px`,
52
+ }}
53
+ >
54
+ <div
55
+ style={{
56
+ width: `${BASE_W}px`,
57
+ height: `${BASE_H}px`,
58
+ transform: `scale(${safeScale})`,
59
+ transformOrigin: "top left",
60
+ }}
61
+ >
62
+ <JobSheetTemplate
63
+ session={session}
64
+ pageIndex={pageIndex}
65
+ pageCount={pageCount}
66
+ template={template}
67
+ photos={sheetPhotos}
68
+ variant={sheetIndex === 0 ? "full" : "photos"}
69
+ />
70
+ </div>
71
+ </div>
72
+ ))}
73
  </div>
74
 
75
  {items
 
136
  </div>
137
  );
138
  }
139
+
140
+ function chunkPhotos(photos: FileMeta[], perSheet: number) {
141
+ if (!photos.length) return [[]];
142
+ const slices: FileMeta[][] = [];
143
+ for (let i = 0; i < photos.length; i += perSheet) {
144
+ slices.push(photos.slice(i, i + perSheet));
145
+ }
146
+ return slices;
147
+ }
148
+
149
+ function resolvePagePhotos(
150
+ session: Session | null,
151
+ page: Page | null | undefined,
152
+ pageIndex: number,
153
+ ): FileMeta[] {
154
+ if (!session) return [];
155
+ const uploads = session.uploads?.photos ?? [];
156
+ const byId = new Map(uploads.map((photo) => [photo.id, photo]));
157
+ const explicit = page?.photo_ids ?? [];
158
+ if (explicit.length) {
159
+ return explicit.map((id) => byId.get(id)).filter(Boolean) as FileMeta[];
160
+ }
161
+ return getPhotosForPage(session, pageIndex, 1);
162
+ }
frontend/src/components/report-editor.js CHANGED
@@ -37,6 +37,13 @@ class ReportEditor extends HTMLElement {
37
  this.hide();
38
  }
39
 
 
 
 
 
 
 
 
40
  // Public API
41
  open({
42
  payload,
@@ -78,8 +85,15 @@ class ReportEditor extends HTMLElement {
78
  this.state.undo = [];
79
  this.state.redo = [];
80
 
 
 
 
 
 
81
  this.show();
82
  this.updateAll();
 
 
83
 
84
  if (this.sessionId) {
85
  this._loadPagesFromServer().then((pages) => {
@@ -207,11 +221,11 @@ class ReportEditor extends HTMLElement {
207
 
208
  <!-- Canvas area -->
209
  <div class="flex justify-center">
210
- <div class="relative" data-canvas-wrap>
211
  <div
212
  data-canvas
213
- class="relative bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden select-none"
214
- style="width: min(100%, 700px); aspect-ratio: 210/297;"
215
  aria-label="Editable A4 canvas"
216
  >
217
  <!-- items injected here -->
@@ -371,6 +385,16 @@ class ReportEditor extends HTMLElement {
371
  this.$imgFile = this.querySelector('[data-file="image"]');
372
  this.$replaceFile = this.querySelector('[data-file="replace"]');
373
 
 
 
 
 
 
 
 
 
 
 
374
  // header buttons
375
  this.querySelector('[data-btn="close"]').addEventListener("click", () => this.close());
376
  this.querySelector('[data-btn="save"]').addEventListener("click", () => this._savePages(true));
@@ -517,9 +541,9 @@ class ReportEditor extends HTMLElement {
517
 
518
  _getTemplate() {
519
  if (!this.state.pages.length) return {};
520
- const first = this.state.pages[0];
521
- if (!first.template) first.template = {};
522
- return first.template;
523
  }
524
 
525
  _bindTemplateFields() {
@@ -564,6 +588,20 @@ class ReportEditor extends HTMLElement {
564
  return selected.length ? selected : uploads;
565
  }
566
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
567
  _photoSlot(photo, fallbackLabel) {
568
  const url =
569
  photo && (photo.url || (this.sessionId ? `${this._apiRoot()}/sessions/${this.sessionId}/uploads/${photo.id}` : ""));
@@ -615,15 +653,16 @@ class ReportEditor extends HTMLElement {
615
  template.condition_description || session.notes || "";
616
  const requiredAction = template.required_action || "";
617
 
618
- const photos = this._selectedPhotos(session);
619
- const start = this.state.activePage * 2;
620
- const photoA = photos[start];
621
- const photoB = photos[start + 1];
 
622
  const pageNum = this.state.activePage + 1;
623
  const pageCount = this.state.pages.length || 1;
624
 
625
  return `
626
- <div class="w-full h-full p-5 text-[11px] text-gray-700">
627
  <header class="mb-4 border-b border-gray-200 pb-3">
628
  <div class="grid grid-cols-[auto,1fr,auto] items-center gap-3">
629
  <div class="flex items-center">
@@ -746,26 +785,8 @@ class ReportEditor extends HTMLElement {
746
  Photo Documentation
747
  </h2>
748
 
749
- <div class="grid grid-cols-2 gap-3">
750
- ${this._photoSlot(photoA, "Figure 1")}
751
- ${this._photoSlot(photoB, "Figure 2")}
752
- </div>
753
- </section>
754
-
755
- <section class="mt-4">
756
- <div class="grid grid-cols-3 gap-3 text-[10px] text-gray-600">
757
- <div>
758
- <div class="border-t border-gray-300 pt-1">Inspected by</div>
759
- <div class="h-5 border-b border-gray-200"></div>
760
- </div>
761
- <div>
762
- <div class="border-t border-gray-300 pt-1">Approved by</div>
763
- <div class="h-5 border-b border-gray-200"></div>
764
- </div>
765
- <div>
766
- <div class="border-t border-gray-300 pt-1">Completed by</div>
767
- <div class="h-5 border-b border-gray-200"></div>
768
- </div>
769
  </div>
770
  </section>
771
 
@@ -961,9 +982,20 @@ class ReportEditor extends HTMLElement {
961
 
962
  const template = document.createElement("div");
963
  template.className = "absolute inset-0";
 
 
 
 
 
 
 
 
 
 
 
964
  template.innerHTML = `
965
  <div style="width:${this.BASE_W}px; height:${this.BASE_H}px; transform: scale(${scale}); transform-origin: top left;">
966
- ${this._templateMarkup()}
967
  </div>
968
  `;
969
  this.$canvas.appendChild(template);
 
37
  this.hide();
38
  }
39
 
40
+ disconnectedCallback() {
41
+ if (this._resizeObserver) {
42
+ this._resizeObserver.disconnect();
43
+ this._resizeObserver = null;
44
+ }
45
+ }
46
+
47
  // Public API
48
  open({
49
  payload,
 
85
  this.state.undo = [];
86
  this.state.redo = [];
87
 
88
+ if (!this.$overlay) {
89
+ this.render();
90
+ this.bind();
91
+ }
92
+
93
  this.show();
94
  this.updateAll();
95
+ requestAnimationFrame(() => this.updateAll());
96
+ setTimeout(() => this.updateAll(), 0);
97
 
98
  if (this.sessionId) {
99
  this._loadPagesFromServer().then((pages) => {
 
221
 
222
  <!-- Canvas area -->
223
  <div class="flex justify-center">
224
+ <div class="relative w-full max-w-[700px]" data-canvas-wrap>
225
  <div
226
  data-canvas
227
+ class="relative bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden select-none w-full"
228
+ style="aspect-ratio: 210/297;"
229
  aria-label="Editable A4 canvas"
230
  >
231
  <!-- items injected here -->
 
385
  this.$imgFile = this.querySelector('[data-file="image"]');
386
  this.$replaceFile = this.querySelector('[data-file="replace"]');
387
 
388
+ if (this.$canvas && "ResizeObserver" in window) {
389
+ this._resizeObserver = new ResizeObserver(() => {
390
+ if (this.state.isOpen) {
391
+ this.renderCanvas();
392
+ this.updateCanvasScale();
393
+ }
394
+ });
395
+ this._resizeObserver.observe(this.$canvas);
396
+ }
397
+
398
  // header buttons
399
  this.querySelector('[data-btn="close"]').addEventListener("click", () => this.close());
400
  this.querySelector('[data-btn="save"]').addEventListener("click", () => this._savePages(true));
 
541
 
542
  _getTemplate() {
543
  if (!this.state.pages.length) return {};
544
+ const page = this.state.pages[this.state.activePage] || this.state.pages[0];
545
+ if (!page.template) page.template = {};
546
+ return page.template;
547
  }
548
 
549
  _bindTemplateFields() {
 
588
  return selected.length ? selected : uploads;
589
  }
590
 
591
+ _photosForActivePage(session) {
592
+ const uploads = (session && session.uploads && session.uploads.photos) || [];
593
+ const byId = new Map(uploads.map((photo) => [photo.id, photo]));
594
+ const page = this.activePage || {};
595
+ const explicit = page.photo_ids || [];
596
+ if (explicit.length) {
597
+ return explicit.map((id) => byId.get(id)).filter(Boolean);
598
+ }
599
+ const selected = this._selectedPhotos(session);
600
+ const perPage = 1;
601
+ const start = this.state.activePage * perPage;
602
+ return selected.slice(start, start + perPage);
603
+ }
604
+
605
  _photoSlot(photo, fallbackLabel) {
606
  const url =
607
  photo && (photo.url || (this.sessionId ? `${this._apiRoot()}/sessions/${this.sessionId}/uploads/${photo.id}` : ""));
 
653
  template.condition_description || session.notes || "";
654
  const requiredAction = template.required_action || "";
655
 
656
+ const photos = this._photosForActivePage(session).slice(0, 6);
657
+ const photoGridClass = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3";
658
+ const photoSlots = photos.length
659
+ ? photos.map((photo, idx) => this._photoSlot(photo, `Figure ${idx + 1}`)).join("")
660
+ : this._photoSlot(null, "No photo selected");
661
  const pageNum = this.state.activePage + 1;
662
  const pageCount = this.state.pages.length || 1;
663
 
664
  return `
665
+ <div class="w-full h-full p-5 text-[11px] text-gray-700" style="color:#374151; font-size:11px;">
666
  <header class="mb-4 border-b border-gray-200 pb-3">
667
  <div class="grid grid-cols-[auto,1fr,auto] items-center gap-3">
668
  <div class="flex items-center">
 
785
  Photo Documentation
786
  </h2>
787
 
788
+ <div class="${photoGridClass}">
789
+ ${photoSlots}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
790
  </div>
791
  </section>
792
 
 
982
 
983
  const template = document.createElement("div");
984
  template.className = "absolute inset-0";
985
+ let templateHtml = "";
986
+ try {
987
+ templateHtml = this._templateMarkup();
988
+ } catch (err) {
989
+ console.error("Template render failed", err);
990
+ templateHtml = `
991
+ <div class="p-4 text-sm text-red-600">
992
+ Template failed to render. Check console for details.
993
+ </div>
994
+ `;
995
+ }
996
  template.innerHTML = `
997
  <div style="width:${this.BASE_W}px; height:${this.BASE_H}px; transform: scale(${scale}); transform-origin: top left;">
998
+ ${templateHtml}
999
  </div>
1000
  `;
1001
  this.$canvas.appendChild(template);
frontend/src/index.css CHANGED
@@ -37,6 +37,11 @@ body {
37
  page-break-inside: avoid;
38
  }
39
 
 
 
 
 
 
40
  report-editor[data-mode="page"] [data-overlay] {
41
  position: static;
42
  inset: auto;
 
37
  page-break-inside: avoid;
38
  }
39
 
40
+ report-editor {
41
+ display: block;
42
+ width: 100%;
43
+ }
44
+
45
  report-editor[data-mode="page"] [data-overlay] {
46
  position: static;
47
  inset: auto;
frontend/src/lib/report.ts CHANGED
@@ -17,7 +17,7 @@ export function getSelectedPhotos(session: Session | null | undefined): FileMeta
17
  export function getPhotosForPage(
18
  session: Session | null | undefined,
19
  pageIndex: number,
20
- perPage = 2,
21
  ): FileMeta[] {
22
  const selected = getSelectedPhotos(session);
23
  const start = pageIndex * perPage;
 
17
  export function getPhotosForPage(
18
  session: Session | null | undefined,
19
  pageIndex: number,
20
+ perPage = 1,
21
  ): FileMeta[] {
22
  const selected = getSelectedPhotos(session);
23
  const start = pageIndex * perPage;
frontend/src/pages/EditLayoutsPage.tsx CHANGED
@@ -9,6 +9,7 @@ import {
9
  Grid,
10
  Layout,
11
  Plus,
 
12
  Trash2,
13
  } from "react-feather";
14
 
@@ -59,19 +60,48 @@ export default function EditLayoutsPage() {
59
  const totalPages = useMemo(() => Math.max(1, pages.length), [pages.length]);
60
  const previewWidth = 220;
61
  const previewScale = previewWidth / BASE_W;
62
- const template = pages[0]?.template;
63
 
64
- async function savePages(next: Page[]) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  if (!sessionId) return;
66
  setIsSaving(true);
67
  setStatus("Saving layout changes...");
68
  try {
69
- const resp = await putJson<{ pages: Page[] }>(
70
- `/sessions/${sessionId}/pages`,
71
- { pages: next },
72
- );
73
- const updated = Array.isArray(resp.pages) ? resp.pages : next;
 
 
 
 
 
 
 
 
 
74
  setPages(updated.length ? updated : [{ items: [] }]);
 
 
 
75
  setStatus("Layout saved.");
76
  } catch (err) {
77
  const message =
@@ -82,23 +112,37 @@ export default function EditLayoutsPage() {
82
  }
83
  }
84
 
85
- function handleAddPage() {
86
  const next = [...pages, { items: [] }];
87
- savePages(next);
88
  }
89
 
90
- function handleRemovePage(index: number) {
91
  if (pages.length <= 1) return;
92
  const next = pages.filter((_, idx) => idx !== index);
93
- savePages(next);
 
 
 
 
 
 
 
94
  }
95
 
96
- function handleMovePage(index: number, direction: number) {
97
  const target = index + direction;
98
  if (target < 0 || target >= pages.length) return;
99
  const next = [...pages];
100
  [next[index], next[target]] = [next[target], next[index]];
101
- savePages(next);
 
 
 
 
 
 
 
102
  }
103
 
104
  return (
@@ -119,6 +163,14 @@ export default function EditLayoutsPage() {
119
 
120
  <nav className="mb-6" aria-label="Report workflow navigation">
121
  <div className="flex flex-wrap gap-2">
 
 
 
 
 
 
 
 
122
  <Link
123
  to={`/report-viewer${sessionQuery}`}
124
  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"
@@ -229,7 +281,7 @@ export default function EditLayoutsPage() {
229
  pageIndex={index}
230
  pageCount={totalPages}
231
  scale={previewScale}
232
- template={template}
233
  />
234
  </div>
235
  </div>
 
9
  Grid,
10
  Layout,
11
  Plus,
12
+ Table,
13
  Trash2,
14
  } from "react-feather";
15
 
 
60
  const totalPages = useMemo(() => Math.max(1, pages.length), [pages.length]);
61
  const previewWidth = 220;
62
  const previewScale = previewWidth / BASE_W;
 
63
 
64
+ function hasExplicitPhotos(source: Page[]) {
65
+ return source.some((page) => (page.photo_ids ?? []).length > 0);
66
+ }
67
+
68
+ function flattenPhotoIds(source: Page[]) {
69
+ const seen = new Set<string>();
70
+ const result: string[] = [];
71
+ source.forEach((page) => {
72
+ (page.photo_ids ?? []).forEach((photoId) => {
73
+ if (!seen.has(photoId)) {
74
+ seen.add(photoId);
75
+ result.push(photoId);
76
+ }
77
+ });
78
+ });
79
+ return result;
80
+ }
81
+
82
+ async function saveLayout(next: Page[], nextSelectedIds?: string[]) {
83
  if (!sessionId) return;
84
  setIsSaving(true);
85
  setStatus("Saving layout changes...");
86
  try {
87
+ const requests: Promise<unknown>[] = [
88
+ putJson<{ pages: Page[] }>(`/sessions/${sessionId}/pages`, { pages: next }),
89
+ ];
90
+ if (nextSelectedIds !== undefined) {
91
+ requests.push(
92
+ putJson<Session>(`/sessions/${sessionId}/selection`, {
93
+ selected_photo_ids: nextSelectedIds,
94
+ }),
95
+ );
96
+ }
97
+ const [pagesResp, sessionResp] = await Promise.all(requests);
98
+ const updatedPages = (pagesResp as { pages?: Page[] }).pages ?? next;
99
+ const updatedSession = sessionResp as Session | undefined;
100
+ const updated = Array.isArray(updatedPages) ? updatedPages : next;
101
  setPages(updated.length ? updated : [{ items: [] }]);
102
+ if (updatedSession) {
103
+ setSession(updatedSession);
104
+ }
105
  setStatus("Layout saved.");
106
  } catch (err) {
107
  const message =
 
112
  }
113
  }
114
 
115
+ async function handleAddPage() {
116
  const next = [...pages, { items: [] }];
117
+ await saveLayout(next);
118
  }
119
 
120
+ async function handleRemovePage(index: number) {
121
  if (pages.length <= 1) return;
122
  const next = pages.filter((_, idx) => idx !== index);
123
+ if (session) {
124
+ const nextSelected = hasExplicitPhotos(next)
125
+ ? flattenPhotoIds(next)
126
+ : session.selected_photo_ids ?? [];
127
+ await saveLayout(next, nextSelected);
128
+ return;
129
+ }
130
+ await saveLayout(next);
131
  }
132
 
133
+ async function handleMovePage(index: number, direction: number) {
134
  const target = index + direction;
135
  if (target < 0 || target >= pages.length) return;
136
  const next = [...pages];
137
  [next[index], next[target]] = [next[target], next[index]];
138
+ if (session) {
139
+ const nextSelected = hasExplicitPhotos(next)
140
+ ? flattenPhotoIds(next)
141
+ : session.selected_photo_ids ?? [];
142
+ await saveLayout(next, nextSelected);
143
+ return;
144
+ }
145
+ await saveLayout(next);
146
  }
147
 
148
  return (
 
163
 
164
  <nav className="mb-6" aria-label="Report workflow navigation">
165
  <div className="flex flex-wrap gap-2">
166
+ <Link
167
+ to={`/input-data${sessionQuery}`}
168
+ 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"
169
+ >
170
+ <Table className="h-4 w-4" />
171
+ Input Data
172
+ </Link>
173
+
174
  <Link
175
  to={`/report-viewer${sessionQuery}`}
176
  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"
 
281
  pageIndex={index}
282
  pageCount={totalPages}
283
  scale={previewScale}
284
+ template={page?.template}
285
  />
286
  </div>
287
  </div>
frontend/src/pages/EditReportPage.tsx CHANGED
@@ -1,6 +1,6 @@
1
- import { useEffect, useMemo, useRef, useState } from "react";
2
  import { Link, useNavigate, useSearchParams } from "react-router-dom";
3
- import { ArrowLeft, Download, Edit3, Grid, Layout } from "react-feather";
4
 
5
  import { API_BASE, request } from "../lib/api";
6
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
@@ -16,9 +16,13 @@ export default function EditReportPage() {
16
  const navigate = useNavigate();
17
 
18
  const [session, setSession] = useState<Session | null>(null);
 
19
  const [error, setError] = useState("");
20
 
21
- const editorRef = useRef<ReportEditorElement | null>(null);
 
 
 
22
 
23
  const pageIndex = useMemo(() => {
24
  const raw = Number(searchParams.get("page") || "1");
@@ -34,8 +38,13 @@ export default function EditReportPage() {
34
  setStoredSessionId(sessionId);
35
  async function load() {
36
  try {
37
- const data = await request<Session>(`/sessions/${sessionId}`);
 
 
 
38
  setSession(data);
 
 
39
  } catch (err) {
40
  const message =
41
  err instanceof Error ? err.message : "Failed to load session.";
@@ -46,9 +55,14 @@ export default function EditReportPage() {
46
  }, [sessionId]);
47
 
48
  useEffect(() => {
49
- if (!sessionId || !session || !editorRef.current) return;
50
- const totalPages = Math.max(1, session.page_count ?? 1);
51
- editorRef.current.open({
 
 
 
 
 
52
  payload: session,
53
  pageIndex,
54
  totalPages,
@@ -56,17 +70,16 @@ export default function EditReportPage() {
56
  apiBase: API_BASE,
57
  mode: "page",
58
  });
59
- }, [sessionId, session, pageIndex]);
60
 
61
  useEffect(() => {
62
- const editor = editorRef.current;
63
- if (!editor) return;
64
  const handleClose = () => {
65
  navigate(`/report-viewer${sessionQuery}`);
66
  };
67
- editor.addEventListener("editor-closed", handleClose);
68
- return () => editor.removeEventListener("editor-closed", handleClose);
69
- }, [navigate, sessionQuery]);
70
 
71
  return (
72
  <PageShell className="max-w-6xl">
@@ -86,6 +99,14 @@ export default function EditReportPage() {
86
 
87
  <nav className="mb-6" aria-label="Report workflow navigation">
88
  <div className="flex flex-wrap gap-2">
 
 
 
 
 
 
 
 
89
  <Link
90
  to={`/report-viewer${sessionQuery}`}
91
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
 
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
  import { Link, useNavigate, useSearchParams } from "react-router-dom";
3
+ import { ArrowLeft, Download, Edit3, Grid, Layout, Table } from "react-feather";
4
 
5
  import { API_BASE, request } from "../lib/api";
6
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
 
16
  const navigate = useNavigate();
17
 
18
  const [session, setSession] = useState<Session | null>(null);
19
+ const [pageCount, setPageCount] = useState<number | null>(null);
20
  const [error, setError] = useState("");
21
 
22
+ const [editorEl, setEditorEl] = useState<ReportEditorElement | null>(null);
23
+ const editorRef = useCallback((node: ReportEditorElement | null) => {
24
+ setEditorEl(node);
25
+ }, []);
26
 
27
  const pageIndex = useMemo(() => {
28
  const raw = Number(searchParams.get("page") || "1");
 
38
  setStoredSessionId(sessionId);
39
  async function load() {
40
  try {
41
+ const [data, pageResp] = await Promise.all([
42
+ request<Session>(`/sessions/${sessionId}`),
43
+ request<{ pages: { items: unknown[] }[] }>(`/sessions/${sessionId}/pages`),
44
+ ]);
45
  setSession(data);
46
+ const loaded = Array.isArray(pageResp.pages) ? pageResp.pages.length : 0;
47
+ setPageCount(loaded || null);
48
  } catch (err) {
49
  const message =
50
  err instanceof Error ? err.message : "Failed to load session.";
 
55
  }, [sessionId]);
56
 
57
  useEffect(() => {
58
+ if (!sessionId || !session || !editorEl) return;
59
+ const totalPages = pageCount && pageCount > 0
60
+ ? pageCount
61
+ : Math.max(
62
+ 1,
63
+ session.page_count ?? 1,
64
+ );
65
+ editorEl.open({
66
  payload: session,
67
  pageIndex,
68
  totalPages,
 
70
  apiBase: API_BASE,
71
  mode: "page",
72
  });
73
+ }, [editorEl, sessionId, session, pageIndex, pageCount]);
74
 
75
  useEffect(() => {
76
+ if (!editorEl) return;
 
77
  const handleClose = () => {
78
  navigate(`/report-viewer${sessionQuery}`);
79
  };
80
+ editorEl.addEventListener("editor-closed", handleClose);
81
+ return () => editorEl.removeEventListener("editor-closed", handleClose);
82
+ }, [editorEl, navigate, sessionQuery]);
83
 
84
  return (
85
  <PageShell className="max-w-6xl">
 
99
 
100
  <nav className="mb-6" aria-label="Report workflow navigation">
101
  <div className="flex flex-wrap gap-2">
102
+ <Link
103
+ to={`/input-data${sessionQuery}`}
104
+ 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"
105
+ >
106
+ <Table className="h-4 w-4" />
107
+ Input Data
108
+ </Link>
109
+
110
  <Link
111
  to={`/report-viewer${sessionQuery}`}
112
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
frontend/src/pages/ExportPage.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
  import { Link, useSearchParams } from "react-router-dom";
3
- import { ArrowLeft, Download, Edit3, Grid, Layout } from "react-feather";
4
 
5
  import { API_BASE, request } from "../lib/api";
6
  import { BASE_W } from "../lib/report";
@@ -79,8 +79,8 @@ export default function ExportPage() {
79
  };
80
  }, [pages, session]);
81
 
82
- const totalPages = Math.max(1, pages.length, session?.page_count ?? 0);
83
- const template = pages[0]?.template;
84
  const serverExportUrl = sessionId
85
  ? `${API_BASE}/sessions/${sessionId}/export`
86
  : "";
@@ -131,6 +131,14 @@ export default function ExportPage() {
131
 
132
  <nav className="mb-6 no-print" aria-label="Report workflow navigation">
133
  <div className="flex flex-wrap gap-2">
 
 
 
 
 
 
 
 
134
  <Link
135
  to={`/report-viewer${sessionQuery}`}
136
  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"
@@ -340,7 +348,7 @@ export default function ExportPage() {
340
  pageIndex={index}
341
  pageCount={totalPages}
342
  scale={previewScale}
343
- template={template}
344
  />
345
  </div>
346
  </div>
 
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
  import { Link, useSearchParams } from "react-router-dom";
3
+ import { ArrowLeft, Download, Edit3, Grid, Layout, Table } from "react-feather";
4
 
5
  import { API_BASE, request } from "../lib/api";
6
  import { BASE_W } from "../lib/report";
 
79
  };
80
  }, [pages, session]);
81
 
82
+ const totalPages =
83
+ pages.length > 0 ? pages.length : Math.max(1, session?.page_count ?? 0);
84
  const serverExportUrl = sessionId
85
  ? `${API_BASE}/sessions/${sessionId}/export`
86
  : "";
 
131
 
132
  <nav className="mb-6 no-print" aria-label="Report workflow navigation">
133
  <div className="flex flex-wrap gap-2">
134
+ <Link
135
+ to={`/input-data${sessionQuery}`}
136
+ 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"
137
+ >
138
+ <Table className="h-4 w-4" />
139
+ Input Data
140
+ </Link>
141
+
142
  <Link
143
  to={`/report-viewer${sessionQuery}`}
144
  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"
 
348
  pageIndex={index}
349
  pageCount={totalPages}
350
  scale={previewScale}
351
+ template={pages[index]?.template}
352
  />
353
  </div>
354
  </div>
frontend/src/pages/InputDataPage.tsx ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { Link, useSearchParams } from "react-router-dom";
3
+ import { ArrowLeft, Download, Edit3, Grid, Layout, Save, Table } from "react-feather";
4
+
5
+ import { putJson, request } from "../lib/api";
6
+ import { formatDocNumber } from "../lib/report";
7
+ import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
8
+ import type { Page, Session, TemplateFields } from "../types/session";
9
+ import { PageFooter } from "../components/PageFooter";
10
+ import { PageHeader } from "../components/PageHeader";
11
+ import { PageShell } from "../components/PageShell";
12
+
13
+ type FieldDef = {
14
+ key: keyof TemplateFields;
15
+ label: string;
16
+ multiline?: boolean;
17
+ };
18
+
19
+ const FIELD_DEFS: FieldDef[] = [
20
+ { key: "inspection_date", label: "Inspection Date" },
21
+ { key: "inspector", label: "Inspector" },
22
+ { key: "accompanied_by", label: "Accompanied By" },
23
+ { key: "document_no", label: "Document No" },
24
+ { key: "project", label: "Project" },
25
+ { key: "client_site", label: "Client / Site" },
26
+ { key: "reference", label: "Reference" },
27
+ { key: "action_type", label: "Action Type" },
28
+ { key: "item_description", label: "Item Description", multiline: true },
29
+ { key: "functional_location", label: "Functional Location" },
30
+ { key: "category", label: "Category" },
31
+ { key: "priority", label: "Priority" },
32
+ { key: "condition_description", label: "Condition Description", multiline: true },
33
+ { key: "required_action", label: "Required Action", multiline: true },
34
+ ];
35
+
36
+ export default function InputDataPage() {
37
+ const [searchParams] = useSearchParams();
38
+ const sessionId = getSessionId(searchParams.toString());
39
+ const sessionQuery = buildSessionQuery(sessionId);
40
+
41
+ const [session, setSession] = useState<Session | null>(null);
42
+ const [pages, setPages] = useState<Page[]>([]);
43
+ const [status, setStatus] = useState("");
44
+ const [isSaving, setIsSaving] = useState(false);
45
+ const canSave = Boolean(sessionId) && !isSaving;
46
+
47
+ useEffect(() => {
48
+ if (!sessionId) {
49
+ setStatus("No active session found. Return to upload to continue.");
50
+ return;
51
+ }
52
+ setStoredSessionId(sessionId);
53
+ async function load() {
54
+ try {
55
+ const data = await request<Session>(`/sessions/${sessionId}`);
56
+ setSession(data);
57
+ const pageResp = await request<{ pages: Page[] }>(
58
+ `/sessions/${sessionId}/pages`,
59
+ );
60
+ const loaded = Array.isArray(pageResp.pages) ? pageResp.pages : [];
61
+ setPages(loaded.length ? loaded : [{ items: [] }]);
62
+ } catch (err) {
63
+ const message =
64
+ err instanceof Error ? err.message : "Failed to load session.";
65
+ setStatus(message);
66
+ }
67
+ }
68
+ load();
69
+ }, [sessionId]);
70
+
71
+ const totalPages = useMemo(() => {
72
+ if (pages.length > 0) return pages.length;
73
+ return Math.max(1, session?.page_count ?? 0);
74
+ }, [pages.length, session?.page_count]);
75
+
76
+ useEffect(() => {
77
+ if (!sessionId) return;
78
+ if (pages.length >= totalPages) return;
79
+ setPages((prev) => {
80
+ const next = [...prev];
81
+ while (next.length < totalPages) {
82
+ next.push({ items: [] });
83
+ }
84
+ return next;
85
+ });
86
+ }, [pages.length, sessionId, totalPages]);
87
+
88
+ function updateField(pageIndex: number, key: keyof TemplateFields, value: string) {
89
+ setPages((prev) =>
90
+ prev.map((page, idx) => {
91
+ if (idx !== pageIndex) return page;
92
+ const template = { ...(page.template ?? {}) };
93
+ template[key] = value;
94
+ return { ...page, template };
95
+ }),
96
+ );
97
+ }
98
+
99
+ function applyRowToAll(pageIndex: number) {
100
+ const source = pages[pageIndex]?.template ?? {};
101
+ setPages((prev) =>
102
+ prev.map((page) => ({
103
+ ...page,
104
+ template: { ...source },
105
+ })),
106
+ );
107
+ }
108
+
109
+ function getFallbackValue(field: keyof TemplateFields): string {
110
+ if (!session) return "";
111
+ switch (field) {
112
+ case "inspection_date":
113
+ return session.inspection_date || "";
114
+ case "document_no":
115
+ return formatDocNumber(session);
116
+ case "project":
117
+ return session.project_name || "";
118
+ case "condition_description":
119
+ return session.notes || "";
120
+ default:
121
+ return "";
122
+ }
123
+ }
124
+
125
+ async function saveAll() {
126
+ if (!sessionId) return;
127
+ setIsSaving(true);
128
+ setStatus("Saving input data...");
129
+ try {
130
+ const resp = await putJson<{ pages: Page[] }>(
131
+ `/sessions/${sessionId}/pages`,
132
+ { pages },
133
+ );
134
+ const updated = Array.isArray(resp.pages) ? resp.pages : pages;
135
+ setPages(updated.length ? updated : [{ items: [] }]);
136
+ setStatus("Input data saved.");
137
+ } catch (err) {
138
+ const message =
139
+ err instanceof Error ? err.message : "Failed to save input data.";
140
+ setStatus(message);
141
+ } finally {
142
+ setIsSaving(false);
143
+ }
144
+ }
145
+
146
+ return (
147
+ <PageShell className="max-w-6xl">
148
+ <PageHeader
149
+ title="RepEx - Report Express"
150
+ subtitle="Input Data"
151
+ right={
152
+ <Link
153
+ to={`/report-viewer${sessionQuery}`}
154
+ 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"
155
+ >
156
+ <ArrowLeft className="h-4 w-4" />
157
+ Back
158
+ </Link>
159
+ }
160
+ />
161
+
162
+ <nav className="mb-6" aria-label="Report workflow navigation">
163
+ <div className="flex flex-wrap gap-2">
164
+ <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">
165
+ <Table className="h-4 w-4" />
166
+ Input Data
167
+ </span>
168
+
169
+ <Link
170
+ to={`/report-viewer${sessionQuery}`}
171
+ 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"
172
+ >
173
+ <Layout className="h-4 w-4" />
174
+ Report Viewer
175
+ </Link>
176
+
177
+ <Link
178
+ to={`/edit-report${sessionQuery}`}
179
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
180
+ >
181
+ <Edit3 className="h-4 w-4" />
182
+ Edit Report
183
+ </Link>
184
+
185
+ <Link
186
+ to={`/edit-layouts${sessionQuery}`}
187
+ 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"
188
+ >
189
+ <Grid className="h-4 w-4" />
190
+ Edit Page Layouts
191
+ </Link>
192
+
193
+ <Link
194
+ to={`/export${sessionQuery}`}
195
+ 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"
196
+ >
197
+ <Download className="h-4 w-4" />
198
+ Export
199
+ </Link>
200
+ </div>
201
+ </nav>
202
+
203
+ <section className="mb-4 rounded-lg border border-gray-200 bg-white p-4">
204
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
205
+ <div>
206
+ <h2 className="text-lg font-semibold text-gray-900">Job Sheet Data</h2>
207
+ <p className="text-sm text-gray-600">
208
+ Update job sheet fields per page. Use "Apply row to all" to copy a
209
+ page's fields across every job sheet.
210
+ </p>
211
+ </div>
212
+ <button
213
+ type="button"
214
+ onClick={saveAll}
215
+ disabled={!canSave}
216
+ className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
217
+ >
218
+ <Save className="h-4 w-4" />
219
+ Save changes
220
+ </button>
221
+ </div>
222
+ {status ? <p className="text-sm text-gray-600 mt-3">{status}</p> : null}
223
+ </section>
224
+
225
+ <div className="rounded-lg border border-gray-200 bg-white overflow-x-auto">
226
+ <table className="min-w-[1200px] w-full text-sm">
227
+ <thead className="bg-gray-50 border-b border-gray-200">
228
+ <tr>
229
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-600">
230
+ Page
231
+ </th>
232
+ {FIELD_DEFS.map((field) => (
233
+ <th
234
+ key={field.key}
235
+ className="px-3 py-2 text-left text-xs font-semibold text-gray-600"
236
+ >
237
+ {field.label}
238
+ </th>
239
+ ))}
240
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-600">
241
+ Actions
242
+ </th>
243
+ </tr>
244
+ </thead>
245
+ <tbody>
246
+ {pages.map((page, pageIndex) => {
247
+ const template = page.template ?? {};
248
+ return (
249
+ <tr key={`row-${pageIndex}`} className="border-b border-gray-100">
250
+ <td className="px-3 py-2 text-xs font-semibold text-gray-700">
251
+ Page {pageIndex + 1}
252
+ </td>
253
+ {FIELD_DEFS.map((field) => (
254
+ <td key={`${pageIndex}-${field.key}`} className="px-3 py-2">
255
+ {field.multiline ? (
256
+ <textarea
257
+ rows={2}
258
+ className="w-full rounded-md border border-gray-200 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
259
+ value={template[field.key] ?? getFallbackValue(field.key)}
260
+ onChange={(event) =>
261
+ updateField(pageIndex, field.key, event.target.value)
262
+ }
263
+ />
264
+ ) : (
265
+ <input
266
+ type="text"
267
+ className="w-full rounded-md border border-gray-200 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
268
+ value={template[field.key] ?? getFallbackValue(field.key)}
269
+ onChange={(event) =>
270
+ updateField(pageIndex, field.key, event.target.value)
271
+ }
272
+ />
273
+ )}
274
+ </td>
275
+ ))}
276
+ <td className="px-3 py-2">
277
+ <button
278
+ type="button"
279
+ onClick={() => applyRowToAll(pageIndex)}
280
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 transition"
281
+ >
282
+ Apply row to all
283
+ </button>
284
+ </td>
285
+ </tr>
286
+ );
287
+ })}
288
+ </tbody>
289
+ </table>
290
+ </div>
291
+
292
+ <PageFooter note="Tip: edit fields per page and save once. Use apply row to keep pages consistent." />
293
+ </PageShell>
294
+ );
295
+ }
frontend/src/pages/ReportViewerPage.tsx CHANGED
@@ -8,6 +8,7 @@ import {
8
  Edit3,
9
  Grid,
10
  Download,
 
11
  } from "react-feather";
12
 
13
  import { request } from "../lib/api";
@@ -68,7 +69,8 @@ export default function ReportViewerPage() {
68
  }, [sessionId]);
69
 
70
  const totalPages = useMemo(() => {
71
- return Math.max(1, pages.length, session?.page_count ?? 0);
 
72
  }, [pages.length, session?.page_count]);
73
 
74
  useEffect(() => {
@@ -89,7 +91,7 @@ export default function ReportViewerPage() {
89
  }, [totalPages]);
90
 
91
  const page = pages[pageIndex] ?? null;
92
- const template = pages[0]?.template;
93
  const sessionQuery = buildSessionQuery(sessionId || "");
94
  const editReportQuery = useMemo(() => {
95
  if (!sessionId) return "";
@@ -145,6 +147,14 @@ export default function ReportViewerPage() {
145
 
146
  <nav className="mb-6 no-print" aria-label="Report workflow navigation">
147
  <div className="flex flex-wrap gap-2">
 
 
 
 
 
 
 
 
148
  <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">
149
  <Layout className="h-4 w-4" />
150
  Report Viewer
@@ -218,9 +228,8 @@ export default function ReportViewerPage() {
218
  <div className="flex justify-center">
219
  <div
220
  ref={stageRef}
221
- className="relative overflow-hidden shadow-sm rounded-xl bg-white border border-gray-200"
222
  style={{
223
- aspectRatio: "210 / 297",
224
  width: "min(100%, 560px)",
225
  }}
226
  >
@@ -231,6 +240,7 @@ export default function ReportViewerPage() {
231
  pageCount={totalPages}
232
  scale={scale}
233
  template={template}
 
234
  />
235
  </div>
236
  </div>
 
8
  Edit3,
9
  Grid,
10
  Download,
11
+ Table,
12
  } from "react-feather";
13
 
14
  import { request } from "../lib/api";
 
69
  }, [sessionId]);
70
 
71
  const totalPages = useMemo(() => {
72
+ if (pages.length > 0) return pages.length;
73
+ return Math.max(1, session?.page_count ?? 0);
74
  }, [pages.length, session?.page_count]);
75
 
76
  useEffect(() => {
 
91
  }, [totalPages]);
92
 
93
  const page = pages[pageIndex] ?? null;
94
+ const template = page?.template;
95
  const sessionQuery = buildSessionQuery(sessionId || "");
96
  const editReportQuery = useMemo(() => {
97
  if (!sessionId) return "";
 
147
 
148
  <nav className="mb-6 no-print" aria-label="Report workflow navigation">
149
  <div className="flex flex-wrap gap-2">
150
+ <Link
151
+ to={`/input-data${sessionQuery}`}
152
+ 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"
153
+ >
154
+ <Table className="h-4 w-4" />
155
+ Input Data
156
+ </Link>
157
+
158
  <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">
159
  <Layout className="h-4 w-4" />
160
  Report Viewer
 
228
  <div className="flex justify-center">
229
  <div
230
  ref={stageRef}
231
+ className="relative shadow-sm rounded-xl bg-white border border-gray-200"
232
  style={{
 
233
  width: "min(100%, 560px)",
234
  }}
235
  >
 
240
  pageCount={totalPages}
241
  scale={scale}
242
  template={template}
243
+ adaptive
244
  />
245
  </div>
246
  </div>
frontend/src/pages/ReviewSetupPage.tsx CHANGED
@@ -26,6 +26,7 @@ export default function ReviewSetupPage() {
26
  const [selectedPhotoIds, setSelectedPhotoIds] = useState<Set<string>>(
27
  new Set(),
28
  );
 
29
  const [statusMessage, setStatusMessage] = useState("");
30
 
31
  useEffect(() => {
@@ -53,20 +54,29 @@ export default function ReviewSetupPage() {
53
  const documents = session?.uploads?.documents ?? [];
54
  const dataFiles = session?.uploads?.data_files ?? [];
55
 
56
- const canContinue = selectedPhotoIds.size > 0;
 
 
 
57
 
58
  const readyStatus = useMemo(() => {
59
  if (!sessionId) return "No active session found. Return to upload to continue.";
60
  if (!canContinue) return "Choose report example images to continue...";
 
 
 
61
  return "Ready. Continue to report viewer.";
62
- }, [canContinue, sessionId]);
63
 
64
  async function handleContinue() {
65
- if (!sessionId || selectedPhotoIds.size === 0) return;
 
66
  try {
67
- await putJson(`/sessions/${sessionId}/selection`, {
68
- selected_photo_ids: Array.from(selectedPhotoIds),
69
- });
 
 
70
  navigate(`/report-viewer?session=${encodeURIComponent(sessionId)}`);
71
  } catch (err) {
72
  const message =
@@ -161,7 +171,7 @@ export default function ReviewSetupPage() {
161
  No photos were uploaded.
162
  </div>
163
  ) : (
164
- photos.map((photo) => {
165
  const isChecked = selectedPhotoIds.has(photo.id);
166
  return (
167
  <label key={photo.id} className="cursor-pointer">
@@ -220,6 +230,21 @@ export default function ReviewSetupPage() {
220
  )}
221
  </div>
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  <div className="mt-4 flex flex-wrap items-center justify-between gap-3">
224
  <div className="text-sm text-gray-600">
225
  Selected for report:{" "}
 
26
  const [selectedPhotoIds, setSelectedPhotoIds] = useState<Set<string>>(
27
  new Set(),
28
  );
29
+ const [showAllPhotos, setShowAllPhotos] = useState(false);
30
  const [statusMessage, setStatusMessage] = useState("");
31
 
32
  useEffect(() => {
 
54
  const documents = session?.uploads?.documents ?? [];
55
  const dataFiles = session?.uploads?.data_files ?? [];
56
 
57
+ const canContinue =
58
+ selectedPhotoIds.size > 0 || (session?.uploads?.data_files?.length ?? 0) > 0;
59
+ const previewCount = 9;
60
+ const visiblePhotos = showAllPhotos ? photos : photos.slice(0, previewCount);
61
 
62
  const readyStatus = useMemo(() => {
63
  if (!sessionId) return "No active session found. Return to upload to continue.";
64
  if (!canContinue) return "Choose report example images to continue...";
65
+ if (selectedPhotoIds.size === 0 && (session?.uploads?.data_files?.length ?? 0) > 0) {
66
+ return "Data file detected. Continue to build the report.";
67
+ }
68
  return "Ready. Continue to report viewer.";
69
+ }, [canContinue, selectedPhotoIds.size, session?.uploads?.data_files?.length, sessionId]);
70
 
71
  async function handleContinue() {
72
+ if (!sessionId) return;
73
+ if (selectedPhotoIds.size === 0 && dataFiles.length === 0) return;
74
  try {
75
+ if (selectedPhotoIds.size > 0) {
76
+ await putJson(`/sessions/${sessionId}/selection`, {
77
+ selected_photo_ids: Array.from(selectedPhotoIds),
78
+ });
79
+ }
80
  navigate(`/report-viewer?session=${encodeURIComponent(sessionId)}`);
81
  } catch (err) {
82
  const message =
 
171
  No photos were uploaded.
172
  </div>
173
  ) : (
174
+ visiblePhotos.map((photo) => {
175
  const isChecked = selectedPhotoIds.has(photo.id);
176
  return (
177
  <label key={photo.id} className="cursor-pointer">
 
230
  )}
231
  </div>
232
 
233
+ {photos.length > previewCount ? (
234
+ <div className="mt-3 flex items-center justify-between gap-3 text-sm text-gray-600">
235
+ <span>
236
+ Showing {visiblePhotos.length} of {photos.length} photos
237
+ </span>
238
+ <button
239
+ type="button"
240
+ onClick={() => setShowAllPhotos((prev) => !prev)}
241
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
242
+ >
243
+ {showAllPhotos ? "Show fewer" : `Show all (${photos.length})`}
244
+ </button>
245
+ </div>
246
+ ) : null}
247
+
248
  <div className="mt-4 flex flex-wrap items-center justify-between gap-3">
249
  <div className="text-sm text-gray-600">
250
  Selected for report:{" "}
frontend/src/types/session.ts CHANGED
@@ -79,6 +79,7 @@ export type TemplateFields = {
79
  export type Page = {
80
  items: PageItem[];
81
  template?: TemplateFields;
 
82
  };
83
 
84
  export type PagesResponse = {
 
79
  export type Page = {
80
  items: PageItem[];
81
  template?: TemplateFields;
82
+ photo_ids?: string[];
83
  };
84
 
85
  export type PagesResponse = {
server/app/api/routes/sessions.py CHANGED
@@ -16,6 +16,7 @@ from ..schemas import (
16
  SessionStatusResponse,
17
  )
18
  from ...services import SessionStore
 
19
 
20
 
21
  router = APIRouter()
@@ -77,6 +78,11 @@ def create_session(
77
  pass
78
 
79
  session = store.add_uploads(session, saved_files)
 
 
 
 
 
80
  return _attach_urls(session)
81
 
82
 
 
16
  SessionStatusResponse,
17
  )
18
  from ...services import SessionStore
19
+ from ...services.data_import import populate_session_from_data_files
20
 
21
 
22
  router = APIRouter()
 
78
  pass
79
 
80
  session = store.add_uploads(session, saved_files)
81
+ try:
82
+ session = populate_session_from_data_files(store, session)
83
+ except Exception:
84
+ # Do not block upload if data parsing fails.
85
+ pass
86
  return _attach_urls(session)
87
 
88
 
server/app/services/data_import.py ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import re
5
+ from datetime import date, datetime
6
+ from pathlib import Path
7
+ from typing import Dict, Iterable, List, Optional
8
+
9
+ from openpyxl import load_workbook
10
+ import xlrd
11
+
12
+ from .session_store import SessionStore
13
+
14
+
15
+ GENERAL_SHEET = "general information"
16
+ HEADINGS_SHEET = "headings"
17
+ ITEMS_SHEET = "item spesific"
18
+ ITEMS_SHEET_ALT = "item specific"
19
+
20
+
21
+ def _normalize_text(value: str) -> str:
22
+ return " ".join(str(value or "").strip().lower().split())
23
+
24
+
25
+ def _cell_to_str(value: object) -> str:
26
+ if value is None:
27
+ return ""
28
+ if isinstance(value, (datetime, date)):
29
+ return value.strftime("%Y-%m-%d")
30
+ if isinstance(value, float):
31
+ if value.is_integer():
32
+ return str(int(value))
33
+ return str(value)
34
+ return str(value).strip()
35
+
36
+
37
+ def _parse_general_info(rows: Iterable[Iterable[object]]) -> Dict[str, str]:
38
+ info: Dict[str, str] = {}
39
+ for row in rows:
40
+ cells = list(row)
41
+ if not cells:
42
+ continue
43
+ key = _normalize_text(cells[0])
44
+ if not key:
45
+ continue
46
+ value = _cell_to_str(cells[1] if len(cells) > 1 else "")
47
+ if value:
48
+ info[key] = value
49
+ return info
50
+
51
+
52
+ def _find_sheet(sheets: Dict[str, object], target: str) -> Optional[object]:
53
+ if target in sheets:
54
+ return sheets[target]
55
+ target_key = _normalize_text(target).replace(" ", "")
56
+ for name, sheet in sheets.items():
57
+ key = _normalize_text(name).replace(" ", "")
58
+ if target_key and target_key in key:
59
+ return sheet
60
+ return None
61
+
62
+
63
+ def _parse_headings(rows: Iterable[Iterable[object]]) -> Dict[str, str]:
64
+ headings: Dict[str, str] = {}
65
+ for idx, row in enumerate(rows):
66
+ cells = list(row)
67
+ if idx == 0:
68
+ continue
69
+ if not cells:
70
+ continue
71
+ key = _cell_to_str(cells[0])
72
+ value = _cell_to_str(cells[1] if len(cells) > 1 else "")
73
+ if key:
74
+ headings[key] = value
75
+ return headings
76
+
77
+
78
+ def _header_map(headers: List[str]) -> Dict[str, List[int]]:
79
+ mapping: Dict[str, List[int]] = {}
80
+ for idx, raw in enumerate(headers):
81
+ name = _normalize_text(raw)
82
+ if not name:
83
+ continue
84
+ mapping.setdefault(name, []).append(idx)
85
+ return mapping
86
+
87
+
88
+ def _looks_like_image_name(value: str) -> bool:
89
+ return bool(re.search(r"\.(jpg|jpeg|png|gif|webp)$", value.strip(), re.IGNORECASE))
90
+
91
+
92
+ def _extract_image_names(value: str) -> List[str]:
93
+ if not value:
94
+ return []
95
+ matches = re.findall(r"[^\s,;]+\\.(?:jpg|jpeg|png|gif|webp)", value, re.IGNORECASE)
96
+ return [match.strip() for match in matches if match.strip()]
97
+
98
+
99
+ def _image_column_indices(headers: List[str]) -> Dict[int, int]:
100
+ indices: Dict[int, int] = {}
101
+ for idx, raw in enumerate(headers):
102
+ name = _normalize_text(raw).replace(" ", "")
103
+ if not name:
104
+ continue
105
+ match = re.search(r"(image|img)(name)?(\\d+)", name)
106
+ if not match:
107
+ continue
108
+ try:
109
+ number = int(match.group(3))
110
+ except ValueError:
111
+ continue
112
+ if 1 <= number <= 6 and number not in indices:
113
+ indices[number] = idx
114
+ return indices
115
+
116
+
117
+ def _row_value(row: List[object], index: Optional[int]) -> str:
118
+ if index is None:
119
+ return ""
120
+ if index >= len(row):
121
+ return ""
122
+ return _cell_to_str(row[index])
123
+
124
+
125
+ def _parse_items(rows: Iterable[Iterable[object]]) -> List[Dict[str, str | List[str]]]:
126
+ rows = list(rows)
127
+ if not rows:
128
+ return []
129
+ headers = [_cell_to_str(cell) for cell in list(rows[0])]
130
+ mapping = _header_map(headers)
131
+ image_indices = _image_column_indices(headers)
132
+
133
+ def first_index(name: str) -> Optional[int]:
134
+ values = mapping.get(_normalize_text(name)) or []
135
+ return values[0] if values else None
136
+
137
+ def second_index(name: str) -> Optional[int]:
138
+ values = mapping.get(_normalize_text(name)) or []
139
+ return values[1] if len(values) > 1 else None
140
+
141
+ def image_index(n: int) -> Optional[int]:
142
+ return image_indices.get(n) or first_index(f"image name {n}") or first_index(
143
+ f"image {n}"
144
+ )
145
+
146
+ items: List[Dict[str, str | List[str]]] = []
147
+ for row in rows[1:]:
148
+ cells = list(row)
149
+ if not any(_cell_to_str(cell) for cell in cells):
150
+ continue
151
+ item_desc = _row_value(cells, second_index("item description")) or _row_value(
152
+ cells, first_index("item description")
153
+ )
154
+ image_names = [
155
+ _row_value(cells, image_index(1)),
156
+ _row_value(cells, image_index(2)),
157
+ _row_value(cells, image_index(3)),
158
+ _row_value(cells, image_index(4)),
159
+ _row_value(cells, image_index(5)),
160
+ _row_value(cells, image_index(6)),
161
+ ]
162
+ image_names = [name for name in image_names if name]
163
+ if len(image_names) < 2:
164
+ for cell in cells:
165
+ value = _cell_to_str(cell)
166
+ if not value:
167
+ continue
168
+ candidates = _extract_image_names(value) if not _looks_like_image_name(value) else [value]
169
+ for candidate in candidates:
170
+ if candidate in image_names:
171
+ continue
172
+ image_names.append(candidate)
173
+ if len(image_names) >= 6:
174
+ break
175
+ if len(image_names) >= 6:
176
+ break
177
+ items.append(
178
+ {
179
+ "reference": _row_value(cells, first_index("ref")),
180
+ "functional_location": _row_value(
181
+ cells, first_index("functional location")
182
+ ),
183
+ "item_description": item_desc,
184
+ "category": _row_value(cells, first_index("category")),
185
+ "priority": _row_value(cells, first_index("priority")),
186
+ "condition_description": _row_value(
187
+ cells, first_index("condition description")
188
+ ),
189
+ "action_type": _row_value(cells, first_index("action type")),
190
+ "required_action": _row_value(cells, first_index("required action")),
191
+ "image_names": [name for name in image_names if name],
192
+ }
193
+ )
194
+ return items
195
+
196
+
197
+ def _parse_csv(path: Path) -> Dict[str, object]:
198
+ with path.open("r", encoding="utf-8-sig", newline="") as handle:
199
+ reader = csv.reader(handle)
200
+ rows = list(reader)
201
+ return {
202
+ "general": {},
203
+ "headings": {},
204
+ "items": _parse_items(rows),
205
+ }
206
+
207
+
208
+ def _parse_excel(path: Path) -> Dict[str, object]:
209
+ workbook = load_workbook(path, data_only=True)
210
+ sheets = {sheet.title.strip().lower(): sheet for sheet in workbook.worksheets}
211
+
212
+ general_sheet = _find_sheet(sheets, GENERAL_SHEET)
213
+ headings_sheet = _find_sheet(sheets, HEADINGS_SHEET)
214
+ items_sheet = _find_sheet(sheets, ITEMS_SHEET) or _find_sheet(sheets, ITEMS_SHEET_ALT)
215
+
216
+ general = (
217
+ _parse_general_info(general_sheet.values) if general_sheet else {}
218
+ )
219
+ headings = _parse_headings(headings_sheet.values) if headings_sheet else {}
220
+ items = _parse_items(items_sheet.values) if items_sheet else []
221
+
222
+ return {
223
+ "general": general,
224
+ "headings": headings,
225
+ "items": items,
226
+ }
227
+
228
+
229
+ def _parse_xls(path: Path) -> Dict[str, object]:
230
+ workbook = xlrd.open_workbook(path)
231
+ sheets = {sheet.name.strip().lower(): sheet for sheet in workbook.sheets()}
232
+
233
+ def sheet_rows(sheet: xlrd.sheet.Sheet) -> Iterable[List[object]]:
234
+ for row_idx in range(sheet.nrows):
235
+ yield sheet.row_values(row_idx)
236
+
237
+ general_sheet = _find_sheet(sheets, GENERAL_SHEET)
238
+ headings_sheet = _find_sheet(sheets, HEADINGS_SHEET)
239
+ items_sheet = _find_sheet(sheets, ITEMS_SHEET) or _find_sheet(sheets, ITEMS_SHEET_ALT)
240
+
241
+ general = _parse_general_info(sheet_rows(general_sheet)) if general_sheet else {}
242
+ headings = _parse_headings(sheet_rows(headings_sheet)) if headings_sheet else {}
243
+ items = _parse_items(sheet_rows(items_sheet)) if items_sheet else []
244
+
245
+ return {
246
+ "general": general,
247
+ "headings": headings,
248
+ "items": items,
249
+ }
250
+
251
+
252
+ def _normalize_key(value: str) -> str:
253
+ cleaned = _normalize_text(value)
254
+ cleaned = re.sub(r"[^a-z0-9]", "", cleaned)
255
+ return cleaned
256
+
257
+
258
+ def _normalize_name(name: str) -> str:
259
+ return _normalize_key(Path(name).name)
260
+
261
+
262
+ def _normalize_stem(name: str) -> str:
263
+ return _normalize_key(Path(name).stem)
264
+
265
+
266
+ def _build_photo_lookup(uploads: List[dict]) -> Dict[str, str]:
267
+ lookup: Dict[str, str] = {}
268
+ for item in uploads:
269
+ name = item.get("name") or ""
270
+ file_id = item.get("id")
271
+ if not name or not file_id:
272
+ continue
273
+ lookup.setdefault(_normalize_name(name), file_id)
274
+ lookup.setdefault(_normalize_stem(name), file_id)
275
+ return lookup
276
+
277
+
278
+ def _photo_ids_for_names(names: List[str], lookup: Dict[str, str]) -> List[str]:
279
+ ids: List[str] = []
280
+ for raw in names:
281
+ if not raw:
282
+ continue
283
+ for part in re.split(r"[;,]", str(raw)):
284
+ part = part.strip()
285
+ if not part:
286
+ continue
287
+ key = _normalize_name(part)
288
+ match = lookup.get(key) or lookup.get(_normalize_stem(part))
289
+ if match and match not in ids:
290
+ ids.append(match)
291
+ return ids
292
+
293
+
294
+ def populate_session_from_data_files(
295
+ store: SessionStore, session: dict
296
+ ) -> dict:
297
+ data_files = session.get("uploads", {}).get("data_files", []) or []
298
+ if not data_files:
299
+ return session
300
+
301
+ def score(file_meta: dict) -> int:
302
+ name = (file_meta.get("name") or "").lower()
303
+ if name.endswith((".xlsx", ".xlsm", ".xls")):
304
+ return 2
305
+ if name.endswith(".csv"):
306
+ return 1
307
+ return 0
308
+
309
+ target = sorted(data_files, key=score, reverse=True)[0]
310
+ path = store.resolve_upload_path(session, target.get("id", ""))
311
+ if not path or not path.exists():
312
+ return session
313
+
314
+ ext = path.suffix.lower()
315
+ if ext in {".xlsx", ".xlsm"}:
316
+ parsed = _parse_excel(path)
317
+ elif ext == ".xls":
318
+ parsed = _parse_xls(path)
319
+ elif ext == ".csv":
320
+ parsed = _parse_csv(path)
321
+ else:
322
+ return session
323
+
324
+ general = parsed.get("general") or {}
325
+ items = parsed.get("items") or []
326
+
327
+ # Update session-wide fields if provided
328
+ project_name = general.get("project name")
329
+ if project_name:
330
+ session["project_name"] = project_name
331
+ inspection_date = general.get("inspection date")
332
+ if inspection_date:
333
+ session["inspection_date"] = inspection_date
334
+
335
+ photo_lookup = _build_photo_lookup(
336
+ session.get("uploads", {}).get("photos", []) or []
337
+ )
338
+
339
+ pages: List[dict] = []
340
+ selected_photo_ids: List[str] = []
341
+ for item in items:
342
+ template = {
343
+ "inspection_date": inspection_date or session.get("inspection_date", ""),
344
+ "inspector": general.get("inspector", ""),
345
+ "accompanied_by": general.get("accompanied by", ""),
346
+ "document_no": general.get("document no", ""),
347
+ "project": general.get("project name", session.get("project_name", "")),
348
+ "client_site": general.get("client / site", ""),
349
+ "reference": item.get("reference", ""),
350
+ "functional_location": item.get("functional_location", ""),
351
+ "item_description": item.get("item_description", ""),
352
+ "category": item.get("category", ""),
353
+ "priority": item.get("priority", ""),
354
+ "condition_description": item.get("condition_description", ""),
355
+ "action_type": item.get("action_type", ""),
356
+ "required_action": item.get("required_action", ""),
357
+ }
358
+ image_names = item.get("image_names", []) or []
359
+ photo_ids = _photo_ids_for_names(image_names, photo_lookup)
360
+ for photo_id in photo_ids:
361
+ if photo_id not in selected_photo_ids:
362
+ selected_photo_ids.append(photo_id)
363
+ pages.append({"items": [], "template": template, "photo_ids": photo_ids})
364
+
365
+ if pages:
366
+ session["pages"] = pages
367
+ session["page_count"] = len(pages)
368
+ if selected_photo_ids:
369
+ session["selected_photo_ids"] = selected_photo_ids
370
+ store.update_session(session)
371
+
372
+ return session
server/app/services/session_store.py CHANGED
@@ -126,11 +126,16 @@ class SessionStore:
126
  "path": item.path,
127
  }
128
  )
 
 
 
129
  self.update_session(session)
130
  return session
131
 
132
  def set_selected_photos(self, session: dict, selected_ids: List[str]) -> dict:
133
  session["selected_photo_ids"] = selected_ids
 
 
134
  self.update_session(session)
135
  return session
136
 
@@ -145,8 +150,13 @@ class SessionStore:
145
  def ensure_pages(self, session: dict) -> List[dict]:
146
  pages = session.get("pages") or []
147
  if pages:
 
 
 
148
  return pages
149
- count = session.get("page_count", 6) or 6
 
 
150
  pages = [{"items": []} for _ in range(count)]
151
  session["pages"] = pages
152
  self.update_session(session)
 
126
  "path": item.path,
127
  }
128
  )
129
+ if not session.get("pages"):
130
+ photo_count = len(session.get("uploads", {}).get("photos", []) or [])
131
+ session["page_count"] = max(1, photo_count)
132
  self.update_session(session)
133
  return session
134
 
135
  def set_selected_photos(self, session: dict, selected_ids: List[str]) -> dict:
136
  session["selected_photo_ids"] = selected_ids
137
+ if not session.get("pages"):
138
+ session["page_count"] = max(1, len(selected_ids))
139
  self.update_session(session)
140
  return session
141
 
 
150
  def ensure_pages(self, session: dict) -> List[dict]:
151
  pages = session.get("pages") or []
152
  if pages:
153
+ if session.get("page_count") != len(pages):
154
+ session["page_count"] = len(pages)
155
+ self.update_session(session)
156
  return pages
157
+ selected_count = len(session.get("selected_photo_ids") or [])
158
+ photo_count = len(session.get("uploads", {}).get("photos", []) or [])
159
+ count = selected_count or photo_count or session.get("page_count", 1) or 1
160
  pages = [{"items": []} for _ in range(count)]
161
  session["pages"] = pages
162
  self.update_session(session)
server/requirements.txt CHANGED
@@ -1,3 +1,5 @@
1
  fastapi>=0.115.0,<1.0.0
2
  uvicorn[standard]>=0.30.0,<0.32.0
3
  python-multipart>=0.0.9,<0.1.0
 
 
 
1
  fastapi>=0.115.0,<1.0.0
2
  uvicorn[standard]>=0.30.0,<0.32.0
3
  python-multipart>=0.0.9,<0.1.0
4
+ openpyxl>=3.1.2,<4.0.0
5
+ xlrd>=2.0.1,<3.0.0