ChristopherJKoen commited on
Commit
303d067
·
1 Parent(s): 41178f4

Imported Data Rework

Browse files
frontend/public/templates/job-sheet-template.html CHANGED
@@ -65,7 +65,7 @@
65
  <div class="flex items-center justify-end">
66
  <img
67
  src="../assets/client-logo.png"
68
- alt="Client logo placeholder"
69
  class="h-10 w-auto object-contain"
70
  loading="eager"
71
  />
@@ -73,45 +73,6 @@
73
  </div>
74
  </header>
75
 
76
- <!-- Inspection Details -->
77
- <section class="mb-4" aria-labelledby="inspection-details-title">
78
- <h2 id="inspection-details-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
79
- Inspection Details
80
- </h2>
81
-
82
- <dl class="grid grid-cols-2 md:grid-cols-4 gap-2 bg-gray-50 border border-gray-200 rounded-lg p-3">
83
- <div class="space-y-0.5">
84
- <dt class="text-xs font-medium text-gray-500">Inspection Date</dt>
85
- <dd class="template-field text-sm font-semibold text-gray-900" contenteditable="true" data-placeholder="YYYY-MM-DD"></dd>
86
- </div>
87
-
88
- <div class="space-y-0.5">
89
- <dt class="text-xs font-medium text-gray-500">Inspector</dt>
90
- <dd class="template-field text-sm font-semibold text-gray-900" contenteditable="true" data-placeholder="Inspector name"></dd>
91
- </div>
92
-
93
- <div class="space-y-0.5">
94
- <dt class="text-xs font-medium text-gray-500">Accompanied By</dt>
95
- <dd class="template-field text-sm font-semibold text-gray-900" contenteditable="true" data-placeholder="Accompanied by"></dd>
96
- </div>
97
-
98
- <div class="space-y-0.5">
99
- <dt class="text-xs font-medium text-gray-500">Document No</dt>
100
- <dd class="template-field text-sm font-mono font-semibold text-gray-900" contenteditable="true" data-placeholder="Document no"></dd>
101
- </div>
102
-
103
- <div class="space-y-0.5 md:col-span-2">
104
- <dt class="text-xs font-medium text-gray-500">Project</dt>
105
- <dd class="template-field text-sm font-semibold text-gray-900" contenteditable="true" data-placeholder="Project name"></dd>
106
- </div>
107
-
108
- <div class="space-y-0.5 md:col-span-2">
109
- <dt class="text-xs font-medium text-gray-500">Client / Site</dt>
110
- <dd class="template-field text-sm font-semibold text-gray-900" contenteditable="true" data-placeholder="Client or site"></dd>
111
- </div>
112
- </dl>
113
- </section>
114
-
115
  <!-- Observations and Findings -->
116
  <section class="mb-4" aria-labelledby="observations-title">
117
  <h2 id="observations-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
@@ -119,34 +80,25 @@
119
  </h2>
120
 
121
  <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
122
- <!-- Left column -->
123
- <div class="space-y-2">
124
- <div class="grid grid-cols-2 gap-2">
125
  <div class="space-y-0.5">
126
- <div class="text-xs font-medium text-gray-500">Reference</div>
127
- <div class="template-field text-sm font-semibold text-gray-900" contenteditable="true" data-placeholder="Reference"></div>
128
  </div>
129
 
130
  <div class="space-y-0.5">
131
- <div class="text-xs font-medium text-gray-500">Action Type</div>
132
- <div class="template-field text-sm font-semibold text-gray-900" contenteditable="true" data-placeholder="Action type"></div>
133
  </div>
134
 
135
- <div class="space-y-0.5 col-span-2">
136
- <div class="text-xs font-medium text-gray-500">Item Description</div>
137
- <div class="template-field text-sm font-semibold text-gray-900" contenteditable="true" data-placeholder="Item description"></div>
138
  </div>
139
  </div>
140
  </div>
141
 
142
- <!-- Right column -->
143
- <div class="space-y-2">
144
- <div class="space-y-0.5">
145
- <div class="text-xs font-medium text-gray-500">Functional Location</div>
146
- <div class="template-field text-sm font-semibold text-gray-900" contenteditable="true" data-placeholder="Functional location"></div>
147
- </div>
148
- </div>
149
-
150
  <!-- Centered Category + Priority (must be direct child of the grid) -->
151
  <div class="md:col-span-2 flex justify-center">
152
  <div class="inline-flex items-center gap-10">
@@ -174,14 +126,16 @@
174
  <div class="md:col-span-2 space-y-1">
175
  <div class="text-xs font-medium text-gray-500">Condition Description</div>
176
  <div class="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
 
177
  <p class="template-field template-field-multiline text-amber-800 text-sm font-semibold leading-snug" contenteditable="true" data-placeholder="Condition description"></p>
178
  </div>
179
  </div>
180
 
181
- <!-- Full-width: Required Action -->
182
  <div class="md:col-span-2 space-y-1">
183
- <div class="text-xs font-medium text-gray-500">Required Action</div>
184
  <div class="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
 
185
  <p class="template-field template-field-multiline text-blue-800 text-sm font-semibold leading-snug" contenteditable="true" data-placeholder="Required action"></p>
186
  </div>
187
  </div>
@@ -194,8 +148,8 @@
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
201
  </div>
@@ -204,7 +158,7 @@
204
  </figcaption>
205
  </figure>
206
 
207
- <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50">
208
  <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
209
  Photo slot
210
  </div>
@@ -213,7 +167,7 @@
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>
@@ -222,7 +176,7 @@
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>
@@ -231,7 +185,7 @@
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>
@@ -240,7 +194,7 @@
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>
@@ -252,9 +206,11 @@
252
  </section>
253
 
254
  <!-- Footer -->
255
- <footer class="mt-4 text-center text-[11px] text-gray-500">
256
- <p>RepEx - (c) 2026 All Rights Reserved</p>
257
- <p class="mt-0.5">Generated by RepEx</p>
 
 
258
  </footer>
259
  </main>
260
  </body>
 
65
  <div class="flex items-center justify-end">
66
  <img
67
  src="../assets/client-logo.png"
68
+ alt="Company logo"
69
  class="h-10 w-auto object-contain"
70
  loading="eager"
71
  />
 
73
  </div>
74
  </header>
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  <!-- Observations and Findings -->
77
  <section class="mb-4" aria-labelledby="observations-title">
78
  <h2 id="observations-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
 
80
  </h2>
81
 
82
  <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
83
+ <div class="md:col-span-2">
84
+ <div class="grid grid-cols-3 gap-3">
 
85
  <div class="space-y-0.5">
86
+ <div class="text-xs font-medium text-gray-500">Ref</div>
87
+ <div class="template-field text-sm font-semibold text-gray-900" contenteditable="true" data-placeholder="Ref"></div>
88
  </div>
89
 
90
  <div class="space-y-0.5">
91
+ <div class="text-xs font-medium text-gray-500">Area</div>
92
+ <div class="template-field text-sm font-semibold text-gray-900" contenteditable="true" data-placeholder="Area"></div>
93
  </div>
94
 
95
+ <div class="space-y-0.5">
96
+ <div class="text-xs font-medium text-gray-500">Location</div>
97
+ <div class="template-field text-sm font-semibold text-gray-900" contenteditable="true" data-placeholder="Location"></div>
98
  </div>
99
  </div>
100
  </div>
101
 
 
 
 
 
 
 
 
 
102
  <!-- Centered Category + Priority (must be direct child of the grid) -->
103
  <div class="md:col-span-2 flex justify-center">
104
  <div class="inline-flex items-center gap-10">
 
126
  <div class="md:col-span-2 space-y-1">
127
  <div class="text-xs font-medium text-gray-500">Condition Description</div>
128
  <div class="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
129
+ <p class="template-field template-field-multiline text-amber-800 text-sm font-semibold leading-snug" contenteditable="true" data-placeholder="Item description"></p>
130
  <p class="template-field template-field-multiline text-amber-800 text-sm font-semibold leading-snug" contenteditable="true" data-placeholder="Condition description"></p>
131
  </div>
132
  </div>
133
 
134
+ <!-- Full-width: Action Required -->
135
  <div class="md:col-span-2 space-y-1">
136
+ <div class="text-xs font-medium text-gray-500">Action Required</div>
137
  <div class="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
138
+ <p class="template-field template-field-multiline text-blue-800 text-sm font-semibold leading-snug" contenteditable="true" data-placeholder="Action type"></p>
139
  <p class="template-field template-field-multiline text-blue-800 text-sm font-semibold leading-snug" contenteditable="true" data-placeholder="Required action"></p>
140
  </div>
141
  </div>
 
148
  Photo Documentation
149
  </h2>
150
 
151
+ <div class="columns-2" style="column-gap:0.75rem;">
152
+ <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50 overflow-hidden break-inside-avoid mb-3">
153
  <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
154
  Photo slot
155
  </div>
 
158
  </figcaption>
159
  </figure>
160
 
161
+ <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50 overflow-hidden break-inside-avoid mb-3">
162
  <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
163
  Photo slot
164
  </div>
 
167
  </figcaption>
168
  </figure>
169
 
170
+ <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50 overflow-hidden break-inside-avoid mb-3">
171
  <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
172
  Photo slot
173
  </div>
 
176
  </figcaption>
177
  </figure>
178
 
179
+ <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50 overflow-hidden break-inside-avoid mb-3">
180
  <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
181
  Photo slot
182
  </div>
 
185
  </figcaption>
186
  </figure>
187
 
188
+ <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50 overflow-hidden break-inside-avoid mb-3">
189
  <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
190
  Photo slot
191
  </div>
 
194
  </figcaption>
195
  </figure>
196
 
197
+ <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50 overflow-hidden break-inside-avoid mb-3">
198
  <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
199
  Photo slot
200
  </div>
 
206
  </section>
207
 
208
  <!-- Footer -->
209
+ <footer class="mt-2 text-[10px] text-gray-500 flex flex-wrap items-center justify-center gap-3">
210
+ <span>Date: <span class="template-field" contenteditable="true" data-placeholder="YYYY-MM-DD"></span></span>
211
+ <span>Inspector: <span class="template-field" contenteditable="true" data-placeholder="Inspector name"></span></span>
212
+ <span>Doc: <span class="template-field" contenteditable="true" data-placeholder="Document no"></span></span>
213
+ <span>Site: <span class="template-field" contenteditable="true" data-placeholder="Client or site"></span></span>
214
  </footer>
215
  </main>
216
  </body>
frontend/src/components/JobSheetTemplate.tsx CHANGED
@@ -1,3 +1,5 @@
 
 
1
  import type { FileMeta, Session, TemplateFields } from "../types/session";
2
  import { formatDocNumber, getPhotosForPage } from "../lib/report";
3
 
@@ -7,28 +9,137 @@ type JobSheetTemplateProps = {
7
  pageCount: number;
8
  template?: TemplateFields;
9
  photos?: FileMeta[];
 
10
  variant?: "full" | "photos";
11
  };
12
 
13
  type PhotoSlotProps = {
14
  url?: string;
15
  label: string;
 
 
 
 
 
 
 
16
  };
17
 
18
- function PhotoSlot({ url, label }: PhotoSlotProps) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  if (!url) {
20
  return (
21
- <div className="h-36 w-full rounded-lg border border-dashed border-gray-300 bg-gray-50 flex items-center justify-center text-xs text-gray-500">
 
 
 
 
 
22
  No photo selected
23
  </div>
24
  );
25
  }
26
  return (
27
- <figure className="rounded-lg border border-gray-200 bg-gray-50 p-2">
 
 
 
 
 
28
  <img
29
  src={url}
30
  alt={label}
31
- className="h-36 w-full object-contain"
32
  loading="eager"
33
  />
34
  <figcaption className="mt-1 text-[10px] text-gray-600 text-center">
@@ -44,18 +155,19 @@ export function JobSheetTemplate({
44
  pageCount,
45
  template,
46
  photos,
 
47
  variant = "full",
48
  }: JobSheetTemplateProps) {
49
  const inspectionDate =
50
  template?.inspection_date ?? session?.inspection_date ?? "";
51
  const inspector = template?.inspector ?? "";
52
- const accompaniedBy = template?.accompanied_by ?? "";
53
  const docNumber =
54
  template?.document_no ?? (session?.id ? formatDocNumber(session) : "");
55
- const projectName = template?.project ?? session?.project_name ?? "";
56
  const clientSite = template?.client_site ?? "";
 
57
 
58
  const reference = template?.reference ?? "";
 
59
  const actionType = template?.action_type ?? "";
60
  const itemDescription = template?.item_description ?? "";
61
  const functionalLocation = template?.functional_location ?? "";
@@ -64,13 +176,58 @@ export function JobSheetTemplate({
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">
73
- <header className="mb-4 border-b border-gray-200 pb-2">
74
  <div className="grid grid-cols-[auto,1fr,auto] items-center gap-3">
75
  <img
76
  src="/assets/prosento-logo.png"
@@ -86,198 +243,123 @@ export function JobSheetTemplate({
86
  </div>
87
  </div>
88
  <img
89
- src="/assets/client-logo.png"
90
- alt="Client logo"
91
  className="h-9 w-auto object-contain"
92
  />
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
  }
 
1
+ import { useEffect, useMemo, useState } from "react";
2
+
3
  import type { FileMeta, Session, TemplateFields } from "../types/session";
4
  import { formatDocNumber, getPhotosForPage } from "../lib/report";
5
 
 
9
  pageCount: number;
10
  template?: TemplateFields;
11
  photos?: FileMeta[];
12
+ orderLocked?: boolean;
13
  variant?: "full" | "photos";
14
  };
15
 
16
  type PhotoSlotProps = {
17
  url?: string;
18
  label: string;
19
+ className?: string;
20
+ imageClassName?: string;
21
+ };
22
+
23
+ type LayoutEntry = {
24
+ photo: FileMeta;
25
+ span: boolean;
26
  };
27
 
28
+ function normalizeKey(value: string) {
29
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
30
+ }
31
+
32
+ function photoKey(photo: FileMeta) {
33
+ return photo.id || photo.url || photo.name || "";
34
+ }
35
+
36
+ function photoUrl(photo: FileMeta) {
37
+ return photo.url || "";
38
+ }
39
+
40
+ function resolveLogoUrl(session: Session | null, rawValue?: string) {
41
+ const value = (rawValue || "").trim();
42
+ if (!value) return "/assets/client-logo.png";
43
+ if (/^(https?:|data:|\/)/i.test(value)) return value;
44
+
45
+ const uploads = session?.uploads?.photos ?? [];
46
+ const key = normalizeKey(value);
47
+ for (const photo of uploads) {
48
+ const name = photo.name || "";
49
+ if (!name) continue;
50
+ const nameKey = normalizeKey(name);
51
+ const stemKey = normalizeKey(name.replace(/\.[^/.]+$/, ""));
52
+ if (key == nameKey || key == stemKey) {
53
+ return photo.url || "/assets/client-logo.png";
54
+ }
55
+ }
56
+ return value;
57
+ }
58
+
59
+ function computeLayout(photos: FileMeta[], ratios: Record<string, number>): LayoutEntry[] {
60
+ const entries = photos.map((photo) => {
61
+ const key = photoKey(photo);
62
+ return {
63
+ photo,
64
+ ratio: key ? ratios[key] ?? 1 : 1,
65
+ };
66
+ });
67
+
68
+ const memo = new Map<string, { cost: number; rows: number[][] }>();
69
+
70
+ function solve(remaining: number[]): { cost: number; rows: number[][] } {
71
+ if (remaining.length == 0) {
72
+ return { cost: 0, rows: [] };
73
+ }
74
+ const cacheKey = remaining.join(",");
75
+ const cached = memo.get(cacheKey);
76
+ if (cached) return cached;
77
+
78
+ const [first, ...rest] = remaining;
79
+ let bestCost = Number.POSITIVE_INFINITY;
80
+ let bestRows: number[][] = [];
81
+
82
+ const single = solve(rest);
83
+ const singleCost = 2 * entries[first].ratio + single.cost;
84
+ if (singleCost < bestCost) {
85
+ bestCost = singleCost;
86
+ bestRows = [[first], ...single.rows];
87
+ }
88
+
89
+ for (let i = 0; i < rest.length; i += 1) {
90
+ const pair = rest[i];
91
+ const next = rest.filter((_, idx) => idx != i);
92
+ const result = solve(next);
93
+ const pairCost = Math.max(entries[first].ratio, entries[pair].ratio) + result.cost;
94
+ if (pairCost < bestCost) {
95
+ bestCost = pairCost;
96
+ bestRows = [[first, pair], ...result.rows];
97
+ }
98
+ }
99
+
100
+ const value = { cost: bestCost, rows: bestRows };
101
+ memo.set(cacheKey, value);
102
+ return value;
103
+ }
104
+
105
+ const indices = entries.map((_, index) => index);
106
+ const solution = solve(indices);
107
+ const layout: LayoutEntry[] = [];
108
+ solution.rows.forEach((row) => {
109
+ if (row.length == 1) {
110
+ layout.push({ photo: entries[row[0]].photo, span: true });
111
+ } else {
112
+ layout.push({ photo: entries[row[0]].photo, span: false });
113
+ layout.push({ photo: entries[row[1]].photo, span: false });
114
+ }
115
+ });
116
+ return layout;
117
+ }
118
+
119
+ function PhotoSlot({ url, label, className = "", imageClassName = "" }: PhotoSlotProps) {
120
  if (!url) {
121
  return (
122
+ <div
123
+ className={[
124
+ "min-h-[120px] w-full rounded-lg border border-dashed border-gray-300 bg-gray-50 flex items-center justify-center text-xs text-gray-500 break-inside-avoid mb-3",
125
+ className,
126
+ ].join(" ")}
127
+ >
128
  No photo selected
129
  </div>
130
  );
131
  }
132
  return (
133
+ <figure
134
+ className={[
135
+ "rounded-lg border border-gray-200 bg-gray-50 p-2 break-inside-avoid mb-3",
136
+ className,
137
+ ].join(" ")}
138
+ >
139
  <img
140
  src={url}
141
  alt={label}
142
+ className={["w-full h-auto object-contain", imageClassName].join(" ")}
143
  loading="eager"
144
  />
145
  <figcaption className="mt-1 text-[10px] text-gray-600 text-center">
 
155
  pageCount,
156
  template,
157
  photos,
158
+ orderLocked = false,
159
  variant = "full",
160
  }: JobSheetTemplateProps) {
161
  const inspectionDate =
162
  template?.inspection_date ?? session?.inspection_date ?? "";
163
  const inspector = template?.inspector ?? "";
 
164
  const docNumber =
165
  template?.document_no ?? (session?.id ? formatDocNumber(session) : "");
 
166
  const clientSite = template?.client_site ?? "";
167
+ const companyLogo = template?.company_logo ?? "";
168
 
169
  const reference = template?.reference ?? "";
170
+ const area = template?.area ?? "";
171
  const actionType = template?.action_type ?? "";
172
  const itemDescription = template?.item_description ?? "";
173
  const functionalLocation = template?.functional_location ?? "";
 
176
  const conditionDescription =
177
  template?.condition_description ?? session?.notes ?? "";
178
  const requiredAction = template?.required_action ?? "";
179
+
180
+ const conditionText = [itemDescription, conditionDescription]
181
+ .filter((value) => value && value.trim())
182
+ .join(" - ");
183
+ const actionText = [actionType, requiredAction]
184
+ .filter((value) => value && value.trim())
185
+ .join(" - ");
186
+
187
+ const resolvedPhotos =
188
+ photos && photos.length ? photos : getPhotosForPage(session, pageIndex, 1);
189
  const limitedPhotos = resolvedPhotos.slice(0, 6);
190
+ const logoUrl = resolveLogoUrl(session, companyLogo);
191
+ const [ratios, setRatios] = useState<Record<string, number>>({});
192
+
193
+ useEffect(() => {
194
+ let active = true;
195
+ const pending = limitedPhotos.filter((photo) => {
196
+ const key = photoKey(photo);
197
+ return key && !ratios[key] && photoUrl(photo);
198
+ });
199
+ if (!pending.length) return undefined;
200
+
201
+ pending.forEach((photo) => {
202
+ const key = photoKey(photo);
203
+ const url = photoUrl(photo);
204
+ if (!key || !url) return;
205
+ const img = new Image();
206
+ img.onload = () => {
207
+ if (!active) return;
208
+ const ratio = img.naturalWidth ? img.naturalHeight / img.naturalWidth : 1;
209
+ setRatios((prev) => ({ ...prev, [key]: ratio || 1 }));
210
+ };
211
+ img.src = url;
212
+ });
213
+
214
+ return () => {
215
+ active = false;
216
+ };
217
+ }, [limitedPhotos, ratios]);
218
+
219
+ const orderedPhotos = useMemo(() => {
220
+ if (!limitedPhotos.length) return [];
221
+ if (orderLocked) return limitedPhotos;
222
+ const layout = computeLayout(limitedPhotos, ratios);
223
+ return layout.map((entry) => entry.photo);
224
+ }, [limitedPhotos, ratios, orderLocked]);
225
+
226
+ const photoColumnsClass = orderedPhotos.length <= 1 ? "columns-1" : "columns-2";
227
 
228
  return (
229
  <div className="w-full h-full p-5 text-[11px] text-gray-700">
230
+ <header className="mb-3 border-b border-gray-200 pb-2">
231
  <div className="grid grid-cols-[auto,1fr,auto] items-center gap-3">
232
  <img
233
  src="/assets/prosento-logo.png"
 
243
  </div>
244
  </div>
245
  <img
246
+ src={logoUrl}
247
+ alt="Company logo"
248
  className="h-9 w-auto object-contain"
249
  />
250
  </div>
251
  </header>
252
 
253
  {variant === "full" ? (
254
+ <section className="mb-3" aria-labelledby="observations-title">
255
+ <h2
256
+ id="observations-title"
257
+ className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2"
258
+ >
259
+ Observations and Findings
260
+ </h2>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
 
262
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
263
+ <div className="md:col-span-2">
264
+ <div className="grid grid-cols-3 gap-3">
265
+ <div className="space-y-0.5">
266
+ <div className="text-[10px] font-medium text-gray-500">Ref</div>
267
+ <div className="template-field text-[11px] font-semibold text-gray-900">
268
+ {reference}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  </div>
270
  </div>
 
 
 
271
  <div className="space-y-0.5">
272
+ <div className="text-[10px] font-medium text-gray-500">Area</div>
273
+ <div className="template-field text-[11px] font-semibold text-gray-900">
274
+ {area}
275
  </div>
276
+ </div>
277
+ <div className="space-y-0.5">
278
+ <div className="text-[10px] font-medium text-gray-500">Location</div>
279
  <div className="template-field text-[11px] font-semibold text-gray-900">
280
  {functionalLocation}
281
  </div>
282
  </div>
283
  </div>
284
+ </div>
285
 
286
+ <div className="md:col-span-2 flex justify-center">
287
+ <div className="inline-flex items-center gap-10">
288
+ <div className="text-center space-y-1">
289
+ <div className="text-[10px] font-medium text-gray-500">
290
+ Category
 
 
 
 
291
  </div>
292
+ <span className="template-field inline-flex items-center justify-center rounded-md border border-gray-200 px-4 py-1 text-[11px] font-semibold text-gray-900 min-w-[96px]">
293
+ {category}
294
+ </span>
295
+ </div>
296
 
297
+ <div className="text-center space-y-1">
298
+ <div className="text-[10px] font-medium text-gray-500">
299
+ Priority
 
 
 
 
300
  </div>
301
+ <span className="template-field inline-flex items-center justify-center rounded-md border border-gray-200 px-4 py-1 text-[11px] font-semibold text-gray-900 min-w-[96px]">
302
+ {priority}
303
+ </span>
304
  </div>
305
  </div>
306
+ </div>
307
 
308
+ <div className="md:col-span-2 space-y-1">
309
+ <div className="text-[10px] font-medium text-gray-500">
310
+ Condition Description
 
 
 
 
 
 
311
  </div>
312
+ <div className="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
313
+ <p className="template-field template-field-multiline text-amber-800 text-[11px] font-semibold leading-snug">
314
+ {conditionText}
315
+ </p>
316
+ </div>
317
+ </div>
318
 
319
+ <div className="md:col-span-2 space-y-1">
320
+ <div className="text-[10px] font-medium text-gray-500">
321
+ Action Required
322
+ </div>
323
+ <div className="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
324
+ <p className="template-field template-field-multiline text-blue-800 text-[11px] font-semibold leading-snug">
325
+ {actionText}
326
+ </p>
 
327
  </div>
328
  </div>
329
+ </div>
330
+ </section>
331
  ) : null}
332
 
333
+ <section className="mb-3 avoid-break">
334
  <div className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
335
  {variant === "photos" ? "Photo Documentation (continued)" : "Photo Documentation"}
336
  </div>
337
+ <div
338
+ className={`${photoColumnsClass}`}
339
+ style={{ columnGap: "0.75rem" }}
340
+ >
341
+ {orderedPhotos.length === 0 ? (
342
+ <PhotoSlot url={undefined} label="No photo selected" className="break-inside-avoid mb-3" />
343
  ) : (
344
+ orderedPhotos.map((photo, index) => (
345
  <PhotoSlot
346
  key={photo?.id || `${index}`}
347
  url={photo?.url}
348
  label={photo?.name || `Figure ${index + 1}`}
349
+ className="break-inside-avoid mb-3"
350
+ imageClassName=""
351
  />
352
  ))
353
  )}
354
  </div>
355
  </section>
356
 
357
+ <footer className="mt-2 text-[10px] text-gray-500 flex flex-wrap items-center justify-center gap-3">
358
+ <span>Date: {inspectionDate || "-"}</span>
359
+ <span>Inspector: {inspector || "-"}</span>
360
+ <span>Doc: {docNumber || "-"}</span>
361
+ <span>Site: {clientSite || "-"}</span>
362
+ </footer>
363
  </div>
364
  );
365
  }
frontend/src/components/ReportPageCanvas.tsx CHANGED
@@ -1,3 +1,4 @@
 
1
  import type { CSSProperties } from "react";
2
 
3
  import type { FileMeta, Page, Session, TemplateFields } from "../types/session";
@@ -28,11 +29,60 @@ export function ReportPageCanvas({
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
@@ -45,16 +95,19 @@ export function ReportPageCanvas({
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
  }}
@@ -65,6 +118,7 @@ export function ReportPageCanvas({
65
  pageCount={pageCount}
66
  template={template}
67
  photos={sheetPhotos}
 
68
  variant={sheetIndex === 0 ? "full" : "photos"}
69
  />
70
  </div>
 
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
  import type { CSSProperties } from "react";
3
 
4
  import type { FileMeta, Page, Session, TemplateFields } from "../types/session";
 
29
  const items = page?.items ?? [];
30
  const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
31
  const photos = resolvePagePhotos(session, page, pageIndex);
32
+ const photosPerSheet = 6;
33
  const photoSheets = chunkPhotos(photos, photosPerSheet);
34
  const sheets = adaptive && photoSheets.length > 1 ? photoSheets : [photos];
35
+ const sheetRefs = useRef<Array<HTMLDivElement | null>>([]);
36
+ const [sheetHeights, setSheetHeights] = useState<number[]>([]);
37
+
38
+ const defaultHeight = BASE_H * safeScale;
39
+ const resolvedHeights = useMemo(() => {
40
+ if (sheetHeights.length !== sheets.length) {
41
+ return sheets.map(() => defaultHeight);
42
+ }
43
+ return sheetHeights.map((height) => (height > 0 ? height : defaultHeight));
44
+ }, [defaultHeight, sheetHeights, sheets.length]);
45
+
46
+ const offsets = useMemo(() => {
47
+ const values: number[] = [];
48
+ let running = 0;
49
+ resolvedHeights.forEach((height) => {
50
+ values.push(running);
51
+ running += height;
52
+ });
53
+ return values;
54
+ }, [resolvedHeights]);
55
+
56
+ const containerHeight = resolvedHeights.reduce((sum, height) => sum + height, 0);
57
+
58
+ const measureHeights = () => {
59
+ const next = sheets.map((_, index) => {
60
+ const node = sheetRefs.current[index];
61
+ if (!node) return defaultHeight;
62
+ const rect = node.getBoundingClientRect();
63
+ return rect.height || defaultHeight;
64
+ });
65
+ setSheetHeights((prev) => {
66
+ if (prev.length === next.length && prev.every((val, idx) => Math.abs(val - next[idx]) < 1)) {
67
+ return prev;
68
+ }
69
+ return next;
70
+ });
71
+ };
72
+
73
+ useLayoutEffect(() => {
74
+ measureHeights();
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, [sheets.length, safeScale]);
77
+
78
+ useEffect(() => {
79
+ const observer = new ResizeObserver(() => measureHeights());
80
+ sheetRefs.current.forEach((node) => {
81
+ if (node) observer.observe(node);
82
+ });
83
+ return () => observer.disconnect();
84
+ // eslint-disable-next-line react-hooks/exhaustive-deps
85
+ }, [sheets.length]);
86
 
87
  return (
88
  <div
 
95
  key={`sheet-${sheetIndex}`}
96
  style={{
97
  position: "absolute",
98
+ top: `${offsets[sheetIndex] ?? 0}px`,
99
  left: 0,
100
  width: `${BASE_W * safeScale}px`,
101
+ height: `${resolvedHeights[sheetIndex] ?? defaultHeight}px`,
102
  }}
103
  >
104
  <div
105
+ ref={(node) => {
106
+ sheetRefs.current[sheetIndex] = node;
107
+ }}
108
  style={{
109
  width: `${BASE_W}px`,
110
+ minHeight: `${BASE_H}px`,
111
  transform: `scale(${safeScale})`,
112
  transformOrigin: "top left",
113
  }}
 
118
  pageCount={pageCount}
119
  template={template}
120
  photos={sheetPhotos}
121
+ orderLocked={page?.photo_order_locked ?? false}
122
  variant={sheetIndex === 0 ? "full" : "photos"}
123
  />
124
  </div>
frontend/src/components/report-editor.js CHANGED
@@ -26,6 +26,7 @@ class ReportEditor extends HTMLElement {
26
  this.sessionId = null;
27
  this.apiBase = null;
28
  this._saveTimer = null;
 
29
  }
30
 
31
  connectedCallback() {
@@ -588,6 +589,124 @@ class ReportEditor extends HTMLElement {
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]));
@@ -603,11 +722,10 @@ class ReportEditor extends HTMLElement {
603
  }
604
 
605
  _photoSlot(photo, fallbackLabel) {
606
- const url =
607
- photo && (photo.url || (this.sessionId ? `${this._apiRoot()}/sessions/${this.sessionId}/uploads/${photo.id}` : ""));
608
  if (!photo || !url) {
609
  return `
610
- <div class="h-36 w-full rounded-lg border border-dashed border-gray-300 bg-gray-50 flex items-center justify-center text-xs text-gray-500">
611
  No photo selected
612
  </div>
613
  `;
@@ -615,8 +733,8 @@ class ReportEditor extends HTMLElement {
615
  const label = this._escape(photo.name || fallbackLabel);
616
  const safeUrl = this._escape(url);
617
  return `
618
- <figure class="rounded-lg border border-gray-200 bg-gray-50 p-2">
619
- <img src="${safeUrl}" alt="${label}" class="h-36 w-full object-contain" />
620
  <figcaption class="mt-1 text-[10px] text-gray-600 text-center">${label}</figcaption>
621
  </figure>
622
  `;
@@ -636,14 +754,14 @@ class ReportEditor extends HTMLElement {
636
  const inspectionDate =
637
  template.inspection_date || session.inspection_date || "";
638
  const inspector = template.inspector || "";
639
- const accompaniedBy = template.accompanied_by || "";
640
  const docNumber =
641
  template.document_no ||
642
  (session.id ? `REP-${String(session.id).slice(0, 8).toUpperCase()}` : "");
643
- const projectName = template.project || session.project_name || "";
644
  const clientSite = template.client_site || "";
 
645
 
646
  const reference = template.reference || "";
 
647
  const actionType = template.action_type || "";
648
  const itemDescription = template.item_description || "";
649
  const functionalLocation = template.functional_location || "";
@@ -654,16 +772,23 @@ class ReportEditor extends HTMLElement {
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">
669
  <img src="/assets/prosento-logo.png" alt="Prosento logo" class="h-10 w-auto object-contain" />
@@ -675,81 +800,34 @@ class ReportEditor extends HTMLElement {
675
  </div>
676
 
677
  <div class="flex items-center justify-end">
678
- <img src="/assets/client-logo.png" alt="Client logo" class="h-10 w-auto object-contain" />
679
  </div>
680
  </div>
681
  </header>
682
 
683
- <section class="mb-4" aria-labelledby="inspection-details-title">
684
- <h2 id="inspection-details-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
685
- Inspection Details
686
- </h2>
687
-
688
- <dl class="grid grid-cols-2 md:grid-cols-4 gap-2 bg-gray-50 border border-gray-200 rounded-lg p-3">
689
- <div class="space-y-0.5">
690
- <dt class="text-xs font-medium text-gray-500">Inspection Date</dt>
691
- ${this._tplField("inspection_date", inspectionDate, "YYYY-MM-DD", "text-sm font-semibold text-gray-900")}
692
- </div>
693
-
694
- <div class="space-y-0.5">
695
- <dt class="text-xs font-medium text-gray-500">Inspector</dt>
696
- ${this._tplField("inspector", inspector, "Inspector name", "text-sm font-semibold text-gray-900")}
697
- </div>
698
-
699
- <div class="space-y-0.5">
700
- <dt class="text-xs font-medium text-gray-500">Accompanied By</dt>
701
- ${this._tplField("accompanied_by", accompaniedBy, "Accompanied by", "text-sm font-semibold text-gray-900")}
702
- </div>
703
-
704
- <div class="space-y-0.5">
705
- <dt class="text-xs font-medium text-gray-500">Document No</dt>
706
- ${this._tplField("document_no", docNumber, "Document no", "text-sm font-mono font-semibold text-gray-900")}
707
- </div>
708
-
709
- <div class="space-y-0.5 md:col-span-2">
710
- <dt class="text-xs font-medium text-gray-500">Project</dt>
711
- ${this._tplField("project", projectName, "Project name", "text-sm font-semibold text-gray-900")}
712
- </div>
713
-
714
- <div class="space-y-0.5 md:col-span-2">
715
- <dt class="text-xs font-medium text-gray-500">Client / Site</dt>
716
- ${this._tplField("client_site", clientSite, "Client or site", "text-sm font-semibold text-gray-900")}
717
- </div>
718
- </dl>
719
- </section>
720
-
721
  <section class="mb-4" aria-labelledby="observations-title">
722
  <h2 id="observations-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
723
  Observations and Findings
724
  </h2>
725
 
726
  <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
727
- <div class="space-y-2">
728
- <div class="grid grid-cols-2 gap-2">
729
  <div class="space-y-0.5">
730
- <div class="text-xs font-medium text-gray-500">Reference</div>
731
- ${this._tplField("reference", reference, "Reference", "text-sm font-semibold text-gray-900")}
732
  </div>
733
-
734
  <div class="space-y-0.5">
735
- <div class="text-xs font-medium text-gray-500">Action Type</div>
736
- ${this._tplField("action_type", actionType, "Action type", "text-sm font-semibold text-gray-900")}
737
  </div>
738
-
739
- <div class="space-y-0.5 col-span-2">
740
- <div class="text-xs font-medium text-gray-500">Item Description</div>
741
- ${this._tplField("item_description", itemDescription, "Item description", "text-sm font-semibold text-gray-900")}
742
  </div>
743
  </div>
744
  </div>
745
 
746
- <div class="space-y-2">
747
- <div class="space-y-0.5">
748
- <div class="text-xs font-medium text-gray-500">Functional Location</div>
749
- ${this._tplField("functional_location", functionalLocation, "Functional location", "text-sm font-semibold text-gray-900")}
750
- </div>
751
- </div>
752
-
753
  <div class="md:col-span-2 flex justify-center">
754
  <div class="inline-flex items-center gap-10">
755
  <div class="text-center space-y-1">
@@ -767,14 +845,20 @@ class ReportEditor extends HTMLElement {
767
  <div class="md:col-span-2 space-y-1">
768
  <div class="text-xs font-medium text-gray-500">Condition Description</div>
769
  <div class="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
770
- ${this._tplField("condition_description", conditionDescription, "Condition description", "template-field-multiline text-amber-800 text-sm font-semibold leading-snug", true)}
 
 
 
771
  </div>
772
  </div>
773
 
774
  <div class="md:col-span-2 space-y-1">
775
- <div class="text-xs font-medium text-gray-500">Required Action</div>
776
  <div class="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
777
- ${this._tplField("required_action", requiredAction, "Required action", "template-field-multiline text-blue-800 text-sm font-semibold leading-snug", true)}
 
 
 
778
  </div>
779
  </div>
780
  </div>
@@ -785,14 +869,16 @@ class ReportEditor extends HTMLElement {
785
  Photo Documentation
786
  </h2>
787
 
788
- <div class="${photoGridClass}">
789
  ${photoSlots}
790
  </div>
791
  </section>
792
 
793
- <footer class="mt-4 text-center text-[11px] text-gray-500">
794
- <p>RepEx - (c) 2026 All Rights Reserved</p>
795
- <p class="mt-0.5">Generated by RepEx</p>
 
 
796
  </footer>
797
  </div>
798
  `;
 
26
  this.sessionId = null;
27
  this.apiBase = null;
28
  this._saveTimer = null;
29
+ this._photoRatios = new Map();
30
  }
31
 
32
  connectedCallback() {
 
589
  return selected.length ? selected : uploads;
590
  }
591
 
592
+ _normalizeKey(value) {
593
+ return String(value || "").toLowerCase().replace(/[^a-z0-9]/g, "");
594
+ }
595
+
596
+ _resolveLogoUrl(session, rawValue) {
597
+ const value = String(rawValue || "").trim();
598
+ if (!value) return "/assets/client-logo.png";
599
+ if (/^(https?:|data:|\/)/i.test(value)) return value;
600
+ const uploads = (session && session.uploads && session.uploads.photos) || [];
601
+ const key = this._normalizeKey(value);
602
+ for (const photo of uploads) {
603
+ const name = photo && photo.name ? photo.name : "";
604
+ if (!name) continue;
605
+ const nameKey = this._normalizeKey(name);
606
+ const stemKey = this._normalizeKey(name.replace(/\.[^/.]+$/, ""));
607
+ if (key === nameKey || key === stemKey) {
608
+ return photo.url || (this.sessionId ? `${this._apiRoot()}/sessions/${this.sessionId}/uploads/${photo.id}` : value);
609
+ }
610
+ }
611
+ return value;
612
+ }
613
+
614
+ _photoKey(photo) {
615
+ if (!photo) return "";
616
+ return photo.id || photo.url || photo.name || "";
617
+ }
618
+
619
+ _photoUrl(photo) {
620
+ if (!photo) return "";
621
+ if (photo.url) return photo.url;
622
+ if (this.sessionId && photo.id) {
623
+ return `${this._apiRoot()}/sessions/${this.sessionId}/uploads/${photo.id}`;
624
+ }
625
+ return "";
626
+ }
627
+
628
+ _photoRatio(photo) {
629
+ const key = this._photoKey(photo);
630
+ if (!key) return 1;
631
+ const ratio = this._photoRatios.get(key);
632
+ return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
633
+ }
634
+
635
+ _ensurePhotoRatios(photos) {
636
+ photos.forEach((photo) => {
637
+ const key = this._photoKey(photo);
638
+ const url = this._photoUrl(photo);
639
+ if (!key || !url || this._photoRatios.has(key)) return;
640
+
641
+ const img = new Image();
642
+ img.onload = () => {
643
+ const ratio = img.naturalWidth ? img.naturalHeight / img.naturalWidth : 1;
644
+ this._photoRatios.set(key, ratio || 1);
645
+ if (this.state.isOpen) {
646
+ this.renderCanvas();
647
+ }
648
+ };
649
+ img.onerror = () => {
650
+ this._photoRatios.set(key, 1);
651
+ };
652
+ img.src = url;
653
+ });
654
+ }
655
+
656
+ _computePhotoLayout(photos) {
657
+ const entries = photos.map((photo) => ({
658
+ photo,
659
+ ratio: this._photoRatio(photo),
660
+ }));
661
+
662
+ const memo = new Map();
663
+ const solve = (remaining) => {
664
+ if (remaining.length === 0) return { cost: 0, rows: [] };
665
+ const cacheKey = remaining.join(",");
666
+ const cached = memo.get(cacheKey);
667
+ if (cached) return cached;
668
+
669
+ const [first, ...rest] = remaining;
670
+ let bestCost = Number.POSITIVE_INFINITY;
671
+ let bestRows = [];
672
+
673
+ const single = solve(rest);
674
+ const singleCost = 2 * entries[first].ratio + single.cost;
675
+ if (singleCost < bestCost) {
676
+ bestCost = singleCost;
677
+ bestRows = [[first], ...single.rows];
678
+ }
679
+
680
+ for (let i = 0; i < rest.length; i += 1) {
681
+ const pair = rest[i];
682
+ const next = rest.filter((_, idx) => idx !== i);
683
+ const result = solve(next);
684
+ const pairCost = Math.max(entries[first].ratio, entries[pair].ratio) + result.cost;
685
+ if (pairCost < bestCost) {
686
+ bestCost = pairCost;
687
+ bestRows = [[first, pair], ...result.rows];
688
+ }
689
+ }
690
+
691
+ const value = { cost: bestCost, rows: bestRows };
692
+ memo.set(cacheKey, value);
693
+ return value;
694
+ };
695
+
696
+ const indices = entries.map((_, index) => index);
697
+ const solution = solve(indices);
698
+ const layout = [];
699
+ solution.rows.forEach((row) => {
700
+ if (row.length === 1) {
701
+ layout.push({ photo: entries[row[0]].photo, span: true });
702
+ } else {
703
+ layout.push({ photo: entries[row[0]].photo, span: false });
704
+ layout.push({ photo: entries[row[1]].photo, span: false });
705
+ }
706
+ });
707
+ return layout;
708
+ }
709
+
710
  _photosForActivePage(session) {
711
  const uploads = (session && session.uploads && session.uploads.photos) || [];
712
  const byId = new Map(uploads.map((photo) => [photo.id, photo]));
 
722
  }
723
 
724
  _photoSlot(photo, fallbackLabel) {
725
+ const url = this._photoUrl(photo);
 
726
  if (!photo || !url) {
727
  return `
728
+ <div class="min-h-[120px] w-full rounded-lg border border-dashed border-gray-300 bg-gray-50 flex items-center justify-center text-xs text-gray-500 break-inside-avoid mb-3">
729
  No photo selected
730
  </div>
731
  `;
 
733
  const label = this._escape(photo.name || fallbackLabel);
734
  const safeUrl = this._escape(url);
735
  return `
736
+ <figure class="rounded-lg border border-gray-200 bg-gray-50 p-2 break-inside-avoid mb-3">
737
+ <img src="${safeUrl}" alt="${label}" class="w-full h-auto object-contain" />
738
  <figcaption class="mt-1 text-[10px] text-gray-600 text-center">${label}</figcaption>
739
  </figure>
740
  `;
 
754
  const inspectionDate =
755
  template.inspection_date || session.inspection_date || "";
756
  const inspector = template.inspector || "";
 
757
  const docNumber =
758
  template.document_no ||
759
  (session.id ? `REP-${String(session.id).slice(0, 8).toUpperCase()}` : "");
 
760
  const clientSite = template.client_site || "";
761
+ const companyLogo = template.company_logo || "";
762
 
763
  const reference = template.reference || "";
764
+ const area = template.area || "";
765
  const actionType = template.action_type || "";
766
  const itemDescription = template.item_description || "";
767
  const functionalLocation = template.functional_location || "";
 
772
  const requiredAction = template.required_action || "";
773
 
774
  const photos = this._photosForActivePage(session).slice(0, 6);
775
+ this._ensurePhotoRatios(photos);
776
+ const orderLocked = !!(this.activePage && this.activePage.photo_order_locked);
777
+ const orderedPhotos = orderLocked
778
+ ? photos
779
+ : this._computePhotoLayout(photos).map((entry) => entry.photo);
780
+ const photoColumnsClass = orderedPhotos.length <= 1 ? "columns-1" : "columns-2";
781
+ const photoSlots = orderedPhotos.length
782
+ ? orderedPhotos
783
+ .map((photo, idx) => this._photoSlot(photo, `Figure ${idx + 1}`))
784
+ .join("")
785
  : this._photoSlot(null, "No photo selected");
786
  const pageNum = this.state.activePage + 1;
787
  const pageCount = this.state.pages.length || 1;
788
 
789
  return `
790
  <div class="w-full h-full p-5 text-[11px] text-gray-700" style="color:#374151; font-size:11px;">
791
+ <header class="mb-3 border-b border-gray-200 pb-2">
792
  <div class="grid grid-cols-[auto,1fr,auto] items-center gap-3">
793
  <div class="flex items-center">
794
  <img src="/assets/prosento-logo.png" alt="Prosento logo" class="h-10 w-auto object-contain" />
 
800
  </div>
801
 
802
  <div class="flex items-center justify-end">
803
+ <img src="${this._escape(this._resolveLogoUrl(session, companyLogo))}" alt="Company logo" class="h-10 w-auto object-contain" />
804
  </div>
805
  </div>
806
  </header>
807
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
808
  <section class="mb-4" aria-labelledby="observations-title">
809
  <h2 id="observations-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
810
  Observations and Findings
811
  </h2>
812
 
813
  <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
814
+ <div class="md:col-span-2">
815
+ <div class="grid grid-cols-3 gap-3">
816
  <div class="space-y-0.5">
817
+ <div class="text-xs font-medium text-gray-500">Ref</div>
818
+ ${this._tplField("reference", reference, "Ref", "text-sm font-semibold text-gray-900")}
819
  </div>
 
820
  <div class="space-y-0.5">
821
+ <div class="text-xs font-medium text-gray-500">Area</div>
822
+ ${this._tplField("area", area, "Area", "text-sm font-semibold text-gray-900")}
823
  </div>
824
+ <div class="space-y-0.5">
825
+ <div class="text-xs font-medium text-gray-500">Location</div>
826
+ ${this._tplField("functional_location", functionalLocation, "Location", "text-sm font-semibold text-gray-900")}
 
827
  </div>
828
  </div>
829
  </div>
830
 
 
 
 
 
 
 
 
831
  <div class="md:col-span-2 flex justify-center">
832
  <div class="inline-flex items-center gap-10">
833
  <div class="text-center space-y-1">
 
845
  <div class="md:col-span-2 space-y-1">
846
  <div class="text-xs font-medium text-gray-500">Condition Description</div>
847
  <div class="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
848
+ <div class="text-amber-800 text-sm font-semibold leading-snug">
849
+ ${this._tplField("item_description", itemDescription, "Item description", "template-field-multiline text-amber-800 text-sm font-semibold leading-snug", true)}
850
+ ${this._tplField("condition_description", conditionDescription, "Condition description", "template-field-multiline text-amber-800 text-sm font-semibold leading-snug", true)}
851
+ </div>
852
  </div>
853
  </div>
854
 
855
  <div class="md:col-span-2 space-y-1">
856
+ <div class="text-xs font-medium text-gray-500">Action Required</div>
857
  <div class="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
858
+ <div class="text-blue-800 text-sm font-semibold leading-snug">
859
+ ${this._tplField("action_type", actionType, "Action type", "template-field-multiline text-blue-800 text-sm font-semibold leading-snug", true)}
860
+ ${this._tplField("required_action", requiredAction, "Required action", "template-field-multiline text-blue-800 text-sm font-semibold leading-snug", true)}
861
+ </div>
862
  </div>
863
  </div>
864
  </div>
 
869
  Photo Documentation
870
  </h2>
871
 
872
+ <div class="${photoColumnsClass}" style="column-gap:0.75rem;">
873
  ${photoSlots}
874
  </div>
875
  </section>
876
 
877
+ <footer class="mt-2 text-[10px] text-gray-500 flex flex-wrap items-center justify-center gap-3">
878
+ <span>Date: ${this._escape(inspectionDate || "-")}</span>
879
+ <span>Inspector: ${this._escape(inspector || "-")}</span>
880
+ <span>Doc: ${this._escape(docNumber || "-")}</span>
881
+ <span>Site: ${this._escape(clientSite || "-")}</span>
882
  </footer>
883
  </div>
884
  `;
frontend/src/pages/InputDataPage.tsx CHANGED
@@ -1,8 +1,8 @@
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";
@@ -16,13 +16,18 @@ type FieldDef = {
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 },
@@ -42,7 +47,19 @@ export default function InputDataPage() {
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) {
@@ -68,6 +85,17 @@ export default function InputDataPage() {
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);
@@ -85,6 +113,25 @@ export default function InputDataPage() {
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) => {
@@ -96,6 +143,11 @@ export default function InputDataPage() {
96
  );
97
  }
98
 
 
 
 
 
 
99
  function applyRowToAll(pageIndex: number) {
100
  const source = pages[pageIndex]?.template ?? {};
101
  setPages((prev) =>
@@ -106,6 +158,152 @@ export default function InputDataPage() {
106
  );
107
  }
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  function getFallbackValue(field: keyof TemplateFields): string {
110
  if (!session) return "";
111
  switch (field) {
@@ -143,6 +341,79 @@ export default function InputDataPage() {
143
  }
144
  }
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  return (
147
  <PageShell className="max-w-6xl">
148
  <PageHeader
@@ -209,49 +480,354 @@ export default function InputDataPage() {
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}
@@ -273,14 +849,137 @@ export default function InputDataPage() {
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
  );
@@ -289,6 +988,17 @@ export default function InputDataPage() {
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
  );
 
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
  import { Link, useSearchParams } from "react-router-dom";
3
  import { ArrowLeft, Download, Edit3, Grid, Layout, Save, Table } from "react-feather";
4
 
5
+ import { API_BASE, postForm, putJson, request } from "../lib/api";
6
  import { formatDocNumber } from "../lib/report";
7
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
8
  import type { Page, Session, TemplateFields } from "../types/session";
 
16
  multiline?: boolean;
17
  };
18
 
19
+ const GENERAL_FIELDS: 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: "company_logo", label: "Company Logo" },
27
+ ];
28
+
29
+ const ITEM_FIELDS: FieldDef[] = [
30
+ { key: "area", label: "Area" },
31
  { key: "reference", label: "Reference" },
32
  { key: "action_type", label: "Action Type" },
33
  { key: "item_description", label: "Item Description", multiline: true },
 
47
  const [pages, setPages] = useState<Page[]>([]);
48
  const [status, setStatus] = useState("");
49
  const [isSaving, setIsSaving] = useState(false);
50
+ const [isUploading, setIsUploading] = useState(false);
51
+ const [copySourceIndex, setCopySourceIndex] = useState(0);
52
+ const [copyTargets, setCopyTargets] = useState("");
53
+ const [showGeneralColumns, setShowGeneralColumns] = useState(false);
54
+ const [generalDirty, setGeneralDirty] = useState(false);
55
+ const [generalTemplate, setGeneralTemplate] = useState<TemplateFields>({});
56
+ const [photoSelections, setPhotoSelections] = useState<Record<number, string>>(
57
+ {},
58
+ );
59
  const canSave = Boolean(sessionId) && !isSaving;
60
+ const excelInputRef = useRef<HTMLInputElement | null>(null);
61
+ const jsonInputRef = useRef<HTMLInputElement | null>(null);
62
+ const uploadInputRefs = useRef<Record<number, HTMLInputElement | null>>({});
63
 
64
  useEffect(() => {
65
  if (!sessionId) {
 
85
  load();
86
  }, [sessionId]);
87
 
88
+ async function refreshSession() {
89
+ if (!sessionId) return;
90
+ const data = await request<Session>(`/sessions/${sessionId}`);
91
+ setSession(data);
92
+ const pageResp = await request<{ pages: Page[] }>(
93
+ `/sessions/${sessionId}/pages`,
94
+ );
95
+ const loaded = Array.isArray(pageResp.pages) ? pageResp.pages : [];
96
+ setPages(loaded.length ? loaded : [{ items: [] }]);
97
+ }
98
+
99
  const totalPages = useMemo(() => {
100
  if (pages.length > 0) return pages.length;
101
  return Math.max(1, session?.page_count ?? 0);
 
113
  });
114
  }, [pages.length, sessionId, totalPages]);
115
 
116
+ useEffect(() => {
117
+ if (copySourceIndex >= pages.length) {
118
+ setCopySourceIndex(Math.max(0, pages.length - 1));
119
+ }
120
+ }, [copySourceIndex, pages.length]);
121
+
122
+ useEffect(() => {
123
+ if (generalDirty) return;
124
+ const source = pages[0]?.template ?? {};
125
+ const next: TemplateFields = {};
126
+ GENERAL_FIELDS.forEach((field) => {
127
+ const value = source[field.key] ?? getFallbackValue(field.key);
128
+ if (value !== undefined) {
129
+ next[field.key] = value;
130
+ }
131
+ });
132
+ setGeneralTemplate(next);
133
+ }, [generalDirty, pages, session]);
134
+
135
  function updateField(pageIndex: number, key: keyof TemplateFields, value: string) {
136
  setPages((prev) =>
137
  prev.map((page, idx) => {
 
143
  );
144
  }
145
 
146
+ function updateGeneralField(key: keyof TemplateFields, value: string) {
147
+ setGeneralTemplate((prev) => ({ ...prev, [key]: value }));
148
+ setGeneralDirty(true);
149
+ }
150
+
151
  function applyRowToAll(pageIndex: number) {
152
  const source = pages[pageIndex]?.template ?? {};
153
  setPages((prev) =>
 
158
  );
159
  }
160
 
161
+ function applyGeneralToAll() {
162
+ if (!pages.length) return;
163
+ setPages((prev) =>
164
+ prev.map((page) => {
165
+ const template = { ...(page.template ?? {}) };
166
+ GENERAL_FIELDS.forEach((field) => {
167
+ const value = generalTemplate[field.key];
168
+ if (value !== undefined) {
169
+ template[field.key] = value;
170
+ }
171
+ });
172
+ return { ...page, template };
173
+ }),
174
+ );
175
+ setGeneralDirty(false);
176
+ setStatus("Applied general info to all pages.");
177
+ }
178
+
179
+ function insertPageAt(index: number, templateSource?: TemplateFields) {
180
+ setPages((prev) => {
181
+ const next = [...prev];
182
+ const fallbackTemplate =
183
+ templateSource ??
184
+ next[Math.max(0, Math.min(index - 1, next.length - 1))]?.template ??
185
+ {};
186
+ next.splice(index, 0, { items: [], template: { ...fallbackTemplate } });
187
+ return next;
188
+ });
189
+ }
190
+
191
+ function removePageAt(index: number) {
192
+ setPages((prev) => {
193
+ if (prev.length <= 1) return prev;
194
+ const next = [...prev];
195
+ next.splice(index, 1);
196
+ return next.length ? next : [{ items: [] }];
197
+ });
198
+ }
199
+
200
+ function updatePhotoSelection(pageIndex: number, value: string) {
201
+ setPhotoSelections((prev) => ({ ...prev, [pageIndex]: value }));
202
+ }
203
+
204
+ function updatePagePhotos(pageIndex: number, nextIds: string[]) {
205
+ setPages((prev) =>
206
+ prev.map((page, idx) =>
207
+ idx === pageIndex ? { ...page, photo_ids: nextIds } : page,
208
+ ),
209
+ );
210
+ }
211
+
212
+ function setPhotoOrderLocked(pageIndex: number, locked: boolean) {
213
+ setPages((prev) =>
214
+ prev.map((page, idx) =>
215
+ idx === pageIndex ? { ...page, photo_order_locked: locked } : page,
216
+ ),
217
+ );
218
+ }
219
+
220
+ function movePhoto(pageIndex: number, from: number, to: number) {
221
+ setPages((prev) =>
222
+ prev.map((page, idx) => {
223
+ if (idx !== pageIndex) return page;
224
+ const ids = [...(page.photo_ids ?? [])];
225
+ if (from < 0 || from >= ids.length || to < 0 || to >= ids.length) {
226
+ return page;
227
+ }
228
+ const [moved] = ids.splice(from, 1);
229
+ ids.splice(to, 0, moved);
230
+ return { ...page, photo_ids: ids };
231
+ }),
232
+ );
233
+ }
234
+
235
+ function removePhoto(pageIndex: number, index: number) {
236
+ setPages((prev) =>
237
+ prev.map((page, idx) => {
238
+ if (idx !== pageIndex) return page;
239
+ const ids = [...(page.photo_ids ?? [])];
240
+ ids.splice(index, 1);
241
+ return { ...page, photo_ids: ids };
242
+ }),
243
+ );
244
+ }
245
+
246
+ function addPhotoToPage(pageIndex: number, photoId: string) {
247
+ if (!photoId) return;
248
+ setPages((prev) =>
249
+ prev.map((page, idx) => {
250
+ if (idx !== pageIndex) return page;
251
+ const ids = [...(page.photo_ids ?? [])];
252
+ if (!ids.includes(photoId)) ids.push(photoId);
253
+ return { ...page, photo_ids: ids };
254
+ }),
255
+ );
256
+ }
257
+
258
+ function parseTargetPages(value: string, max: number): number[] {
259
+ const results = new Set<number>();
260
+ const parts = value
261
+ .split(",")
262
+ .map((part) => part.trim())
263
+ .filter(Boolean);
264
+ parts.forEach((part) => {
265
+ if (part.includes("-")) {
266
+ const [startRaw, endRaw] = part.split("-").map((chunk) => chunk.trim());
267
+ const start = Number.parseInt(startRaw, 10);
268
+ const end = Number.parseInt(endRaw, 10);
269
+ if (Number.isNaN(start) || Number.isNaN(end)) return;
270
+ const min = Math.min(start, end);
271
+ const maxRange = Math.max(start, end);
272
+ for (let idx = min; idx <= maxRange; idx += 1) {
273
+ if (idx >= 1 && idx <= max) results.add(idx - 1);
274
+ }
275
+ } else {
276
+ const pageNum = Number.parseInt(part, 10);
277
+ if (!Number.isNaN(pageNum) && pageNum >= 1 && pageNum <= max) {
278
+ results.add(pageNum - 1);
279
+ }
280
+ }
281
+ });
282
+ return Array.from(results).sort((a, b) => a - b);
283
+ }
284
+
285
+ function copyPageToTargets() {
286
+ if (!pages.length) return;
287
+ const targets = parseTargetPages(copyTargets, pages.length).filter(
288
+ (idx) => idx !== copySourceIndex,
289
+ );
290
+ if (!targets.length) {
291
+ setStatus("No valid target pages selected for copy.");
292
+ return;
293
+ }
294
+ const sourceTemplate = pages[copySourceIndex]?.template ?? {};
295
+ setPages((prev) =>
296
+ prev.map((page, idx) =>
297
+ targets.includes(idx) ? { ...page, template: { ...sourceTemplate } } : page,
298
+ ),
299
+ );
300
+ setStatus(
301
+ `Copied page ${copySourceIndex + 1} to ${targets
302
+ .map((idx) => idx + 1)
303
+ .join(", ")}.`,
304
+ );
305
+ }
306
+
307
  function getFallbackValue(field: keyof TemplateFields): string {
308
  if (!session) return "";
309
  switch (field) {
 
341
  }
342
  }
343
 
344
+ async function uploadDataFile(file: File) {
345
+ if (!sessionId) return;
346
+ setIsUploading(true);
347
+ setStatus("Uploading data file...");
348
+ try {
349
+ const form = new FormData();
350
+ form.append("file", file);
351
+ await postForm<Session>(`/sessions/${sessionId}/data-files`, form);
352
+ await refreshSession();
353
+ setStatus("Data file imported.");
354
+ setGeneralDirty(false);
355
+ } catch (err) {
356
+ const message =
357
+ err instanceof Error ? err.message : "Failed to import data file.";
358
+ setStatus(message);
359
+ } finally {
360
+ setIsUploading(false);
361
+ }
362
+ }
363
+
364
+ async function uploadJsonFile(file: File) {
365
+ if (!sessionId) return;
366
+ setIsUploading(true);
367
+ setStatus("Uploading JSON package...");
368
+ try {
369
+ const form = new FormData();
370
+ form.append("file", file);
371
+ await postForm<Session>(`/sessions/${sessionId}/import-json`, form);
372
+ await refreshSession();
373
+ setStatus("JSON package imported.");
374
+ setGeneralDirty(false);
375
+ } catch (err) {
376
+ const message =
377
+ err instanceof Error ? err.message : "Failed to import JSON package.";
378
+ setStatus(message);
379
+ } finally {
380
+ setIsUploading(false);
381
+ }
382
+ }
383
+
384
+ async function uploadPhotoForPage(pageIndex: number, file: File) {
385
+ if (!sessionId) return;
386
+ setIsUploading(true);
387
+ setStatus(`Uploading image ${file.name}...`);
388
+ const existing = new Set(
389
+ (session?.uploads?.photos ?? []).map((photo) => photo.id),
390
+ );
391
+ try {
392
+ const form = new FormData();
393
+ form.append("file", file);
394
+ const updated = await postForm<Session>(
395
+ `/sessions/${sessionId}/uploads`,
396
+ form,
397
+ );
398
+ setSession(updated);
399
+ const newPhoto =
400
+ (updated.uploads?.photos ?? []).find((photo) => !existing.has(photo.id)) ??
401
+ (updated.uploads?.photos ?? []).find((photo) => photo.name === file.name);
402
+ if (newPhoto) {
403
+ addPhotoToPage(pageIndex, newPhoto.id);
404
+ setStatus(`Uploaded ${newPhoto.name}.`);
405
+ } else {
406
+ setStatus("Uploaded image. Refresh to see new file.");
407
+ }
408
+ } catch (err) {
409
+ const message =
410
+ err instanceof Error ? err.message : "Failed to upload image.";
411
+ setStatus(message);
412
+ } finally {
413
+ setIsUploading(false);
414
+ }
415
+ }
416
+
417
  return (
418
  <PageShell className="max-w-6xl">
419
  <PageHeader
 
480
  page's fields across every job sheet.
481
  </p>
482
  </div>
483
+ <div className="flex flex-wrap gap-2">
484
+ <button
485
+ type="button"
486
+ onClick={() => insertPageAt(pages.length, {})}
487
+ disabled={!sessionId}
488
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
489
+ >
490
+ Add blank page
491
+ </button>
492
+ <button
493
+ type="button"
494
+ onClick={saveAll}
495
+ disabled={!canSave}
496
+ 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"
497
+ >
498
+ <Save className="h-4 w-4" />
499
+ Save changes
500
+ </button>
501
+ </div>
502
  </div>
503
  {status ? <p className="text-sm text-gray-600 mt-3">{status}</p> : null}
504
  </section>
505
 
506
+ <section className="mb-4 rounded-lg border border-gray-200 bg-white p-4">
507
+ <div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
508
+ <div>
509
+ <h3 className="text-base font-semibold text-gray-900">Copy page to targets</h3>
510
+ <p className="text-sm text-gray-600">
511
+ Choose a source page and the pages to overwrite (e.g. 2,4-6).
512
+ </p>
513
+ </div>
514
+ <div className="flex flex-wrap items-end gap-2">
515
+ <label className="text-xs text-gray-600">
516
+ Source page
517
+ <select
518
+ className="mt-1 w-36 rounded-md border border-gray-200 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
519
+ value={copySourceIndex}
520
+ onChange={(event) => setCopySourceIndex(Number(event.target.value))}
521
+ >
522
+ {pages.map((_, idx) => (
523
+ <option key={`copy-source-${idx}`} value={idx}>
524
+ Page {idx + 1}
525
+ </option>
526
+ ))}
527
+ </select>
528
+ </label>
529
+ <label className="text-xs text-gray-600">
530
+ Target pages
531
+ <input
532
+ type="text"
533
+ placeholder="e.g. 2,4-6"
534
+ className="mt-1 w-44 rounded-md border border-gray-200 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
535
+ value={copyTargets}
536
+ onChange={(event) => setCopyTargets(event.target.value)}
537
+ />
538
+ </label>
539
+ <button
540
+ type="button"
541
+ onClick={copyPageToTargets}
542
+ disabled={!pages.length}
543
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
544
+ >
545
+ Copy page data
546
+ </button>
547
+ </div>
548
+ </div>
549
+ </section>
550
+
551
+ <section className="mb-4 rounded-lg border border-gray-200 bg-white p-4">
552
+ <div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
553
+ <div>
554
+ <h3 className="text-base font-semibold text-gray-900">
555
+ Import / Export data files
556
+ </h3>
557
+ <p className="text-sm text-gray-600">
558
+ Upload an Excel/CSV data file or a JSON package to populate job sheets.
559
+ </p>
560
+ </div>
561
+ <div className="flex flex-wrap gap-2">
562
+ <button
563
+ type="button"
564
+ onClick={() => excelInputRef.current?.click()}
565
+ disabled={!sessionId || isUploading}
566
+ 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"
567
+ >
568
+ Upload Excel/CSV
569
+ </button>
570
+ <button
571
+ type="button"
572
+ onClick={() => jsonInputRef.current?.click()}
573
+ disabled={!sessionId || isUploading}
574
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
575
+ >
576
+ Upload JSON
577
+ </button>
578
+ <a
579
+ href={
580
+ sessionId
581
+ ? `${API_BASE}/sessions/${sessionId}/export.xlsx`
582
+ : "#"
583
+ }
584
+ 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"
585
+ >
586
+ Download Excel
587
+ </a>
588
+ <a
589
+ href={
590
+ sessionId
591
+ ? `${API_BASE}/sessions/${sessionId}/export`
592
+ : "#"
593
+ }
594
+ 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"
595
+ >
596
+ Download JSON
597
+ </a>
598
+ </div>
599
+ </div>
600
+ <input
601
+ ref={excelInputRef}
602
+ type="file"
603
+ accept=".xlsx,.xls,.csv"
604
+ className="hidden"
605
+ onChange={(event) => {
606
+ const file = event.target.files?.[0];
607
+ if (file) uploadDataFile(file);
608
+ event.target.value = "";
609
+ }}
610
+ />
611
+ <input
612
+ ref={jsonInputRef}
613
+ type="file"
614
+ accept=".json,application/json"
615
+ className="hidden"
616
+ onChange={(event) => {
617
+ const file = event.target.files?.[0];
618
+ if (file) uploadJsonFile(file);
619
+ event.target.value = "";
620
+ }}
621
+ />
622
+ </section>
623
+
624
+ <section className="mb-4 rounded-lg border border-gray-200 bg-white p-4">
625
+ <div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr),320px] gap-4 items-start">
626
+ <div className="min-w-0">
627
+ <div className="flex flex-wrap items-center justify-between gap-2">
628
+ <div>
629
+ <h3 className="text-base font-semibold text-gray-900">
630
+ General Information
631
+ </h3>
632
+ <p className="text-sm text-gray-600">
633
+ Update the global inspection details once, then apply to all pages.
634
+ </p>
635
+ </div>
636
+ <div className="flex flex-wrap gap-2">
637
+ {generalDirty ? (
638
+ <button
639
+ type="button"
640
+ onClick={applyGeneralToAll}
641
+ 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"
642
+ >
643
+ Apply general info to all pages
644
+ </button>
645
+ ) : null}
646
+ </div>
647
+ </div>
648
+
649
+ <div className="mt-4 rounded-lg border border-gray-200 bg-white overflow-x-auto w-full max-w-full">
650
+ <table className="min-w-[560px] w-full text-sm">
651
+ <thead className="bg-gray-50 border-b border-gray-200">
652
+ <tr>
653
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-600 min-w-[180px]">
654
+ Field
655
+ </th>
656
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-600">
657
+ Value
658
+ </th>
659
+ </tr>
660
+ </thead>
661
+ <tbody>
662
+ {GENERAL_FIELDS.map((field) => (
663
+ <tr key={`general-${field.key}`} className="border-b border-gray-100">
664
+ <td className="px-3 py-2 text-xs font-semibold text-gray-700">
665
+ {field.label}
666
+ </td>
667
+ <td className="px-3 py-2">
668
+ <input
669
+ type="text"
670
+ 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"
671
+ value={generalTemplate[field.key] ?? getFallbackValue(field.key)}
672
+ onChange={(event) =>
673
+ updateGeneralField(field.key, event.target.value)
674
+ }
675
+ />
676
+ </td>
677
+ </tr>
678
+ ))}
679
+ </tbody>
680
+ </table>
681
+ </div>
682
+ </div>
683
+
684
+ <div className="w-full lg:w-[320px] shrink-0">
685
+ <h3 className="text-base font-semibold text-gray-900">Headings</h3>
686
+ <p className="text-sm text-gray-600">
687
+ Imported heading numbers from the Excel sheet.
688
+ </p>
689
+ <div className="mt-3 rounded-lg border border-gray-200 bg-white overflow-x-auto">
690
+ <table className="min-w-[280px] w-full text-sm">
691
+ <thead className="bg-gray-50 border-b border-gray-200">
692
+ <tr>
693
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-600">
694
+ Number
695
+ </th>
696
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-600">
697
+ Heading
698
+ </th>
699
+ </tr>
700
+ </thead>
701
+ <tbody>
702
+ {session?.headings?.length ? (
703
+ session.headings.map((heading, idx) => (
704
+ <tr key={`heading-${idx}`} className="border-b border-gray-100">
705
+ <td className="px-3 py-2 text-xs font-semibold text-gray-700">
706
+ {heading.number}
707
+ </td>
708
+ <td className="px-3 py-2 text-sm text-gray-700">
709
+ {heading.name}
710
+ </td>
711
+ </tr>
712
+ ))
713
+ ) : (
714
+ <tr>
715
+ <td
716
+ colSpan={2}
717
+ className="px-3 py-4 text-sm text-gray-500 text-center"
718
+ >
719
+ No headings found.
720
+ </td>
721
+ </tr>
722
+ )}
723
+ </tbody>
724
+ </table>
725
+ </div>
726
+ </div>
727
+ </div>
728
+ </section>
729
+
730
  <div className="rounded-lg border border-gray-200 bg-white overflow-x-auto">
731
+ <div className="px-3 py-2 text-xs text-gray-500 bg-gray-50 border-b border-gray-200">
732
+ General Info (double-click the header below to expand/collapse columns)
733
+ </div>
734
+ <table className="min-w-[2400px] w-full text-sm">
735
  <thead className="bg-gray-50 border-b border-gray-200">
736
  <tr>
737
+ <th
738
+ rowSpan={2}
739
+ className="px-3 py-2 text-left text-xs font-semibold text-gray-600"
740
+ >
741
  Page
742
  </th>
743
+ <th
744
+ colSpan={showGeneralColumns ? GENERAL_FIELDS.length : 1}
745
+ onDoubleClick={() => setShowGeneralColumns((prev) => !prev)}
746
+ className="px-3 py-2 text-left text-xs font-semibold text-gray-600 cursor-pointer select-none"
747
+ title="Double-click to toggle general columns"
748
+ >
749
+ General Info
750
+ </th>
751
+ <th
752
+ colSpan={ITEM_FIELDS.length + 1}
753
+ className="px-3 py-2 text-left text-xs font-semibold text-gray-600"
754
+ >
755
+ Item Details
756
+ </th>
757
+ <th
758
+ rowSpan={2}
759
+ className="px-3 py-2 text-left text-xs font-semibold text-gray-600 min-w-[520px]"
760
+ >
761
+ Actions
762
+ </th>
763
+ </tr>
764
+ <tr>
765
+ {showGeneralColumns ? (
766
+ GENERAL_FIELDS.map((field) => (
767
+ <th
768
+ key={`general-col-${field.key}`}
769
+ className="px-3 py-2 text-left text-xs font-semibold text-gray-600 min-w-[180px]"
770
+ >
771
+ {field.label}
772
+ </th>
773
+ ))
774
+ ) : (
775
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 min-w-[180px]">
776
+ General info hidden
777
+ </th>
778
+ )}
779
+ {ITEM_FIELDS.map((field) => (
780
  <th
781
+ key={`item-col-${field.key}`}
782
+ className="px-3 py-2 text-left text-xs font-semibold text-gray-600 min-w-[180px]"
783
  >
784
  {field.label}
785
  </th>
786
  ))}
787
+ <th className="px-3 py-2 text-left text-xs font-semibold text-gray-600 min-w-[320px]">
788
+ Images
789
  </th>
790
  </tr>
791
  </thead>
792
  <tbody>
793
  {pages.map((page, pageIndex) => {
794
  const template = page.template ?? {};
795
+ const photoIds = page.photo_ids ?? [];
796
+ const orderLocked = page.photo_order_locked ?? false;
797
+ const photoLookup = new Map(
798
+ (session?.uploads?.photos ?? []).map((photo) => [photo.id, photo]),
799
+ );
800
+ const availablePhotos = (session?.uploads?.photos ?? []).filter(
801
+ (photo) => !photoIds.includes(photo.id),
802
+ );
803
  return (
804
  <tr key={`row-${pageIndex}`} className="border-b border-gray-100">
805
  <td className="px-3 py-2 text-xs font-semibold text-gray-700">
806
  Page {pageIndex + 1}
807
  </td>
808
+ {showGeneralColumns ? (
809
+ GENERAL_FIELDS.map((field) => (
810
+ <td
811
+ key={`${pageIndex}-general-${field.key}`}
812
+ className="px-3 py-2 min-w-[180px]"
813
+ >
814
+ <input
815
+ type="text"
816
+ 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"
817
+ value={template[field.key] ?? getFallbackValue(field.key)}
818
+ onChange={(event) =>
819
+ updateField(pageIndex, field.key, event.target.value)
820
+ }
821
+ />
822
+ </td>
823
+ ))
824
+ ) : (
825
+ <td className="px-3 py-2 min-w-[180px] text-xs text-gray-500">
826
+ (hidden)
827
+ </td>
828
+ )}
829
+ {ITEM_FIELDS.map((field) => (
830
+ <td key={`${pageIndex}-${field.key}`} className="px-3 py-2 min-w-[180px]">
831
  {field.multiline ? (
832
  <textarea
833
  rows={2}
 
849
  )}
850
  </td>
851
  ))}
852
+ <td className="px-3 py-2 min-w-[320px]">
853
+ <div className="space-y-2">
854
+ {photoIds.length ? (
855
+ photoIds.map((photoId, idx) => {
856
+ const photo = photoLookup.get(photoId);
857
+ return (
858
+ <div key={`${pageIndex}-photo-${photoId}`} className="flex flex-wrap items-center gap-2 text-xs">
859
+ <span className="font-semibold text-gray-700">
860
+ {photo?.name || photoId}
861
+ </span>
862
+ <button
863
+ type="button"
864
+ onClick={() => movePhoto(pageIndex, idx, idx - 1)}
865
+ disabled={idx === 0}
866
+ className="rounded border border-gray-200 px-2 py-0.5 text-[11px] text-gray-600 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
867
+ >
868
+ Up
869
+ </button>
870
+ <button
871
+ type="button"
872
+ onClick={() => movePhoto(pageIndex, idx, idx + 1)}
873
+ disabled={idx === photoIds.length - 1}
874
+ className="rounded border border-gray-200 px-2 py-0.5 text-[11px] text-gray-600 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
875
+ >
876
+ Down
877
+ </button>
878
+ <button
879
+ type="button"
880
+ onClick={() => removePhoto(pageIndex, idx)}
881
+ className="rounded border border-red-200 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700 hover:bg-red-100"
882
+ >
883
+ Remove
884
+ </button>
885
+ </div>
886
+ );
887
+ })
888
+ ) : (
889
+ <div className="text-xs text-gray-500">No images linked.</div>
890
+ )}
891
+ <div className="flex flex-wrap items-center gap-2">
892
+ <select
893
+ className="rounded-md border border-gray-200 px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-200"
894
+ value={photoSelections[pageIndex] ?? ""}
895
+ onChange={(event) =>
896
+ updatePhotoSelection(pageIndex, event.target.value)
897
+ }
898
+ >
899
+ <option value="">Select image</option>
900
+ {availablePhotos.map((photo) => (
901
+ <option key={`photo-option-${photo.id}`} value={photo.id}>
902
+ {photo.name}
903
+ </option>
904
+ ))}
905
+ </select>
906
+ <button
907
+ type="button"
908
+ onClick={() =>
909
+ addPhotoToPage(pageIndex, photoSelections[pageIndex] ?? "")
910
+ }
911
+ disabled={!photoSelections[pageIndex]}
912
+ className="rounded border border-gray-200 px-2 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
913
+ >
914
+ Add
915
+ </button>
916
+ <button
917
+ type="button"
918
+ onClick={() => uploadInputRefs.current[pageIndex]?.click()}
919
+ disabled={isUploading}
920
+ className="rounded border border-gray-200 px-2 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
921
+ >
922
+ Upload
923
+ </button>
924
+ <button
925
+ type="button"
926
+ onClick={() => setPhotoOrderLocked(pageIndex, !orderLocked)}
927
+ disabled={photoIds.length === 0}
928
+ className="rounded border border-gray-200 px-2 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
929
+ >
930
+ {orderLocked ? "Auto order" : "Apply order"}
931
+ </button>
932
+ <span className="text-[11px] text-gray-500">
933
+ {orderLocked ? "Manual order" : "Auto order"}
934
+ </span>
935
+ <input
936
+ ref={(node) => {
937
+ uploadInputRefs.current[pageIndex] = node;
938
+ }}
939
+ type="file"
940
+ accept="image/*"
941
+ className="hidden"
942
+ onChange={(event) => {
943
+ const file = event.target.files?.[0];
944
+ if (file) uploadPhotoForPage(pageIndex, file);
945
+ event.target.value = "";
946
+ }}
947
+ />
948
+ </div>
949
+ </div>
950
+ </td>
951
+ <td className="px-3 py-2 min-w-[520px]">
952
+ <div className="flex flex-wrap items-center gap-2">
953
+ <button
954
+ type="button"
955
+ onClick={() => applyRowToAll(pageIndex)}
956
+ 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"
957
+ >
958
+ Apply row to all
959
+ </button>
960
+ <button
961
+ type="button"
962
+ onClick={() => insertPageAt(pageIndex, template)}
963
+ 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"
964
+ >
965
+ Insert above
966
+ </button>
967
+ <button
968
+ type="button"
969
+ onClick={() => insertPageAt(pageIndex + 1, template)}
970
+ 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"
971
+ >
972
+ Insert below
973
+ </button>
974
+ <button
975
+ type="button"
976
+ onClick={() => removePageAt(pageIndex)}
977
+ disabled={pages.length <= 1}
978
+ className="inline-flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-semibold text-red-700 hover:bg-red-100 transition disabled:opacity-50 disabled:cursor-not-allowed"
979
+ >
980
+ Delete page
981
+ </button>
982
+ </div>
983
  </td>
984
  </tr>
985
  );
 
988
  </table>
989
  </div>
990
 
991
+ <div className="mt-3 flex justify-end">
992
+ <button
993
+ type="button"
994
+ onClick={() => insertPageAt(pages.length, pages[pages.length - 1]?.template)}
995
+ disabled={!sessionId}
996
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
997
+ >
998
+ Add page at end
999
+ </button>
1000
+ </div>
1001
+
1002
  <PageFooter note="Tip: edit fields per page and save once. Use apply row to keep pages consistent." />
1003
  </PageShell>
1004
  );
frontend/src/types/session.ts CHANGED
@@ -13,6 +13,11 @@ export type SessionUploads = {
13
  data_files?: FileMeta[];
14
  };
15
 
 
 
 
 
 
16
  export type Session = {
17
  id: string;
18
  status: string;
@@ -24,6 +29,7 @@ export type Session = {
24
  uploads: SessionUploads;
25
  selected_photo_ids: string[];
26
  page_count: number;
 
27
  layout?: Record<string, unknown> | null;
28
  };
29
 
@@ -66,6 +72,8 @@ export type TemplateFields = {
66
  document_no?: string;
67
  project?: string;
68
  client_site?: string;
 
 
69
  reference?: string;
70
  action_type?: string;
71
  item_description?: string;
@@ -80,6 +88,7 @@ export type Page = {
80
  items: PageItem[];
81
  template?: TemplateFields;
82
  photo_ids?: string[];
 
83
  };
84
 
85
  export type PagesResponse = {
 
13
  data_files?: FileMeta[];
14
  };
15
 
16
+ export type Heading = {
17
+ number: string;
18
+ name: string;
19
+ };
20
+
21
  export type Session = {
22
  id: string;
23
  status: string;
 
29
  uploads: SessionUploads;
30
  selected_photo_ids: string[];
31
  page_count: number;
32
+ headings?: Heading[];
33
  layout?: Record<string, unknown> | null;
34
  };
35
 
 
72
  document_no?: string;
73
  project?: string;
74
  client_site?: string;
75
+ company_logo?: string;
76
+ area?: string;
77
  reference?: string;
78
  action_type?: string;
79
  item_description?: string;
 
88
  items: PageItem[];
89
  template?: TemplateFields;
90
  photo_ids?: string[];
91
+ photo_order_locked?: boolean;
92
  };
93
 
94
  export type PagesResponse = {
server/app/api/routes/sessions.py CHANGED
@@ -6,6 +6,7 @@ from typing import List
6
 
7
  from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
8
  from fastapi.responses import FileResponse
 
9
 
10
  from ..deps import get_session_store
11
  from ..schemas import (
@@ -16,6 +17,7 @@ from ..schemas import (
16
  SessionStatusResponse,
17
  )
18
  from ...services import SessionStore
 
19
  from ...services.data_import import populate_session_from_data_files
20
 
21
 
@@ -162,6 +164,109 @@ def get_upload(
162
  return FileResponse(path)
163
 
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  @router.get("/{session_id}/export")
166
  def export_package(
167
  session_id: str, store: SessionStore = Depends(get_session_store)
@@ -184,3 +289,105 @@ def export_package(
184
  media_type="application/json",
185
  filename=f"repex_report_{session_id}.json",
186
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
8
  from fastapi.responses import FileResponse
9
+ from openpyxl import Workbook
10
 
11
  from ..deps import get_session_store
12
  from ..schemas import (
 
17
  SessionStatusResponse,
18
  )
19
  from ...services import SessionStore
20
+ from ...services.session_store import DATA_EXTS, IMAGE_EXTS
21
  from ...services.data_import import populate_session_from_data_files
22
 
23
 
 
164
  return FileResponse(path)
165
 
166
 
167
+ @router.post("/{session_id}/data-files", response_model=SessionResponse)
168
+ def upload_data_file(
169
+ session_id: str,
170
+ file: UploadFile = File(...),
171
+ store: SessionStore = Depends(get_session_store),
172
+ ) -> SessionResponse:
173
+ session_id = _normalize_session_id(session_id, store)
174
+ session = store.get_session(session_id)
175
+ if not session:
176
+ raise HTTPException(status_code=404, detail="Session not found.")
177
+ filename = (file.filename or "").lower()
178
+ if filename and Path(filename).suffix.lower() not in DATA_EXTS:
179
+ raise HTTPException(status_code=400, detail="Unsupported data file type.")
180
+
181
+ try:
182
+ saved_file = store.save_upload(session_id, file)
183
+ except ValueError as exc:
184
+ raise HTTPException(status_code=413, detail=str(exc)) from exc
185
+ finally:
186
+ try:
187
+ file.file.close()
188
+ except Exception:
189
+ pass
190
+
191
+ session = store.add_uploads(session, [saved_file])
192
+ try:
193
+ session = populate_session_from_data_files(store, session)
194
+ except Exception:
195
+ pass
196
+ return _attach_urls(session)
197
+
198
+
199
+ @router.post("/{session_id}/uploads", response_model=SessionResponse)
200
+ def upload_photo(
201
+ session_id: str,
202
+ file: UploadFile = File(...),
203
+ store: SessionStore = Depends(get_session_store),
204
+ ) -> SessionResponse:
205
+ session_id = _normalize_session_id(session_id, store)
206
+ session = store.get_session(session_id)
207
+ if not session:
208
+ raise HTTPException(status_code=404, detail="Session not found.")
209
+ filename = file.filename or ""
210
+ if Path(filename).suffix.lower() not in IMAGE_EXTS:
211
+ raise HTTPException(status_code=400, detail="Unsupported image file type.")
212
+
213
+ try:
214
+ saved_file = store.save_upload(session_id, file)
215
+ except ValueError as exc:
216
+ raise HTTPException(status_code=413, detail=str(exc)) from exc
217
+ finally:
218
+ try:
219
+ file.file.close()
220
+ except Exception:
221
+ pass
222
+
223
+ session = store.add_uploads(session, [saved_file])
224
+ return _attach_urls(session)
225
+
226
+
227
+ @router.post("/{session_id}/import-json", response_model=SessionResponse)
228
+ def import_json(
229
+ session_id: str,
230
+ file: UploadFile = File(...),
231
+ store: SessionStore = Depends(get_session_store),
232
+ ) -> SessionResponse:
233
+ session_id = _normalize_session_id(session_id, store)
234
+ session = store.get_session(session_id)
235
+ if not session:
236
+ raise HTTPException(status_code=404, detail="Session not found.")
237
+ try:
238
+ payload = json.loads(file.file.read())
239
+ except Exception as exc:
240
+ raise HTTPException(status_code=400, detail="Invalid JSON file.") from exc
241
+ finally:
242
+ try:
243
+ file.file.close()
244
+ except Exception:
245
+ pass
246
+
247
+ imported_session = payload.get("session") if isinstance(payload, dict) else None
248
+ pages = payload.get("pages") if isinstance(payload, dict) else None
249
+ if not pages and isinstance(imported_session, dict):
250
+ pages = imported_session.get("pages")
251
+ if pages:
252
+ session = store.set_pages(session, pages)
253
+
254
+ if isinstance(imported_session, dict):
255
+ for key in (
256
+ "project_name",
257
+ "inspection_date",
258
+ "notes",
259
+ "selected_photo_ids",
260
+ "page_count",
261
+ "headings",
262
+ ):
263
+ if key in imported_session and imported_session[key] is not None:
264
+ session[key] = imported_session[key]
265
+ store.update_session(session)
266
+
267
+ return _attach_urls(session)
268
+
269
+
270
  @router.get("/{session_id}/export")
271
  def export_package(
272
  session_id: str, store: SessionStore = Depends(get_session_store)
 
289
  media_type="application/json",
290
  filename=f"repex_report_{session_id}.json",
291
  )
292
+
293
+
294
+ @router.get("/{session_id}/export.xlsx")
295
+ def export_excel(
296
+ session_id: str, store: SessionStore = Depends(get_session_store)
297
+ ) -> FileResponse:
298
+ session_id = _normalize_session_id(session_id, store)
299
+ session = store.get_session(session_id)
300
+ if not session:
301
+ raise HTTPException(status_code=404, detail="Session not found.")
302
+
303
+ pages = store.ensure_pages(session)
304
+ first_template = (pages[0].get("template") or {}) if pages else {}
305
+
306
+ wb = Workbook()
307
+ ws_general = wb.active
308
+ ws_general.title = "General Information"
309
+ ws_general.append(["Project Name", session.get("project_name", "")])
310
+ ws_general.append(["Inspection Date", session.get("inspection_date", "")])
311
+ ws_general.append(["Inspector", first_template.get("inspector", "")])
312
+ ws_general.append(["Accompanied by", first_template.get("accompanied_by", "")])
313
+ ws_general.append(["Document No", first_template.get("document_no", "")])
314
+ ws_general.append(["Client / Site", first_template.get("client_site", "")])
315
+ ws_general.append(
316
+ ["Client Logo Image Name", first_template.get("company_logo", "")]
317
+ )
318
+
319
+ ws_headings = wb.create_sheet("Headings")
320
+ ws_headings.append(["Heading Number", "Heading Name"])
321
+ headings = session.get("headings") or []
322
+ if isinstance(headings, dict):
323
+ headings = [{"number": key, "name": value} for key, value in headings.items()]
324
+ for heading in headings:
325
+ if not isinstance(heading, dict):
326
+ continue
327
+ ws_headings.append(
328
+ [heading.get("number", ""), heading.get("name", "")]
329
+ )
330
+
331
+ ws_items = wb.create_sheet("Item Spesific")
332
+ ws_items.append(
333
+ [
334
+ "REF",
335
+ "Area",
336
+ "Functional Location",
337
+ "Item Description",
338
+ "Category",
339
+ "Priority",
340
+ "Item Description",
341
+ "Condition Description",
342
+ "Action Type",
343
+ "Required Action",
344
+ "Figure Caption",
345
+ "Figure Description",
346
+ "Image Name 1",
347
+ "Image Name 2",
348
+ "Image Name 3",
349
+ "Image Name 4",
350
+ "Image Name 5",
351
+ "Image Name 6",
352
+ ]
353
+ )
354
+
355
+ upload_lookup = {
356
+ item.get("id"): item.get("name")
357
+ for item in (session.get("uploads") or {}).get("photos", [])
358
+ }
359
+
360
+ for page in pages:
361
+ template = page.get("template") or {}
362
+ photo_names = [
363
+ upload_lookup.get(photo_id, "")
364
+ for photo_id in (page.get("photo_ids") or [])
365
+ ]
366
+ while len(photo_names) < 6:
367
+ photo_names.append("")
368
+
369
+ ws_items.append(
370
+ [
371
+ template.get("reference", ""),
372
+ template.get("area", ""),
373
+ template.get("functional_location", ""),
374
+ template.get("item_description", ""),
375
+ template.get("category", ""),
376
+ template.get("priority", ""),
377
+ template.get("item_description", ""),
378
+ template.get("condition_description", ""),
379
+ template.get("action_type", ""),
380
+ template.get("required_action", ""),
381
+ "",
382
+ "",
383
+ *photo_names[:6],
384
+ ]
385
+ )
386
+
387
+ export_path = Path(store.session_dir(session_id)) / "export.xlsx"
388
+ wb.save(export_path)
389
+ return FileResponse(
390
+ export_path,
391
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
392
+ filename=f"repex_report_{session_id}.xlsx",
393
+ )
server/app/api/schemas.py CHANGED
@@ -14,6 +14,11 @@ class FileMeta(BaseModel):
14
  url: Optional[str] = None
15
 
16
 
 
 
 
 
 
17
  class SessionResponse(BaseModel):
18
  id: str
19
  status: str
@@ -25,6 +30,7 @@ class SessionResponse(BaseModel):
25
  uploads: Dict[str, List[FileMeta]] = Field(default_factory=dict)
26
  selected_photo_ids: List[str] = Field(default_factory=list)
27
  page_count: int = 0
 
28
 
29
 
30
  class SessionStatusResponse(BaseModel):
 
14
  url: Optional[str] = None
15
 
16
 
17
+ class Heading(BaseModel):
18
+ number: str = ""
19
+ name: str = ""
20
+
21
+
22
  class SessionResponse(BaseModel):
23
  id: str
24
  status: str
 
30
  uploads: Dict[str, List[FileMeta]] = Field(default_factory=dict)
31
  selected_photo_ids: List[str] = Field(default_factory=list)
32
  page_count: int = 0
33
+ headings: List[Heading] = Field(default_factory=list)
34
 
35
 
36
  class SessionStatusResponse(BaseModel):
server/app/services/data_import.py CHANGED
@@ -60,18 +60,46 @@ def _find_sheet(sheets: Dict[str, object], target: str) -> Optional[object]:
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
 
@@ -96,6 +124,20 @@ def _extract_image_names(value: str) -> List[str]:
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):
@@ -144,6 +186,12 @@ def _parse_items(rows: Iterable[Iterable[object]]) -> List[Dict[str, str | List[
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):
@@ -151,6 +199,9 @@ def _parse_items(rows: Iterable[Iterable[object]]) -> List[Dict[str, str | List[
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)),
@@ -176,7 +227,8 @@ def _parse_items(rows: Iterable[Iterable[object]]) -> List[Dict[str, str | List[
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
  ),
@@ -322,6 +374,7 @@ def populate_session_from_data_files(
322
  return session
323
 
324
  general = parsed.get("general") or {}
 
325
  items = parsed.get("items") or []
326
 
327
  # Update session-wide fields if provided
@@ -336,9 +389,22 @@ def populate_session_from_data_files(
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", ""),
@@ -346,7 +412,9 @@ def populate_session_from_data_files(
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", ""),
 
60
  return None
61
 
62
 
63
+ def _parse_headings(rows: Iterable[Iterable[object]]) -> List[Dict[str, str]]:
64
+ headings: List[Dict[str, str]] = []
65
+ rows = [list(row) for row in rows]
66
+ if not rows:
67
+ return headings
68
+
69
+ header_row_index: Optional[int] = None
70
+ number_idx: Optional[int] = None
71
+ name_idx: Optional[int] = None
72
+
73
+ for idx, row in enumerate(rows[:5]):
74
+ headers = [_normalize_text(cell) for cell in row]
75
+ for col_idx, header in enumerate(headers):
76
+ if "heading number" in header or header == "number":
77
+ number_idx = col_idx
78
+ if "heading name" in header or header == "name":
79
+ name_idx = col_idx
80
+ if number_idx is not None or name_idx is not None:
81
+ header_row_index = idx
82
+ break
83
+
84
+ start_index = (header_row_index + 1) if header_row_index is not None else 1
85
+
86
+ for row in rows[start_index:]:
87
+ if not any(_cell_to_str(cell) for cell in row):
88
  continue
89
+
90
+ number = _cell_to_str(row[number_idx]) if number_idx is not None and number_idx < len(row) else ""
91
+ name = _cell_to_str(row[name_idx]) if name_idx is not None and name_idx < len(row) else ""
92
+
93
+ if not number and not name:
94
+ combined = _cell_to_str(row[0] if row else "")
95
+ match = re.match(r"^(\\d+)\\s*[-–.]?\\s*(.+)$", combined)
96
+ if match:
97
+ number = match.group(1)
98
+ name = match.group(2)
99
+
100
+ if number or name:
101
+ headings.append({"number": number, "name": name})
102
+
103
  return headings
104
 
105
 
 
124
  return [match.strip() for match in matches if match.strip()]
125
 
126
 
127
+ def _find_reference_value(cells: List[object]) -> str:
128
+ dotted_ref = re.compile(r"^\d+(?:\.\d+)+[a-z]?$", re.IGNORECASE)
129
+ numeric_ref = re.compile(r"^\d+$")
130
+ for cell in cells:
131
+ value = _cell_to_str(cell)
132
+ if value and dotted_ref.match(value):
133
+ return value
134
+ if cells:
135
+ first_value = _cell_to_str(cells[0])
136
+ if numeric_ref.match(first_value):
137
+ return first_value
138
+ return ""
139
+
140
+
141
  def _image_column_indices(headers: List[str]) -> Dict[int, int]:
142
  indices: Dict[int, int] = {}
143
  for idx, raw in enumerate(headers):
 
186
  )
187
 
188
  items: List[Dict[str, str | List[str]]] = []
189
+ ref_index = first_index("ref") or first_index("reference")
190
+ area_index = (
191
+ first_index("area")
192
+ or first_index("heading name")
193
+ or first_index("heading")
194
+ )
195
  for row in rows[1:]:
196
  cells = list(row)
197
  if not any(_cell_to_str(cell) for cell in cells):
 
199
  item_desc = _row_value(cells, second_index("item description")) or _row_value(
200
  cells, first_index("item description")
201
  )
202
+ reference = _row_value(cells, ref_index)
203
+ if not reference:
204
+ reference = _find_reference_value(cells)
205
  image_names = [
206
  _row_value(cells, image_index(1)),
207
  _row_value(cells, image_index(2)),
 
227
  break
228
  items.append(
229
  {
230
+ "reference": reference,
231
+ "area": _row_value(cells, area_index),
232
  "functional_location": _row_value(
233
  cells, first_index("functional location")
234
  ),
 
374
  return session
375
 
376
  general = parsed.get("general") or {}
377
+ headings = parsed.get("headings") or []
378
  items = parsed.get("items") or []
379
 
380
  # Update session-wide fields if provided
 
389
  session.get("uploads", {}).get("photos", []) or []
390
  )
391
 
392
+ if isinstance(headings, dict):
393
+ headings = [
394
+ {"number": key, "name": value} for key, value in headings.items()
395
+ ]
396
+ if headings:
397
+ session["headings"] = headings
398
+
399
  pages: List[dict] = []
400
  selected_photo_ids: List[str] = []
401
  for item in items:
402
+ company_logo = (
403
+ general.get("client logo image name")
404
+ or general.get("client logo")
405
+ or general.get("company logo")
406
+ or ""
407
+ )
408
  template = {
409
  "inspection_date": inspection_date or session.get("inspection_date", ""),
410
  "inspector": general.get("inspector", ""),
 
412
  "document_no": general.get("document no", ""),
413
  "project": general.get("project name", session.get("project_name", "")),
414
  "client_site": general.get("client / site", ""),
415
+ "company_logo": company_logo,
416
  "reference": item.get("reference", ""),
417
+ "area": item.get("area", ""),
418
  "functional_location": item.get("functional_location", ""),
419
  "item_description": item.get("item_description", ""),
420
  "category": item.get("category", ""),