ChristopherJKoen commited on
Commit
58c8c26
·
1 Parent(s): 9b70d7b

Report Edit

Browse files
Files changed (39) hide show
  1. README.md +0 -27
  2. _move_test.txt +0 -1
  3. assets/client-logo.png +0 -0
  4. assets/prosento-logo.png +0 -0
  5. components/report-editor.js +0 -1185
  6. edit-layouts.html +0 -265
  7. export.html +0 -265
  8. frontend/public/templates/job-sheet-template.html +50 -88
  9. frontend/src/App.tsx +2 -0
  10. frontend/src/components/JobSheetTemplate.tsx +163 -41
  11. frontend/src/components/ReportPageCanvas.tsx +5 -2
  12. frontend/src/components/report-editor.js +181 -52
  13. frontend/src/index.css +47 -0
  14. frontend/src/pages/EditLayoutsPage.tsx +46 -1
  15. frontend/src/pages/EditReportPage.tsx +137 -0
  16. frontend/src/pages/ExportPage.tsx +11 -1
  17. frontend/src/pages/ReportViewerPage.tsx +14 -41
  18. frontend/src/types/custom-elements.d.ts +1 -0
  19. frontend/src/types/session.ts +18 -0
  20. index.html +0 -398
  21. legacy/static-site/README.md +0 -19
  22. legacy/static-site/assets/assets/client-logo.png +0 -0
  23. legacy/static-site/assets/assets/prosento-logo.png +0 -0
  24. legacy/static-site/components/components/report-editor.js +0 -1185
  25. legacy/static-site/edit-layouts.html +0 -265
  26. legacy/static-site/export.html +0 -265
  27. legacy/static-site/index.html +0 -398
  28. legacy/static-site/processing.html +0 -80
  29. legacy/static-site/report-viewer.html +0 -412
  30. legacy/static-site/review-setup.html +0 -376
  31. legacy/static-site/script.js +0 -107
  32. legacy/static-site/style.css +0 -1
  33. legacy/static-site/templates/job-sheet-template.html +0 -286
  34. processing.html +0 -80
  35. report-viewer.html +0 -412
  36. review-setup.html +0 -376
  37. script.js +0 -107
  38. style.css +0 -1
  39. templates/job-sheet-template.html +0 -286
README.md CHANGED
@@ -2,13 +2,6 @@
2
 
3
  React (Vite) frontend + FastAPI backend with local session storage.
4
 
5
- ## Canonical Structure
6
-
7
- The active app is in `frontend/` and `server/`. Any static HTML files at the
8
- repo root are legacy prototypes and are not used by the running app.
9
-
10
- Legacy copies are stored under `legacy/static-site/` for reference only.
11
-
12
  ## Project Layout
13
 
14
  ```
@@ -35,8 +28,6 @@ Legacy copies are stored under `legacy/static-site/` for reference only.
35
  lib/
36
  index.html
37
  vite.config.ts
38
- legacy/
39
- static-site/ # archived static HTML prototype (not used)
40
  ```
41
 
42
  ## Quick Start (Dev)
@@ -81,21 +72,3 @@ Environment variables for the API:
81
 
82
  Frontend environment variables:
83
  - `VITE_API_BASE` (optional, default: `/api`)
84
-
85
- ## Optional Cleanup
86
-
87
- If you want a cleaner repo, you can remove the legacy static files from the root:
88
-
89
- - `assets/`
90
- - `components/`
91
- - `templates/`
92
- - `index.html`
93
- - `processing.html`
94
- - `review-setup.html`
95
- - `report-viewer.html`
96
- - `edit-layouts.html`
97
- - `export.html`
98
- - `style.css`
99
- - `script.js`
100
-
101
- They are already copied to `legacy/static-site/` for reference.
 
2
 
3
  React (Vite) frontend + FastAPI backend with local session storage.
4
 
 
 
 
 
 
 
 
5
  ## Project Layout
6
 
7
  ```
 
28
  lib/
29
  index.html
30
  vite.config.ts
 
 
31
  ```
32
 
33
  ## Quick Start (Dev)
 
72
 
73
  Frontend environment variables:
74
  - `VITE_API_BASE` (optional, default: `/api`)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
_move_test.txt DELETED
@@ -1 +0,0 @@
1
- Temporary file created during automated cleanup. Safe to delete.
 
 
assets/client-logo.png DELETED
Binary file (68 Bytes)
 
assets/prosento-logo.png DELETED
Binary file (11.7 kB)
 
components/report-editor.js DELETED
@@ -1,1185 +0,0 @@
1
- class ReportEditor extends HTMLElement {
2
- constructor() {
3
- super();
4
- this._mounted = false;
5
-
6
- this.BASE_W = 595; // A4 points-ish (screen independent model)
7
- this.BASE_H = 842;
8
-
9
- this.state = {
10
- isOpen: false,
11
- zoom: 1,
12
- activePage: 0,
13
- pages: [], // [{ items: [...] }]
14
- selectedId: null,
15
- tool: "select", // select | text | rect
16
- dragging: null, // { id, startX, startY, origX, origY }
17
- resizing: null, // { id, handle, startX, startY, orig }
18
- undo: [], // stack of serialized states (current page)
19
- redo: [],
20
- payload: null,
21
- };
22
-
23
- this.sessionId = null;
24
- this.apiBase = null;
25
- this._saveTimer = null;
26
- }
27
-
28
- connectedCallback() {
29
- if (this._mounted) return;
30
- this._mounted = true;
31
- this.render();
32
- this.bind();
33
- this.hide();
34
- }
35
-
36
- // Public API
37
- open({ payload, pageIndex = 0, totalPages = 6, sessionId = null } = {}) {
38
- this.state.payload = payload ?? null;
39
- this.state.isOpen = true;
40
- this.sessionId =
41
- sessionId ||
42
- (window.REPEX && typeof window.REPEX.getSessionId === "function"
43
- ? window.REPEX.getSessionId()
44
- : null);
45
- this.apiBase = window.REPEX && window.REPEX.apiBase ? window.REPEX.apiBase : null;
46
-
47
- // Load existing editor pages from storage, else initialize
48
- const stored = this._loadPages();
49
- if (stored && Array.isArray(stored.pages) && stored.pages.length) {
50
- this.state.pages = stored.pages;
51
- } else {
52
- this.state.pages = Array.from({ length: totalPages }, () => ({ items: [] }));
53
- this._savePages();
54
- }
55
-
56
- this.state.activePage = Math.min(Math.max(0, pageIndex), this.state.pages.length - 1);
57
- this.state.selectedId = null;
58
- this.state.tool = "select";
59
- this.state.undo = [];
60
- this.state.redo = [];
61
-
62
- this.show();
63
- this.updateAll();
64
-
65
- if (this.sessionId) {
66
- this._loadPagesFromServer().then((pages) => {
67
- if (pages && pages.length) {
68
- this.state.pages = pages;
69
- this.state.activePage = Math.min(
70
- Math.max(0, pageIndex),
71
- this.state.pages.length - 1
72
- );
73
- this.updateAll();
74
- }
75
- });
76
- }
77
- }
78
-
79
- close() {
80
- this.state.isOpen = false;
81
- this.hide();
82
- this.dispatchEvent(new CustomEvent("editor-closed", { bubbles: true }));
83
- }
84
-
85
- // ---------- Rendering ----------
86
- render() {
87
- this.innerHTML = `
88
- <div class="fixed inset-0 z-50 hidden" data-overlay>
89
- <div class="absolute inset-0 bg-black/30"></div>
90
-
91
- <div class="relative h-full w-full flex items-center justify-center p-4">
92
- <div class="w-full max-w-6xl bg-white rounded-xl shadow-sm ring-1 ring-gray-200 overflow-hidden">
93
- <!-- Header -->
94
- <div class="flex items-center justify-between px-4 py-3 border-b border-gray-200">
95
- <div class="flex items-center gap-2">
96
- <div class="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-gray-50 border border-gray-200">
97
- <span class="text-xs font-bold text-gray-700">A4</span>
98
- </div>
99
- <div>
100
- <div class="text-sm font-semibold text-gray-900">Edit Report</div>
101
- <div class="text-xs text-gray-500">Drag, resize, format and arrange elements</div>
102
- </div>
103
- </div>
104
-
105
- <div class="flex items-center gap-2">
106
- <button data-btn="undo"
107
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed">
108
- <i data-feather="rotate-ccw" class="h-4 w-4"></i> Undo
109
- </button>
110
- <button data-btn="redo"
111
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed">
112
- <i data-feather="rotate-cw" class="h-4 w-4"></i> Redo
113
- </button>
114
-
115
- <button data-btn="save"
116
- class="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-3 py-2 text-sm font-semibold text-white hover:bg-emerald-700 transition">
117
- <i data-feather="save" class="h-4 w-4"></i> Save
118
- </button>
119
-
120
- <button data-btn="close"
121
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition">
122
- <i data-feather="x" class="h-4 w-4"></i> Done
123
- </button>
124
- </div>
125
- </div>
126
-
127
- <!-- Body -->
128
- <div class="grid grid-cols-1 lg:grid-cols-[240px,1fr,280px] gap-0 min-h-[70vh]">
129
- <!-- Pages sidebar -->
130
- <aside class="border-r border-gray-200 bg-white p-3">
131
- <div class="flex items-center justify-between mb-3">
132
- <div class="text-sm font-semibold text-gray-900">Pages</div>
133
- <button data-btn="add-page"
134
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
135
- <i data-feather="plus" class="h-4 w-4"></i> Add
136
- </button>
137
- </div>
138
-
139
- <div class="space-y-2 max-h-[60vh] overflow-auto pr-1" data-page-list></div>
140
-
141
- <div class="mt-3 text-xs text-gray-500">
142
- Tip: Click a page to edit. Your edits are saved to the server.
143
- </div>
144
- </aside>
145
-
146
- <!-- Canvas + toolbar -->
147
- <section class="bg-gray-50 p-3">
148
- <!-- Toolbar -->
149
- <div class="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 mb-3">
150
- <div class="flex flex-wrap items-center gap-2">
151
- <button data-tool="select"
152
- class="toolBtn 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">
153
- <i data-feather="mouse-pointer" class="h-4 w-4"></i> Select
154
- </button>
155
-
156
- <button data-tool="text"
157
- class="toolBtn 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">
158
- <i data-feather="type" class="h-4 w-4"></i> Text
159
- </button>
160
-
161
- <button data-tool="rect"
162
- class="toolBtn 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">
163
- <i data-feather="square" class="h-4 w-4"></i> Shape
164
- </button>
165
-
166
- <button data-btn="add-image"
167
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition">
168
- <i data-feather="image" class="h-4 w-4"></i> Image
169
- </button>
170
-
171
- <input data-file="image" type="file" accept="image/*" class="hidden" />
172
- </div>
173
-
174
- <div class="flex items-center gap-2">
175
- <div class="text-xs font-semibold text-gray-600">Zoom</div>
176
- <button data-btn="zoom-out"
177
- class="inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white p-2 hover:bg-gray-50 transition">
178
- <i data-feather="minus" class="h-4 w-4"></i>
179
- </button>
180
- <div class="text-xs font-semibold text-gray-700 w-14 text-center" data-zoom-label>100%</div>
181
- <button data-btn="zoom-in"
182
- class="inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white p-2 hover:bg-gray-50 transition">
183
- <i data-feather="plus" class="h-4 w-4"></i>
184
- </button>
185
- </div>
186
- </div>
187
-
188
- <!-- Canvas area -->
189
- <div class="flex justify-center">
190
- <div class="relative" data-canvas-wrap>
191
- <div
192
- data-canvas
193
- class="relative bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden select-none"
194
- style="width: min(100%, 700px); aspect-ratio: 210/297;"
195
- aria-label="Editable A4 canvas"
196
- >
197
- <!-- items injected here -->
198
- </div>
199
- </div>
200
- </div>
201
-
202
- <div class="mt-3 text-xs text-gray-500">
203
- Drag elements to move. Drag corner handles to resize. Double-click text to edit.
204
- </div>
205
- </section>
206
-
207
- <!-- Properties panel -->
208
- <aside class="border-l border-gray-200 bg-white p-3">
209
- <div class="text-sm font-semibold text-gray-900 mb-2">Properties</div>
210
-
211
- <div data-empty-props class="text-sm text-gray-600 rounded-lg border border-gray-200 bg-gray-50 p-3">
212
- Select an element to edit formatting and layout options.
213
- </div>
214
-
215
- <div data-props class="hidden space-y-4">
216
- <!-- Arrange -->
217
- <div class="rounded-lg border border-gray-200 p-3">
218
- <div class="text-xs font-semibold text-gray-600 mb-2">Arrange</div>
219
- <div class="flex flex-wrap gap-2">
220
- <button data-btn="bring-front"
221
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
222
- <i data-feather="chevrons-up" class="h-4 w-4"></i> Front
223
- </button>
224
- <button data-btn="send-back"
225
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
226
- <i data-feather="chevrons-down" class="h-4 w-4"></i> Back
227
- </button>
228
- <button data-btn="duplicate"
229
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
230
- <i data-feather="copy" class="h-4 w-4"></i> Duplicate
231
- </button>
232
- <button data-btn="delete"
233
- class="inline-flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs font-semibold text-red-700 hover:bg-red-100 transition">
234
- <i data-feather="trash-2" class="h-4 w-4"></i> Delete
235
- </button>
236
- </div>
237
- </div>
238
-
239
- <!-- Text controls -->
240
- <div data-props-text class="rounded-lg border border-gray-200 p-3 hidden">
241
- <div class="text-xs font-semibold text-gray-600 mb-2">Text</div>
242
-
243
- <div class="grid grid-cols-2 gap-2">
244
- <label class="text-xs text-gray-600">
245
- Font size
246
- <input data-prop="fontSize" type="number" min="8" max="72"
247
- class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500" />
248
- </label>
249
-
250
- <label class="text-xs text-gray-600">
251
- Color
252
- <input data-prop="color" type="color"
253
- class="mt-1 w-full h-[40px] rounded-lg border border-gray-300 px-2 py-1" />
254
- </label>
255
- </div>
256
-
257
- <div class="flex flex-wrap gap-2 mt-2">
258
- <button data-btn="bold"
259
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
260
- <i data-feather="bold" class="h-4 w-4"></i>
261
- </button>
262
- <button data-btn="italic"
263
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
264
- <i data-feather="italic" class="h-4 w-4"></i>
265
- </button>
266
- <button data-btn="underline"
267
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
268
- <i data-feather="underline" class="h-4 w-4"></i>
269
- </button>
270
-
271
- <button data-btn="align-left"
272
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
273
- <i data-feather="align-left" class="h-4 w-4"></i>
274
- </button>
275
- <button data-btn="align-center"
276
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
277
- <i data-feather="align-center" class="h-4 w-4"></i>
278
- </button>
279
- <button data-btn="align-right"
280
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
281
- <i data-feather="align-right" class="h-4 w-4"></i>
282
- </button>
283
- </div>
284
- </div>
285
-
286
- <!-- Shape controls -->
287
- <div data-props-rect class="rounded-lg border border-gray-200 p-3 hidden">
288
- <div class="text-xs font-semibold text-gray-600 mb-2">Shape</div>
289
-
290
- <div class="grid grid-cols-2 gap-2">
291
- <label class="text-xs text-gray-600">
292
- Fill
293
- <input data-prop="fill" type="color"
294
- class="mt-1 w-full h-[40px] rounded-lg border border-gray-300 px-2 py-1" />
295
- </label>
296
-
297
- <label class="text-xs text-gray-600">
298
- Border
299
- <input data-prop="stroke" type="color"
300
- class="mt-1 w-full h-[40px] rounded-lg border border-gray-300 px-2 py-1" />
301
- </label>
302
- </div>
303
-
304
- <label class="text-xs text-gray-600 block mt-2">
305
- Border width
306
- <input data-prop="strokeWidth" type="number" min="0" max="12"
307
- class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500" />
308
- </label>
309
- </div>
310
-
311
- <!-- Image controls -->
312
- <div data-props-image class="rounded-lg border border-gray-200 p-3 hidden">
313
- <div class="text-xs font-semibold text-gray-600 mb-2">Image</div>
314
- <button data-btn="replace-image"
315
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
316
- <i data-feather="refresh-cw" class="h-4 w-4"></i> Replace image
317
- </button>
318
- <input data-file="replace" type="file" accept="image/*" class="hidden" />
319
- </div>
320
- </div>
321
-
322
- <div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
323
- <div class="font-semibold text-gray-800 mb-1">Keyboard shortcuts</div>
324
- <ul class="list-disc pl-4 space-y-1">
325
- <li><span class="font-semibold">Delete</span>: remove selected</li>
326
- <li><span class="font-semibold">Ctrl/Cmd+Z</span>: undo</li>
327
- <li><span class="font-semibold">Ctrl/Cmd+Y</span>: redo</li>
328
- <li><span class="font-semibold">Esc</span>: close editor</li>
329
- </ul>
330
- </div>
331
- </aside>
332
- </div>
333
- </div>
334
- </div>
335
- </div>
336
- `;
337
- }
338
-
339
- bind() {
340
- this.$overlay = this.querySelector("[data-overlay]");
341
- this.$pageList = this.querySelector("[data-page-list]");
342
- this.$canvas = this.querySelector("[data-canvas]");
343
- this.$zoomLabel = this.querySelector("[data-zoom-label]");
344
-
345
- this.$emptyProps = this.querySelector("[data-empty-props]");
346
- this.$props = this.querySelector("[data-props]");
347
- this.$propsText = this.querySelector("[data-props-text]");
348
- this.$propsRect = this.querySelector("[data-props-rect]");
349
- this.$propsImage = this.querySelector("[data-props-image]");
350
-
351
- this.$imgFile = this.querySelector('[data-file="image"]');
352
- this.$replaceFile = this.querySelector('[data-file="replace"]');
353
-
354
- // header buttons
355
- this.querySelector('[data-btn="close"]').addEventListener("click", () => this.close());
356
- this.querySelector('[data-btn="save"]').addEventListener("click", () => this._savePages(true));
357
- this.querySelector('[data-btn="undo"]').addEventListener("click", () => this.undo());
358
- this.querySelector('[data-btn="redo"]').addEventListener("click", () => this.redo());
359
-
360
- // tools
361
- this.querySelectorAll(".toolBtn").forEach(btn => {
362
- btn.addEventListener("click", () => {
363
- this.state.tool = btn.dataset.tool;
364
- this.updateToolbar();
365
- });
366
- });
367
-
368
- // toolbar buttons
369
- this.querySelector('[data-btn="add-image"]').addEventListener("click", () => this.$imgFile.click());
370
- this.$imgFile.addEventListener("change", (e) => this._handleImageUpload(e, "add"));
371
-
372
- this.querySelector('[data-btn="zoom-in"]').addEventListener("click", () => this.setZoom(this.state.zoom + 0.1));
373
- this.querySelector('[data-btn="zoom-out"]').addEventListener("click", () => this.setZoom(this.state.zoom - 0.1));
374
-
375
- // pages
376
- this.querySelector('[data-btn="add-page"]').addEventListener("click", () => this.addPage());
377
-
378
- // properties buttons
379
- this.querySelector('[data-btn="delete"]').addEventListener("click", () => this.deleteSelected());
380
- this.querySelector('[data-btn="duplicate"]').addEventListener("click", () => this.duplicateSelected());
381
- this.querySelector('[data-btn="bring-front"]').addEventListener("click", () => this.bringFront());
382
- this.querySelector('[data-btn="send-back"]').addEventListener("click", () => this.sendBack());
383
-
384
- // text props
385
- this.querySelector('[data-btn="bold"]').addEventListener("click", () => this.toggleTextStyle("bold"));
386
- this.querySelector('[data-btn="italic"]').addEventListener("click", () => this.toggleTextStyle("italic"));
387
- this.querySelector('[data-btn="underline"]').addEventListener("click", () => this.toggleTextStyle("underline"));
388
- this.querySelector('[data-btn="align-left"]').addEventListener("click", () => this.setTextAlign("left"));
389
- this.querySelector('[data-btn="align-center"]').addEventListener("click", () => this.setTextAlign("center"));
390
- this.querySelector('[data-btn="align-right"]').addEventListener("click", () => this.setTextAlign("right"));
391
-
392
- this.querySelector('[data-prop="fontSize"]').addEventListener("input", (e) => this.setProp("fontSize", Number(e.target.value || 12)));
393
- this.querySelector('[data-prop="color"]').addEventListener("input", (e) => this.setProp("color", e.target.value));
394
-
395
- // rect props
396
- this.querySelector('[data-prop="fill"]').addEventListener("input", (e) => this.setProp("fill", e.target.value));
397
- this.querySelector('[data-prop="stroke"]').addEventListener("input", (e) => this.setProp("stroke", e.target.value));
398
- this.querySelector('[data-prop="strokeWidth"]').addEventListener("input", (e) => this.setProp("strokeWidth", Number(e.target.value || 0)));
399
-
400
- // image replace
401
- this.querySelector('[data-btn="replace-image"]').addEventListener("click", () => this.$replaceFile.click());
402
- this.$replaceFile.addEventListener("change", (e) => this._handleImageUpload(e, "replace"));
403
-
404
- // canvas interactions
405
- this.$canvas.addEventListener("pointerdown", (e) => this.onCanvasPointerDown(e));
406
- window.addEventListener("pointermove", (e) => this.onPointerMove(e));
407
- window.addEventListener("pointerup", () => this.onPointerUp());
408
-
409
- // keyboard shortcuts
410
- window.addEventListener("keydown", (e) => {
411
- if (!this.state.isOpen) return;
412
-
413
- if (e.key === "Escape") {
414
- e.preventDefault();
415
- this.close();
416
- }
417
-
418
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
419
- e.preventDefault();
420
- this.undo();
421
- }
422
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") {
423
- e.preventDefault();
424
- this.redo();
425
- }
426
-
427
- if (e.key === "Delete" || e.key === "Backspace") {
428
- // avoid deleting while typing in contenteditable
429
- const active = document.activeElement;
430
- const isEditingText = active && active.getAttribute && active.getAttribute("contenteditable") === "true";
431
- if (!isEditingText) this.deleteSelected();
432
- }
433
- });
434
- }
435
-
436
- // ---------- Core helpers ----------
437
- show() {
438
- this.$overlay.classList.remove("hidden");
439
- this.state.isOpen = true;
440
- this.updateAll();
441
- }
442
-
443
- hide() {
444
- this.$overlay.classList.add("hidden");
445
- this.state.isOpen = false;
446
- }
447
-
448
- setZoom(z) {
449
- const clamped = Math.max(0.6, Math.min(1.4, Number(z.toFixed(2))));
450
- this.state.zoom = clamped;
451
- this.updateCanvasScale();
452
- }
453
-
454
- get activePage() {
455
- return this.state.pages[this.state.activePage];
456
- }
457
-
458
- updateAll() {
459
- this.updateToolbar();
460
- this.renderPageList();
461
- this.renderCanvas();
462
- this.updateCanvasScale();
463
- this.updatePropsPanel();
464
- this.updateUndoRedoButtons();
465
- this._refreshIcons();
466
- }
467
-
468
- updateToolbar() {
469
- this.querySelectorAll(".toolBtn").forEach(btn => {
470
- const active = btn.dataset.tool === this.state.tool;
471
- btn.classList.toggle("bg-gray-900", active);
472
- btn.classList.toggle("text-white", active);
473
- btn.classList.toggle("border-gray-900", active);
474
-
475
- if (!active) {
476
- btn.classList.add("bg-white", "text-gray-800", "border-gray-200");
477
- btn.classList.remove("bg-gray-900", "text-white", "border-gray-900");
478
- } else {
479
- btn.classList.remove("bg-white", "text-gray-800", "border-gray-200");
480
- }
481
- });
482
- }
483
-
484
- updateCanvasScale() {
485
- if (!this.$canvas) return;
486
- this.$canvas.style.transformOrigin = "top center";
487
- this.$canvas.style.transform = `scale(${this.state.zoom})`;
488
- this.$zoomLabel.textContent = `${Math.round(this.state.zoom * 100)}%`;
489
- }
490
-
491
- _refreshIcons() {
492
- if (window.feather && typeof window.feather.replace === "function") {
493
- window.feather.replace();
494
- }
495
- }
496
-
497
- // ---------- Storage ----------
498
- _storageKey() {
499
- if (this.sessionId) {
500
- return `repex_report_pages_v1_${this.sessionId}`;
501
- }
502
- return "repex_report_pages_v1";
503
- }
504
-
505
- _loadPages() {
506
- try {
507
- const raw = localStorage.getItem(this._storageKey());
508
- return raw ? JSON.parse(raw) : null;
509
- } catch {
510
- return null;
511
- }
512
- }
513
-
514
- _savePages(showToast = false) {
515
- try {
516
- localStorage.setItem(this._storageKey(), JSON.stringify({ pages: this.state.pages }));
517
- this._scheduleServerSave();
518
- if (showToast) this._toast("Saved");
519
- } catch {
520
- if (showToast) this._toast("Save failed");
521
- }
522
- }
523
-
524
- _apiRoot() {
525
- if (this.apiBase) return this.apiBase.replace(/\/$/, "");
526
- if (window.REPEX && window.REPEX.apiBase) return window.REPEX.apiBase.replace(/\/$/, "");
527
- return "";
528
- }
529
-
530
- async _loadPagesFromServer() {
531
- const base = this._apiRoot();
532
- if (!base || !this.sessionId) return null;
533
- try {
534
- const res = await fetch(`${base}/sessions/${this.sessionId}/pages`);
535
- if (!res.ok) return null;
536
- const data = await res.json();
537
- if (data && Array.isArray(data.pages)) {
538
- return data.pages;
539
- }
540
- } catch {}
541
- return null;
542
- }
543
-
544
- _scheduleServerSave() {
545
- if (!this.sessionId) return;
546
- if (this._saveTimer) clearTimeout(this._saveTimer);
547
- this._saveTimer = setTimeout(() => {
548
- this._savePagesToServer();
549
- }, 800);
550
- }
551
-
552
- async _savePagesToServer() {
553
- const base = this._apiRoot();
554
- if (!base || !this.sessionId) return;
555
- try {
556
- const res = await fetch(`${base}/sessions/${this.sessionId}/pages`, {
557
- method: "PUT",
558
- headers: { "Content-Type": "application/json" },
559
- body: JSON.stringify({ pages: this.state.pages }),
560
- });
561
- if (!res.ok) {
562
- throw new Error("Failed");
563
- }
564
- } catch {
565
- this._toast("Sync failed");
566
- }
567
- }
568
-
569
- _toast(text) {
570
- const el = document.createElement("div");
571
- el.className = "fixed z-[60] bottom-5 left-1/2 -translate-x-1/2 rounded-lg bg-gray-900 text-white text-sm font-semibold px-4 py-2 shadow";
572
- el.textContent = text;
573
- document.body.appendChild(el);
574
- setTimeout(() => el.remove(), 1200);
575
- }
576
-
577
- // ---------- Page list ----------
578
- renderPageList() {
579
- this.$pageList.innerHTML = "";
580
-
581
- this.state.pages.forEach((_, idx) => {
582
- const active = idx === this.state.activePage;
583
-
584
- const btn = document.createElement("button");
585
- btn.type = "button";
586
- btn.className =
587
- "w-full text-left rounded-lg border px-3 py-2 transition " +
588
- (active
589
- ? "border-gray-900 bg-gray-900 text-white"
590
- : "border-gray-200 bg-white text-gray-800 hover:bg-gray-50");
591
- btn.innerHTML = `
592
- <div class="flex items-center justify-between">
593
- <div class="text-sm font-semibold">Page ${idx + 1}</div>
594
- <div class="text-xs ${active ? "text-white/80" : "text-gray-500"}">${this.state.pages[idx].items.length} items</div>
595
- </div>
596
- `;
597
- btn.addEventListener("click", () => {
598
- this.state.activePage = idx;
599
- this.state.selectedId = null;
600
- this.state.undo = [];
601
- this.state.redo = [];
602
- this.updateAll();
603
- });
604
-
605
- this.$pageList.appendChild(btn);
606
- });
607
- }
608
-
609
- addPage() {
610
- this._pushUndoSnapshot();
611
- this.state.pages.push({ items: [] });
612
- this.state.activePage = this.state.pages.length - 1;
613
- this.state.selectedId = null;
614
- this._savePages();
615
- this.updateAll();
616
- }
617
-
618
- // ---------- Canvas rendering ----------
619
- renderCanvas() {
620
- this.$canvas.innerHTML = "";
621
-
622
- // Click-away surface
623
- const surface = document.createElement("div");
624
- surface.className = "absolute inset-0";
625
- surface.addEventListener("pointerdown", (e) => {
626
- // only clear selection if clicking empty space
627
- if (e.target === surface) {
628
- this.state.selectedId = null;
629
- this.updatePropsPanel();
630
- this.renderCanvas();
631
- }
632
- });
633
- this.$canvas.appendChild(surface);
634
-
635
- const items = this.activePage.items;
636
- const selectedId = this.state.selectedId;
637
-
638
- items
639
- .slice()
640
- .sort((a, b) => (a.z ?? 0) - (b.z ?? 0))
641
- .forEach(item => {
642
- const wrapper = document.createElement("div");
643
- wrapper.dataset.itemId = item.id;
644
- wrapper.className = "absolute";
645
-
646
- // scaled px placement based on model units
647
- const scale = this._canvasScale();
648
- wrapper.style.left = `${item.x * scale}px`;
649
- wrapper.style.top = `${item.y * scale}px`;
650
- wrapper.style.width = `${item.w * scale}px`;
651
- wrapper.style.height = `${item.h * scale}px`;
652
- wrapper.style.zIndex = String(item.z ?? 0);
653
-
654
- const isSelected = selectedId === item.id;
655
- if (isSelected) wrapper.classList.add("ring-2", "ring-blue-300");
656
-
657
- // content
658
- if (item.type === "text") {
659
- const content = document.createElement("div");
660
- content.className = "w-full h-full p-2 overflow-hidden";
661
- content.setAttribute("contenteditable", "true");
662
- content.style.fontSize = `${(item.style?.fontSize ?? 14) * scale}px`;
663
- content.style.fontWeight = item.style?.bold ? "700" : "400";
664
- content.style.fontStyle = item.style?.italic ? "italic" : "normal";
665
- content.style.textDecoration = item.style?.underline ? "underline" : "none";
666
- content.style.color = item.style?.color ?? "#111827";
667
- content.style.textAlign = item.style?.align ?? "left";
668
- content.style.whiteSpace = "pre-wrap";
669
- content.style.outline = "none";
670
- content.innerText = item.content ?? "Double-click to edit";
671
-
672
- // update model when typing (debounced)
673
- let t = null;
674
- content.addEventListener("input", () => {
675
- clearTimeout(t);
676
- t = setTimeout(() => {
677
- const it = this._findItem(item.id);
678
- if (!it) return;
679
- it.content = content.innerText;
680
- this._savePages();
681
- }, 250);
682
- });
683
-
684
- // selecting the item (click wrapper)
685
- content.addEventListener("pointerdown", (e) => {
686
- e.stopPropagation();
687
- this.selectItem(item.id);
688
- });
689
-
690
- wrapper.appendChild(content);
691
- }
692
-
693
- if (item.type === "image") {
694
- const img = document.createElement("img");
695
- img.className = "w-full h-full object-contain bg-white";
696
- img.src = item.src;
697
- img.alt = item.name ?? "Image";
698
- img.draggable = false;
699
- img.addEventListener("pointerdown", (e) => {
700
- e.stopPropagation();
701
- this.selectItem(item.id);
702
- });
703
- wrapper.appendChild(img);
704
- }
705
-
706
- if (item.type === "rect") {
707
- const box = document.createElement("div");
708
- box.className = "w-full h-full";
709
- box.style.background = item.style?.fill ?? "#ffffff";
710
- box.style.borderColor = item.style?.stroke ?? "#111827";
711
- box.style.borderWidth = `${(item.style?.strokeWidth ?? 1) * scale}px`;
712
- box.style.borderStyle = "solid";
713
- box.addEventListener("pointerdown", (e) => {
714
- e.stopPropagation();
715
- this.selectItem(item.id);
716
- });
717
- wrapper.appendChild(box);
718
- }
719
-
720
- // wrapper drag handler
721
- wrapper.addEventListener("pointerdown", (e) => this.onItemPointerDown(e, item.id));
722
-
723
- // resize handles (selected only)
724
- if (isSelected) {
725
- ["nw", "ne", "sw", "se"].forEach(handle => {
726
- const h = document.createElement("div");
727
- h.dataset.handle = handle;
728
- h.className =
729
- "absolute w-3 h-3 bg-white border border-blue-300 rounded-sm";
730
- if (handle === "nw") { h.style.left = "-6px"; h.style.top = "-6px"; }
731
- if (handle === "ne") { h.style.right = "-6px"; h.style.top = "-6px"; }
732
- if (handle === "sw") { h.style.left = "-6px"; h.style.bottom = "-6px"; }
733
- if (handle === "se") { h.style.right = "-6px"; h.style.bottom = "-6px"; }
734
-
735
- h.style.cursor = `${handle}-resize`;
736
- h.addEventListener("pointerdown", (e) => {
737
- e.stopPropagation();
738
- this.startResize(e, item.id, handle);
739
- });
740
- wrapper.appendChild(h);
741
- });
742
- }
743
-
744
- this.$canvas.appendChild(wrapper);
745
- });
746
- }
747
-
748
- _canvasScale() {
749
- // actual displayed width divided by model width
750
- const rect = this.$canvas.getBoundingClientRect();
751
- const w = rect.width; // already pre-zoom; we apply zoom with CSS transform
752
- return w / this.BASE_W;
753
- }
754
-
755
- // ---------- Item creation ----------
756
- onCanvasPointerDown(e) {
757
- // prevent adding when clicking existing item
758
- const hit = e.target.closest("[data-item-id]");
759
- if (hit) return;
760
-
761
- const { x, y } = this._eventToModelPoint(e);
762
-
763
- if (this.state.tool === "text") {
764
- this._pushUndoSnapshot();
765
- const id = this._id();
766
- this.activePage.items.push({
767
- id,
768
- type: "text",
769
- x: this._clamp(x, 0, this.BASE_W - 200),
770
- y: this._clamp(y, 0, this.BASE_H - 80),
771
- w: 220,
772
- h: 80,
773
- z: this._maxZ() + 1,
774
- content: "New text",
775
- style: { fontSize: 14, bold: false, italic: false, underline: false, color: "#111827", align: "left" }
776
- });
777
- this.selectItem(id);
778
- this._savePages();
779
- this.renderCanvas();
780
- this.updatePropsPanel();
781
- return;
782
- }
783
-
784
- if (this.state.tool === "rect") {
785
- this._pushUndoSnapshot();
786
- const id = this._id();
787
- this.activePage.items.push({
788
- id,
789
- type: "rect",
790
- x: this._clamp(x, 0, this.BASE_W - 200),
791
- y: this._clamp(y, 0, this.BASE_H - 120),
792
- w: 220,
793
- h: 120,
794
- z: this._maxZ() + 1,
795
- style: { fill: "#ffffff", stroke: "#111827", strokeWidth: 1 }
796
- });
797
- this.selectItem(id);
798
- this._savePages();
799
- this.renderCanvas();
800
- this.updatePropsPanel();
801
- return;
802
- }
803
-
804
- // select tool clicking empty space clears selection
805
- if (this.state.tool === "select") {
806
- this.state.selectedId = null;
807
- this.updatePropsPanel();
808
- this.renderCanvas();
809
- }
810
- }
811
-
812
- _eventToModelPoint(e) {
813
- const canvasRect = this.$canvas.getBoundingClientRect();
814
- // account for zoom (transform), use client coords mapping
815
- const zoom = this.state.zoom;
816
- const xPx = (e.clientX - canvasRect.left) / zoom;
817
- const yPx = (e.clientY - canvasRect.top) / zoom;
818
-
819
- const scale = canvasRect.width / this.BASE_W; // pre-zoom scale
820
- return { x: xPx / scale, y: yPx / scale };
821
- }
822
-
823
- // ---------- Selection / Drag / Resize ----------
824
- selectItem(id) {
825
- this.state.selectedId = id;
826
- this.updatePropsPanel();
827
- this.renderCanvas();
828
- }
829
-
830
- onItemPointerDown(e, id) {
831
- // ignore if resizing handle
832
- if (e.target && e.target.dataset && e.target.dataset.handle) return;
833
-
834
- // select
835
- this.selectItem(id);
836
-
837
- // start drag only when using select tool
838
- if (this.state.tool !== "select") return;
839
-
840
- this._pushUndoSnapshot();
841
-
842
- const it = this._findItem(id);
843
- if (!it) return;
844
-
845
- const { x, y } = this._eventToModelPoint(e);
846
- this.state.dragging = {
847
- id,
848
- startX: x,
849
- startY: y,
850
- origX: it.x,
851
- origY: it.y
852
- };
853
-
854
- e.preventDefault();
855
- }
856
-
857
- startResize(e, id, handle) {
858
- this._pushUndoSnapshot();
859
-
860
- const it = this._findItem(id);
861
- if (!it) return;
862
-
863
- const { x, y } = this._eventToModelPoint(e);
864
- this.state.resizing = {
865
- id,
866
- handle,
867
- startX: x,
868
- startY: y,
869
- orig: { x: it.x, y: it.y, w: it.w, h: it.h }
870
- };
871
- e.preventDefault();
872
- }
873
-
874
- onPointerMove(e) {
875
- if (!this.state.isOpen) return;
876
-
877
- if (this.state.dragging) {
878
- const d = this.state.dragging;
879
- const it = this._findItem(d.id);
880
- if (!it) return;
881
-
882
- const { x, y } = this._eventToModelPoint(e);
883
- const dx = x - d.startX;
884
- const dy = y - d.startY;
885
-
886
- it.x = this._clamp(d.origX + dx, 0, this.BASE_W - it.w);
887
- it.y = this._clamp(d.origY + dy, 0, this.BASE_H - it.h);
888
-
889
- this._savePages();
890
- this.renderCanvas();
891
- return;
892
- }
893
-
894
- if (this.state.resizing) {
895
- const r = this.state.resizing;
896
- const it = this._findItem(r.id);
897
- if (!it) return;
898
-
899
- const { x, y } = this._eventToModelPoint(e);
900
- const dx = x - r.startX;
901
- const dy = y - r.startY;
902
-
903
- const o = r.orig;
904
- const minW = 40, minH = 30;
905
-
906
- let nx = o.x, ny = o.y, nw = o.w, nh = o.h;
907
-
908
- if (r.handle.includes("e")) nw = this._clamp(o.w + dx, minW, this.BASE_W - o.x);
909
- if (r.handle.includes("s")) nh = this._clamp(o.h + dy, minH, this.BASE_H - o.y);
910
- if (r.handle.includes("w")) {
911
- nw = this._clamp(o.w - dx, minW, o.w + o.x);
912
- nx = this._clamp(o.x + dx, 0, o.x + o.w - minW);
913
- }
914
- if (r.handle.includes("n")) {
915
- nh = this._clamp(o.h - dy, minH, o.h + o.y);
916
- ny = this._clamp(o.y + dy, 0, o.y + o.h - minH);
917
- }
918
-
919
- it.x = nx; it.y = ny; it.w = nw; it.h = nh;
920
-
921
- this._savePages();
922
- this.renderCanvas();
923
- }
924
- }
925
-
926
- onPointerUp() {
927
- if (!this.state.isOpen) return;
928
-
929
- if (this.state.dragging) {
930
- this.state.dragging = null;
931
- this.updateUndoRedoButtons();
932
- }
933
- if (this.state.resizing) {
934
- this.state.resizing = null;
935
- this.updateUndoRedoButtons();
936
- }
937
- }
938
-
939
- // ---------- Properties panel ----------
940
- updatePropsPanel() {
941
- const it = this._findItem(this.state.selectedId);
942
- const has = !!it;
943
-
944
- this.$emptyProps.classList.toggle("hidden", has);
945
- this.$props.classList.toggle("hidden", !has);
946
-
947
- // hide all groups first
948
- this.$propsText.classList.add("hidden");
949
- this.$propsRect.classList.add("hidden");
950
- this.$propsImage.classList.add("hidden");
951
-
952
- if (!it) return;
953
-
954
- if (it.type === "text") {
955
- this.$propsText.classList.remove("hidden");
956
- this.querySelector('[data-prop="fontSize"]').value = it.style?.fontSize ?? 14;
957
- this.querySelector('[data-prop="color"]').value = it.style?.color ?? "#111827";
958
- }
959
-
960
- if (it.type === "rect") {
961
- this.$propsRect.classList.remove("hidden");
962
- this.querySelector('[data-prop="fill"]').value = it.style?.fill ?? "#ffffff";
963
- this.querySelector('[data-prop="stroke"]').value = it.style?.stroke ?? "#111827";
964
- this.querySelector('[data-prop="strokeWidth"]').value = it.style?.strokeWidth ?? 1;
965
- }
966
-
967
- if (it.type === "image") {
968
- this.$propsImage.classList.remove("hidden");
969
- }
970
-
971
- this._refreshIcons();
972
- }
973
-
974
- setProp(key, value) {
975
- const it = this._findItem(this.state.selectedId);
976
- if (!it) return;
977
-
978
- this._pushUndoSnapshot();
979
-
980
- it.style = it.style || {};
981
- it.style[key] = value;
982
-
983
- this._savePages();
984
- this.renderCanvas();
985
- this.updatePropsPanel();
986
- this.updateUndoRedoButtons();
987
- }
988
-
989
- toggleTextStyle(which) {
990
- const it = this._findItem(this.state.selectedId);
991
- if (!it || it.type !== "text") return;
992
-
993
- this._pushUndoSnapshot();
994
-
995
- it.style = it.style || {};
996
- if (which === "bold") it.style.bold = !it.style.bold;
997
- if (which === "italic") it.style.italic = !it.style.italic;
998
- if (which === "underline") it.style.underline = !it.style.underline;
999
-
1000
- this._savePages();
1001
- this.renderCanvas();
1002
- this.updateUndoRedoButtons();
1003
- }
1004
-
1005
- setTextAlign(align) {
1006
- const it = this._findItem(this.state.selectedId);
1007
- if (!it || it.type !== "text") return;
1008
- this.setProp("align", align);
1009
- }
1010
-
1011
- // ---------- Arrange ----------
1012
- bringFront() {
1013
- const it = this._findItem(this.state.selectedId);
1014
- if (!it) return;
1015
- this._pushUndoSnapshot();
1016
- it.z = this._maxZ() + 1;
1017
- this._savePages();
1018
- this.renderCanvas();
1019
- this.updateUndoRedoButtons();
1020
- }
1021
-
1022
- sendBack() {
1023
- const it = this._findItem(this.state.selectedId);
1024
- if (!it) return;
1025
- this._pushUndoSnapshot();
1026
- it.z = this._minZ() - 1;
1027
- this._savePages();
1028
- this.renderCanvas();
1029
- this.updateUndoRedoButtons();
1030
- }
1031
-
1032
- duplicateSelected() {
1033
- const it = this._findItem(this.state.selectedId);
1034
- if (!it) return;
1035
- this._pushUndoSnapshot();
1036
-
1037
- const copy = JSON.parse(JSON.stringify(it));
1038
- copy.id = this._id();
1039
- copy.x = this._clamp(copy.x + 12, 0, this.BASE_W - copy.w);
1040
- copy.y = this._clamp(copy.y + 12, 0, this.BASE_H - copy.h);
1041
- copy.z = this._maxZ() + 1;
1042
-
1043
- this.activePage.items.push(copy);
1044
- this.state.selectedId = copy.id;
1045
-
1046
- this._savePages();
1047
- this.updateAll();
1048
- this.updateUndoRedoButtons();
1049
- }
1050
-
1051
- deleteSelected() {
1052
- const id = this.state.selectedId;
1053
- if (!id) return;
1054
-
1055
- this._pushUndoSnapshot();
1056
-
1057
- this.activePage.items = this.activePage.items.filter(x => x.id !== id);
1058
- this.state.selectedId = null;
1059
-
1060
- this._savePages();
1061
- this.updateAll();
1062
- this.updateUndoRedoButtons();
1063
- }
1064
-
1065
- // ---------- Images ----------
1066
- _handleImageUpload(e, mode) {
1067
- const file = e.target.files && e.target.files[0];
1068
- if (!file) return;
1069
-
1070
- const reader = new FileReader();
1071
- reader.onload = () => {
1072
- if (mode === "add") {
1073
- this._pushUndoSnapshot();
1074
- const id = this._id();
1075
- const w = 260, h = 180;
1076
- this.activePage.items.push({
1077
- id,
1078
- type: "image",
1079
- x: (this.BASE_W - w) / 2,
1080
- y: (this.BASE_H - h) / 2,
1081
- w, h,
1082
- z: this._maxZ() + 1,
1083
- src: reader.result,
1084
- name: file.name
1085
- });
1086
- this.selectItem(id);
1087
- this._savePages();
1088
- this.updateAll();
1089
- this.updateUndoRedoButtons();
1090
- }
1091
-
1092
- if (mode === "replace") {
1093
- const it = this._findItem(this.state.selectedId);
1094
- if (!it || it.type !== "image") return;
1095
- this._pushUndoSnapshot();
1096
- it.src = reader.result;
1097
- it.name = file.name;
1098
- this._savePages();
1099
- this.updateAll();
1100
- this.updateUndoRedoButtons();
1101
- }
1102
- };
1103
- reader.readAsDataURL(file);
1104
-
1105
- // reset input
1106
- e.target.value = "";
1107
- }
1108
-
1109
- // ---------- Undo / redo ----------
1110
- _pushUndoSnapshot() {
1111
- // store snapshot of active page items
1112
- const snap = JSON.stringify(this.activePage.items);
1113
- const last = this.state.undo[this.state.undo.length - 1];
1114
- if (last !== snap) this.state.undo.push(snap);
1115
- // clear redo on new change
1116
- this.state.redo = [];
1117
- this.updateUndoRedoButtons();
1118
- }
1119
-
1120
- undo() {
1121
- if (!this.state.undo.length) return;
1122
-
1123
- const current = JSON.stringify(this.activePage.items);
1124
- const prev = this.state.undo.pop();
1125
- this.state.redo.push(current);
1126
-
1127
- // restore prev
1128
- try {
1129
- this.activePage.items = JSON.parse(prev);
1130
- } catch {}
1131
- this.state.selectedId = null;
1132
-
1133
- this._savePages();
1134
- this.updateAll();
1135
- }
1136
-
1137
- redo() {
1138
- if (!this.state.redo.length) return;
1139
-
1140
- const current = JSON.stringify(this.activePage.items);
1141
- const next = this.state.redo.pop();
1142
- this.state.undo.push(current);
1143
-
1144
- try {
1145
- this.activePage.items = JSON.parse(next);
1146
- } catch {}
1147
- this.state.selectedId = null;
1148
-
1149
- this._savePages();
1150
- this.updateAll();
1151
- }
1152
-
1153
- updateUndoRedoButtons() {
1154
- const undoBtn = this.querySelector('[data-btn="undo"]');
1155
- const redoBtn = this.querySelector('[data-btn="redo"]');
1156
- if (undoBtn) undoBtn.disabled = this.state.undo.length === 0;
1157
- if (redoBtn) redoBtn.disabled = this.state.redo.length === 0;
1158
- }
1159
-
1160
- // ---------- Utils ----------
1161
- _findItem(id) {
1162
- if (!id) return null;
1163
- return this.activePage.items.find(x => x.id === id) || null;
1164
- }
1165
-
1166
- _maxZ() {
1167
- const items = this.activePage.items;
1168
- return items.length ? Math.max(...items.map(i => i.z ?? 0)) : 0;
1169
- }
1170
-
1171
- _minZ() {
1172
- const items = this.activePage.items;
1173
- return items.length ? Math.min(...items.map(i => i.z ?? 0)) : 0;
1174
- }
1175
-
1176
- _id() {
1177
- return "it_" + Math.random().toString(16).slice(2) + "_" + Date.now().toString(16);
1178
- }
1179
-
1180
- _clamp(n, a, b) {
1181
- return Math.max(a, Math.min(b, n));
1182
- }
1183
- }
1184
-
1185
- customElements.define("report-editor", ReportEditor);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
edit-layouts.html DELETED
@@ -1,265 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width,initial-scale=1" />
6
- <title>RepEx - Report Express | Export</title>
7
-
8
- <link rel="stylesheet" href="style.css" />
9
- <script src="https://cdn.tailwindcss.com"></script>
10
- <script defer src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
11
-
12
- <style>
13
- img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
14
- @media print {
15
- body { background: #fff !important; }
16
- .no-print { display: none !important; }
17
- main { box-shadow: none !important; border: none !important; margin: 0 !important; padding: 0 !important; }
18
- }
19
- </style>
20
- </head>
21
-
22
- <body class="bg-gray-50 min-h-screen">
23
- <main class="max-w-4xl mx-auto my-8 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8">
24
- <!-- Header -->
25
- <header class="mb-8 border-b border-gray-200 pb-4">
26
- <div class="grid grid-cols-[auto,1fr,auto] items-center gap-4">
27
- <div class="flex items-center">
28
- <img
29
- src="assets/prosento-logo.png"
30
- alt="Company logo"
31
- class="h-12 w-auto object-contain"
32
- loading="eager"
33
- />
34
- </div>
35
-
36
- <div class="text-center">
37
- <h1 class="text-2xl md:text-3xl font-bold text-gray-900 whitespace-nowrap">
38
- RepEx - Report Express
39
- </h1>
40
- <p class="text-gray-600 whitespace-nowrap">Export</p>
41
- </div>
42
-
43
- <div class="flex justify-end gap-2 no-print">
44
- <a
45
- href="report-viewer.html"
46
- class="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"
47
- >
48
- <i data-feather="arrow-left" class="h-4 w-4"></i>
49
- Back
50
- </a>
51
- </div>
52
- </div>
53
- </header>
54
-
55
- <!-- Workflow navigation -->
56
- <nav class="mb-6 no-print" aria-label="Report workflow navigation">
57
- <div class="flex flex-wrap gap-2">
58
- <a
59
- href="report-viewer.html"
60
- class="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"
61
- >
62
- <i data-feather="layout" class="h-4 w-4"></i>
63
- Report Viewer
64
- </a>
65
-
66
- <a
67
- href="edit-layouts.html"
68
- class="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"
69
- >
70
- <i data-feather="grid" class="h-4 w-4"></i>
71
- Edit Page Layouts
72
- </a>
73
-
74
- <a
75
- href="export.html"
76
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-900 px-4 py-2 text-sm font-semibold text-white"
77
- >
78
- <i data-feather="download" class="h-4 w-4"></i>
79
- Export
80
- </a>
81
- </div>
82
- </nav>
83
-
84
- <section class="grid grid-cols-1 lg:grid-cols-[1fr,360px] gap-6">
85
- <!-- Export options -->
86
- <div class="rounded-lg border border-gray-200 bg-white p-4">
87
- <h2 class="text-lg font-semibold text-gray-900 mb-2">Export Options</h2>
88
- <p class="text-sm text-gray-600 mb-4">
89
- PDF export comes next. For now, you can export a report “package” as JSON (pages + layout settings + payload).
90
- </p>
91
-
92
- <div class="space-y-4">
93
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
94
- <div class="flex items-start justify-between gap-3">
95
- <div>
96
- <div class="text-sm font-semibold text-gray-900">Report package (.json)</div>
97
- <div class="text-xs text-gray-500">Includes edited pages, layout settings and upload metadata</div>
98
- </div>
99
- <span class="text-xs font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-md px-2 py-1">
100
- Available
101
- </span>
102
- </div>
103
-
104
- <div class="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
105
- <label class="inline-flex items-center gap-2 text-sm text-gray-700">
106
- <input id="incPages" type="checkbox" class="rounded border-gray-300" checked />
107
- Include pages
108
- </label>
109
- <label class="inline-flex items-center gap-2 text-sm text-gray-700">
110
- <input id="incLayout" type="checkbox" class="rounded border-gray-300" checked />
111
- Include layout settings
112
- </label>
113
- <label class="inline-flex items-center gap-2 text-sm text-gray-700">
114
- <input id="incPayload" type="checkbox" class="rounded border-gray-300" checked />
115
- Include upload payload
116
- </label>
117
- <label class="inline-flex items-center gap-2 text-sm text-gray-700">
118
- <input id="incTimestamp" type="checkbox" class="rounded border-gray-300" checked />
119
- Include timestamp
120
- </label>
121
- </div>
122
-
123
- <button id="downloadJson" type="button"
124
- class="mt-4 inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-2.5 text-white font-semibold hover:bg-emerald-700 transition">
125
- <i data-feather="download" class="h-4 w-4"></i>
126
- Download JSON package
127
- </button>
128
- </div>
129
-
130
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
131
- <div class="flex items-start justify-between gap-3">
132
- <div>
133
- <div class="text-sm font-semibold text-gray-900">PDF export</div>
134
- <div class="text-xs text-gray-500">Will export as print-ready A4 PDF</div>
135
- </div>
136
- <span class="text-xs font-semibold text-gray-600 bg-white border border-gray-200 rounded-md px-2 py-1">
137
- Coming soon
138
- </span>
139
- </div>
140
-
141
- <button type="button" disabled
142
- class="mt-4 inline-flex items-center justify-center gap-2 rounded-lg bg-gray-200 px-4 py-2.5 text-gray-500 font-semibold cursor-not-allowed">
143
- <i data-feather="file-text" class="h-4 w-4"></i>
144
- Export PDF (disabled)
145
- </button>
146
- </div>
147
- </div>
148
- </div>
149
-
150
- <!-- Summary -->
151
- <aside class="rounded-lg border border-gray-200 bg-white p-4">
152
- <h2 class="text-lg font-semibold text-gray-900 mb-2">Export Summary</h2>
153
- <p class="text-sm text-gray-600 mb-4">This is what will be included based on your current session.</p>
154
-
155
- <div class="space-y-3">
156
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
157
- <div class="text-xs font-semibold text-gray-600">Pages</div>
158
- <div id="sumPages" class="text-sm font-semibold text-gray-900">—</div>
159
- </div>
160
-
161
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
162
- <div class="text-xs font-semibold text-gray-600">Selected example photos</div>
163
- <div id="sumPhotos" class="text-sm font-semibold text-gray-900">—</div>
164
- </div>
165
-
166
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
167
- <div class="text-xs font-semibold text-gray-600">Documents</div>
168
- <div id="sumDocs" class="text-sm font-semibold text-gray-900">—</div>
169
- </div>
170
-
171
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
172
- <div class="text-xs font-semibold text-gray-600">Data files</div>
173
- <div id="sumData" class="text-sm font-semibold text-gray-900">—</div>
174
- </div>
175
-
176
- <div class="text-xs text-gray-500">
177
- Stored in the active server session.
178
- </div>
179
- </div>
180
- </aside>
181
- </section>
182
-
183
- <footer class="mt-12 text-center text-xs text-gray-500 no-print">
184
- <p>Prosento - © 2026 All Rights Reserved</p>
185
- <p class="mt-1">Export: JSON package now; PDF export will be added next.</p>
186
- </footer>
187
- </main>
188
-
189
- <script src="script.js"></script>
190
- <script>
191
- document.addEventListener('DOMContentLoaded', () => {
192
- if (window.feather && typeof window.feather.replace === 'function') feather.replace();
193
-
194
- const sessionId = window.REPEX.getSessionId();
195
- window.REPEX.setSessionId(sessionId);
196
- const sumPages = document.getElementById('sumPages');
197
- const sumPhotos = document.getElementById('sumPhotos');
198
- const sumDocs = document.getElementById('sumDocs');
199
- const sumData = document.getElementById('sumData');
200
-
201
- const incPages = document.getElementById('incPages');
202
- const incLayout = document.getElementById('incLayout');
203
- const incPayload = document.getElementById('incPayload');
204
- const incTimestamp = document.getElementById('incTimestamp');
205
- const downloadJson = document.getElementById('downloadJson');
206
-
207
- if (!sessionId) {
208
- sumPages.textContent = 'No active session.';
209
- sumPhotos.textContent = '-';
210
- sumDocs.textContent = '-';
211
- sumData.textContent = '-';
212
- downloadJson.disabled = true;
213
- return;
214
- }
215
-
216
- let session = null;
217
- let pages = [];
218
-
219
- function downloadBlob(filename, text) {
220
- const blob = new Blob([text], { type: 'application/json' });
221
- const url = URL.createObjectURL(blob);
222
- const a = document.createElement('a');
223
- a.href = url;
224
- a.download = filename;
225
- document.body.appendChild(a);
226
- a.click();
227
- a.remove();
228
- URL.revokeObjectURL(url);
229
- }
230
-
231
- function updateSummary() {
232
- const totalItems = pages.reduce((acc, p) => acc + ((p?.items?.length) || 0), 0);
233
- sumPages.textContent = pages.length ? `${pages.length} pages ? ${totalItems} total items` : 'No saved pages yet';
234
- sumPhotos.textContent = `${session?.selected_photo_ids?.length ?? 0}`;
235
- sumDocs.textContent = `${session?.uploads?.documents?.length ?? 0}`;
236
- sumData.textContent = `${session?.uploads?.data_files?.length ?? 0}`;
237
- }
238
-
239
- async function loadData() {
240
- try {
241
- session = await window.REPEX.request(`/sessions/${sessionId}`);
242
- const pageResp = await window.REPEX.request(`/sessions/${sessionId}/pages`);
243
- pages = Array.isArray(pageResp?.pages) ? pageResp.pages : [];
244
- updateSummary();
245
- } catch (err) {
246
- sumPages.textContent = err.message || 'Failed to load session.';
247
- }
248
- }
249
-
250
- downloadJson.addEventListener('click', () => {
251
- const pack = {};
252
- if (incPages.checked) pack.pages = pages;
253
- if (incLayout.checked) pack.layout = session?.layout ?? null;
254
- if (incPayload.checked) pack.payload = session;
255
- if (incTimestamp.checked) pack.exportedAt = new Date().toISOString();
256
-
257
- const filename = `repex_report_package_${new Date().toISOString().replace(/[:.]/g,'-')}.json`;
258
- downloadBlob(filename, JSON.stringify(pack, null, 2));
259
- });
260
-
261
- loadData();
262
- });
263
- </script>
264
- </body>
265
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
export.html DELETED
@@ -1,265 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width,initial-scale=1" />
6
- <title>RepEx - Report Express | Export</title>
7
-
8
- <link rel="stylesheet" href="style.css" />
9
- <script src="https://cdn.tailwindcss.com"></script>
10
- <script defer src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
11
-
12
- <style>
13
- img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
14
- @media print {
15
- body { background: #fff !important; }
16
- .no-print { display: none !important; }
17
- main { box-shadow: none !important; border: none !important; margin: 0 !important; padding: 0 !important; }
18
- }
19
- </style>
20
- </head>
21
-
22
- <body class="bg-gray-50 min-h-screen">
23
- <main class="max-w-4xl mx-auto my-8 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8">
24
- <!-- Header -->
25
- <header class="mb-8 border-b border-gray-200 pb-4">
26
- <div class="grid grid-cols-[auto,1fr,auto] items-center gap-4">
27
- <div class="flex items-center">
28
- <img
29
- src="assets/prosento-logo.png"
30
- alt="Company logo"
31
- class="h-12 w-auto object-contain"
32
- loading="eager"
33
- />
34
- </div>
35
-
36
- <div class="text-center">
37
- <h1 class="text-2xl md:text-3xl font-bold text-gray-900 whitespace-nowrap">
38
- RepEx - Report Express
39
- </h1>
40
- <p class="text-gray-600 whitespace-nowrap">Export</p>
41
- </div>
42
-
43
- <div class="flex justify-end gap-2 no-print">
44
- <a
45
- href="report-viewer.html"
46
- class="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"
47
- >
48
- <i data-feather="arrow-left" class="h-4 w-4"></i>
49
- Back
50
- </a>
51
- </div>
52
- </div>
53
- </header>
54
-
55
- <!-- Workflow navigation -->
56
- <nav class="mb-6 no-print" aria-label="Report workflow navigation">
57
- <div class="flex flex-wrap gap-2">
58
- <a
59
- href="report-viewer.html"
60
- class="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"
61
- >
62
- <i data-feather="layout" class="h-4 w-4"></i>
63
- Report Viewer
64
- </a>
65
-
66
- <a
67
- href="edit-layouts.html"
68
- class="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"
69
- >
70
- <i data-feather="grid" class="h-4 w-4"></i>
71
- Edit Page Layouts
72
- </a>
73
-
74
- <a
75
- href="export.html"
76
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-900 px-4 py-2 text-sm font-semibold text-white"
77
- >
78
- <i data-feather="download" class="h-4 w-4"></i>
79
- Export
80
- </a>
81
- </div>
82
- </nav>
83
-
84
- <section class="grid grid-cols-1 lg:grid-cols-[1fr,360px] gap-6">
85
- <!-- Export options -->
86
- <div class="rounded-lg border border-gray-200 bg-white p-4">
87
- <h2 class="text-lg font-semibold text-gray-900 mb-2">Export Options</h2>
88
- <p class="text-sm text-gray-600 mb-4">
89
- PDF export comes next. For now, you can export a report “package” as JSON (pages + layout settings + payload).
90
- </p>
91
-
92
- <div class="space-y-4">
93
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
94
- <div class="flex items-start justify-between gap-3">
95
- <div>
96
- <div class="text-sm font-semibold text-gray-900">Report package (.json)</div>
97
- <div class="text-xs text-gray-500">Includes edited pages, layout settings and upload metadata</div>
98
- </div>
99
- <span class="text-xs font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-md px-2 py-1">
100
- Available
101
- </span>
102
- </div>
103
-
104
- <div class="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
105
- <label class="inline-flex items-center gap-2 text-sm text-gray-700">
106
- <input id="incPages" type="checkbox" class="rounded border-gray-300" checked />
107
- Include pages
108
- </label>
109
- <label class="inline-flex items-center gap-2 text-sm text-gray-700">
110
- <input id="incLayout" type="checkbox" class="rounded border-gray-300" checked />
111
- Include layout settings
112
- </label>
113
- <label class="inline-flex items-center gap-2 text-sm text-gray-700">
114
- <input id="incPayload" type="checkbox" class="rounded border-gray-300" checked />
115
- Include upload payload
116
- </label>
117
- <label class="inline-flex items-center gap-2 text-sm text-gray-700">
118
- <input id="incTimestamp" type="checkbox" class="rounded border-gray-300" checked />
119
- Include timestamp
120
- </label>
121
- </div>
122
-
123
- <button id="downloadJson" type="button"
124
- class="mt-4 inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-2.5 text-white font-semibold hover:bg-emerald-700 transition">
125
- <i data-feather="download" class="h-4 w-4"></i>
126
- Download JSON package
127
- </button>
128
- </div>
129
-
130
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
131
- <div class="flex items-start justify-between gap-3">
132
- <div>
133
- <div class="text-sm font-semibold text-gray-900">PDF export</div>
134
- <div class="text-xs text-gray-500">Will export as print-ready A4 PDF</div>
135
- </div>
136
- <span class="text-xs font-semibold text-gray-600 bg-white border border-gray-200 rounded-md px-2 py-1">
137
- Coming soon
138
- </span>
139
- </div>
140
-
141
- <button type="button" disabled
142
- class="mt-4 inline-flex items-center justify-center gap-2 rounded-lg bg-gray-200 px-4 py-2.5 text-gray-500 font-semibold cursor-not-allowed">
143
- <i data-feather="file-text" class="h-4 w-4"></i>
144
- Export PDF (disabled)
145
- </button>
146
- </div>
147
- </div>
148
- </div>
149
-
150
- <!-- Summary -->
151
- <aside class="rounded-lg border border-gray-200 bg-white p-4">
152
- <h2 class="text-lg font-semibold text-gray-900 mb-2">Export Summary</h2>
153
- <p class="text-sm text-gray-600 mb-4">This is what will be included based on your current session.</p>
154
-
155
- <div class="space-y-3">
156
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
157
- <div class="text-xs font-semibold text-gray-600">Pages</div>
158
- <div id="sumPages" class="text-sm font-semibold text-gray-900">—</div>
159
- </div>
160
-
161
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
162
- <div class="text-xs font-semibold text-gray-600">Selected example photos</div>
163
- <div id="sumPhotos" class="text-sm font-semibold text-gray-900">—</div>
164
- </div>
165
-
166
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
167
- <div class="text-xs font-semibold text-gray-600">Documents</div>
168
- <div id="sumDocs" class="text-sm font-semibold text-gray-900">—</div>
169
- </div>
170
-
171
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
172
- <div class="text-xs font-semibold text-gray-600">Data files</div>
173
- <div id="sumData" class="text-sm font-semibold text-gray-900">—</div>
174
- </div>
175
-
176
- <div class="text-xs text-gray-500">
177
- Stored in the active server session.
178
- </div>
179
- </div>
180
- </aside>
181
- </section>
182
-
183
- <footer class="mt-12 text-center text-xs text-gray-500 no-print">
184
- <p>Prosento - © 2026 All Rights Reserved</p>
185
- <p class="mt-1">Export: JSON package now; PDF export will be added next.</p>
186
- </footer>
187
- </main>
188
-
189
- <script src="script.js"></script>
190
- <script>
191
- document.addEventListener('DOMContentLoaded', () => {
192
- if (window.feather && typeof window.feather.replace === 'function') feather.replace();
193
-
194
- const sessionId = window.REPEX.getSessionId();
195
- window.REPEX.setSessionId(sessionId);
196
- const sumPages = document.getElementById('sumPages');
197
- const sumPhotos = document.getElementById('sumPhotos');
198
- const sumDocs = document.getElementById('sumDocs');
199
- const sumData = document.getElementById('sumData');
200
-
201
- const incPages = document.getElementById('incPages');
202
- const incLayout = document.getElementById('incLayout');
203
- const incPayload = document.getElementById('incPayload');
204
- const incTimestamp = document.getElementById('incTimestamp');
205
- const downloadJson = document.getElementById('downloadJson');
206
-
207
- if (!sessionId) {
208
- sumPages.textContent = 'No active session.';
209
- sumPhotos.textContent = '-';
210
- sumDocs.textContent = '-';
211
- sumData.textContent = '-';
212
- downloadJson.disabled = true;
213
- return;
214
- }
215
-
216
- let session = null;
217
- let pages = [];
218
-
219
- function downloadBlob(filename, text) {
220
- const blob = new Blob([text], { type: 'application/json' });
221
- const url = URL.createObjectURL(blob);
222
- const a = document.createElement('a');
223
- a.href = url;
224
- a.download = filename;
225
- document.body.appendChild(a);
226
- a.click();
227
- a.remove();
228
- URL.revokeObjectURL(url);
229
- }
230
-
231
- function updateSummary() {
232
- const totalItems = pages.reduce((acc, p) => acc + ((p?.items?.length) || 0), 0);
233
- sumPages.textContent = pages.length ? `${pages.length} pages ? ${totalItems} total items` : 'No saved pages yet';
234
- sumPhotos.textContent = `${session?.selected_photo_ids?.length ?? 0}`;
235
- sumDocs.textContent = `${session?.uploads?.documents?.length ?? 0}`;
236
- sumData.textContent = `${session?.uploads?.data_files?.length ?? 0}`;
237
- }
238
-
239
- async function loadData() {
240
- try {
241
- session = await window.REPEX.request(`/sessions/${sessionId}`);
242
- const pageResp = await window.REPEX.request(`/sessions/${sessionId}/pages`);
243
- pages = Array.isArray(pageResp?.pages) ? pageResp.pages : [];
244
- updateSummary();
245
- } catch (err) {
246
- sumPages.textContent = err.message || 'Failed to load session.';
247
- }
248
- }
249
-
250
- downloadJson.addEventListener('click', () => {
251
- const pack = {};
252
- if (incPages.checked) pack.pages = pages;
253
- if (incLayout.checked) pack.layout = session?.layout ?? null;
254
- if (incPayload.checked) pack.payload = session;
255
- if (incTimestamp.checked) pack.exportedAt = new Date().toISOString();
256
-
257
- const filename = `repex_report_package_${new Date().toISOString().replace(/[:.]/g,'-')}.json`;
258
- downloadBlob(filename, JSON.stringify(pack, null, 2));
259
- });
260
-
261
- loadData();
262
- });
263
- </script>
264
- </body>
265
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/public/templates/job-sheet-template.html CHANGED
@@ -3,10 +3,7 @@
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>SIMM Inspection Job Sheet</title>
7
-
8
- <!-- Optional project stylesheet -->
9
- <link rel="stylesheet" href="../style.css" />
10
 
11
  <!-- Tailwind (CDN OK for prototypes; compile for production) -->
12
  <script src="https://cdn.tailwindcss.com"></script>
@@ -25,6 +22,23 @@
25
 
26
  .avoid-break { break-inside: avoid; page-break-inside: avoid; }
27
  img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  </style>
29
  </head>
30
 
@@ -44,8 +58,8 @@
44
  </div>
45
 
46
  <div class="text-center leading-tight">
47
- <h1 class="text-2xl font-bold text-gray-900 whitespace-nowrap">SIMM Inspection Job Sheet</h1>
48
- <p class="text-sm text-gray-600 whitespace-nowrap">Structural Inspection and Maintenance Management</p>
49
  </div>
50
 
51
  <div class="flex items-center justify-end">
@@ -68,32 +82,32 @@
68
  <dl class="grid grid-cols-2 md:grid-cols-4 gap-2 bg-gray-50 border border-gray-200 rounded-lg p-3">
69
  <div class="space-y-0.5">
70
  <dt class="text-xs font-medium text-gray-500">Inspection Date</dt>
71
- <dd class="text-sm font-semibold text-gray-900">2023-11-15</dd>
72
  </div>
73
 
74
  <div class="space-y-0.5">
75
  <dt class="text-xs font-medium text-gray-500">Inspector</dt>
76
- <dd class="text-sm font-semibold text-gray-900">John Doe</dd>
77
  </div>
78
 
79
  <div class="space-y-0.5">
80
  <dt class="text-xs font-medium text-gray-500">Accompanied By</dt>
81
- <dd class="text-sm font-semibold text-gray-900">John Doe</dd>
82
  </div>
83
 
84
  <div class="space-y-0.5">
85
  <dt class="text-xs font-medium text-gray-500">Document No</dt>
86
- <dd class="text-sm font-mono font-semibold text-gray-900">SIMM-JS-2023-001</dd>
87
  </div>
88
 
89
  <div class="space-y-0.5 md:col-span-2">
90
  <dt class="text-xs font-medium text-gray-500">Project</dt>
91
- <dd class="text-sm font-semibold text-gray-900">North Pit - Sector 4 Conveyor Upgrade</dd>
92
  </div>
93
 
94
  <div class="space-y-0.5 md:col-span-2">
95
  <dt class="text-xs font-medium text-gray-500">Client / Site</dt>
96
- <dd class="text-sm font-semibold text-gray-900">Tronox</dd>
97
  </div>
98
  </dl>
99
  </section>
@@ -110,17 +124,17 @@
110
  <div class="grid grid-cols-2 gap-2">
111
  <div class="space-y-0.5">
112
  <div class="text-xs font-medium text-gray-500">Reference</div>
113
- <div class="text-sm font-semibold text-gray-900">REF-7821</div>
114
  </div>
115
 
116
  <div class="space-y-0.5">
117
  <div class="text-xs font-medium text-gray-500">Action Type</div>
118
- <div class="text-sm font-semibold text-gray-900">Structural Repair</div>
119
  </div>
120
 
121
  <div class="space-y-0.5 col-span-2">
122
  <div class="text-xs font-medium text-gray-500">Item Description</div>
123
- <div class="text-sm font-semibold text-gray-900">Main support beam for conveyor CV-04</div>
124
  </div>
125
  </div>
126
  </div>
@@ -129,7 +143,7 @@
129
  <div class="space-y-2">
130
  <div class="space-y-0.5">
131
  <div class="text-xs font-medium text-gray-500">Functional Location</div>
132
- <div class="text-sm font-semibold text-gray-900">Conveyor Support - CV-04-SUP-02</div>
133
  </div>
134
  </div>
135
 
@@ -139,23 +153,19 @@
139
  <div class="text-center space-y-1">
140
  <div class="text-xs font-medium text-gray-500">Category</div>
141
  <span
142
- id="condition-rating"
143
- class="badge inline-flex items-center justify-center rounded-md border px-4 py-1 text-sm font-semibold"
144
- aria-label="Category rating"
145
- >
146
- <!-- populated by JS -->
147
- </span>
148
  </div>
149
 
150
  <div class="text-center space-y-1">
151
  <div class="text-xs font-medium text-gray-500">Priority</div>
152
  <span
153
- id="priority-rating"
154
- class="badge inline-flex items-center justify-center rounded-md border px-4 py-1 text-sm font-semibold"
155
- aria-label="Priority rating"
156
- >
157
- <!-- populated by JS -->
158
- </span>
159
  </div>
160
  </div>
161
  </div>
@@ -164,9 +174,7 @@
164
  <div class="md:col-span-2 space-y-1">
165
  <div class="text-xs font-medium text-gray-500">Condition Description</div>
166
  <div class="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
167
- <p class="text-amber-800 text-sm font-semibold leading-snug">
168
- Visible corrosion on lower flange with ~15% material loss. Surface pitting along entire length.
169
- </p>
170
  </div>
171
  </div>
172
 
@@ -174,10 +182,7 @@
174
  <div class="md:col-span-2 space-y-1">
175
  <div class="text-xs font-medium text-gray-500">Required Action</div>
176
  <div class="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
177
- <p class="text-blue-800 text-sm font-semibold leading-snug">
178
- Clean exposed rebar; apply corrosion protection; use wet-to-dry epoxy; reinstate with concrete repair.
179
- Complete within next 12 months to limit further degradation.
180
- </p>
181
  </div>
182
  </div>
183
  </div>
@@ -191,29 +196,20 @@
191
 
192
  <div class="grid grid-cols-2 gap-3">
193
  <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50">
194
- <img
195
- src="https://huggingface.co/spaces/ChristopherJKoen/minefix-simm-inspector/resolve/main/images/Screenshot%202026-02-02%20100102.png"
196
- alt="Photo 1"
197
- class="w-full h-40 object-contain mx-auto"
198
- loading="eager"
199
- />
200
  <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
201
- Fig 1: Ref 1.1 - Concrete spalling (example)
202
  </figcaption>
203
  </figure>
204
 
205
- <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50 relative">
206
- <img
207
- src="https://huggingface.co/spaces/ChristopherJKoen/minefix-simm-inspector/resolve/main/images/Picture2.png"
208
- alt="Photo 2"
209
- class="w-full h-40 object-contain mx-auto"
210
- loading="eager"
211
- />
212
- <div class="absolute top-2 left-2 bg-white/95 text-black text-[11px] font-bold px-2 py-1 rounded border border-gray-300">
213
- #1.2 [C3, P3] Moderate Corrosion
214
  </div>
215
  <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
216
- Fig 2: Ref 1.2 - Walkway corrosion (example)
217
  </figcaption>
218
  </figure>
219
  </div>
@@ -244,43 +240,9 @@
244
 
245
  <!-- Footer -->
246
  <footer class="mt-4 text-center text-[11px] text-gray-500">
247
- <p>Prosento - (c) 2026 All Rights Reserved</p>
248
- <p class="mt-0.5">Automatically generated job sheet</p>
249
  </footer>
250
  </main>
251
-
252
- <script>
253
- document.addEventListener('DOMContentLoaded', () => {
254
- // Badge tone system
255
- const TONES = {
256
- amber: ['bg-amber-50', 'text-amber-800', 'border-amber-200'],
257
- emerald: ['bg-emerald-50', 'text-emerald-800', 'border-emerald-200'],
258
- gray: ['bg-gray-50', 'text-gray-700', 'border-gray-200'],
259
- };
260
-
261
- const BASE_BADGE = 'inline-flex items-center justify-center rounded-md border px-4 py-1 text-sm font-semibold';
262
-
263
- function setBadge(id, text, toneKey) {
264
- const el = document.getElementById(id);
265
- if (!el) return;
266
- const tone = TONES[toneKey] || TONES.gray;
267
- el.className = `${BASE_BADGE} ${tone.join(' ')}`;
268
- el.textContent = text;
269
- }
270
-
271
- setBadge('condition-rating', '3 - Poor', 'amber');
272
- setBadge('priority-rating', '3 - 3 Years', 'emerald');
273
-
274
- // Icons
275
- if (window.feather && typeof window.feather.replace === 'function') {
276
- window.feather.replace();
277
- }
278
-
279
- // Ensure images are loaded before printing
280
- window.addEventListener('beforeprint', () => {
281
- document.querySelectorAll('img').forEach(img => (img.loading = 'eager'));
282
- });
283
- });
284
- </script>
285
  </body>
286
  </html>
 
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>RepEx Inspection Job Sheet</title>
 
 
 
7
 
8
  <!-- Tailwind (CDN OK for prototypes; compile for production) -->
9
  <script src="https://cdn.tailwindcss.com"></script>
 
22
 
23
  .avoid-break { break-inside: avoid; page-break-inside: avoid; }
24
  img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
25
+
26
+ .template-field {
27
+ display: block;
28
+ min-height: 1.1em;
29
+ border-bottom: 1px dotted #d1d5db;
30
+ padding-bottom: 1px;
31
+ }
32
+
33
+ .template-field-multiline {
34
+ min-height: 2.4em;
35
+ white-space: pre-wrap;
36
+ }
37
+
38
+ .template-field[contenteditable="true"]:empty:before {
39
+ content: attr(data-placeholder);
40
+ color: #9ca3af;
41
+ }
42
  </style>
43
  </head>
44
 
 
58
  </div>
59
 
60
  <div class="text-center leading-tight">
61
+ <h1 class="text-2xl font-bold text-gray-900 whitespace-nowrap">RepEx Inspection Job Sheet</h1>
62
+ <p class="text-sm text-gray-600 whitespace-nowrap">Report Express - Inspection Management</p>
63
  </div>
64
 
65
  <div class="flex items-center justify-end">
 
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>
 
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>
 
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
 
 
153
  <div class="text-center space-y-1">
154
  <div class="text-xs font-medium text-gray-500">Category</div>
155
  <span
156
+ class="template-field inline-flex items-center justify-center rounded-md border border-gray-200 px-4 py-1 text-sm font-semibold min-w-[96px]"
157
+ contenteditable="true"
158
+ data-placeholder="Category"
159
+ ></span>
 
 
160
  </div>
161
 
162
  <div class="text-center space-y-1">
163
  <div class="text-xs font-medium text-gray-500">Priority</div>
164
  <span
165
+ class="template-field inline-flex items-center justify-center rounded-md border border-gray-200 px-4 py-1 text-sm font-semibold min-w-[96px]"
166
+ contenteditable="true"
167
+ data-placeholder="Priority"
168
+ ></span>
 
 
169
  </div>
170
  </div>
171
  </div>
 
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
 
 
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>
188
  </div>
 
196
 
197
  <div class="grid grid-cols-2 gap-3">
198
  <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50">
199
+ <div class="h-40 w-full flex items-center justify-center text-xs text-gray-400 border border-dashed border-gray-300 rounded-md">
200
+ Photo slot
201
+ </div>
 
 
 
202
  <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
203
+ Figure 1
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>
211
  <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
212
+ Figure 2
213
  </figcaption>
214
  </figure>
215
  </div>
 
240
 
241
  <!-- Footer -->
242
  <footer class="mt-4 text-center text-[11px] text-gray-500">
243
+ <p>RepEx - (c) 2026 All Rights Reserved</p>
244
+ <p class="mt-0.5">Generated by RepEx</p>
245
  </footer>
246
  </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  </body>
248
  </html>
frontend/src/App.tsx CHANGED
@@ -4,6 +4,7 @@ import UploadPage from "./pages/UploadPage";
4
  import ProcessingPage from "./pages/ProcessingPage";
5
  import ReviewSetupPage from "./pages/ReviewSetupPage";
6
  import ReportViewerPage from "./pages/ReportViewerPage";
 
7
  import EditLayoutsPage from "./pages/EditLayoutsPage";
8
  import ExportPage from "./pages/ExportPage";
9
 
@@ -15,6 +16,7 @@ export default function App() {
15
  <Route path="/processing" element={<ProcessingPage />} />
16
  <Route path="/review-setup" element={<ReviewSetupPage />} />
17
  <Route path="/report-viewer" element={<ReportViewerPage />} />
 
18
  <Route path="/edit-layouts" element={<EditLayoutsPage />} />
19
  <Route path="/export" element={<ExportPage />} />
20
  <Route path="*" element={<Navigate to="/" replace />} />
 
4
  import ProcessingPage from "./pages/ProcessingPage";
5
  import ReviewSetupPage from "./pages/ReviewSetupPage";
6
  import ReportViewerPage from "./pages/ReportViewerPage";
7
+ import EditReportPage from "./pages/EditReportPage";
8
  import EditLayoutsPage from "./pages/EditLayoutsPage";
9
  import ExportPage from "./pages/ExportPage";
10
 
 
16
  <Route path="/processing" element={<ProcessingPage />} />
17
  <Route path="/review-setup" element={<ReviewSetupPage />} />
18
  <Route path="/report-viewer" element={<ReportViewerPage />} />
19
+ <Route path="/edit-report" element={<EditReportPage />} />
20
  <Route path="/edit-layouts" element={<EditLayoutsPage />} />
21
  <Route path="/export" element={<ExportPage />} />
22
  <Route path="*" element={<Navigate to="/" replace />} />
frontend/src/components/JobSheetTemplate.tsx CHANGED
@@ -1,10 +1,11 @@
1
- import type { Session } from "../types/session";
2
  import { formatDocNumber, getPhotosForPage } from "../lib/report";
3
 
4
  type JobSheetTemplateProps = {
5
  session: Session | null;
6
  pageIndex: number;
7
  pageCount: number;
 
8
  };
9
 
10
  type PhotoSlotProps = {
@@ -35,12 +36,30 @@ function PhotoSlot({ url, label }: PhotoSlotProps) {
35
  );
36
  }
37
 
38
- export function JobSheetTemplate({ session, pageIndex, pageCount }: JobSheetTemplateProps) {
39
- const inspectionDate = session?.inspection_date || "YYYY-MM-DD";
40
- const projectName = session?.project_name || "Project name";
41
- const notes =
42
- session?.notes || "Condition notes will appear here once supplied.";
43
- const docNumber = formatDocNumber(session);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  const photos = getPhotosForPage(session, pageIndex, 2);
45
 
46
  return (
@@ -54,7 +73,7 @@ export function JobSheetTemplate({ session, pageIndex, pageCount }: JobSheetTemp
54
  />
55
  <div className="text-center leading-tight">
56
  <div className="text-base font-semibold text-gray-900">
57
- Inspection Job Sheet
58
  </div>
59
  <div className="text-[11px] text-gray-500">
60
  Page {pageIndex + 1} of {pageCount}
@@ -68,61 +87,163 @@ export function JobSheetTemplate({ session, pageIndex, pageCount }: JobSheetTemp
68
  </div>
69
  </header>
70
 
71
- <section className="mb-4">
72
- <div className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
 
 
 
73
  Inspection Details
74
- </div>
75
- <dl className="grid grid-cols-2 gap-2 rounded-lg border border-gray-200 bg-gray-50 p-3">
76
- <div>
77
- <dt className="text-[10px] text-gray-500">Inspection date</dt>
78
- <dd className="text-[11px] font-semibold text-gray-900">
 
 
 
79
  {inspectionDate}
80
  </dd>
81
  </div>
82
- <div>
83
- <dt className="text-[10px] text-gray-500">Document no</dt>
84
- <dd className="text-[11px] font-semibold text-gray-900">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  {docNumber}
86
  </dd>
87
  </div>
88
- <div className="col-span-2">
89
- <dt className="text-[10px] text-gray-500">Project</dt>
90
- <dd className="text-[11px] font-semibold text-gray-900">
 
91
  {projectName}
92
  </dd>
93
  </div>
 
 
 
 
 
 
 
 
 
94
  </dl>
95
  </section>
96
 
97
- <section className="mb-4">
98
- <div className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
 
 
 
99
  Observations and Findings
100
- </div>
101
- <div className="rounded-lg border border-gray-200 bg-gray-50 p-3 space-y-2">
102
- <div className="grid grid-cols-2 gap-2">
103
- <div>
104
- <div className="text-[10px] text-gray-500">Category</div>
105
- <div className="text-[11px] font-semibold text-gray-900">
106
- Structural condition
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  </div>
108
  </div>
109
- <div>
110
- <div className="text-[10px] text-gray-500">Priority</div>
111
- <div className="text-[11px] font-semibold text-gray-900">
112
- 3 - 3 years
 
 
 
 
 
113
  </div>
114
  </div>
115
  </div>
116
- <div>
117
- <div className="text-[10px] text-gray-500">Condition notes</div>
118
- <p className="text-[11px] text-gray-700 leading-snug">
119
- {notes}
120
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  </div>
122
  </div>
123
  </section>
124
 
125
- <section className="mb-4">
126
  <div className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
127
  Photo Documentation
128
  </div>
@@ -156,7 +277,8 @@ export function JobSheetTemplate({ session, pageIndex, pageCount }: JobSheetTemp
156
  </section>
157
 
158
  <footer className="mt-4 text-center text-[10px] text-gray-500">
159
- <p>Prosento - (c) 2026 All Rights Reserved</p>
 
160
  </footer>
161
  </div>
162
  );
 
1
+ import type { Session, TemplateFields } from "../types/session";
2
  import { formatDocNumber, getPhotosForPage } from "../lib/report";
3
 
4
  type JobSheetTemplateProps = {
5
  session: Session | null;
6
  pageIndex: number;
7
  pageCount: number;
8
+ template?: TemplateFields;
9
  };
10
 
11
  type PhotoSlotProps = {
 
36
  );
37
  }
38
 
39
+ export function JobSheetTemplate({
40
+ session,
41
+ pageIndex,
42
+ pageCount,
43
+ template,
44
+ }: JobSheetTemplateProps) {
45
+ const inspectionDate =
46
+ template?.inspection_date ?? session?.inspection_date ?? "";
47
+ const inspector = template?.inspector ?? "";
48
+ const accompaniedBy = template?.accompanied_by ?? "";
49
+ const docNumber =
50
+ template?.document_no ?? (session?.id ? formatDocNumber(session) : "");
51
+ const projectName = template?.project ?? session?.project_name ?? "";
52
+ const clientSite = template?.client_site ?? "";
53
+
54
+ const reference = template?.reference ?? "";
55
+ const actionType = template?.action_type ?? "";
56
+ const itemDescription = template?.item_description ?? "";
57
+ const functionalLocation = template?.functional_location ?? "";
58
+ const category = template?.category ?? "";
59
+ const priority = template?.priority ?? "";
60
+ const conditionDescription =
61
+ template?.condition_description ?? session?.notes ?? "";
62
+ const requiredAction = template?.required_action ?? "";
63
  const photos = getPhotosForPage(session, pageIndex, 2);
64
 
65
  return (
 
73
  />
74
  <div className="text-center leading-tight">
75
  <div className="text-base font-semibold text-gray-900">
76
+ RepEx Inspection Job Sheet
77
  </div>
78
  <div className="text-[11px] text-gray-500">
79
  Page {pageIndex + 1} of {pageCount}
 
87
  </div>
88
  </header>
89
 
90
+ <section className="mb-4" aria-labelledby="inspection-details-title">
91
+ <h2
92
+ id="inspection-details-title"
93
+ className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2"
94
+ >
95
  Inspection Details
96
+ </h2>
97
+
98
+ <dl className="grid grid-cols-2 md:grid-cols-4 gap-2 bg-gray-50 border border-gray-200 rounded-lg p-3">
99
+ <div className="space-y-0.5">
100
+ <dt className="text-[10px] font-medium text-gray-500">
101
+ Inspection Date
102
+ </dt>
103
+ <dd className="template-field text-[11px] font-semibold text-gray-900">
104
  {inspectionDate}
105
  </dd>
106
  </div>
107
+
108
+ <div className="space-y-0.5">
109
+ <dt className="text-[10px] font-medium text-gray-500">Inspector</dt>
110
+ <dd className="template-field text-[11px] font-semibold text-gray-900">
111
+ {inspector}
112
+ </dd>
113
+ </div>
114
+
115
+ <div className="space-y-0.5">
116
+ <dt className="text-[10px] font-medium text-gray-500">
117
+ Accompanied By
118
+ </dt>
119
+ <dd className="template-field text-[11px] font-semibold text-gray-900">
120
+ {accompaniedBy}
121
+ </dd>
122
+ </div>
123
+
124
+ <div className="space-y-0.5">
125
+ <dt className="text-[10px] font-medium text-gray-500">Document No</dt>
126
+ <dd className="template-field text-[11px] font-mono font-semibold text-gray-900">
127
  {docNumber}
128
  </dd>
129
  </div>
130
+
131
+ <div className="space-y-0.5 md:col-span-2">
132
+ <dt className="text-[10px] font-medium text-gray-500">Project</dt>
133
+ <dd className="template-field text-[11px] font-semibold text-gray-900">
134
  {projectName}
135
  </dd>
136
  </div>
137
+
138
+ <div className="space-y-0.5 md:col-span-2">
139
+ <dt className="text-[10px] font-medium text-gray-500">
140
+ Client / Site
141
+ </dt>
142
+ <dd className="template-field text-[11px] font-semibold text-gray-900">
143
+ {clientSite}
144
+ </dd>
145
+ </div>
146
  </dl>
147
  </section>
148
 
149
+ <section className="mb-4" aria-labelledby="observations-title">
150
+ <h2
151
+ id="observations-title"
152
+ className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2"
153
+ >
154
  Observations and Findings
155
+ </h2>
156
+
157
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
158
+ <div className="space-y-2">
159
+ <div className="grid grid-cols-2 gap-2">
160
+ <div className="space-y-0.5">
161
+ <div className="text-[10px] font-medium text-gray-500">
162
+ Reference
163
+ </div>
164
+ <div className="template-field text-[11px] font-semibold text-gray-900">
165
+ {reference}
166
+ </div>
167
+ </div>
168
+
169
+ <div className="space-y-0.5">
170
+ <div className="text-[10px] font-medium text-gray-500">
171
+ Action Type
172
+ </div>
173
+ <div className="template-field text-[11px] font-semibold text-gray-900">
174
+ {actionType}
175
+ </div>
176
+ </div>
177
+
178
+ <div className="space-y-0.5 col-span-2">
179
+ <div className="text-[10px] font-medium text-gray-500">
180
+ Item Description
181
+ </div>
182
+ <div className="template-field text-[11px] font-semibold text-gray-900">
183
+ {itemDescription}
184
+ </div>
185
  </div>
186
  </div>
187
+ </div>
188
+
189
+ <div className="space-y-2">
190
+ <div className="space-y-0.5">
191
+ <div className="text-[10px] font-medium text-gray-500">
192
+ Functional Location
193
+ </div>
194
+ <div className="template-field text-[11px] font-semibold text-gray-900">
195
+ {functionalLocation}
196
  </div>
197
  </div>
198
  </div>
199
+
200
+ <div className="md:col-span-2 flex justify-center">
201
+ <div className="inline-flex items-center gap-10">
202
+ <div className="text-center space-y-1">
203
+ <div className="text-[10px] font-medium text-gray-500">
204
+ Category
205
+ </div>
206
+ <span className="template-field inline-flex items-center justify-center rounded-md border border-gray-200 px-4 py-1 text-[11px] font-semibold text-gray-900 min-w-[96px]">
207
+ {category}
208
+ </span>
209
+ </div>
210
+
211
+ <div className="text-center space-y-1">
212
+ <div className="text-[10px] font-medium text-gray-500">
213
+ Priority
214
+ </div>
215
+ <span className="template-field inline-flex items-center justify-center rounded-md border border-gray-200 px-4 py-1 text-[11px] font-semibold text-gray-900 min-w-[96px]">
216
+ {priority}
217
+ </span>
218
+ </div>
219
+ </div>
220
+ </div>
221
+
222
+ <div className="md:col-span-2 space-y-1">
223
+ <div className="text-[10px] font-medium text-gray-500">
224
+ Condition Description
225
+ </div>
226
+ <div className="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
227
+ <p className="template-field template-field-multiline text-amber-800 text-[11px] font-semibold leading-snug">
228
+ {conditionDescription}
229
+ </p>
230
+ </div>
231
+ </div>
232
+
233
+ <div className="md:col-span-2 space-y-1">
234
+ <div className="text-[10px] font-medium text-gray-500">
235
+ Required Action
236
+ </div>
237
+ <div className="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
238
+ <p className="template-field template-field-multiline text-blue-800 text-[11px] font-semibold leading-snug">
239
+ {requiredAction}
240
+ </p>
241
+ </div>
242
  </div>
243
  </div>
244
  </section>
245
 
246
+ <section className="mb-4 avoid-break">
247
  <div className="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
248
  Photo Documentation
249
  </div>
 
277
  </section>
278
 
279
  <footer className="mt-4 text-center text-[10px] text-gray-500">
280
+ <p>RepEx - (c) 2026 All Rights Reserved</p>
281
+ <p className="mt-0.5">Generated by RepEx</p>
282
  </footer>
283
  </div>
284
  );
frontend/src/components/ReportPageCanvas.tsx CHANGED
@@ -1,7 +1,7 @@
1
  import type { CSSProperties } from "react";
2
 
3
- import type { Page, Session } from "../types/session";
4
- import { BASE_W } from "../lib/report";
5
  import { JobSheetTemplate } from "./JobSheetTemplate";
6
 
7
  type ReportPageCanvasProps = {
@@ -10,6 +10,7 @@ type ReportPageCanvasProps = {
10
  pageIndex: number;
11
  pageCount: number;
12
  scale: number;
 
13
  className?: string;
14
  };
15
 
@@ -19,6 +20,7 @@ export function ReportPageCanvas({
19
  pageIndex,
20
  pageCount,
21
  scale,
 
22
  className = "",
23
  }: ReportPageCanvasProps) {
24
  const items = page?.items ?? [];
@@ -39,6 +41,7 @@ export function ReportPageCanvas({
39
  session={session}
40
  pageIndex={pageIndex}
41
  pageCount={pageCount}
 
42
  />
43
  </div>
44
  </div>
 
1
  import type { CSSProperties } from "react";
2
 
3
+ import type { Page, Session, TemplateFields } from "../types/session";
4
+ import { BASE_H, BASE_W } from "../lib/report";
5
  import { JobSheetTemplate } from "./JobSheetTemplate";
6
 
7
  type ReportPageCanvasProps = {
 
10
  pageIndex: number;
11
  pageCount: number;
12
  scale: number;
13
+ template?: TemplateFields;
14
  className?: string;
15
  };
16
 
 
20
  pageIndex,
21
  pageCount,
22
  scale,
23
+ template,
24
  className = "",
25
  }: ReportPageCanvasProps) {
26
  const items = page?.items ?? [];
 
41
  session={session}
42
  pageIndex={pageIndex}
43
  pageCount={pageCount}
44
+ template={template}
45
  />
46
  </div>
47
  </div>
frontend/src/components/report-editor.js CHANGED
@@ -4,6 +4,7 @@ class ReportEditor extends HTMLElement {
4
  constructor() {
5
  super();
6
  this._mounted = false;
 
7
 
8
  this.BASE_W = 595; // A4 points-ish (screen independent model)
9
  this.BASE_H = 842;
@@ -32,11 +33,21 @@ class ReportEditor extends HTMLElement {
32
  this._mounted = true;
33
  this.render();
34
  this.bind();
 
35
  this.hide();
36
  }
37
 
38
  // Public API
39
- open({ payload, pageIndex = 0, totalPages = 6, sessionId = null, apiBase = null } = {}) {
 
 
 
 
 
 
 
 
 
40
  this.state.payload = payload ?? null;
41
  this.state.isOpen = true;
42
  this.sessionId =
@@ -95,10 +106,10 @@ class ReportEditor extends HTMLElement {
95
  render() {
96
  this.innerHTML = `
97
  <div class="fixed inset-0 z-50 hidden" data-overlay>
98
- <div class="absolute inset-0 bg-black/30"></div>
99
 
100
- <div class="relative h-full w-full flex items-center justify-center p-4">
101
- <div class="w-full max-w-6xl bg-white rounded-xl shadow-sm ring-1 ring-gray-200 overflow-hidden">
102
  <!-- Header -->
103
  <div class="flex items-center justify-between px-4 py-3 border-b border-gray-200">
104
  <div class="flex items-center gap-2">
@@ -446,6 +457,7 @@ class ReportEditor extends HTMLElement {
446
  show() {
447
  this.$overlay.classList.remove("hidden");
448
  this.state.isOpen = true;
 
449
  this.updateAll();
450
  }
451
 
@@ -503,6 +515,38 @@ class ReportEditor extends HTMLElement {
503
  }
504
  }
505
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  _escape(value) {
507
  return String(value || "")
508
  .replace(/&/g, "&amp;")
@@ -540,18 +584,36 @@ class ReportEditor extends HTMLElement {
540
  `;
541
  }
542
 
 
 
 
 
 
 
 
543
  _templateMarkup() {
544
  const session = this.state.payload || {};
545
- const inspectionDate = this._escape(session.inspection_date || "YYYY-MM-DD");
546
- const projectName = this._escape(session.project_name || "Project name");
547
- const notes = this._escape(
548
- session.notes || "Condition notes will appear here once supplied."
549
- );
550
- const docNumber = this._escape(
551
- session.id
552
- ? `REP-${String(session.id).slice(0, 8).toUpperCase()}`
553
- : "REP-00000000"
554
- );
 
 
 
 
 
 
 
 
 
 
 
555
 
556
  const photos = this._selectedPhotos(session);
557
  const start = this.state.activePage * 2;
@@ -562,63 +624,128 @@ class ReportEditor extends HTMLElement {
562
 
563
  return `
564
  <div class="w-full h-full p-5 text-[11px] text-gray-700">
565
- <header class="mb-4 border-b border-gray-200 pb-2">
566
  <div class="grid grid-cols-[auto,1fr,auto] items-center gap-3">
567
- <img src="/assets/prosento-logo.png" alt="Prosento logo" class="h-9 w-auto object-contain" />
 
 
 
568
  <div class="text-center leading-tight">
569
- <div class="text-base font-semibold text-gray-900">Inspection Job Sheet</div>
570
- <div class="text-[11px] text-gray-500">Page ${pageNum} of ${pageCount}</div>
 
 
 
 
571
  </div>
572
- <img src="/assets/client-logo.png" alt="Client logo" class="h-9 w-auto object-contain" />
573
  </div>
574
  </header>
575
 
576
- <section class="mb-4">
577
- <div class="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
578
  Inspection Details
579
- </div>
580
- <dl class="grid grid-cols-2 gap-2 rounded-lg border border-gray-200 bg-gray-50 p-3">
581
- <div>
582
- <dt class="text-[10px] text-gray-500">Inspection date</dt>
583
- <dd class="text-[11px] font-semibold text-gray-900">${inspectionDate}</dd>
 
584
  </div>
585
- <div>
586
- <dt class="text-[10px] text-gray-500">Document no</dt>
587
- <dd class="text-[11px] font-semibold text-gray-900">${docNumber}</dd>
 
 
 
 
 
 
 
 
 
 
 
588
  </div>
589
- <div class="col-span-2">
590
- <dt class="text-[10px] text-gray-500">Project</dt>
591
- <dd class="text-[11px] font-semibold text-gray-900">${projectName}</dd>
 
 
 
 
 
 
592
  </div>
593
  </dl>
594
  </section>
595
 
596
- <section class="mb-4">
597
- <div class="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
598
  Observations and Findings
599
- </div>
600
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3 space-y-2">
601
- <div class="grid grid-cols-2 gap-2">
602
- <div>
603
- <div class="text-[10px] text-gray-500">Category</div>
604
- <div class="text-[11px] font-semibold text-gray-900">Structural condition</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
605
  </div>
606
- <div>
607
- <div class="text-[10px] text-gray-500">Priority</div>
608
- <div class="text-[11px] font-semibold text-gray-900">3 - 3 years</div>
 
 
 
609
  </div>
610
  </div>
611
- <div>
612
- <div class="text-[10px] text-gray-500">Condition notes</div>
613
- <p class="text-[11px] text-gray-700 leading-snug">${notes}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
614
  </div>
615
  </div>
616
  </section>
617
 
618
- <section class="mb-4">
619
- <div class="text-[11px] font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
620
  Photo Documentation
621
- </div>
 
622
  <div class="grid grid-cols-2 gap-3">
623
  ${this._photoSlot(photoA, "Figure 1")}
624
  ${this._photoSlot(photoB, "Figure 2")}
@@ -642,8 +769,9 @@ class ReportEditor extends HTMLElement {
642
  </div>
643
  </section>
644
 
645
- <footer class="mt-4 text-center text-[10px] text-gray-500">
646
- <p>Prosento - (c) 2026 All Rights Reserved</p>
 
647
  </footer>
648
  </div>
649
  `;
@@ -832,13 +960,14 @@ class ReportEditor extends HTMLElement {
832
  const scale = this._canvasScale() || 1;
833
 
834
  const template = document.createElement("div");
835
- template.className = "absolute inset-0 pointer-events-none";
836
  template.innerHTML = `
837
  <div style="width:${this.BASE_W}px; height:${this.BASE_H}px; transform: scale(${scale}); transform-origin: top left;">
838
  ${this._templateMarkup()}
839
  </div>
840
  `;
841
  this.$canvas.appendChild(template);
 
842
 
843
  const items = this.activePage.items;
844
  const selectedId = this.state.selectedId;
 
4
  constructor() {
5
  super();
6
  this._mounted = false;
7
+ this.mode = "overlay";
8
 
9
  this.BASE_W = 595; // A4 points-ish (screen independent model)
10
  this.BASE_H = 842;
 
33
  this._mounted = true;
34
  this.render();
35
  this.bind();
36
+ this.setAttribute("data-mode", this.mode);
37
  this.hide();
38
  }
39
 
40
  // Public API
41
+ open({
42
+ payload,
43
+ pageIndex = 0,
44
+ totalPages = 6,
45
+ sessionId = null,
46
+ apiBase = null,
47
+ mode = "overlay",
48
+ } = {}) {
49
+ this.mode = mode === "page" ? "page" : "overlay";
50
+ this.setAttribute("data-mode", this.mode);
51
  this.state.payload = payload ?? null;
52
  this.state.isOpen = true;
53
  this.sessionId =
 
106
  render() {
107
  this.innerHTML = `
108
  <div class="fixed inset-0 z-50 hidden" data-overlay>
109
+ <div class="absolute inset-0 bg-black/30" data-backdrop></div>
110
 
111
+ <div class="relative h-full w-full flex items-center justify-center p-4" data-shell-wrap>
112
+ <div class="w-full max-w-6xl bg-white rounded-xl shadow-sm ring-1 ring-gray-200 overflow-hidden" data-shell>
113
  <!-- Header -->
114
  <div class="flex items-center justify-between px-4 py-3 border-b border-gray-200">
115
  <div class="flex items-center gap-2">
 
457
  show() {
458
  this.$overlay.classList.remove("hidden");
459
  this.state.isOpen = true;
460
+ this.setAttribute("data-mode", this.mode);
461
  this.updateAll();
462
  }
463
 
 
515
  }
516
  }
517
 
518
+ _getTemplate() {
519
+ if (!this.state.pages.length) return {};
520
+ const first = this.state.pages[0];
521
+ if (!first.template) first.template = {};
522
+ return first.template;
523
+ }
524
+
525
+ _bindTemplateFields() {
526
+ if (!this.$canvas) return;
527
+ const template = this._getTemplate();
528
+ this.$canvas.querySelectorAll("[data-template-field]").forEach((el) => {
529
+ const key = el.dataset.templateField;
530
+ if (!key) return;
531
+ const value = template[key] || "";
532
+ if (document.activeElement !== el && el.textContent !== value) {
533
+ el.textContent = value;
534
+ }
535
+ el.oninput = () => {
536
+ template[key] = el.textContent || "";
537
+ this._savePages();
538
+ };
539
+ el.onpointerdown = (e) => {
540
+ e.stopPropagation();
541
+ };
542
+ el.onkeydown = (e) => {
543
+ if (e.key === "Enter" && el.dataset.multiline !== "true") {
544
+ e.preventDefault();
545
+ }
546
+ };
547
+ });
548
+ }
549
+
550
  _escape(value) {
551
  return String(value || "")
552
  .replace(/&/g, "&amp;")
 
584
  `;
585
  }
586
 
587
+ _tplField(key, value, placeholder, className = "", multiline = false) {
588
+ const safeValue = this._escape(value || "");
589
+ const safePlaceholder = this._escape(placeholder || "");
590
+ const multiAttr = multiline ? ' data-multiline="true"' : "";
591
+ return `<div class="template-field ${className}" data-template-field="${key}" contenteditable="true" data-placeholder="${safePlaceholder}"${multiAttr}>${safeValue}</div>`;
592
+ }
593
+
594
  _templateMarkup() {
595
  const session = this.state.payload || {};
596
+ const template = this._getTemplate();
597
+
598
+ const inspectionDate =
599
+ template.inspection_date || session.inspection_date || "";
600
+ const inspector = template.inspector || "";
601
+ const accompaniedBy = template.accompanied_by || "";
602
+ const docNumber =
603
+ template.document_no ||
604
+ (session.id ? `REP-${String(session.id).slice(0, 8).toUpperCase()}` : "");
605
+ const projectName = template.project || session.project_name || "";
606
+ const clientSite = template.client_site || "";
607
+
608
+ const reference = template.reference || "";
609
+ const actionType = template.action_type || "";
610
+ const itemDescription = template.item_description || "";
611
+ const functionalLocation = template.functional_location || "";
612
+ const category = template.category || "";
613
+ const priority = template.priority || "";
614
+ const conditionDescription =
615
+ template.condition_description || session.notes || "";
616
+ const requiredAction = template.required_action || "";
617
 
618
  const photos = this._selectedPhotos(session);
619
  const start = this.state.activePage * 2;
 
624
 
625
  return `
626
  <div class="w-full h-full p-5 text-[11px] text-gray-700">
627
+ <header class="mb-4 border-b border-gray-200 pb-3">
628
  <div class="grid grid-cols-[auto,1fr,auto] items-center gap-3">
629
+ <div class="flex items-center">
630
+ <img src="/assets/prosento-logo.png" alt="Prosento logo" class="h-10 w-auto object-contain" />
631
+ </div>
632
+
633
  <div class="text-center leading-tight">
634
+ <h1 class="text-2xl font-bold text-gray-900 whitespace-nowrap">RepEx Inspection Job Sheet</h1>
635
+ <p class="text-sm text-gray-600 whitespace-nowrap">Report Express - Inspection Management</p>
636
+ </div>
637
+
638
+ <div class="flex items-center justify-end">
639
+ <img src="/assets/client-logo.png" alt="Client logo" class="h-10 w-auto object-contain" />
640
  </div>
 
641
  </div>
642
  </header>
643
 
644
+ <section class="mb-4" aria-labelledby="inspection-details-title">
645
+ <h2 id="inspection-details-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
646
  Inspection Details
647
+ </h2>
648
+
649
+ <dl class="grid grid-cols-2 md:grid-cols-4 gap-2 bg-gray-50 border border-gray-200 rounded-lg p-3">
650
+ <div class="space-y-0.5">
651
+ <dt class="text-xs font-medium text-gray-500">Inspection Date</dt>
652
+ ${this._tplField("inspection_date", inspectionDate, "YYYY-MM-DD", "text-sm font-semibold text-gray-900")}
653
  </div>
654
+
655
+ <div class="space-y-0.5">
656
+ <dt class="text-xs font-medium text-gray-500">Inspector</dt>
657
+ ${this._tplField("inspector", inspector, "Inspector name", "text-sm font-semibold text-gray-900")}
658
+ </div>
659
+
660
+ <div class="space-y-0.5">
661
+ <dt class="text-xs font-medium text-gray-500">Accompanied By</dt>
662
+ ${this._tplField("accompanied_by", accompaniedBy, "Accompanied by", "text-sm font-semibold text-gray-900")}
663
+ </div>
664
+
665
+ <div class="space-y-0.5">
666
+ <dt class="text-xs font-medium text-gray-500">Document No</dt>
667
+ ${this._tplField("document_no", docNumber, "Document no", "text-sm font-mono font-semibold text-gray-900")}
668
  </div>
669
+
670
+ <div class="space-y-0.5 md:col-span-2">
671
+ <dt class="text-xs font-medium text-gray-500">Project</dt>
672
+ ${this._tplField("project", projectName, "Project name", "text-sm font-semibold text-gray-900")}
673
+ </div>
674
+
675
+ <div class="space-y-0.5 md:col-span-2">
676
+ <dt class="text-xs font-medium text-gray-500">Client / Site</dt>
677
+ ${this._tplField("client_site", clientSite, "Client or site", "text-sm font-semibold text-gray-900")}
678
  </div>
679
  </dl>
680
  </section>
681
 
682
+ <section class="mb-4" aria-labelledby="observations-title">
683
+ <h2 id="observations-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
684
  Observations and Findings
685
+ </h2>
686
+
687
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
688
+ <div class="space-y-2">
689
+ <div class="grid grid-cols-2 gap-2">
690
+ <div class="space-y-0.5">
691
+ <div class="text-xs font-medium text-gray-500">Reference</div>
692
+ ${this._tplField("reference", reference, "Reference", "text-sm font-semibold text-gray-900")}
693
+ </div>
694
+
695
+ <div class="space-y-0.5">
696
+ <div class="text-xs font-medium text-gray-500">Action Type</div>
697
+ ${this._tplField("action_type", actionType, "Action type", "text-sm font-semibold text-gray-900")}
698
+ </div>
699
+
700
+ <div class="space-y-0.5 col-span-2">
701
+ <div class="text-xs font-medium text-gray-500">Item Description</div>
702
+ ${this._tplField("item_description", itemDescription, "Item description", "text-sm font-semibold text-gray-900")}
703
+ </div>
704
  </div>
705
+ </div>
706
+
707
+ <div class="space-y-2">
708
+ <div class="space-y-0.5">
709
+ <div class="text-xs font-medium text-gray-500">Functional Location</div>
710
+ ${this._tplField("functional_location", functionalLocation, "Functional location", "text-sm font-semibold text-gray-900")}
711
  </div>
712
  </div>
713
+
714
+ <div class="md:col-span-2 flex justify-center">
715
+ <div class="inline-flex items-center gap-10">
716
+ <div class="text-center space-y-1">
717
+ <div class="text-xs font-medium text-gray-500">Category</div>
718
+ ${this._tplField("category", category, "Category", "inline-flex items-center justify-center rounded-md border border-gray-200 px-4 py-1 text-sm font-semibold text-gray-900 min-w-[96px]")}
719
+ </div>
720
+
721
+ <div class="text-center space-y-1">
722
+ <div class="text-xs font-medium text-gray-500">Priority</div>
723
+ ${this._tplField("priority", priority, "Priority", "inline-flex items-center justify-center rounded-md border border-gray-200 px-4 py-1 text-sm font-semibold text-gray-900 min-w-[96px]")}
724
+ </div>
725
+ </div>
726
+ </div>
727
+
728
+ <div class="md:col-span-2 space-y-1">
729
+ <div class="text-xs font-medium text-gray-500">Condition Description</div>
730
+ <div class="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
731
+ ${this._tplField("condition_description", conditionDescription, "Condition description", "template-field-multiline text-amber-800 text-sm font-semibold leading-snug", true)}
732
+ </div>
733
+ </div>
734
+
735
+ <div class="md:col-span-2 space-y-1">
736
+ <div class="text-xs font-medium text-gray-500">Required Action</div>
737
+ <div class="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
738
+ ${this._tplField("required_action", requiredAction, "Required action", "template-field-multiline text-blue-800 text-sm font-semibold leading-snug", true)}
739
+ </div>
740
  </div>
741
  </div>
742
  </section>
743
 
744
+ <section class="mb-4 avoid-break" aria-labelledby="photo-doc-title">
745
+ <h2 id="photo-doc-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
746
  Photo Documentation
747
+ </h2>
748
+
749
  <div class="grid grid-cols-2 gap-3">
750
  ${this._photoSlot(photoA, "Figure 1")}
751
  ${this._photoSlot(photoB, "Figure 2")}
 
769
  </div>
770
  </section>
771
 
772
+ <footer class="mt-4 text-center text-[11px] text-gray-500">
773
+ <p>RepEx - (c) 2026 All Rights Reserved</p>
774
+ <p class="mt-0.5">Generated by RepEx</p>
775
  </footer>
776
  </div>
777
  `;
 
960
  const scale = this._canvasScale() || 1;
961
 
962
  const template = document.createElement("div");
963
+ template.className = "absolute inset-0";
964
  template.innerHTML = `
965
  <div style="width:${this.BASE_W}px; height:${this.BASE_H}px; transform: scale(${scale}); transform-origin: top left;">
966
  ${this._templateMarkup()}
967
  </div>
968
  `;
969
  this.$canvas.appendChild(template);
970
+ this._bindTemplateFields();
971
 
972
  const items = this.activePage.items;
973
  const selectedId = this.state.selectedId;
frontend/src/index.css CHANGED
@@ -15,6 +15,53 @@ body {
15
  @apply bg-gray-50 text-gray-900;
16
  }
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  @media print {
19
  @page {
20
  size: A4;
 
15
  @apply bg-gray-50 text-gray-900;
16
  }
17
 
18
+ .template-field {
19
+ display: block;
20
+ min-height: 1.1em;
21
+ border-bottom: 1px dotted #d1d5db;
22
+ padding-bottom: 1px;
23
+ }
24
+
25
+ .template-field-multiline {
26
+ min-height: 2.4em;
27
+ white-space: pre-wrap;
28
+ }
29
+
30
+ .template-field[contenteditable="true"]:empty:before {
31
+ content: attr(data-placeholder);
32
+ color: #9ca3af;
33
+ }
34
+
35
+ .avoid-break {
36
+ break-inside: avoid;
37
+ page-break-inside: avoid;
38
+ }
39
+
40
+ report-editor[data-mode="page"] [data-overlay] {
41
+ position: static;
42
+ inset: auto;
43
+ width: 100%;
44
+ height: auto;
45
+ z-index: auto;
46
+ }
47
+
48
+ report-editor[data-mode="page"] [data-backdrop] {
49
+ display: none;
50
+ }
51
+
52
+ report-editor[data-mode="page"] [data-shell-wrap] {
53
+ position: static;
54
+ height: auto;
55
+ width: 100%;
56
+ padding: 0;
57
+ }
58
+
59
+ report-editor[data-mode="page"] [data-shell] {
60
+ max-width: none;
61
+ border-radius: 0;
62
+ box-shadow: none;
63
+ }
64
+
65
  @media print {
66
  @page {
67
  size: A4;
frontend/src/pages/EditLayoutsPage.tsx CHANGED
@@ -1,6 +1,16 @@
1
  import { useEffect, useMemo, useState } from "react";
2
  import { Link, useSearchParams } from "react-router-dom";
3
- import { ArrowLeft, ChevronDown, ChevronUp, Plus, Trash2 } from "react-feather";
 
 
 
 
 
 
 
 
 
 
4
 
5
  import { putJson, request } from "../lib/api";
6
  import { BASE_W } from "../lib/report";
@@ -49,6 +59,7 @@ export default function EditLayoutsPage() {
49
  const totalPages = useMemo(() => Math.max(1, pages.length), [pages.length]);
50
  const previewWidth = 220;
51
  const previewScale = previewWidth / BASE_W;
 
52
 
53
  async function savePages(next: Page[]) {
54
  if (!sessionId) return;
@@ -106,6 +117,39 @@ export default function EditLayoutsPage() {
106
  }
107
  />
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  <section className="mb-6 rounded-lg border border-gray-200 bg-white p-4">
110
  <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
111
  <div>
@@ -185,6 +229,7 @@ export default function EditLayoutsPage() {
185
  pageIndex={index}
186
  pageCount={totalPages}
187
  scale={previewScale}
 
188
  />
189
  </div>
190
  </div>
 
1
  import { useEffect, useMemo, useState } from "react";
2
  import { Link, useSearchParams } from "react-router-dom";
3
+ import {
4
+ ArrowLeft,
5
+ ChevronDown,
6
+ ChevronUp,
7
+ Download,
8
+ Edit3,
9
+ Grid,
10
+ Layout,
11
+ Plus,
12
+ Trash2,
13
+ } from "react-feather";
14
 
15
  import { putJson, request } from "../lib/api";
16
  import { BASE_W } from "../lib/report";
 
59
  const totalPages = useMemo(() => Math.max(1, pages.length), [pages.length]);
60
  const previewWidth = 220;
61
  const previewScale = previewWidth / BASE_W;
62
+ const template = pages[0]?.template;
63
 
64
  async function savePages(next: Page[]) {
65
  if (!sessionId) return;
 
117
  }
118
  />
119
 
120
+ <nav className="mb-6" aria-label="Report workflow navigation">
121
+ <div className="flex flex-wrap gap-2">
122
+ <Link
123
+ to={`/report-viewer${sessionQuery}`}
124
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
125
+ >
126
+ <Layout className="h-4 w-4" />
127
+ Report Viewer
128
+ </Link>
129
+
130
+ <Link
131
+ to={`/edit-report${sessionQuery}`}
132
+ 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"
133
+ >
134
+ <Edit3 className="h-4 w-4" />
135
+ Edit Report
136
+ </Link>
137
+
138
+ <span className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-900 px-4 py-2 text-sm font-semibold text-white">
139
+ <Grid className="h-4 w-4" />
140
+ Edit Page Layouts
141
+ </span>
142
+
143
+ <Link
144
+ to={`/export${sessionQuery}`}
145
+ 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"
146
+ >
147
+ <Download className="h-4 w-4" />
148
+ Export
149
+ </Link>
150
+ </div>
151
+ </nav>
152
+
153
  <section className="mb-6 rounded-lg border border-gray-200 bg-white p-4">
154
  <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
155
  <div>
 
229
  pageIndex={index}
230
  pageCount={totalPages}
231
  scale={previewScale}
232
+ template={template}
233
  />
234
  </div>
235
  </div>
frontend/src/pages/EditReportPage.tsx ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { Link, useNavigate, useSearchParams } from "react-router-dom";
3
+ import { ArrowLeft, Download, Edit3, Grid, Layout } from "react-feather";
4
+
5
+ import { API_BASE, request } from "../lib/api";
6
+ import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
7
+ import type { Session } from "../types/session";
8
+ import { PageFooter } from "../components/PageFooter";
9
+ import { PageHeader } from "../components/PageHeader";
10
+ import { PageShell } from "../components/PageShell";
11
+
12
+ export default function EditReportPage() {
13
+ const [searchParams] = useSearchParams();
14
+ const sessionId = getSessionId(searchParams.toString());
15
+ const sessionQuery = buildSessionQuery(sessionId);
16
+ const navigate = useNavigate();
17
+
18
+ const [session, setSession] = useState<Session | null>(null);
19
+ const [error, setError] = useState("");
20
+
21
+ const editorRef = useRef<ReportEditorElement | null>(null);
22
+
23
+ const pageIndex = useMemo(() => {
24
+ const raw = Number(searchParams.get("page") || "1");
25
+ if (!Number.isFinite(raw) || raw <= 0) return 0;
26
+ return Math.max(0, Math.floor(raw) - 1);
27
+ }, [searchParams]);
28
+
29
+ useEffect(() => {
30
+ if (!sessionId) {
31
+ setError("No active session found. Return to upload to continue.");
32
+ return;
33
+ }
34
+ setStoredSessionId(sessionId);
35
+ async function load() {
36
+ try {
37
+ const data = await request<Session>(`/sessions/${sessionId}`);
38
+ setSession(data);
39
+ } catch (err) {
40
+ const message =
41
+ err instanceof Error ? err.message : "Failed to load session.";
42
+ setError(message);
43
+ }
44
+ }
45
+ load();
46
+ }, [sessionId]);
47
+
48
+ useEffect(() => {
49
+ if (!sessionId || !session || !editorRef.current) return;
50
+ const totalPages = Math.max(1, session.page_count ?? 1);
51
+ editorRef.current.open({
52
+ payload: session,
53
+ pageIndex,
54
+ totalPages,
55
+ sessionId,
56
+ apiBase: API_BASE,
57
+ mode: "page",
58
+ });
59
+ }, [sessionId, session, pageIndex]);
60
+
61
+ useEffect(() => {
62
+ const editor = editorRef.current;
63
+ if (!editor) return;
64
+ const handleClose = () => {
65
+ navigate(`/report-viewer${sessionQuery}`);
66
+ };
67
+ editor.addEventListener("editor-closed", handleClose);
68
+ return () => editor.removeEventListener("editor-closed", handleClose);
69
+ }, [navigate, sessionQuery]);
70
+
71
+ return (
72
+ <PageShell className="max-w-6xl">
73
+ <PageHeader
74
+ title="RepEx - Report Express"
75
+ subtitle="Edit Report"
76
+ right={
77
+ <Link
78
+ to={`/report-viewer${sessionQuery}`}
79
+ 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"
80
+ >
81
+ <ArrowLeft className="h-4 w-4" />
82
+ Back
83
+ </Link>
84
+ }
85
+ />
86
+
87
+ <nav className="mb-6" aria-label="Report workflow navigation">
88
+ <div className="flex flex-wrap gap-2">
89
+ <Link
90
+ to={`/report-viewer${sessionQuery}`}
91
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
92
+ >
93
+ <Layout className="h-4 w-4" />
94
+ Report Viewer
95
+ </Link>
96
+
97
+ <span className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-900 px-4 py-2 text-sm font-semibold text-white">
98
+ <Edit3 className="h-4 w-4" />
99
+ Edit Report
100
+ </span>
101
+
102
+ <Link
103
+ to={`/edit-layouts${sessionQuery}`}
104
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
105
+ >
106
+ <Grid className="h-4 w-4" />
107
+ Edit Page Layouts
108
+ </Link>
109
+
110
+ <Link
111
+ to={`/export${sessionQuery}`}
112
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
113
+ >
114
+ <Download className="h-4 w-4" />
115
+ Export
116
+ </Link>
117
+ </div>
118
+ </nav>
119
+
120
+ {error ? (
121
+ <div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 mb-4">
122
+ {error}
123
+ </div>
124
+ ) : null}
125
+
126
+ {!session && !error ? (
127
+ <div className="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 mb-4">
128
+ Loading editor...
129
+ </div>
130
+ ) : null}
131
+
132
+ <report-editor ref={editorRef} data-mode="page" />
133
+
134
+ <PageFooter note={`Editing page ${pageIndex + 1}. Changes save automatically.`} />
135
+ </PageShell>
136
+ );
137
+ }
frontend/src/pages/ExportPage.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
  import { Link, useSearchParams } from "react-router-dom";
3
- import { ArrowLeft, Download, Grid, Layout } from "react-feather";
4
 
5
  import { API_BASE, request } from "../lib/api";
6
  import { BASE_W } from "../lib/report";
@@ -80,6 +80,7 @@ export default function ExportPage() {
80
  }, [pages, session]);
81
 
82
  const totalPages = Math.max(1, pages.length, session?.page_count ?? 0);
 
83
  const serverExportUrl = sessionId
84
  ? `${API_BASE}/sessions/${sessionId}/export`
85
  : "";
@@ -138,6 +139,14 @@ export default function ExportPage() {
138
  Report Viewer
139
  </Link>
140
 
 
 
 
 
 
 
 
 
141
  <Link
142
  to={`/edit-layouts${sessionQuery}`}
143
  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"
@@ -331,6 +340,7 @@ export default function ExportPage() {
331
  pageIndex={index}
332
  pageCount={totalPages}
333
  scale={previewScale}
 
334
  />
335
  </div>
336
  </div>
 
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
  import { Link, useSearchParams } from "react-router-dom";
3
+ import { ArrowLeft, Download, Edit3, Grid, Layout } from "react-feather";
4
 
5
  import { API_BASE, request } from "../lib/api";
6
  import { BASE_W } from "../lib/report";
 
80
  }, [pages, session]);
81
 
82
  const totalPages = Math.max(1, pages.length, session?.page_count ?? 0);
83
+ const template = pages[0]?.template;
84
  const serverExportUrl = sessionId
85
  ? `${API_BASE}/sessions/${sessionId}/export`
86
  : "";
 
139
  Report Viewer
140
  </Link>
141
 
142
+ <Link
143
+ to={`/edit-report${sessionQuery}`}
144
+ className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
145
+ >
146
+ <Edit3 className="h-4 w-4" />
147
+ Edit Report
148
+ </Link>
149
+
150
  <Link
151
  to={`/edit-layouts${sessionQuery}`}
152
  className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
 
340
  pageIndex={index}
341
  pageCount={totalPages}
342
  scale={previewScale}
343
+ template={template}
344
  />
345
  </div>
346
  </div>
frontend/src/pages/ReportViewerPage.tsx CHANGED
@@ -10,7 +10,7 @@ import {
10
  Download,
11
  } from "react-feather";
12
 
13
- import { API_BASE, request } from "../lib/api";
14
  import { BASE_W } from "../lib/report";
15
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
16
  import type { Page, Session } from "../types/session";
@@ -24,11 +24,9 @@ export default function ReportViewerPage() {
24
  const [pages, setPages] = useState<Page[]>([]);
25
  const [pageIndex, setPageIndex] = useState(0);
26
  const [scale, setScale] = useState(1);
27
- const [editMode, setEditMode] = useState(false);
28
  const [error, setError] = useState("");
29
 
30
  const stageRef = useRef<HTMLDivElement | null>(null);
31
- const editorRef = useRef<ReportEditorElement | null>(null);
32
 
33
  useEffect(() => {
34
  if (!sessionId) {
@@ -79,7 +77,6 @@ export default function ReportViewerPage() {
79
 
80
  useEffect(() => {
81
  const handler = (event: KeyboardEvent) => {
82
- if (editMode) return;
83
  if (event.key === "ArrowRight") {
84
  setPageIndex((idx) => Math.min(totalPages - 1, idx + 1));
85
  }
@@ -89,28 +86,18 @@ export default function ReportViewerPage() {
89
  };
90
  window.addEventListener("keydown", handler);
91
  return () => window.removeEventListener("keydown", handler);
92
- }, [editMode, totalPages]);
93
-
94
- useEffect(() => {
95
- const editor = editorRef.current;
96
- if (!editor) return;
97
- const closeHandler = () => {
98
- setEditMode(false);
99
- if (sessionId) {
100
- request<{ pages: Page[] }>(`/sessions/${sessionId}/pages`)
101
- .then((resp) => {
102
- const loaded = Array.isArray(resp.pages) ? resp.pages : [];
103
- setPages(loaded);
104
- })
105
- .catch(() => {});
106
- }
107
- };
108
- editor.addEventListener("editor-closed", closeHandler);
109
- return () => editor.removeEventListener("editor-closed", closeHandler);
110
- }, [sessionId]);
111
 
112
  const page = pages[pageIndex] ?? null;
 
113
  const sessionQuery = buildSessionQuery(sessionId || "");
 
 
 
 
 
 
 
114
 
115
  const viewerMeta = useMemo(() => {
116
  if (!session) return "Loading...";
@@ -124,18 +111,6 @@ export default function ReportViewerPage() {
124
  );
125
  }, [pages.length, session]);
126
 
127
- function openEditor() {
128
- if (!editorRef.current || !sessionId || !session) return;
129
- setEditMode(true);
130
- editorRef.current.open({
131
- payload: session,
132
- pageIndex,
133
- totalPages,
134
- sessionId,
135
- apiBase: API_BASE,
136
- });
137
- }
138
-
139
  return (
140
  <main className="max-w-4xl mx-auto my-8 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8 print:ring-0 print:shadow-none print:rounded-none">
141
  <header className="mb-8 border-b border-gray-200 pb-4">
@@ -175,14 +150,13 @@ export default function ReportViewerPage() {
175
  Report Viewer
176
  </span>
177
 
178
- <button
179
- type="button"
180
- onClick={openEditor}
181
  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"
182
  >
183
  <Edit3 className="h-4 w-4" />
184
  Edit Report
185
- </button>
186
 
187
  <Link
188
  to={`/edit-layouts${sessionQuery}`}
@@ -205,7 +179,6 @@ export default function ReportViewerPage() {
205
  <section
206
  id="viewerSection"
207
  aria-label="Report viewer"
208
- className={editMode ? "hidden" : ""}
209
  >
210
  <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
211
  <div>
@@ -257,6 +230,7 @@ export default function ReportViewerPage() {
257
  pageIndex={pageIndex}
258
  pageCount={totalPages}
259
  scale={scale}
 
260
  />
261
  </div>
262
  </div>
@@ -272,7 +246,6 @@ export default function ReportViewerPage() {
272
  <p className="mt-1">Viewer now renders saved edits from the editor.</p>
273
  </footer>
274
 
275
- <report-editor ref={editorRef} />
276
  </main>
277
  );
278
  }
 
10
  Download,
11
  } from "react-feather";
12
 
13
+ import { request } from "../lib/api";
14
  import { BASE_W } from "../lib/report";
15
  import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
16
  import type { Page, Session } from "../types/session";
 
24
  const [pages, setPages] = useState<Page[]>([]);
25
  const [pageIndex, setPageIndex] = useState(0);
26
  const [scale, setScale] = useState(1);
 
27
  const [error, setError] = useState("");
28
 
29
  const stageRef = useRef<HTMLDivElement | null>(null);
 
30
 
31
  useEffect(() => {
32
  if (!sessionId) {
 
77
 
78
  useEffect(() => {
79
  const handler = (event: KeyboardEvent) => {
 
80
  if (event.key === "ArrowRight") {
81
  setPageIndex((idx) => Math.min(totalPages - 1, idx + 1));
82
  }
 
86
  };
87
  window.addEventListener("keydown", handler);
88
  return () => window.removeEventListener("keydown", handler);
89
+ }, [totalPages]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
  const page = pages[pageIndex] ?? null;
92
+ const template = pages[0]?.template;
93
  const sessionQuery = buildSessionQuery(sessionId || "");
94
+ const editReportQuery = useMemo(() => {
95
+ if (!sessionId) return "";
96
+ const params = new URLSearchParams();
97
+ params.set("session", sessionId);
98
+ params.set("page", String(pageIndex + 1));
99
+ return `?${params.toString()}`;
100
+ }, [sessionId, pageIndex]);
101
 
102
  const viewerMeta = useMemo(() => {
103
  if (!session) return "Loading...";
 
111
  );
112
  }, [pages.length, session]);
113
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  return (
115
  <main className="max-w-4xl mx-auto my-8 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8 print:ring-0 print:shadow-none print:rounded-none">
116
  <header className="mb-8 border-b border-gray-200 pb-4">
 
150
  Report Viewer
151
  </span>
152
 
153
+ <Link
154
+ to={`/edit-report${editReportQuery}`}
 
155
  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"
156
  >
157
  <Edit3 className="h-4 w-4" />
158
  Edit Report
159
+ </Link>
160
 
161
  <Link
162
  to={`/edit-layouts${sessionQuery}`}
 
179
  <section
180
  id="viewerSection"
181
  aria-label="Report viewer"
 
182
  >
183
  <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
184
  <div>
 
230
  pageIndex={pageIndex}
231
  pageCount={totalPages}
232
  scale={scale}
233
+ template={template}
234
  />
235
  </div>
236
  </div>
 
246
  <p className="mt-1">Viewer now renders saved edits from the editor.</p>
247
  </footer>
248
 
 
249
  </main>
250
  );
251
  }
frontend/src/types/custom-elements.d.ts CHANGED
@@ -8,6 +8,7 @@ declare global {
8
  totalPages?: number;
9
  sessionId?: string | null;
10
  apiBase?: string | null;
 
11
  }
12
 
13
  interface ReportEditorElement extends HTMLElement {
 
8
  totalPages?: number;
9
  sessionId?: string | null;
10
  apiBase?: string | null;
11
+ mode?: "overlay" | "page";
12
  }
13
 
14
  interface ReportEditorElement extends HTMLElement {
frontend/src/types/session.ts CHANGED
@@ -59,8 +59,26 @@ export type PageItem = {
59
  style?: PageItemStyle;
60
  };
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  export type Page = {
63
  items: PageItem[];
 
64
  };
65
 
66
  export type PagesResponse = {
 
59
  style?: PageItemStyle;
60
  };
61
 
62
+ export type TemplateFields = {
63
+ inspection_date?: string;
64
+ inspector?: string;
65
+ accompanied_by?: string;
66
+ document_no?: string;
67
+ project?: string;
68
+ client_site?: string;
69
+ reference?: string;
70
+ action_type?: string;
71
+ item_description?: string;
72
+ functional_location?: string;
73
+ category?: string;
74
+ priority?: string;
75
+ condition_description?: string;
76
+ required_action?: string;
77
+ };
78
+
79
  export type Page = {
80
  items: PageItem[];
81
+ template?: TemplateFields;
82
  };
83
 
84
  export type PagesResponse = {
index.html DELETED
@@ -1,398 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width,initial-scale=1" />
6
- <title>RepEx - Report Express</title>
7
-
8
- <!-- Project styles (optional) -->
9
- <link rel="stylesheet" href="style.css" />
10
-
11
- <!-- Tailwind (CDN is fine for prototypes; for production, compile Tailwind) -->
12
- <script src="https://cdn.tailwindcss.com"></script>
13
-
14
- <!-- Feather Icons (include once) -->
15
- <script defer src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
16
-
17
- <style>
18
- img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
19
- </style>
20
- </head>
21
-
22
- <body class="bg-gray-50 min-h-screen">
23
- <!-- Page container (matches the previous site's “white card + subtle ring” style) -->
24
- <main class="max-w-4xl mx-auto my-8 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8">
25
- <!-- Top Header (logo + app name) -->
26
- <header class="mb-10 border-b border-gray-200 pb-4">
27
- <div class="grid grid-cols-[auto,1fr,auto] items-center gap-4">
28
- <!-- Logo -->
29
- <div class="flex items-center">
30
- <img
31
- src="assets/prosento-logo.png"
32
- alt="Company logo"
33
- class="h-12 w-auto object-contain"
34
- loading="eager"
35
- />
36
- </div>
37
-
38
- <!-- Title -->
39
- <div class="text-center">
40
- <h1 class="text-2xl md:text-3xl font-bold text-gray-900 whitespace-nowrap">
41
- Prosento RepEx
42
- </h1>
43
- <p class="text-gray-600 whitespace-nowrap">
44
- Upload photos and documents to generate professional job reports instantly
45
- </p>
46
- </div>
47
-
48
- <!-- Right action -->
49
- <div class="flex justify-end">
50
- <a
51
- href="#upload"
52
- class="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"
53
- >
54
- <i data-feather="arrow-down-circle" class="h-4 w-4"></i>
55
- Upload
56
- </a>
57
- </div>
58
- </div>
59
- </header>
60
-
61
- <!-- Hero CTA -->
62
- <section class="text-center mb-12">
63
- <h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-3">
64
- Generate inspection-ready reports in minutes
65
- </h2>
66
- <p class="text-base md:text-lg text-gray-600 mb-8">
67
- Drag-and-drop files, add quick context, and produce a clean, consistent report format every time.
68
- </p>
69
-
70
- <div class="flex flex-col sm:flex-row justify-center gap-3">
71
- <a
72
- href="#upload"
73
- class="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-white font-semibold hover:bg-blue-700 transition"
74
- >
75
- <i data-feather="upload" class="h-5 w-5"></i>
76
- Start Uploading
77
- </a>
78
-
79
- <a
80
- href="#how-it-works"
81
- class="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-6 py-3 text-gray-800 font-semibold hover:bg-gray-50 transition"
82
- >
83
- <i data-feather="info" class="h-5 w-5"></i>
84
- Learn More
85
- </a>
86
- </div>
87
- </section>
88
-
89
- <!-- How it works -->
90
- <section id="how-it-works" class="mb-12">
91
- <h2 class="text-xl md:text-2xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-6">
92
- How It Works
93
- </h2>
94
-
95
- <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
96
- <article class="rounded-lg border border-gray-200 bg-gray-50 p-5">
97
- <div class="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-blue-50 border border-blue-100">
98
- <i data-feather="camera" class="h-5 w-5 text-blue-700"></i>
99
- </div>
100
- <h3 class="text-base font-semibold text-gray-900 mb-1">Capture site photos</h3>
101
- <p class="text-sm text-gray-600">
102
- Take clear photos of structural elements and issues during your inspection.
103
- </p>
104
- </article>
105
-
106
- <article class="rounded-lg border border-gray-200 bg-gray-50 p-5">
107
- <div class="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-emerald-50 border border-emerald-100">
108
- <i data-feather="upload-cloud" class="h-5 w-5 text-emerald-700"></i>
109
- </div>
110
- <h3 class="text-base font-semibold text-gray-900 mb-1">Upload documents</h3>
111
- <p class="text-sm text-gray-600">
112
- Add notes, measurements, and supporting PDFs/DOCX to complete the context.
113
- </p>
114
- </article>
115
-
116
- <article class="rounded-lg border border-gray-200 bg-gray-50 p-5">
117
- <div class="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-blue-50 border border-blue-100">
118
- <i data-feather="file-text" class="h-5 w-5 text-blue-700"></i>
119
- </div>
120
- <h3 class="text-base font-semibold text-gray-900 mb-1">Generate report</h3>
121
- <p class="text-sm text-gray-600">
122
- Produce a consistent, professional report that’s ready for submission.
123
- </p>
124
- </article>
125
- </div>
126
- </section>
127
-
128
- <!-- Upload -->
129
- <section id="upload" class="mb-12">
130
- <h2 class="text-xl md:text-2xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-6">
131
- Upload Your Files
132
- </h2>
133
-
134
- <div class="rounded-lg border border-gray-200 bg-white p-6">
135
- <!-- Drop zone -->
136
- <div
137
- id="dropZone"
138
- class="rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center hover:border-blue-400 transition mb-6"
139
- role="button"
140
- tabindex="0"
141
- aria-label="Drag and drop files here"
142
- >
143
- <div class="mx-auto mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 border border-blue-100">
144
- <i data-feather="upload" class="h-5 w-5 text-blue-700"></i>
145
- </div>
146
-
147
- <h3 class="text-base font-semibold text-gray-900">Drag &amp; drop files here</h3>
148
- <p class="text-sm text-gray-600 mt-1 mb-4">or browse to upload</p>
149
-
150
- <button
151
- id="browseFiles"
152
- type="button"
153
- class="inline-flex items-center justify-center rounded-lg bg-blue-600 px-5 py-2.5 text-white font-semibold hover:bg-blue-700 transition"
154
- >
155
- Browse Files
156
- </button>
157
-
158
- <input
159
- id="uploadInput"
160
- type="file"
161
- class="hidden"
162
- multiple
163
- accept=".jpg,.jpeg,.png,.webp,.pdf,.doc,.docx,.csv,.xls,.xlsx"
164
- />
165
-
166
- <p id="fileList" class="text-xs text-gray-500 mt-4">
167
- Supports JPG, PNG, PDF, DOCX, CSV, XLSX (Max 50MB each)
168
- </p>
169
- </div>
170
-
171
- <!-- Metadata -->
172
- <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
173
- <div class="space-y-1">
174
- <label for="projectName" class="block text-sm font-medium text-gray-700">Project Name</label>
175
- <input
176
- id="projectName"
177
- name="projectName"
178
- type="text"
179
- placeholder="e.g., North Pit Conveyor Support"
180
- class="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500"
181
- />
182
- </div>
183
-
184
- <div class="space-y-1">
185
- <label for="inspectionDate" class="block text-sm font-medium text-gray-700">Inspection Date</label>
186
- <input
187
- id="inspectionDate"
188
- name="inspectionDate"
189
- type="date"
190
- class="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500"
191
- />
192
- </div>
193
- </div>
194
-
195
- <div class="space-y-1 mb-6">
196
- <label for="notes" class="block text-sm font-medium text-gray-700">Additional Notes</label>
197
- <textarea
198
- id="notes"
199
- name="notes"
200
- rows="4"
201
- placeholder="Add any context you'd like included in the report..."
202
- class="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500"
203
- ></textarea>
204
- </div>
205
-
206
- <button
207
- id="generateReport"
208
- type="button"
209
- class="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-6 py-3 text-white font-semibold hover:bg-emerald-700 transition"
210
- >
211
- <i data-feather="file-plus" class="h-5 w-5"></i>
212
- Generate Report
213
- </button>
214
- <p id="uploadStatus" class="text-sm text-gray-600 mt-3"></p>
215
- </div>
216
- </section>
217
-
218
- <!-- Recent Reports -->
219
- <section class="mb-6">
220
- <h2 class="text-xl md:text-2xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-6">
221
- Recent Reports
222
- </h2>
223
-
224
- <div class="rounded-lg border border-gray-200 bg-white overflow-hidden">
225
- <div class="overflow-x-auto">
226
- <table class="min-w-full divide-y divide-gray-200">
227
- <thead class="bg-gray-50">
228
- <tr>
229
- <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
230
- Report ID
231
- </th>
232
- <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
233
- Project
234
- </th>
235
- <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
236
- Date
237
- </th>
238
- <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
239
- Status
240
- </th>
241
- <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
242
- Actions
243
- </th>
244
- </tr>
245
- </thead>
246
-
247
- <tbody id="recentReports" class="bg-white divide-y divide-gray-200">
248
- <tr>
249
- <td colspan="5" class="px-6 py-6 text-sm text-gray-500 text-center">
250
- No recent reports yet.
251
- </td>
252
- </tr>
253
- </tbody>
254
- </table>
255
- </div>
256
- </div>
257
- </section>
258
-
259
- <!-- Footer -->
260
- <footer class="mt-12 text-center text-xs text-gray-500">
261
- <p>Prosento - © 2026 All Rights Reserved</p>
262
- <p class="mt-1">RepEx is a report automation interface. All uploads should comply with site data policies.</p>
263
- </footer>
264
- </main>
265
-
266
- <!-- Your app script (optional) -->
267
- <script src="script.js"></script>
268
-
269
- <script>
270
- document.addEventListener('DOMContentLoaded', () => {
271
- if (window.feather && typeof window.feather.replace === 'function') {
272
- window.feather.replace();
273
- }
274
-
275
- const dropZone = document.getElementById('dropZone');
276
- const browseFiles = document.getElementById('browseFiles');
277
- const uploadInput = document.getElementById('uploadInput');
278
- const fileList = document.getElementById('fileList');
279
- const generateReport = document.getElementById('generateReport');
280
- const uploadStatus = document.getElementById('uploadStatus');
281
-
282
- const projectName = document.getElementById('projectName');
283
- const inspectionDate = document.getElementById('inspectionDate');
284
- const notes = document.getElementById('notes');
285
-
286
- let selectedFiles = [];
287
-
288
- const updateFileList = () => {
289
- if (!selectedFiles.length) {
290
- fileList.textContent = 'Supports JPG, PNG, PDF, DOCX, CSV, XLSX (Max 50MB each)';
291
- return;
292
- }
293
- const summary = selectedFiles
294
- .map((file) => `${file.name} (${window.REPEX.formatBytes(file.size)})`)
295
- .join(' • ');
296
- fileList.textContent = summary;
297
- };
298
-
299
- browseFiles.addEventListener('click', () => uploadInput.click());
300
- uploadInput.addEventListener('change', (event) => {
301
- selectedFiles = Array.from(event.target.files || []);
302
- updateFileList();
303
- });
304
-
305
- const setDragState = (active) => {
306
- dropZone.classList.toggle('border-blue-500', active);
307
- dropZone.classList.toggle('bg-blue-50', active);
308
- };
309
-
310
- ['dragenter', 'dragover'].forEach((evt) => {
311
- dropZone.addEventListener(evt, (event) => {
312
- event.preventDefault();
313
- setDragState(true);
314
- });
315
- });
316
- ['dragleave', 'drop'].forEach((evt) => {
317
- dropZone.addEventListener(evt, (event) => {
318
- event.preventDefault();
319
- setDragState(false);
320
- });
321
- });
322
- dropZone.addEventListener('drop', (event) => {
323
- const files = Array.from(event.dataTransfer.files || []);
324
- if (files.length) {
325
- selectedFiles = files;
326
- uploadInput.value = '';
327
- updateFileList();
328
- }
329
- });
330
-
331
- generateReport.addEventListener('click', async () => {
332
- if (!selectedFiles.length) {
333
- uploadStatus.textContent = 'Please add at least one file to continue.';
334
- uploadStatus.classList.add('text-amber-700');
335
- return;
336
- }
337
- generateReport.disabled = true;
338
- uploadStatus.textContent = 'Uploading files...';
339
- uploadStatus.classList.remove('text-amber-700');
340
-
341
- const formData = new FormData();
342
- formData.append('project_name', projectName.value.trim());
343
- formData.append('inspection_date', inspectionDate.value);
344
- formData.append('notes', notes.value.trim());
345
- selectedFiles.forEach((file) => formData.append('files', file));
346
-
347
- try {
348
- const session = await window.REPEX.postForm('/sessions', formData);
349
- window.REPEX.setSessionId(session.id);
350
- window.location.href = `processing.html?session=${encodeURIComponent(session.id)}`;
351
- } catch (err) {
352
- uploadStatus.textContent = err.message || 'Upload failed.';
353
- uploadStatus.classList.add('text-red-600');
354
- generateReport.disabled = false;
355
- }
356
- });
357
-
358
- async function loadRecentReports() {
359
- const table = document.getElementById('recentReports');
360
- if (!table) return;
361
- try {
362
- const sessions = await window.REPEX.request('/sessions');
363
- if (!Array.isArray(sessions) || sessions.length === 0) {
364
- return;
365
- }
366
- table.innerHTML = '';
367
- sessions.slice(0, 5).forEach((session) => {
368
- const row = document.createElement('tr');
369
- row.innerHTML = `
370
- <td class="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">#${session.id.slice(0, 8)}</td>
371
- <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">${session.project_name || 'Untitled project'}</td>
372
- <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">${session.created_at ? new Date(session.created_at).toLocaleDateString() : '-'}</td>
373
- <td class="px-6 py-4 whitespace-nowrap">
374
- <span class="inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-semibold bg-emerald-50 text-emerald-700 border-emerald-200">
375
- ${session.status || 'ready'}
376
- </span>
377
- </td>
378
- <td class="px-6 py-4 whitespace-nowrap text-sm">
379
- <a href="report-viewer.html?session=${session.id}" class="inline-flex items-center text-blue-700 hover:text-blue-800" aria-label="View report">
380
- <i data-feather="edit" class="h-4 w-4"></i>
381
- </a>
382
- </td>
383
- `;
384
- table.appendChild(row);
385
- });
386
- if (window.feather && typeof window.feather.replace === 'function') {
387
- window.feather.replace();
388
- }
389
- } catch {
390
- // ignore for now
391
- }
392
- }
393
-
394
- loadRecentReports();
395
- });
396
- </script>
397
- </body>
398
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
legacy/static-site/README.md DELETED
@@ -1,19 +0,0 @@
1
- # Legacy static site (archived)
2
-
3
- This folder is a copy of the original static HTML prototype.
4
- It is no longer used by the React/Vite frontend or the FastAPI server.
5
-
6
- If you do not need the static version, you can safely delete the following
7
- from the repo root:
8
-
9
- - assets/
10
- - components/
11
- - templates/
12
- - index.html
13
- - processing.html
14
- - review-setup.html
15
- - report-viewer.html
16
- - edit-layouts.html
17
- - export.html
18
- - style.css
19
- - script.js
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
legacy/static-site/assets/assets/client-logo.png DELETED
Binary file (68 Bytes)
 
legacy/static-site/assets/assets/prosento-logo.png DELETED
Binary file (11.7 kB)
 
legacy/static-site/components/components/report-editor.js DELETED
@@ -1,1185 +0,0 @@
1
- class ReportEditor extends HTMLElement {
2
- constructor() {
3
- super();
4
- this._mounted = false;
5
-
6
- this.BASE_W = 595; // A4 points-ish (screen independent model)
7
- this.BASE_H = 842;
8
-
9
- this.state = {
10
- isOpen: false,
11
- zoom: 1,
12
- activePage: 0,
13
- pages: [], // [{ items: [...] }]
14
- selectedId: null,
15
- tool: "select", // select | text | rect
16
- dragging: null, // { id, startX, startY, origX, origY }
17
- resizing: null, // { id, handle, startX, startY, orig }
18
- undo: [], // stack of serialized states (current page)
19
- redo: [],
20
- payload: null,
21
- };
22
-
23
- this.sessionId = null;
24
- this.apiBase = null;
25
- this._saveTimer = null;
26
- }
27
-
28
- connectedCallback() {
29
- if (this._mounted) return;
30
- this._mounted = true;
31
- this.render();
32
- this.bind();
33
- this.hide();
34
- }
35
-
36
- // Public API
37
- open({ payload, pageIndex = 0, totalPages = 6, sessionId = null } = {}) {
38
- this.state.payload = payload ?? null;
39
- this.state.isOpen = true;
40
- this.sessionId =
41
- sessionId ||
42
- (window.REPEX && typeof window.REPEX.getSessionId === "function"
43
- ? window.REPEX.getSessionId()
44
- : null);
45
- this.apiBase = window.REPEX && window.REPEX.apiBase ? window.REPEX.apiBase : null;
46
-
47
- // Load existing editor pages from storage, else initialize
48
- const stored = this._loadPages();
49
- if (stored && Array.isArray(stored.pages) && stored.pages.length) {
50
- this.state.pages = stored.pages;
51
- } else {
52
- this.state.pages = Array.from({ length: totalPages }, () => ({ items: [] }));
53
- this._savePages();
54
- }
55
-
56
- this.state.activePage = Math.min(Math.max(0, pageIndex), this.state.pages.length - 1);
57
- this.state.selectedId = null;
58
- this.state.tool = "select";
59
- this.state.undo = [];
60
- this.state.redo = [];
61
-
62
- this.show();
63
- this.updateAll();
64
-
65
- if (this.sessionId) {
66
- this._loadPagesFromServer().then((pages) => {
67
- if (pages && pages.length) {
68
- this.state.pages = pages;
69
- this.state.activePage = Math.min(
70
- Math.max(0, pageIndex),
71
- this.state.pages.length - 1
72
- );
73
- this.updateAll();
74
- }
75
- });
76
- }
77
- }
78
-
79
- close() {
80
- this.state.isOpen = false;
81
- this.hide();
82
- this.dispatchEvent(new CustomEvent("editor-closed", { bubbles: true }));
83
- }
84
-
85
- // ---------- Rendering ----------
86
- render() {
87
- this.innerHTML = `
88
- <div class="fixed inset-0 z-50 hidden" data-overlay>
89
- <div class="absolute inset-0 bg-black/30"></div>
90
-
91
- <div class="relative h-full w-full flex items-center justify-center p-4">
92
- <div class="w-full max-w-6xl bg-white rounded-xl shadow-sm ring-1 ring-gray-200 overflow-hidden">
93
- <!-- Header -->
94
- <div class="flex items-center justify-between px-4 py-3 border-b border-gray-200">
95
- <div class="flex items-center gap-2">
96
- <div class="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-gray-50 border border-gray-200">
97
- <span class="text-xs font-bold text-gray-700">A4</span>
98
- </div>
99
- <div>
100
- <div class="text-sm font-semibold text-gray-900">Edit Report</div>
101
- <div class="text-xs text-gray-500">Drag, resize, format and arrange elements</div>
102
- </div>
103
- </div>
104
-
105
- <div class="flex items-center gap-2">
106
- <button data-btn="undo"
107
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed">
108
- <i data-feather="rotate-ccw" class="h-4 w-4"></i> Undo
109
- </button>
110
- <button data-btn="redo"
111
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed">
112
- <i data-feather="rotate-cw" class="h-4 w-4"></i> Redo
113
- </button>
114
-
115
- <button data-btn="save"
116
- class="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-3 py-2 text-sm font-semibold text-white hover:bg-emerald-700 transition">
117
- <i data-feather="save" class="h-4 w-4"></i> Save
118
- </button>
119
-
120
- <button data-btn="close"
121
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition">
122
- <i data-feather="x" class="h-4 w-4"></i> Done
123
- </button>
124
- </div>
125
- </div>
126
-
127
- <!-- Body -->
128
- <div class="grid grid-cols-1 lg:grid-cols-[240px,1fr,280px] gap-0 min-h-[70vh]">
129
- <!-- Pages sidebar -->
130
- <aside class="border-r border-gray-200 bg-white p-3">
131
- <div class="flex items-center justify-between mb-3">
132
- <div class="text-sm font-semibold text-gray-900">Pages</div>
133
- <button data-btn="add-page"
134
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
135
- <i data-feather="plus" class="h-4 w-4"></i> Add
136
- </button>
137
- </div>
138
-
139
- <div class="space-y-2 max-h-[60vh] overflow-auto pr-1" data-page-list></div>
140
-
141
- <div class="mt-3 text-xs text-gray-500">
142
- Tip: Click a page to edit. Your edits are saved to the server.
143
- </div>
144
- </aside>
145
-
146
- <!-- Canvas + toolbar -->
147
- <section class="bg-gray-50 p-3">
148
- <!-- Toolbar -->
149
- <div class="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 mb-3">
150
- <div class="flex flex-wrap items-center gap-2">
151
- <button data-tool="select"
152
- class="toolBtn 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">
153
- <i data-feather="mouse-pointer" class="h-4 w-4"></i> Select
154
- </button>
155
-
156
- <button data-tool="text"
157
- class="toolBtn 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">
158
- <i data-feather="type" class="h-4 w-4"></i> Text
159
- </button>
160
-
161
- <button data-tool="rect"
162
- class="toolBtn 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">
163
- <i data-feather="square" class="h-4 w-4"></i> Shape
164
- </button>
165
-
166
- <button data-btn="add-image"
167
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition">
168
- <i data-feather="image" class="h-4 w-4"></i> Image
169
- </button>
170
-
171
- <input data-file="image" type="file" accept="image/*" class="hidden" />
172
- </div>
173
-
174
- <div class="flex items-center gap-2">
175
- <div class="text-xs font-semibold text-gray-600">Zoom</div>
176
- <button data-btn="zoom-out"
177
- class="inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white p-2 hover:bg-gray-50 transition">
178
- <i data-feather="minus" class="h-4 w-4"></i>
179
- </button>
180
- <div class="text-xs font-semibold text-gray-700 w-14 text-center" data-zoom-label>100%</div>
181
- <button data-btn="zoom-in"
182
- class="inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white p-2 hover:bg-gray-50 transition">
183
- <i data-feather="plus" class="h-4 w-4"></i>
184
- </button>
185
- </div>
186
- </div>
187
-
188
- <!-- Canvas area -->
189
- <div class="flex justify-center">
190
- <div class="relative" data-canvas-wrap>
191
- <div
192
- data-canvas
193
- class="relative bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden select-none"
194
- style="width: min(100%, 700px); aspect-ratio: 210/297;"
195
- aria-label="Editable A4 canvas"
196
- >
197
- <!-- items injected here -->
198
- </div>
199
- </div>
200
- </div>
201
-
202
- <div class="mt-3 text-xs text-gray-500">
203
- Drag elements to move. Drag corner handles to resize. Double-click text to edit.
204
- </div>
205
- </section>
206
-
207
- <!-- Properties panel -->
208
- <aside class="border-l border-gray-200 bg-white p-3">
209
- <div class="text-sm font-semibold text-gray-900 mb-2">Properties</div>
210
-
211
- <div data-empty-props class="text-sm text-gray-600 rounded-lg border border-gray-200 bg-gray-50 p-3">
212
- Select an element to edit formatting and layout options.
213
- </div>
214
-
215
- <div data-props class="hidden space-y-4">
216
- <!-- Arrange -->
217
- <div class="rounded-lg border border-gray-200 p-3">
218
- <div class="text-xs font-semibold text-gray-600 mb-2">Arrange</div>
219
- <div class="flex flex-wrap gap-2">
220
- <button data-btn="bring-front"
221
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
222
- <i data-feather="chevrons-up" class="h-4 w-4"></i> Front
223
- </button>
224
- <button data-btn="send-back"
225
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
226
- <i data-feather="chevrons-down" class="h-4 w-4"></i> Back
227
- </button>
228
- <button data-btn="duplicate"
229
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
230
- <i data-feather="copy" class="h-4 w-4"></i> Duplicate
231
- </button>
232
- <button data-btn="delete"
233
- class="inline-flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs font-semibold text-red-700 hover:bg-red-100 transition">
234
- <i data-feather="trash-2" class="h-4 w-4"></i> Delete
235
- </button>
236
- </div>
237
- </div>
238
-
239
- <!-- Text controls -->
240
- <div data-props-text class="rounded-lg border border-gray-200 p-3 hidden">
241
- <div class="text-xs font-semibold text-gray-600 mb-2">Text</div>
242
-
243
- <div class="grid grid-cols-2 gap-2">
244
- <label class="text-xs text-gray-600">
245
- Font size
246
- <input data-prop="fontSize" type="number" min="8" max="72"
247
- class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500" />
248
- </label>
249
-
250
- <label class="text-xs text-gray-600">
251
- Color
252
- <input data-prop="color" type="color"
253
- class="mt-1 w-full h-[40px] rounded-lg border border-gray-300 px-2 py-1" />
254
- </label>
255
- </div>
256
-
257
- <div class="flex flex-wrap gap-2 mt-2">
258
- <button data-btn="bold"
259
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
260
- <i data-feather="bold" class="h-4 w-4"></i>
261
- </button>
262
- <button data-btn="italic"
263
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
264
- <i data-feather="italic" class="h-4 w-4"></i>
265
- </button>
266
- <button data-btn="underline"
267
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
268
- <i data-feather="underline" class="h-4 w-4"></i>
269
- </button>
270
-
271
- <button data-btn="align-left"
272
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
273
- <i data-feather="align-left" class="h-4 w-4"></i>
274
- </button>
275
- <button data-btn="align-center"
276
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
277
- <i data-feather="align-center" class="h-4 w-4"></i>
278
- </button>
279
- <button data-btn="align-right"
280
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
281
- <i data-feather="align-right" class="h-4 w-4"></i>
282
- </button>
283
- </div>
284
- </div>
285
-
286
- <!-- Shape controls -->
287
- <div data-props-rect class="rounded-lg border border-gray-200 p-3 hidden">
288
- <div class="text-xs font-semibold text-gray-600 mb-2">Shape</div>
289
-
290
- <div class="grid grid-cols-2 gap-2">
291
- <label class="text-xs text-gray-600">
292
- Fill
293
- <input data-prop="fill" type="color"
294
- class="mt-1 w-full h-[40px] rounded-lg border border-gray-300 px-2 py-1" />
295
- </label>
296
-
297
- <label class="text-xs text-gray-600">
298
- Border
299
- <input data-prop="stroke" type="color"
300
- class="mt-1 w-full h-[40px] rounded-lg border border-gray-300 px-2 py-1" />
301
- </label>
302
- </div>
303
-
304
- <label class="text-xs text-gray-600 block mt-2">
305
- Border width
306
- <input data-prop="strokeWidth" type="number" min="0" max="12"
307
- class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500" />
308
- </label>
309
- </div>
310
-
311
- <!-- Image controls -->
312
- <div data-props-image class="rounded-lg border border-gray-200 p-3 hidden">
313
- <div class="text-xs font-semibold text-gray-600 mb-2">Image</div>
314
- <button data-btn="replace-image"
315
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition">
316
- <i data-feather="refresh-cw" class="h-4 w-4"></i> Replace image
317
- </button>
318
- <input data-file="replace" type="file" accept="image/*" class="hidden" />
319
- </div>
320
- </div>
321
-
322
- <div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
323
- <div class="font-semibold text-gray-800 mb-1">Keyboard shortcuts</div>
324
- <ul class="list-disc pl-4 space-y-1">
325
- <li><span class="font-semibold">Delete</span>: remove selected</li>
326
- <li><span class="font-semibold">Ctrl/Cmd+Z</span>: undo</li>
327
- <li><span class="font-semibold">Ctrl/Cmd+Y</span>: redo</li>
328
- <li><span class="font-semibold">Esc</span>: close editor</li>
329
- </ul>
330
- </div>
331
- </aside>
332
- </div>
333
- </div>
334
- </div>
335
- </div>
336
- `;
337
- }
338
-
339
- bind() {
340
- this.$overlay = this.querySelector("[data-overlay]");
341
- this.$pageList = this.querySelector("[data-page-list]");
342
- this.$canvas = this.querySelector("[data-canvas]");
343
- this.$zoomLabel = this.querySelector("[data-zoom-label]");
344
-
345
- this.$emptyProps = this.querySelector("[data-empty-props]");
346
- this.$props = this.querySelector("[data-props]");
347
- this.$propsText = this.querySelector("[data-props-text]");
348
- this.$propsRect = this.querySelector("[data-props-rect]");
349
- this.$propsImage = this.querySelector("[data-props-image]");
350
-
351
- this.$imgFile = this.querySelector('[data-file="image"]');
352
- this.$replaceFile = this.querySelector('[data-file="replace"]');
353
-
354
- // header buttons
355
- this.querySelector('[data-btn="close"]').addEventListener("click", () => this.close());
356
- this.querySelector('[data-btn="save"]').addEventListener("click", () => this._savePages(true));
357
- this.querySelector('[data-btn="undo"]').addEventListener("click", () => this.undo());
358
- this.querySelector('[data-btn="redo"]').addEventListener("click", () => this.redo());
359
-
360
- // tools
361
- this.querySelectorAll(".toolBtn").forEach(btn => {
362
- btn.addEventListener("click", () => {
363
- this.state.tool = btn.dataset.tool;
364
- this.updateToolbar();
365
- });
366
- });
367
-
368
- // toolbar buttons
369
- this.querySelector('[data-btn="add-image"]').addEventListener("click", () => this.$imgFile.click());
370
- this.$imgFile.addEventListener("change", (e) => this._handleImageUpload(e, "add"));
371
-
372
- this.querySelector('[data-btn="zoom-in"]').addEventListener("click", () => this.setZoom(this.state.zoom + 0.1));
373
- this.querySelector('[data-btn="zoom-out"]').addEventListener("click", () => this.setZoom(this.state.zoom - 0.1));
374
-
375
- // pages
376
- this.querySelector('[data-btn="add-page"]').addEventListener("click", () => this.addPage());
377
-
378
- // properties buttons
379
- this.querySelector('[data-btn="delete"]').addEventListener("click", () => this.deleteSelected());
380
- this.querySelector('[data-btn="duplicate"]').addEventListener("click", () => this.duplicateSelected());
381
- this.querySelector('[data-btn="bring-front"]').addEventListener("click", () => this.bringFront());
382
- this.querySelector('[data-btn="send-back"]').addEventListener("click", () => this.sendBack());
383
-
384
- // text props
385
- this.querySelector('[data-btn="bold"]').addEventListener("click", () => this.toggleTextStyle("bold"));
386
- this.querySelector('[data-btn="italic"]').addEventListener("click", () => this.toggleTextStyle("italic"));
387
- this.querySelector('[data-btn="underline"]').addEventListener("click", () => this.toggleTextStyle("underline"));
388
- this.querySelector('[data-btn="align-left"]').addEventListener("click", () => this.setTextAlign("left"));
389
- this.querySelector('[data-btn="align-center"]').addEventListener("click", () => this.setTextAlign("center"));
390
- this.querySelector('[data-btn="align-right"]').addEventListener("click", () => this.setTextAlign("right"));
391
-
392
- this.querySelector('[data-prop="fontSize"]').addEventListener("input", (e) => this.setProp("fontSize", Number(e.target.value || 12)));
393
- this.querySelector('[data-prop="color"]').addEventListener("input", (e) => this.setProp("color", e.target.value));
394
-
395
- // rect props
396
- this.querySelector('[data-prop="fill"]').addEventListener("input", (e) => this.setProp("fill", e.target.value));
397
- this.querySelector('[data-prop="stroke"]').addEventListener("input", (e) => this.setProp("stroke", e.target.value));
398
- this.querySelector('[data-prop="strokeWidth"]').addEventListener("input", (e) => this.setProp("strokeWidth", Number(e.target.value || 0)));
399
-
400
- // image replace
401
- this.querySelector('[data-btn="replace-image"]').addEventListener("click", () => this.$replaceFile.click());
402
- this.$replaceFile.addEventListener("change", (e) => this._handleImageUpload(e, "replace"));
403
-
404
- // canvas interactions
405
- this.$canvas.addEventListener("pointerdown", (e) => this.onCanvasPointerDown(e));
406
- window.addEventListener("pointermove", (e) => this.onPointerMove(e));
407
- window.addEventListener("pointerup", () => this.onPointerUp());
408
-
409
- // keyboard shortcuts
410
- window.addEventListener("keydown", (e) => {
411
- if (!this.state.isOpen) return;
412
-
413
- if (e.key === "Escape") {
414
- e.preventDefault();
415
- this.close();
416
- }
417
-
418
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
419
- e.preventDefault();
420
- this.undo();
421
- }
422
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") {
423
- e.preventDefault();
424
- this.redo();
425
- }
426
-
427
- if (e.key === "Delete" || e.key === "Backspace") {
428
- // avoid deleting while typing in contenteditable
429
- const active = document.activeElement;
430
- const isEditingText = active && active.getAttribute && active.getAttribute("contenteditable") === "true";
431
- if (!isEditingText) this.deleteSelected();
432
- }
433
- });
434
- }
435
-
436
- // ---------- Core helpers ----------
437
- show() {
438
- this.$overlay.classList.remove("hidden");
439
- this.state.isOpen = true;
440
- this.updateAll();
441
- }
442
-
443
- hide() {
444
- this.$overlay.classList.add("hidden");
445
- this.state.isOpen = false;
446
- }
447
-
448
- setZoom(z) {
449
- const clamped = Math.max(0.6, Math.min(1.4, Number(z.toFixed(2))));
450
- this.state.zoom = clamped;
451
- this.updateCanvasScale();
452
- }
453
-
454
- get activePage() {
455
- return this.state.pages[this.state.activePage];
456
- }
457
-
458
- updateAll() {
459
- this.updateToolbar();
460
- this.renderPageList();
461
- this.renderCanvas();
462
- this.updateCanvasScale();
463
- this.updatePropsPanel();
464
- this.updateUndoRedoButtons();
465
- this._refreshIcons();
466
- }
467
-
468
- updateToolbar() {
469
- this.querySelectorAll(".toolBtn").forEach(btn => {
470
- const active = btn.dataset.tool === this.state.tool;
471
- btn.classList.toggle("bg-gray-900", active);
472
- btn.classList.toggle("text-white", active);
473
- btn.classList.toggle("border-gray-900", active);
474
-
475
- if (!active) {
476
- btn.classList.add("bg-white", "text-gray-800", "border-gray-200");
477
- btn.classList.remove("bg-gray-900", "text-white", "border-gray-900");
478
- } else {
479
- btn.classList.remove("bg-white", "text-gray-800", "border-gray-200");
480
- }
481
- });
482
- }
483
-
484
- updateCanvasScale() {
485
- if (!this.$canvas) return;
486
- this.$canvas.style.transformOrigin = "top center";
487
- this.$canvas.style.transform = `scale(${this.state.zoom})`;
488
- this.$zoomLabel.textContent = `${Math.round(this.state.zoom * 100)}%`;
489
- }
490
-
491
- _refreshIcons() {
492
- if (window.feather && typeof window.feather.replace === "function") {
493
- window.feather.replace();
494
- }
495
- }
496
-
497
- // ---------- Storage ----------
498
- _storageKey() {
499
- if (this.sessionId) {
500
- return `repex_report_pages_v1_${this.sessionId}`;
501
- }
502
- return "repex_report_pages_v1";
503
- }
504
-
505
- _loadPages() {
506
- try {
507
- const raw = localStorage.getItem(this._storageKey());
508
- return raw ? JSON.parse(raw) : null;
509
- } catch {
510
- return null;
511
- }
512
- }
513
-
514
- _savePages(showToast = false) {
515
- try {
516
- localStorage.setItem(this._storageKey(), JSON.stringify({ pages: this.state.pages }));
517
- this._scheduleServerSave();
518
- if (showToast) this._toast("Saved");
519
- } catch {
520
- if (showToast) this._toast("Save failed");
521
- }
522
- }
523
-
524
- _apiRoot() {
525
- if (this.apiBase) return this.apiBase.replace(/\/$/, "");
526
- if (window.REPEX && window.REPEX.apiBase) return window.REPEX.apiBase.replace(/\/$/, "");
527
- return "";
528
- }
529
-
530
- async _loadPagesFromServer() {
531
- const base = this._apiRoot();
532
- if (!base || !this.sessionId) return null;
533
- try {
534
- const res = await fetch(`${base}/sessions/${this.sessionId}/pages`);
535
- if (!res.ok) return null;
536
- const data = await res.json();
537
- if (data && Array.isArray(data.pages)) {
538
- return data.pages;
539
- }
540
- } catch {}
541
- return null;
542
- }
543
-
544
- _scheduleServerSave() {
545
- if (!this.sessionId) return;
546
- if (this._saveTimer) clearTimeout(this._saveTimer);
547
- this._saveTimer = setTimeout(() => {
548
- this._savePagesToServer();
549
- }, 800);
550
- }
551
-
552
- async _savePagesToServer() {
553
- const base = this._apiRoot();
554
- if (!base || !this.sessionId) return;
555
- try {
556
- const res = await fetch(`${base}/sessions/${this.sessionId}/pages`, {
557
- method: "PUT",
558
- headers: { "Content-Type": "application/json" },
559
- body: JSON.stringify({ pages: this.state.pages }),
560
- });
561
- if (!res.ok) {
562
- throw new Error("Failed");
563
- }
564
- } catch {
565
- this._toast("Sync failed");
566
- }
567
- }
568
-
569
- _toast(text) {
570
- const el = document.createElement("div");
571
- el.className = "fixed z-[60] bottom-5 left-1/2 -translate-x-1/2 rounded-lg bg-gray-900 text-white text-sm font-semibold px-4 py-2 shadow";
572
- el.textContent = text;
573
- document.body.appendChild(el);
574
- setTimeout(() => el.remove(), 1200);
575
- }
576
-
577
- // ---------- Page list ----------
578
- renderPageList() {
579
- this.$pageList.innerHTML = "";
580
-
581
- this.state.pages.forEach((_, idx) => {
582
- const active = idx === this.state.activePage;
583
-
584
- const btn = document.createElement("button");
585
- btn.type = "button";
586
- btn.className =
587
- "w-full text-left rounded-lg border px-3 py-2 transition " +
588
- (active
589
- ? "border-gray-900 bg-gray-900 text-white"
590
- : "border-gray-200 bg-white text-gray-800 hover:bg-gray-50");
591
- btn.innerHTML = `
592
- <div class="flex items-center justify-between">
593
- <div class="text-sm font-semibold">Page ${idx + 1}</div>
594
- <div class="text-xs ${active ? "text-white/80" : "text-gray-500"}">${this.state.pages[idx].items.length} items</div>
595
- </div>
596
- `;
597
- btn.addEventListener("click", () => {
598
- this.state.activePage = idx;
599
- this.state.selectedId = null;
600
- this.state.undo = [];
601
- this.state.redo = [];
602
- this.updateAll();
603
- });
604
-
605
- this.$pageList.appendChild(btn);
606
- });
607
- }
608
-
609
- addPage() {
610
- this._pushUndoSnapshot();
611
- this.state.pages.push({ items: [] });
612
- this.state.activePage = this.state.pages.length - 1;
613
- this.state.selectedId = null;
614
- this._savePages();
615
- this.updateAll();
616
- }
617
-
618
- // ---------- Canvas rendering ----------
619
- renderCanvas() {
620
- this.$canvas.innerHTML = "";
621
-
622
- // Click-away surface
623
- const surface = document.createElement("div");
624
- surface.className = "absolute inset-0";
625
- surface.addEventListener("pointerdown", (e) => {
626
- // only clear selection if clicking empty space
627
- if (e.target === surface) {
628
- this.state.selectedId = null;
629
- this.updatePropsPanel();
630
- this.renderCanvas();
631
- }
632
- });
633
- this.$canvas.appendChild(surface);
634
-
635
- const items = this.activePage.items;
636
- const selectedId = this.state.selectedId;
637
-
638
- items
639
- .slice()
640
- .sort((a, b) => (a.z ?? 0) - (b.z ?? 0))
641
- .forEach(item => {
642
- const wrapper = document.createElement("div");
643
- wrapper.dataset.itemId = item.id;
644
- wrapper.className = "absolute";
645
-
646
- // scaled px placement based on model units
647
- const scale = this._canvasScale();
648
- wrapper.style.left = `${item.x * scale}px`;
649
- wrapper.style.top = `${item.y * scale}px`;
650
- wrapper.style.width = `${item.w * scale}px`;
651
- wrapper.style.height = `${item.h * scale}px`;
652
- wrapper.style.zIndex = String(item.z ?? 0);
653
-
654
- const isSelected = selectedId === item.id;
655
- if (isSelected) wrapper.classList.add("ring-2", "ring-blue-300");
656
-
657
- // content
658
- if (item.type === "text") {
659
- const content = document.createElement("div");
660
- content.className = "w-full h-full p-2 overflow-hidden";
661
- content.setAttribute("contenteditable", "true");
662
- content.style.fontSize = `${(item.style?.fontSize ?? 14) * scale}px`;
663
- content.style.fontWeight = item.style?.bold ? "700" : "400";
664
- content.style.fontStyle = item.style?.italic ? "italic" : "normal";
665
- content.style.textDecoration = item.style?.underline ? "underline" : "none";
666
- content.style.color = item.style?.color ?? "#111827";
667
- content.style.textAlign = item.style?.align ?? "left";
668
- content.style.whiteSpace = "pre-wrap";
669
- content.style.outline = "none";
670
- content.innerText = item.content ?? "Double-click to edit";
671
-
672
- // update model when typing (debounced)
673
- let t = null;
674
- content.addEventListener("input", () => {
675
- clearTimeout(t);
676
- t = setTimeout(() => {
677
- const it = this._findItem(item.id);
678
- if (!it) return;
679
- it.content = content.innerText;
680
- this._savePages();
681
- }, 250);
682
- });
683
-
684
- // selecting the item (click wrapper)
685
- content.addEventListener("pointerdown", (e) => {
686
- e.stopPropagation();
687
- this.selectItem(item.id);
688
- });
689
-
690
- wrapper.appendChild(content);
691
- }
692
-
693
- if (item.type === "image") {
694
- const img = document.createElement("img");
695
- img.className = "w-full h-full object-contain bg-white";
696
- img.src = item.src;
697
- img.alt = item.name ?? "Image";
698
- img.draggable = false;
699
- img.addEventListener("pointerdown", (e) => {
700
- e.stopPropagation();
701
- this.selectItem(item.id);
702
- });
703
- wrapper.appendChild(img);
704
- }
705
-
706
- if (item.type === "rect") {
707
- const box = document.createElement("div");
708
- box.className = "w-full h-full";
709
- box.style.background = item.style?.fill ?? "#ffffff";
710
- box.style.borderColor = item.style?.stroke ?? "#111827";
711
- box.style.borderWidth = `${(item.style?.strokeWidth ?? 1) * scale}px`;
712
- box.style.borderStyle = "solid";
713
- box.addEventListener("pointerdown", (e) => {
714
- e.stopPropagation();
715
- this.selectItem(item.id);
716
- });
717
- wrapper.appendChild(box);
718
- }
719
-
720
- // wrapper drag handler
721
- wrapper.addEventListener("pointerdown", (e) => this.onItemPointerDown(e, item.id));
722
-
723
- // resize handles (selected only)
724
- if (isSelected) {
725
- ["nw", "ne", "sw", "se"].forEach(handle => {
726
- const h = document.createElement("div");
727
- h.dataset.handle = handle;
728
- h.className =
729
- "absolute w-3 h-3 bg-white border border-blue-300 rounded-sm";
730
- if (handle === "nw") { h.style.left = "-6px"; h.style.top = "-6px"; }
731
- if (handle === "ne") { h.style.right = "-6px"; h.style.top = "-6px"; }
732
- if (handle === "sw") { h.style.left = "-6px"; h.style.bottom = "-6px"; }
733
- if (handle === "se") { h.style.right = "-6px"; h.style.bottom = "-6px"; }
734
-
735
- h.style.cursor = `${handle}-resize`;
736
- h.addEventListener("pointerdown", (e) => {
737
- e.stopPropagation();
738
- this.startResize(e, item.id, handle);
739
- });
740
- wrapper.appendChild(h);
741
- });
742
- }
743
-
744
- this.$canvas.appendChild(wrapper);
745
- });
746
- }
747
-
748
- _canvasScale() {
749
- // actual displayed width divided by model width
750
- const rect = this.$canvas.getBoundingClientRect();
751
- const w = rect.width; // already pre-zoom; we apply zoom with CSS transform
752
- return w / this.BASE_W;
753
- }
754
-
755
- // ---------- Item creation ----------
756
- onCanvasPointerDown(e) {
757
- // prevent adding when clicking existing item
758
- const hit = e.target.closest("[data-item-id]");
759
- if (hit) return;
760
-
761
- const { x, y } = this._eventToModelPoint(e);
762
-
763
- if (this.state.tool === "text") {
764
- this._pushUndoSnapshot();
765
- const id = this._id();
766
- this.activePage.items.push({
767
- id,
768
- type: "text",
769
- x: this._clamp(x, 0, this.BASE_W - 200),
770
- y: this._clamp(y, 0, this.BASE_H - 80),
771
- w: 220,
772
- h: 80,
773
- z: this._maxZ() + 1,
774
- content: "New text",
775
- style: { fontSize: 14, bold: false, italic: false, underline: false, color: "#111827", align: "left" }
776
- });
777
- this.selectItem(id);
778
- this._savePages();
779
- this.renderCanvas();
780
- this.updatePropsPanel();
781
- return;
782
- }
783
-
784
- if (this.state.tool === "rect") {
785
- this._pushUndoSnapshot();
786
- const id = this._id();
787
- this.activePage.items.push({
788
- id,
789
- type: "rect",
790
- x: this._clamp(x, 0, this.BASE_W - 200),
791
- y: this._clamp(y, 0, this.BASE_H - 120),
792
- w: 220,
793
- h: 120,
794
- z: this._maxZ() + 1,
795
- style: { fill: "#ffffff", stroke: "#111827", strokeWidth: 1 }
796
- });
797
- this.selectItem(id);
798
- this._savePages();
799
- this.renderCanvas();
800
- this.updatePropsPanel();
801
- return;
802
- }
803
-
804
- // select tool clicking empty space clears selection
805
- if (this.state.tool === "select") {
806
- this.state.selectedId = null;
807
- this.updatePropsPanel();
808
- this.renderCanvas();
809
- }
810
- }
811
-
812
- _eventToModelPoint(e) {
813
- const canvasRect = this.$canvas.getBoundingClientRect();
814
- // account for zoom (transform), use client coords mapping
815
- const zoom = this.state.zoom;
816
- const xPx = (e.clientX - canvasRect.left) / zoom;
817
- const yPx = (e.clientY - canvasRect.top) / zoom;
818
-
819
- const scale = canvasRect.width / this.BASE_W; // pre-zoom scale
820
- return { x: xPx / scale, y: yPx / scale };
821
- }
822
-
823
- // ---------- Selection / Drag / Resize ----------
824
- selectItem(id) {
825
- this.state.selectedId = id;
826
- this.updatePropsPanel();
827
- this.renderCanvas();
828
- }
829
-
830
- onItemPointerDown(e, id) {
831
- // ignore if resizing handle
832
- if (e.target && e.target.dataset && e.target.dataset.handle) return;
833
-
834
- // select
835
- this.selectItem(id);
836
-
837
- // start drag only when using select tool
838
- if (this.state.tool !== "select") return;
839
-
840
- this._pushUndoSnapshot();
841
-
842
- const it = this._findItem(id);
843
- if (!it) return;
844
-
845
- const { x, y } = this._eventToModelPoint(e);
846
- this.state.dragging = {
847
- id,
848
- startX: x,
849
- startY: y,
850
- origX: it.x,
851
- origY: it.y
852
- };
853
-
854
- e.preventDefault();
855
- }
856
-
857
- startResize(e, id, handle) {
858
- this._pushUndoSnapshot();
859
-
860
- const it = this._findItem(id);
861
- if (!it) return;
862
-
863
- const { x, y } = this._eventToModelPoint(e);
864
- this.state.resizing = {
865
- id,
866
- handle,
867
- startX: x,
868
- startY: y,
869
- orig: { x: it.x, y: it.y, w: it.w, h: it.h }
870
- };
871
- e.preventDefault();
872
- }
873
-
874
- onPointerMove(e) {
875
- if (!this.state.isOpen) return;
876
-
877
- if (this.state.dragging) {
878
- const d = this.state.dragging;
879
- const it = this._findItem(d.id);
880
- if (!it) return;
881
-
882
- const { x, y } = this._eventToModelPoint(e);
883
- const dx = x - d.startX;
884
- const dy = y - d.startY;
885
-
886
- it.x = this._clamp(d.origX + dx, 0, this.BASE_W - it.w);
887
- it.y = this._clamp(d.origY + dy, 0, this.BASE_H - it.h);
888
-
889
- this._savePages();
890
- this.renderCanvas();
891
- return;
892
- }
893
-
894
- if (this.state.resizing) {
895
- const r = this.state.resizing;
896
- const it = this._findItem(r.id);
897
- if (!it) return;
898
-
899
- const { x, y } = this._eventToModelPoint(e);
900
- const dx = x - r.startX;
901
- const dy = y - r.startY;
902
-
903
- const o = r.orig;
904
- const minW = 40, minH = 30;
905
-
906
- let nx = o.x, ny = o.y, nw = o.w, nh = o.h;
907
-
908
- if (r.handle.includes("e")) nw = this._clamp(o.w + dx, minW, this.BASE_W - o.x);
909
- if (r.handle.includes("s")) nh = this._clamp(o.h + dy, minH, this.BASE_H - o.y);
910
- if (r.handle.includes("w")) {
911
- nw = this._clamp(o.w - dx, minW, o.w + o.x);
912
- nx = this._clamp(o.x + dx, 0, o.x + o.w - minW);
913
- }
914
- if (r.handle.includes("n")) {
915
- nh = this._clamp(o.h - dy, minH, o.h + o.y);
916
- ny = this._clamp(o.y + dy, 0, o.y + o.h - minH);
917
- }
918
-
919
- it.x = nx; it.y = ny; it.w = nw; it.h = nh;
920
-
921
- this._savePages();
922
- this.renderCanvas();
923
- }
924
- }
925
-
926
- onPointerUp() {
927
- if (!this.state.isOpen) return;
928
-
929
- if (this.state.dragging) {
930
- this.state.dragging = null;
931
- this.updateUndoRedoButtons();
932
- }
933
- if (this.state.resizing) {
934
- this.state.resizing = null;
935
- this.updateUndoRedoButtons();
936
- }
937
- }
938
-
939
- // ---------- Properties panel ----------
940
- updatePropsPanel() {
941
- const it = this._findItem(this.state.selectedId);
942
- const has = !!it;
943
-
944
- this.$emptyProps.classList.toggle("hidden", has);
945
- this.$props.classList.toggle("hidden", !has);
946
-
947
- // hide all groups first
948
- this.$propsText.classList.add("hidden");
949
- this.$propsRect.classList.add("hidden");
950
- this.$propsImage.classList.add("hidden");
951
-
952
- if (!it) return;
953
-
954
- if (it.type === "text") {
955
- this.$propsText.classList.remove("hidden");
956
- this.querySelector('[data-prop="fontSize"]').value = it.style?.fontSize ?? 14;
957
- this.querySelector('[data-prop="color"]').value = it.style?.color ?? "#111827";
958
- }
959
-
960
- if (it.type === "rect") {
961
- this.$propsRect.classList.remove("hidden");
962
- this.querySelector('[data-prop="fill"]').value = it.style?.fill ?? "#ffffff";
963
- this.querySelector('[data-prop="stroke"]').value = it.style?.stroke ?? "#111827";
964
- this.querySelector('[data-prop="strokeWidth"]').value = it.style?.strokeWidth ?? 1;
965
- }
966
-
967
- if (it.type === "image") {
968
- this.$propsImage.classList.remove("hidden");
969
- }
970
-
971
- this._refreshIcons();
972
- }
973
-
974
- setProp(key, value) {
975
- const it = this._findItem(this.state.selectedId);
976
- if (!it) return;
977
-
978
- this._pushUndoSnapshot();
979
-
980
- it.style = it.style || {};
981
- it.style[key] = value;
982
-
983
- this._savePages();
984
- this.renderCanvas();
985
- this.updatePropsPanel();
986
- this.updateUndoRedoButtons();
987
- }
988
-
989
- toggleTextStyle(which) {
990
- const it = this._findItem(this.state.selectedId);
991
- if (!it || it.type !== "text") return;
992
-
993
- this._pushUndoSnapshot();
994
-
995
- it.style = it.style || {};
996
- if (which === "bold") it.style.bold = !it.style.bold;
997
- if (which === "italic") it.style.italic = !it.style.italic;
998
- if (which === "underline") it.style.underline = !it.style.underline;
999
-
1000
- this._savePages();
1001
- this.renderCanvas();
1002
- this.updateUndoRedoButtons();
1003
- }
1004
-
1005
- setTextAlign(align) {
1006
- const it = this._findItem(this.state.selectedId);
1007
- if (!it || it.type !== "text") return;
1008
- this.setProp("align", align);
1009
- }
1010
-
1011
- // ---------- Arrange ----------
1012
- bringFront() {
1013
- const it = this._findItem(this.state.selectedId);
1014
- if (!it) return;
1015
- this._pushUndoSnapshot();
1016
- it.z = this._maxZ() + 1;
1017
- this._savePages();
1018
- this.renderCanvas();
1019
- this.updateUndoRedoButtons();
1020
- }
1021
-
1022
- sendBack() {
1023
- const it = this._findItem(this.state.selectedId);
1024
- if (!it) return;
1025
- this._pushUndoSnapshot();
1026
- it.z = this._minZ() - 1;
1027
- this._savePages();
1028
- this.renderCanvas();
1029
- this.updateUndoRedoButtons();
1030
- }
1031
-
1032
- duplicateSelected() {
1033
- const it = this._findItem(this.state.selectedId);
1034
- if (!it) return;
1035
- this._pushUndoSnapshot();
1036
-
1037
- const copy = JSON.parse(JSON.stringify(it));
1038
- copy.id = this._id();
1039
- copy.x = this._clamp(copy.x + 12, 0, this.BASE_W - copy.w);
1040
- copy.y = this._clamp(copy.y + 12, 0, this.BASE_H - copy.h);
1041
- copy.z = this._maxZ() + 1;
1042
-
1043
- this.activePage.items.push(copy);
1044
- this.state.selectedId = copy.id;
1045
-
1046
- this._savePages();
1047
- this.updateAll();
1048
- this.updateUndoRedoButtons();
1049
- }
1050
-
1051
- deleteSelected() {
1052
- const id = this.state.selectedId;
1053
- if (!id) return;
1054
-
1055
- this._pushUndoSnapshot();
1056
-
1057
- this.activePage.items = this.activePage.items.filter(x => x.id !== id);
1058
- this.state.selectedId = null;
1059
-
1060
- this._savePages();
1061
- this.updateAll();
1062
- this.updateUndoRedoButtons();
1063
- }
1064
-
1065
- // ---------- Images ----------
1066
- _handleImageUpload(e, mode) {
1067
- const file = e.target.files && e.target.files[0];
1068
- if (!file) return;
1069
-
1070
- const reader = new FileReader();
1071
- reader.onload = () => {
1072
- if (mode === "add") {
1073
- this._pushUndoSnapshot();
1074
- const id = this._id();
1075
- const w = 260, h = 180;
1076
- this.activePage.items.push({
1077
- id,
1078
- type: "image",
1079
- x: (this.BASE_W - w) / 2,
1080
- y: (this.BASE_H - h) / 2,
1081
- w, h,
1082
- z: this._maxZ() + 1,
1083
- src: reader.result,
1084
- name: file.name
1085
- });
1086
- this.selectItem(id);
1087
- this._savePages();
1088
- this.updateAll();
1089
- this.updateUndoRedoButtons();
1090
- }
1091
-
1092
- if (mode === "replace") {
1093
- const it = this._findItem(this.state.selectedId);
1094
- if (!it || it.type !== "image") return;
1095
- this._pushUndoSnapshot();
1096
- it.src = reader.result;
1097
- it.name = file.name;
1098
- this._savePages();
1099
- this.updateAll();
1100
- this.updateUndoRedoButtons();
1101
- }
1102
- };
1103
- reader.readAsDataURL(file);
1104
-
1105
- // reset input
1106
- e.target.value = "";
1107
- }
1108
-
1109
- // ---------- Undo / redo ----------
1110
- _pushUndoSnapshot() {
1111
- // store snapshot of active page items
1112
- const snap = JSON.stringify(this.activePage.items);
1113
- const last = this.state.undo[this.state.undo.length - 1];
1114
- if (last !== snap) this.state.undo.push(snap);
1115
- // clear redo on new change
1116
- this.state.redo = [];
1117
- this.updateUndoRedoButtons();
1118
- }
1119
-
1120
- undo() {
1121
- if (!this.state.undo.length) return;
1122
-
1123
- const current = JSON.stringify(this.activePage.items);
1124
- const prev = this.state.undo.pop();
1125
- this.state.redo.push(current);
1126
-
1127
- // restore prev
1128
- try {
1129
- this.activePage.items = JSON.parse(prev);
1130
- } catch {}
1131
- this.state.selectedId = null;
1132
-
1133
- this._savePages();
1134
- this.updateAll();
1135
- }
1136
-
1137
- redo() {
1138
- if (!this.state.redo.length) return;
1139
-
1140
- const current = JSON.stringify(this.activePage.items);
1141
- const next = this.state.redo.pop();
1142
- this.state.undo.push(current);
1143
-
1144
- try {
1145
- this.activePage.items = JSON.parse(next);
1146
- } catch {}
1147
- this.state.selectedId = null;
1148
-
1149
- this._savePages();
1150
- this.updateAll();
1151
- }
1152
-
1153
- updateUndoRedoButtons() {
1154
- const undoBtn = this.querySelector('[data-btn="undo"]');
1155
- const redoBtn = this.querySelector('[data-btn="redo"]');
1156
- if (undoBtn) undoBtn.disabled = this.state.undo.length === 0;
1157
- if (redoBtn) redoBtn.disabled = this.state.redo.length === 0;
1158
- }
1159
-
1160
- // ---------- Utils ----------
1161
- _findItem(id) {
1162
- if (!id) return null;
1163
- return this.activePage.items.find(x => x.id === id) || null;
1164
- }
1165
-
1166
- _maxZ() {
1167
- const items = this.activePage.items;
1168
- return items.length ? Math.max(...items.map(i => i.z ?? 0)) : 0;
1169
- }
1170
-
1171
- _minZ() {
1172
- const items = this.activePage.items;
1173
- return items.length ? Math.min(...items.map(i => i.z ?? 0)) : 0;
1174
- }
1175
-
1176
- _id() {
1177
- return "it_" + Math.random().toString(16).slice(2) + "_" + Date.now().toString(16);
1178
- }
1179
-
1180
- _clamp(n, a, b) {
1181
- return Math.max(a, Math.min(b, n));
1182
- }
1183
- }
1184
-
1185
- customElements.define("report-editor", ReportEditor);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
legacy/static-site/edit-layouts.html DELETED
@@ -1,265 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width,initial-scale=1" />
6
- <title>RepEx - Report Express | Export</title>
7
-
8
- <link rel="stylesheet" href="style.css" />
9
- <script src="https://cdn.tailwindcss.com"></script>
10
- <script defer src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
11
-
12
- <style>
13
- img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
14
- @media print {
15
- body { background: #fff !important; }
16
- .no-print { display: none !important; }
17
- main { box-shadow: none !important; border: none !important; margin: 0 !important; padding: 0 !important; }
18
- }
19
- </style>
20
- </head>
21
-
22
- <body class="bg-gray-50 min-h-screen">
23
- <main class="max-w-4xl mx-auto my-8 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8">
24
- <!-- Header -->
25
- <header class="mb-8 border-b border-gray-200 pb-4">
26
- <div class="grid grid-cols-[auto,1fr,auto] items-center gap-4">
27
- <div class="flex items-center">
28
- <img
29
- src="assets/prosento-logo.png"
30
- alt="Company logo"
31
- class="h-12 w-auto object-contain"
32
- loading="eager"
33
- />
34
- </div>
35
-
36
- <div class="text-center">
37
- <h1 class="text-2xl md:text-3xl font-bold text-gray-900 whitespace-nowrap">
38
- RepEx - Report Express
39
- </h1>
40
- <p class="text-gray-600 whitespace-nowrap">Export</p>
41
- </div>
42
-
43
- <div class="flex justify-end gap-2 no-print">
44
- <a
45
- href="report-viewer.html"
46
- class="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"
47
- >
48
- <i data-feather="arrow-left" class="h-4 w-4"></i>
49
- Back
50
- </a>
51
- </div>
52
- </div>
53
- </header>
54
-
55
- <!-- Workflow navigation -->
56
- <nav class="mb-6 no-print" aria-label="Report workflow navigation">
57
- <div class="flex flex-wrap gap-2">
58
- <a
59
- href="report-viewer.html"
60
- class="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"
61
- >
62
- <i data-feather="layout" class="h-4 w-4"></i>
63
- Report Viewer
64
- </a>
65
-
66
- <a
67
- href="edit-layouts.html"
68
- class="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"
69
- >
70
- <i data-feather="grid" class="h-4 w-4"></i>
71
- Edit Page Layouts
72
- </a>
73
-
74
- <a
75
- href="export.html"
76
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-900 px-4 py-2 text-sm font-semibold text-white"
77
- >
78
- <i data-feather="download" class="h-4 w-4"></i>
79
- Export
80
- </a>
81
- </div>
82
- </nav>
83
-
84
- <section class="grid grid-cols-1 lg:grid-cols-[1fr,360px] gap-6">
85
- <!-- Export options -->
86
- <div class="rounded-lg border border-gray-200 bg-white p-4">
87
- <h2 class="text-lg font-semibold text-gray-900 mb-2">Export Options</h2>
88
- <p class="text-sm text-gray-600 mb-4">
89
- PDF export comes next. For now, you can export a report “package” as JSON (pages + layout settings + payload).
90
- </p>
91
-
92
- <div class="space-y-4">
93
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
94
- <div class="flex items-start justify-between gap-3">
95
- <div>
96
- <div class="text-sm font-semibold text-gray-900">Report package (.json)</div>
97
- <div class="text-xs text-gray-500">Includes edited pages, layout settings and upload metadata</div>
98
- </div>
99
- <span class="text-xs font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-md px-2 py-1">
100
- Available
101
- </span>
102
- </div>
103
-
104
- <div class="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
105
- <label class="inline-flex items-center gap-2 text-sm text-gray-700">
106
- <input id="incPages" type="checkbox" class="rounded border-gray-300" checked />
107
- Include pages
108
- </label>
109
- <label class="inline-flex items-center gap-2 text-sm text-gray-700">
110
- <input id="incLayout" type="checkbox" class="rounded border-gray-300" checked />
111
- Include layout settings
112
- </label>
113
- <label class="inline-flex items-center gap-2 text-sm text-gray-700">
114
- <input id="incPayload" type="checkbox" class="rounded border-gray-300" checked />
115
- Include upload payload
116
- </label>
117
- <label class="inline-flex items-center gap-2 text-sm text-gray-700">
118
- <input id="incTimestamp" type="checkbox" class="rounded border-gray-300" checked />
119
- Include timestamp
120
- </label>
121
- </div>
122
-
123
- <button id="downloadJson" type="button"
124
- class="mt-4 inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-2.5 text-white font-semibold hover:bg-emerald-700 transition">
125
- <i data-feather="download" class="h-4 w-4"></i>
126
- Download JSON package
127
- </button>
128
- </div>
129
-
130
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
131
- <div class="flex items-start justify-between gap-3">
132
- <div>
133
- <div class="text-sm font-semibold text-gray-900">PDF export</div>
134
- <div class="text-xs text-gray-500">Will export as print-ready A4 PDF</div>
135
- </div>
136
- <span class="text-xs font-semibold text-gray-600 bg-white border border-gray-200 rounded-md px-2 py-1">
137
- Coming soon
138
- </span>
139
- </div>
140
-
141
- <button type="button" disabled
142
- class="mt-4 inline-flex items-center justify-center gap-2 rounded-lg bg-gray-200 px-4 py-2.5 text-gray-500 font-semibold cursor-not-allowed">
143
- <i data-feather="file-text" class="h-4 w-4"></i>
144
- Export PDF (disabled)
145
- </button>
146
- </div>
147
- </div>
148
- </div>
149
-
150
- <!-- Summary -->
151
- <aside class="rounded-lg border border-gray-200 bg-white p-4">
152
- <h2 class="text-lg font-semibold text-gray-900 mb-2">Export Summary</h2>
153
- <p class="text-sm text-gray-600 mb-4">This is what will be included based on your current session.</p>
154
-
155
- <div class="space-y-3">
156
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
157
- <div class="text-xs font-semibold text-gray-600">Pages</div>
158
- <div id="sumPages" class="text-sm font-semibold text-gray-900">—</div>
159
- </div>
160
-
161
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
162
- <div class="text-xs font-semibold text-gray-600">Selected example photos</div>
163
- <div id="sumPhotos" class="text-sm font-semibold text-gray-900">—</div>
164
- </div>
165
-
166
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
167
- <div class="text-xs font-semibold text-gray-600">Documents</div>
168
- <div id="sumDocs" class="text-sm font-semibold text-gray-900">—</div>
169
- </div>
170
-
171
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
172
- <div class="text-xs font-semibold text-gray-600">Data files</div>
173
- <div id="sumData" class="text-sm font-semibold text-gray-900">—</div>
174
- </div>
175
-
176
- <div class="text-xs text-gray-500">
177
- Stored in the active server session.
178
- </div>
179
- </div>
180
- </aside>
181
- </section>
182
-
183
- <footer class="mt-12 text-center text-xs text-gray-500 no-print">
184
- <p>Prosento - © 2026 All Rights Reserved</p>
185
- <p class="mt-1">Export: JSON package now; PDF export will be added next.</p>
186
- </footer>
187
- </main>
188
-
189
- <script src="script.js"></script>
190
- <script>
191
- document.addEventListener('DOMContentLoaded', () => {
192
- if (window.feather && typeof window.feather.replace === 'function') feather.replace();
193
-
194
- const sessionId = window.REPEX.getSessionId();
195
- window.REPEX.setSessionId(sessionId);
196
- const sumPages = document.getElementById('sumPages');
197
- const sumPhotos = document.getElementById('sumPhotos');
198
- const sumDocs = document.getElementById('sumDocs');
199
- const sumData = document.getElementById('sumData');
200
-
201
- const incPages = document.getElementById('incPages');
202
- const incLayout = document.getElementById('incLayout');
203
- const incPayload = document.getElementById('incPayload');
204
- const incTimestamp = document.getElementById('incTimestamp');
205
- const downloadJson = document.getElementById('downloadJson');
206
-
207
- if (!sessionId) {
208
- sumPages.textContent = 'No active session.';
209
- sumPhotos.textContent = '-';
210
- sumDocs.textContent = '-';
211
- sumData.textContent = '-';
212
- downloadJson.disabled = true;
213
- return;
214
- }
215
-
216
- let session = null;
217
- let pages = [];
218
-
219
- function downloadBlob(filename, text) {
220
- const blob = new Blob([text], { type: 'application/json' });
221
- const url = URL.createObjectURL(blob);
222
- const a = document.createElement('a');
223
- a.href = url;
224
- a.download = filename;
225
- document.body.appendChild(a);
226
- a.click();
227
- a.remove();
228
- URL.revokeObjectURL(url);
229
- }
230
-
231
- function updateSummary() {
232
- const totalItems = pages.reduce((acc, p) => acc + ((p?.items?.length) || 0), 0);
233
- sumPages.textContent = pages.length ? `${pages.length} pages ? ${totalItems} total items` : 'No saved pages yet';
234
- sumPhotos.textContent = `${session?.selected_photo_ids?.length ?? 0}`;
235
- sumDocs.textContent = `${session?.uploads?.documents?.length ?? 0}`;
236
- sumData.textContent = `${session?.uploads?.data_files?.length ?? 0}`;
237
- }
238
-
239
- async function loadData() {
240
- try {
241
- session = await window.REPEX.request(`/sessions/${sessionId}`);
242
- const pageResp = await window.REPEX.request(`/sessions/${sessionId}/pages`);
243
- pages = Array.isArray(pageResp?.pages) ? pageResp.pages : [];
244
- updateSummary();
245
- } catch (err) {
246
- sumPages.textContent = err.message || 'Failed to load session.';
247
- }
248
- }
249
-
250
- downloadJson.addEventListener('click', () => {
251
- const pack = {};
252
- if (incPages.checked) pack.pages = pages;
253
- if (incLayout.checked) pack.layout = session?.layout ?? null;
254
- if (incPayload.checked) pack.payload = session;
255
- if (incTimestamp.checked) pack.exportedAt = new Date().toISOString();
256
-
257
- const filename = `repex_report_package_${new Date().toISOString().replace(/[:.]/g,'-')}.json`;
258
- downloadBlob(filename, JSON.stringify(pack, null, 2));
259
- });
260
-
261
- loadData();
262
- });
263
- </script>
264
- </body>
265
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
legacy/static-site/export.html DELETED
@@ -1,265 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width,initial-scale=1" />
6
- <title>RepEx - Report Express | Export</title>
7
-
8
- <link rel="stylesheet" href="style.css" />
9
- <script src="https://cdn.tailwindcss.com"></script>
10
- <script defer src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
11
-
12
- <style>
13
- img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
14
- @media print {
15
- body { background: #fff !important; }
16
- .no-print { display: none !important; }
17
- main { box-shadow: none !important; border: none !important; margin: 0 !important; padding: 0 !important; }
18
- }
19
- </style>
20
- </head>
21
-
22
- <body class="bg-gray-50 min-h-screen">
23
- <main class="max-w-4xl mx-auto my-8 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8">
24
- <!-- Header -->
25
- <header class="mb-8 border-b border-gray-200 pb-4">
26
- <div class="grid grid-cols-[auto,1fr,auto] items-center gap-4">
27
- <div class="flex items-center">
28
- <img
29
- src="assets/prosento-logo.png"
30
- alt="Company logo"
31
- class="h-12 w-auto object-contain"
32
- loading="eager"
33
- />
34
- </div>
35
-
36
- <div class="text-center">
37
- <h1 class="text-2xl md:text-3xl font-bold text-gray-900 whitespace-nowrap">
38
- RepEx - Report Express
39
- </h1>
40
- <p class="text-gray-600 whitespace-nowrap">Export</p>
41
- </div>
42
-
43
- <div class="flex justify-end gap-2 no-print">
44
- <a
45
- href="report-viewer.html"
46
- class="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"
47
- >
48
- <i data-feather="arrow-left" class="h-4 w-4"></i>
49
- Back
50
- </a>
51
- </div>
52
- </div>
53
- </header>
54
-
55
- <!-- Workflow navigation -->
56
- <nav class="mb-6 no-print" aria-label="Report workflow navigation">
57
- <div class="flex flex-wrap gap-2">
58
- <a
59
- href="report-viewer.html"
60
- class="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"
61
- >
62
- <i data-feather="layout" class="h-4 w-4"></i>
63
- Report Viewer
64
- </a>
65
-
66
- <a
67
- href="edit-layouts.html"
68
- class="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"
69
- >
70
- <i data-feather="grid" class="h-4 w-4"></i>
71
- Edit Page Layouts
72
- </a>
73
-
74
- <a
75
- href="export.html"
76
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-900 px-4 py-2 text-sm font-semibold text-white"
77
- >
78
- <i data-feather="download" class="h-4 w-4"></i>
79
- Export
80
- </a>
81
- </div>
82
- </nav>
83
-
84
- <section class="grid grid-cols-1 lg:grid-cols-[1fr,360px] gap-6">
85
- <!-- Export options -->
86
- <div class="rounded-lg border border-gray-200 bg-white p-4">
87
- <h2 class="text-lg font-semibold text-gray-900 mb-2">Export Options</h2>
88
- <p class="text-sm text-gray-600 mb-4">
89
- PDF export comes next. For now, you can export a report “package” as JSON (pages + layout settings + payload).
90
- </p>
91
-
92
- <div class="space-y-4">
93
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
94
- <div class="flex items-start justify-between gap-3">
95
- <div>
96
- <div class="text-sm font-semibold text-gray-900">Report package (.json)</div>
97
- <div class="text-xs text-gray-500">Includes edited pages, layout settings and upload metadata</div>
98
- </div>
99
- <span class="text-xs font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-md px-2 py-1">
100
- Available
101
- </span>
102
- </div>
103
-
104
- <div class="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
105
- <label class="inline-flex items-center gap-2 text-sm text-gray-700">
106
- <input id="incPages" type="checkbox" class="rounded border-gray-300" checked />
107
- Include pages
108
- </label>
109
- <label class="inline-flex items-center gap-2 text-sm text-gray-700">
110
- <input id="incLayout" type="checkbox" class="rounded border-gray-300" checked />
111
- Include layout settings
112
- </label>
113
- <label class="inline-flex items-center gap-2 text-sm text-gray-700">
114
- <input id="incPayload" type="checkbox" class="rounded border-gray-300" checked />
115
- Include upload payload
116
- </label>
117
- <label class="inline-flex items-center gap-2 text-sm text-gray-700">
118
- <input id="incTimestamp" type="checkbox" class="rounded border-gray-300" checked />
119
- Include timestamp
120
- </label>
121
- </div>
122
-
123
- <button id="downloadJson" type="button"
124
- class="mt-4 inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-2.5 text-white font-semibold hover:bg-emerald-700 transition">
125
- <i data-feather="download" class="h-4 w-4"></i>
126
- Download JSON package
127
- </button>
128
- </div>
129
-
130
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
131
- <div class="flex items-start justify-between gap-3">
132
- <div>
133
- <div class="text-sm font-semibold text-gray-900">PDF export</div>
134
- <div class="text-xs text-gray-500">Will export as print-ready A4 PDF</div>
135
- </div>
136
- <span class="text-xs font-semibold text-gray-600 bg-white border border-gray-200 rounded-md px-2 py-1">
137
- Coming soon
138
- </span>
139
- </div>
140
-
141
- <button type="button" disabled
142
- class="mt-4 inline-flex items-center justify-center gap-2 rounded-lg bg-gray-200 px-4 py-2.5 text-gray-500 font-semibold cursor-not-allowed">
143
- <i data-feather="file-text" class="h-4 w-4"></i>
144
- Export PDF (disabled)
145
- </button>
146
- </div>
147
- </div>
148
- </div>
149
-
150
- <!-- Summary -->
151
- <aside class="rounded-lg border border-gray-200 bg-white p-4">
152
- <h2 class="text-lg font-semibold text-gray-900 mb-2">Export Summary</h2>
153
- <p class="text-sm text-gray-600 mb-4">This is what will be included based on your current session.</p>
154
-
155
- <div class="space-y-3">
156
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
157
- <div class="text-xs font-semibold text-gray-600">Pages</div>
158
- <div id="sumPages" class="text-sm font-semibold text-gray-900">—</div>
159
- </div>
160
-
161
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
162
- <div class="text-xs font-semibold text-gray-600">Selected example photos</div>
163
- <div id="sumPhotos" class="text-sm font-semibold text-gray-900">—</div>
164
- </div>
165
-
166
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
167
- <div class="text-xs font-semibold text-gray-600">Documents</div>
168
- <div id="sumDocs" class="text-sm font-semibold text-gray-900">—</div>
169
- </div>
170
-
171
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
172
- <div class="text-xs font-semibold text-gray-600">Data files</div>
173
- <div id="sumData" class="text-sm font-semibold text-gray-900">—</div>
174
- </div>
175
-
176
- <div class="text-xs text-gray-500">
177
- Stored in the active server session.
178
- </div>
179
- </div>
180
- </aside>
181
- </section>
182
-
183
- <footer class="mt-12 text-center text-xs text-gray-500 no-print">
184
- <p>Prosento - © 2026 All Rights Reserved</p>
185
- <p class="mt-1">Export: JSON package now; PDF export will be added next.</p>
186
- </footer>
187
- </main>
188
-
189
- <script src="script.js"></script>
190
- <script>
191
- document.addEventListener('DOMContentLoaded', () => {
192
- if (window.feather && typeof window.feather.replace === 'function') feather.replace();
193
-
194
- const sessionId = window.REPEX.getSessionId();
195
- window.REPEX.setSessionId(sessionId);
196
- const sumPages = document.getElementById('sumPages');
197
- const sumPhotos = document.getElementById('sumPhotos');
198
- const sumDocs = document.getElementById('sumDocs');
199
- const sumData = document.getElementById('sumData');
200
-
201
- const incPages = document.getElementById('incPages');
202
- const incLayout = document.getElementById('incLayout');
203
- const incPayload = document.getElementById('incPayload');
204
- const incTimestamp = document.getElementById('incTimestamp');
205
- const downloadJson = document.getElementById('downloadJson');
206
-
207
- if (!sessionId) {
208
- sumPages.textContent = 'No active session.';
209
- sumPhotos.textContent = '-';
210
- sumDocs.textContent = '-';
211
- sumData.textContent = '-';
212
- downloadJson.disabled = true;
213
- return;
214
- }
215
-
216
- let session = null;
217
- let pages = [];
218
-
219
- function downloadBlob(filename, text) {
220
- const blob = new Blob([text], { type: 'application/json' });
221
- const url = URL.createObjectURL(blob);
222
- const a = document.createElement('a');
223
- a.href = url;
224
- a.download = filename;
225
- document.body.appendChild(a);
226
- a.click();
227
- a.remove();
228
- URL.revokeObjectURL(url);
229
- }
230
-
231
- function updateSummary() {
232
- const totalItems = pages.reduce((acc, p) => acc + ((p?.items?.length) || 0), 0);
233
- sumPages.textContent = pages.length ? `${pages.length} pages ? ${totalItems} total items` : 'No saved pages yet';
234
- sumPhotos.textContent = `${session?.selected_photo_ids?.length ?? 0}`;
235
- sumDocs.textContent = `${session?.uploads?.documents?.length ?? 0}`;
236
- sumData.textContent = `${session?.uploads?.data_files?.length ?? 0}`;
237
- }
238
-
239
- async function loadData() {
240
- try {
241
- session = await window.REPEX.request(`/sessions/${sessionId}`);
242
- const pageResp = await window.REPEX.request(`/sessions/${sessionId}/pages`);
243
- pages = Array.isArray(pageResp?.pages) ? pageResp.pages : [];
244
- updateSummary();
245
- } catch (err) {
246
- sumPages.textContent = err.message || 'Failed to load session.';
247
- }
248
- }
249
-
250
- downloadJson.addEventListener('click', () => {
251
- const pack = {};
252
- if (incPages.checked) pack.pages = pages;
253
- if (incLayout.checked) pack.layout = session?.layout ?? null;
254
- if (incPayload.checked) pack.payload = session;
255
- if (incTimestamp.checked) pack.exportedAt = new Date().toISOString();
256
-
257
- const filename = `repex_report_package_${new Date().toISOString().replace(/[:.]/g,'-')}.json`;
258
- downloadBlob(filename, JSON.stringify(pack, null, 2));
259
- });
260
-
261
- loadData();
262
- });
263
- </script>
264
- </body>
265
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
legacy/static-site/index.html DELETED
@@ -1,398 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width,initial-scale=1" />
6
- <title>RepEx - Report Express</title>
7
-
8
- <!-- Project styles (optional) -->
9
- <link rel="stylesheet" href="style.css" />
10
-
11
- <!-- Tailwind (CDN is fine for prototypes; for production, compile Tailwind) -->
12
- <script src="https://cdn.tailwindcss.com"></script>
13
-
14
- <!-- Feather Icons (include once) -->
15
- <script defer src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
16
-
17
- <style>
18
- img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
19
- </style>
20
- </head>
21
-
22
- <body class="bg-gray-50 min-h-screen">
23
- <!-- Page container (matches the previous site's “white card + subtle ring” style) -->
24
- <main class="max-w-4xl mx-auto my-8 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8">
25
- <!-- Top Header (logo + app name) -->
26
- <header class="mb-10 border-b border-gray-200 pb-4">
27
- <div class="grid grid-cols-[auto,1fr,auto] items-center gap-4">
28
- <!-- Logo -->
29
- <div class="flex items-center">
30
- <img
31
- src="assets/prosento-logo.png"
32
- alt="Company logo"
33
- class="h-12 w-auto object-contain"
34
- loading="eager"
35
- />
36
- </div>
37
-
38
- <!-- Title -->
39
- <div class="text-center">
40
- <h1 class="text-2xl md:text-3xl font-bold text-gray-900 whitespace-nowrap">
41
- Prosento RepEx
42
- </h1>
43
- <p class="text-gray-600 whitespace-nowrap">
44
- Upload photos and documents to generate professional job reports instantly
45
- </p>
46
- </div>
47
-
48
- <!-- Right action -->
49
- <div class="flex justify-end">
50
- <a
51
- href="#upload"
52
- class="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"
53
- >
54
- <i data-feather="arrow-down-circle" class="h-4 w-4"></i>
55
- Upload
56
- </a>
57
- </div>
58
- </div>
59
- </header>
60
-
61
- <!-- Hero CTA -->
62
- <section class="text-center mb-12">
63
- <h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-3">
64
- Generate inspection-ready reports in minutes
65
- </h2>
66
- <p class="text-base md:text-lg text-gray-600 mb-8">
67
- Drag-and-drop files, add quick context, and produce a clean, consistent report format every time.
68
- </p>
69
-
70
- <div class="flex flex-col sm:flex-row justify-center gap-3">
71
- <a
72
- href="#upload"
73
- class="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-white font-semibold hover:bg-blue-700 transition"
74
- >
75
- <i data-feather="upload" class="h-5 w-5"></i>
76
- Start Uploading
77
- </a>
78
-
79
- <a
80
- href="#how-it-works"
81
- class="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-6 py-3 text-gray-800 font-semibold hover:bg-gray-50 transition"
82
- >
83
- <i data-feather="info" class="h-5 w-5"></i>
84
- Learn More
85
- </a>
86
- </div>
87
- </section>
88
-
89
- <!-- How it works -->
90
- <section id="how-it-works" class="mb-12">
91
- <h2 class="text-xl md:text-2xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-6">
92
- How It Works
93
- </h2>
94
-
95
- <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
96
- <article class="rounded-lg border border-gray-200 bg-gray-50 p-5">
97
- <div class="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-blue-50 border border-blue-100">
98
- <i data-feather="camera" class="h-5 w-5 text-blue-700"></i>
99
- </div>
100
- <h3 class="text-base font-semibold text-gray-900 mb-1">Capture site photos</h3>
101
- <p class="text-sm text-gray-600">
102
- Take clear photos of structural elements and issues during your inspection.
103
- </p>
104
- </article>
105
-
106
- <article class="rounded-lg border border-gray-200 bg-gray-50 p-5">
107
- <div class="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-emerald-50 border border-emerald-100">
108
- <i data-feather="upload-cloud" class="h-5 w-5 text-emerald-700"></i>
109
- </div>
110
- <h3 class="text-base font-semibold text-gray-900 mb-1">Upload documents</h3>
111
- <p class="text-sm text-gray-600">
112
- Add notes, measurements, and supporting PDFs/DOCX to complete the context.
113
- </p>
114
- </article>
115
-
116
- <article class="rounded-lg border border-gray-200 bg-gray-50 p-5">
117
- <div class="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-blue-50 border border-blue-100">
118
- <i data-feather="file-text" class="h-5 w-5 text-blue-700"></i>
119
- </div>
120
- <h3 class="text-base font-semibold text-gray-900 mb-1">Generate report</h3>
121
- <p class="text-sm text-gray-600">
122
- Produce a consistent, professional report that’s ready for submission.
123
- </p>
124
- </article>
125
- </div>
126
- </section>
127
-
128
- <!-- Upload -->
129
- <section id="upload" class="mb-12">
130
- <h2 class="text-xl md:text-2xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-6">
131
- Upload Your Files
132
- </h2>
133
-
134
- <div class="rounded-lg border border-gray-200 bg-white p-6">
135
- <!-- Drop zone -->
136
- <div
137
- id="dropZone"
138
- class="rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center hover:border-blue-400 transition mb-6"
139
- role="button"
140
- tabindex="0"
141
- aria-label="Drag and drop files here"
142
- >
143
- <div class="mx-auto mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 border border-blue-100">
144
- <i data-feather="upload" class="h-5 w-5 text-blue-700"></i>
145
- </div>
146
-
147
- <h3 class="text-base font-semibold text-gray-900">Drag &amp; drop files here</h3>
148
- <p class="text-sm text-gray-600 mt-1 mb-4">or browse to upload</p>
149
-
150
- <button
151
- id="browseFiles"
152
- type="button"
153
- class="inline-flex items-center justify-center rounded-lg bg-blue-600 px-5 py-2.5 text-white font-semibold hover:bg-blue-700 transition"
154
- >
155
- Browse Files
156
- </button>
157
-
158
- <input
159
- id="uploadInput"
160
- type="file"
161
- class="hidden"
162
- multiple
163
- accept=".jpg,.jpeg,.png,.webp,.pdf,.doc,.docx,.csv,.xls,.xlsx"
164
- />
165
-
166
- <p id="fileList" class="text-xs text-gray-500 mt-4">
167
- Supports JPG, PNG, PDF, DOCX, CSV, XLSX (Max 50MB each)
168
- </p>
169
- </div>
170
-
171
- <!-- Metadata -->
172
- <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
173
- <div class="space-y-1">
174
- <label for="projectName" class="block text-sm font-medium text-gray-700">Project Name</label>
175
- <input
176
- id="projectName"
177
- name="projectName"
178
- type="text"
179
- placeholder="e.g., North Pit Conveyor Support"
180
- class="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500"
181
- />
182
- </div>
183
-
184
- <div class="space-y-1">
185
- <label for="inspectionDate" class="block text-sm font-medium text-gray-700">Inspection Date</label>
186
- <input
187
- id="inspectionDate"
188
- name="inspectionDate"
189
- type="date"
190
- class="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500"
191
- />
192
- </div>
193
- </div>
194
-
195
- <div class="space-y-1 mb-6">
196
- <label for="notes" class="block text-sm font-medium text-gray-700">Additional Notes</label>
197
- <textarea
198
- id="notes"
199
- name="notes"
200
- rows="4"
201
- placeholder="Add any context you'd like included in the report..."
202
- class="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500"
203
- ></textarea>
204
- </div>
205
-
206
- <button
207
- id="generateReport"
208
- type="button"
209
- class="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-6 py-3 text-white font-semibold hover:bg-emerald-700 transition"
210
- >
211
- <i data-feather="file-plus" class="h-5 w-5"></i>
212
- Generate Report
213
- </button>
214
- <p id="uploadStatus" class="text-sm text-gray-600 mt-3"></p>
215
- </div>
216
- </section>
217
-
218
- <!-- Recent Reports -->
219
- <section class="mb-6">
220
- <h2 class="text-xl md:text-2xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-6">
221
- Recent Reports
222
- </h2>
223
-
224
- <div class="rounded-lg border border-gray-200 bg-white overflow-hidden">
225
- <div class="overflow-x-auto">
226
- <table class="min-w-full divide-y divide-gray-200">
227
- <thead class="bg-gray-50">
228
- <tr>
229
- <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
230
- Report ID
231
- </th>
232
- <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
233
- Project
234
- </th>
235
- <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
236
- Date
237
- </th>
238
- <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
239
- Status
240
- </th>
241
- <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
242
- Actions
243
- </th>
244
- </tr>
245
- </thead>
246
-
247
- <tbody id="recentReports" class="bg-white divide-y divide-gray-200">
248
- <tr>
249
- <td colspan="5" class="px-6 py-6 text-sm text-gray-500 text-center">
250
- No recent reports yet.
251
- </td>
252
- </tr>
253
- </tbody>
254
- </table>
255
- </div>
256
- </div>
257
- </section>
258
-
259
- <!-- Footer -->
260
- <footer class="mt-12 text-center text-xs text-gray-500">
261
- <p>Prosento - © 2026 All Rights Reserved</p>
262
- <p class="mt-1">RepEx is a report automation interface. All uploads should comply with site data policies.</p>
263
- </footer>
264
- </main>
265
-
266
- <!-- Your app script (optional) -->
267
- <script src="script.js"></script>
268
-
269
- <script>
270
- document.addEventListener('DOMContentLoaded', () => {
271
- if (window.feather && typeof window.feather.replace === 'function') {
272
- window.feather.replace();
273
- }
274
-
275
- const dropZone = document.getElementById('dropZone');
276
- const browseFiles = document.getElementById('browseFiles');
277
- const uploadInput = document.getElementById('uploadInput');
278
- const fileList = document.getElementById('fileList');
279
- const generateReport = document.getElementById('generateReport');
280
- const uploadStatus = document.getElementById('uploadStatus');
281
-
282
- const projectName = document.getElementById('projectName');
283
- const inspectionDate = document.getElementById('inspectionDate');
284
- const notes = document.getElementById('notes');
285
-
286
- let selectedFiles = [];
287
-
288
- const updateFileList = () => {
289
- if (!selectedFiles.length) {
290
- fileList.textContent = 'Supports JPG, PNG, PDF, DOCX, CSV, XLSX (Max 50MB each)';
291
- return;
292
- }
293
- const summary = selectedFiles
294
- .map((file) => `${file.name} (${window.REPEX.formatBytes(file.size)})`)
295
- .join(' • ');
296
- fileList.textContent = summary;
297
- };
298
-
299
- browseFiles.addEventListener('click', () => uploadInput.click());
300
- uploadInput.addEventListener('change', (event) => {
301
- selectedFiles = Array.from(event.target.files || []);
302
- updateFileList();
303
- });
304
-
305
- const setDragState = (active) => {
306
- dropZone.classList.toggle('border-blue-500', active);
307
- dropZone.classList.toggle('bg-blue-50', active);
308
- };
309
-
310
- ['dragenter', 'dragover'].forEach((evt) => {
311
- dropZone.addEventListener(evt, (event) => {
312
- event.preventDefault();
313
- setDragState(true);
314
- });
315
- });
316
- ['dragleave', 'drop'].forEach((evt) => {
317
- dropZone.addEventListener(evt, (event) => {
318
- event.preventDefault();
319
- setDragState(false);
320
- });
321
- });
322
- dropZone.addEventListener('drop', (event) => {
323
- const files = Array.from(event.dataTransfer.files || []);
324
- if (files.length) {
325
- selectedFiles = files;
326
- uploadInput.value = '';
327
- updateFileList();
328
- }
329
- });
330
-
331
- generateReport.addEventListener('click', async () => {
332
- if (!selectedFiles.length) {
333
- uploadStatus.textContent = 'Please add at least one file to continue.';
334
- uploadStatus.classList.add('text-amber-700');
335
- return;
336
- }
337
- generateReport.disabled = true;
338
- uploadStatus.textContent = 'Uploading files...';
339
- uploadStatus.classList.remove('text-amber-700');
340
-
341
- const formData = new FormData();
342
- formData.append('project_name', projectName.value.trim());
343
- formData.append('inspection_date', inspectionDate.value);
344
- formData.append('notes', notes.value.trim());
345
- selectedFiles.forEach((file) => formData.append('files', file));
346
-
347
- try {
348
- const session = await window.REPEX.postForm('/sessions', formData);
349
- window.REPEX.setSessionId(session.id);
350
- window.location.href = `processing.html?session=${encodeURIComponent(session.id)}`;
351
- } catch (err) {
352
- uploadStatus.textContent = err.message || 'Upload failed.';
353
- uploadStatus.classList.add('text-red-600');
354
- generateReport.disabled = false;
355
- }
356
- });
357
-
358
- async function loadRecentReports() {
359
- const table = document.getElementById('recentReports');
360
- if (!table) return;
361
- try {
362
- const sessions = await window.REPEX.request('/sessions');
363
- if (!Array.isArray(sessions) || sessions.length === 0) {
364
- return;
365
- }
366
- table.innerHTML = '';
367
- sessions.slice(0, 5).forEach((session) => {
368
- const row = document.createElement('tr');
369
- row.innerHTML = `
370
- <td class="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">#${session.id.slice(0, 8)}</td>
371
- <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">${session.project_name || 'Untitled project'}</td>
372
- <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">${session.created_at ? new Date(session.created_at).toLocaleDateString() : '-'}</td>
373
- <td class="px-6 py-4 whitespace-nowrap">
374
- <span class="inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-semibold bg-emerald-50 text-emerald-700 border-emerald-200">
375
- ${session.status || 'ready'}
376
- </span>
377
- </td>
378
- <td class="px-6 py-4 whitespace-nowrap text-sm">
379
- <a href="report-viewer.html?session=${session.id}" class="inline-flex items-center text-blue-700 hover:text-blue-800" aria-label="View report">
380
- <i data-feather="edit" class="h-4 w-4"></i>
381
- </a>
382
- </td>
383
- `;
384
- table.appendChild(row);
385
- });
386
- if (window.feather && typeof window.feather.replace === 'function') {
387
- window.feather.replace();
388
- }
389
- } catch {
390
- // ignore for now
391
- }
392
- }
393
-
394
- loadRecentReports();
395
- });
396
- </script>
397
- </body>
398
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
legacy/static-site/processing.html DELETED
@@ -1,80 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width,initial-scale=1" />
6
- <title>Processing - RepEx</title>
7
-
8
- <link rel="stylesheet" href="style.css" />
9
- <script src="https://cdn.tailwindcss.com"></script>
10
- <script defer src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
11
- </head>
12
-
13
- <body class="bg-gray-50 min-h-screen">
14
- <main class="max-w-2xl mx-auto my-10 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8">
15
- <header class="flex items-center gap-4 border-b border-gray-200 pb-4 mb-6">
16
- <img
17
- src="assets/prosento-logo.png"
18
- alt="Prosento logo"
19
- class="h-10 w-auto object-contain"
20
- loading="eager"
21
- />
22
- <div>
23
- <h1 class="text-xl font-semibold text-gray-900">Prosento RepEx</h1>
24
- <p class="text-sm text-gray-600">Report processing</p>
25
- </div>
26
- </header>
27
-
28
- <section class="text-center">
29
- <div class="mx-auto mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 border border-blue-100">
30
- <i data-feather="loader" class="h-5 w-5 text-blue-700"></i>
31
- </div>
32
- <h2 class="text-2xl font-semibold text-gray-900 mb-2">Generating your report</h2>
33
- <p class="text-gray-600 mb-6">
34
- We are preparing your files and building the final report. This may take a few minutes.
35
- </p>
36
-
37
- <div class="w-full rounded-full bg-gray-200 h-3 overflow-hidden">
38
- <div class="h-full bg-blue-600 w-1/2 animate-pulse"></div>
39
- </div>
40
-
41
- <p class="text-xs text-gray-500 mt-4">
42
- You can leave this tab open while we process your submission.
43
- </p>
44
- </section>
45
- </main>
46
-
47
- <script src="script.js"></script>
48
- <script>
49
- document.addEventListener('DOMContentLoaded', () => {
50
- if (window.feather && typeof window.feather.replace === 'function') {
51
- window.feather.replace();
52
- }
53
-
54
- const sessionId = window.REPEX.getSessionId();
55
- window.REPEX.setSessionId(sessionId);
56
- if (!sessionId) {
57
- const message = document.createElement('p');
58
- message.className = 'text-sm text-red-600 mt-6 text-center';
59
- message.textContent = 'No active session found. Return to the upload page.';
60
- document.querySelector('main').appendChild(message);
61
- return;
62
- }
63
-
64
- const poll = async () => {
65
- try {
66
- const status = await window.REPEX.request(`/sessions/${sessionId}/status`);
67
- if (status?.status === 'ready') {
68
- window.location.href = `review-setup.html?session=${encodeURIComponent(sessionId)}`;
69
- }
70
- } catch {
71
- // keep polling on transient errors
72
- }
73
- };
74
-
75
- poll();
76
- setInterval(poll, 2000);
77
- });
78
- </script>
79
- </body>
80
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
legacy/static-site/report-viewer.html DELETED
@@ -1,412 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width,initial-scale=1" />
6
- <title>RepEx - Report Express | Report Viewer</title>
7
-
8
- <link rel="stylesheet" href="style.css" />
9
- <script src="https://cdn.tailwindcss.com"></script>
10
- <script src="components/report-editor.js"></script>
11
- <script defer src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
12
-
13
- <style>
14
- @page { size: A4; margin: 0; }
15
-
16
- /* A4 canvas container */
17
- .a4-page {
18
- aspect-ratio: 210 / 297;
19
- width: min(100%, 560px);
20
- border-radius: 12px;
21
- }
22
-
23
- /* Viewer uses white page unless empty */
24
- .page-white {
25
- background: #ffffff;
26
- border: 1px solid #e5e7eb; /* gray-200 */
27
- }
28
-
29
- /* If a page has no items, show placeholder red */
30
- .page-empty {
31
- background: #fee2e2; /* red-200-ish */
32
- border: 1px solid #fca5a5; /* red-300-ish */
33
- }
34
-
35
- @media print {
36
- body { background: #fff !important; }
37
- .print-a4 {
38
- width: 210mm !important;
39
- height: 297mm !important;
40
- aspect-ratio: auto !important;
41
- border-radius: 0 !important;
42
- box-shadow: none !important;
43
- }
44
- .no-print { display: none !important; }
45
- main { box-shadow: none !important; border: none !important; margin: 0 !important; padding: 0 !important; }
46
- }
47
-
48
- img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
49
- </style>
50
- </head>
51
-
52
- <body class="bg-gray-50 min-h-screen">
53
- <main class="max-w-4xl mx-auto my-8 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8 print:ring-0 print:shadow-none print:rounded-none">
54
- <!-- Header -->
55
- <header class="mb-8 border-b border-gray-200 pb-4">
56
- <div class="grid grid-cols-[auto,1fr,auto] items-center gap-4">
57
- <div class="flex items-center">
58
- <img
59
- src="assets/prosento-logo.png"
60
- alt="Company logo"
61
- class="h-12 w-auto object-contain"
62
- loading="eager"
63
- />
64
- </div>
65
-
66
- <div class="text-center">
67
- <h1 class="text-2xl md:text-3xl font-bold text-gray-900 whitespace-nowrap">
68
- RepEx - Report Express
69
- </h1>
70
- <p class="text-gray-600 whitespace-nowrap">Report Viewer</p>
71
- </div>
72
-
73
- <div class="flex justify-end gap-2 no-print">
74
- <a
75
- href="review-setup.html"
76
- class="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"
77
- >
78
- <i data-feather="arrow-left" class="h-4 w-4"></i>
79
- Back
80
- </a>
81
- </div>
82
- </div>
83
- </header>
84
-
85
- <!-- Workflow buttons -->
86
- <nav class="mb-6 no-print" aria-label="Report workflow navigation">
87
- <div class="flex flex-wrap gap-2">
88
- <span
89
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-900 px-4 py-2 text-sm font-semibold text-white"
90
- >
91
- <i data-feather="layout" class="h-4 w-4"></i>
92
- Report Viewer
93
- </span>
94
-
95
- <button
96
- type="button"
97
- id="edit-report"
98
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
99
- >
100
- <i data-feather="edit-3" class="h-4 w-4"></i>
101
- Edit Report
102
- </button>
103
-
104
- <a
105
- href="edit-layouts.html"
106
- class="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"
107
- >
108
- <i data-feather="grid" class="h-4 w-4"></i>
109
- Edit Page Layouts
110
- </a>
111
-
112
- <a
113
- href="export.html"
114
- class="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"
115
- >
116
- <i data-feather="download" class="h-4 w-4"></i>
117
- Export
118
- </a>
119
- </div>
120
- </nav>
121
-
122
- <!-- Viewer -->
123
- <section id="viewerSection" aria-label="Report viewer">
124
- <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
125
- <div>
126
- <h2 class="text-xl font-semibold text-gray-800">Report Pages</h2>
127
- <p id="viewerMeta" class="text-sm text-gray-600">Loading…</p>
128
- </div>
129
-
130
- <div class="flex items-center gap-2 no-print">
131
- <button
132
- id="prevPage"
133
- type="button"
134
- class="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"
135
- >
136
- <i data-feather="chevron-left" class="h-4 w-4"></i>
137
- Prev
138
- </button>
139
-
140
- <div class="text-sm font-semibold text-gray-700">
141
- Page <span id="pageNumber">1</span> / <span id="pageTotal">1</span>
142
- </div>
143
-
144
- <button
145
- id="nextPage"
146
- type="button"
147
- class="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"
148
- >
149
- Next
150
- <i data-feather="chevron-right" class="h-4 w-4"></i>
151
- </button>
152
- </div>
153
- </div>
154
-
155
- <!-- Page stage -->
156
- <div class="flex justify-center">
157
- <div
158
- id="pageStage"
159
- class="a4-page print-a4 relative overflow-hidden shadow-sm page-empty"
160
- aria-label="Rendered A4 page"
161
- ></div>
162
- </div>
163
-
164
- <p class="mt-4 text-xs text-gray-500 no-print">
165
- Tip: Use keyboard arrows (← / →) to change pages.
166
- </p>
167
- </section>
168
-
169
- <!-- Footer -->
170
- <footer class="mt-12 text-center text-xs text-gray-500 no-print">
171
- <p>Prosento - © 2026 All Rights Reserved</p>
172
- <p class="mt-1">Viewer now renders saved edits from the editor.</p>
173
- </footer>
174
- </main>
175
-
176
- <script src="script.js"></script>
177
- <script>
178
- document.addEventListener('DOMContentLoaded', () => {
179
- if (window.feather && typeof window.feather.replace === 'function') {
180
- window.feather.replace();
181
- }
182
-
183
- const sessionId = window.REPEX.getSessionId();
184
- window.REPEX.setSessionId(sessionId);
185
- if (!sessionId) {
186
- const meta = document.getElementById('viewerMeta');
187
- if (meta) meta.textContent = 'No active session found. Return to upload to continue.';
188
- return;
189
- }
190
-
191
- const editor = document.createElement('report-editor');
192
- document.body.appendChild(editor);
193
- editor.style.display = 'none';
194
-
195
- const viewerSection = document.getElementById('viewerSection');
196
- const pageStage = document.getElementById('pageStage');
197
- const viewerMeta = document.getElementById('viewerMeta');
198
-
199
- const prevPageBtn = document.getElementById('prevPage');
200
- const nextPageBtn = document.getElementById('nextPage');
201
- const pageNumber = document.getElementById('pageNumber');
202
- const pageTotal = document.getElementById('pageTotal');
203
- const editBtn = document.getElementById('edit-report');
204
-
205
- const BASE_W = 595;
206
- const BASE_H = 842;
207
-
208
- const state = {
209
- pageIndex: 0,
210
- totalPages: 6,
211
- editMode: false,
212
- pages: [],
213
- session: null,
214
- };
215
-
216
- function setMeta() {
217
- const selected = state.session?.selected_photo_ids?.length ?? 0;
218
- const docs = state.session?.uploads?.documents?.length ?? 0;
219
- const dataFiles = state.session?.uploads?.data_files?.length ?? 0;
220
- const hasEdits = state.pages.length > 0;
221
- viewerMeta.textContent =
222
- `Selected example photos: ${selected} ? Documents: ${docs} ? Data files: ${dataFiles}` +
223
- (hasEdits ? ' ? Edited pages loaded' : ' ? No saved edits yet');
224
- }
225
-
226
- function renderControls() {
227
- pageTotal.textContent = String(state.totalPages || 1);
228
- pageNumber.textContent = String(state.pageIndex + 1);
229
- prevPageBtn.disabled = state.pageIndex === 0;
230
- nextPageBtn.disabled = state.pageIndex >= state.totalPages - 1;
231
- }
232
-
233
- function stageScale() {
234
- const rect = pageStage.getBoundingClientRect();
235
- return rect.width / BASE_W;
236
- }
237
-
238
- function clearStage() {
239
- pageStage.innerHTML = '';
240
- }
241
-
242
- function renderStageFromPage(pageObj) {
243
- clearStage();
244
-
245
- const items = pageObj?.items ?? [];
246
- const hasItems = items.length > 0;
247
-
248
- pageStage.classList.toggle('page-empty', !hasItems);
249
- pageStage.classList.toggle('page-white', hasItems);
250
-
251
- if (!hasItems) return;
252
-
253
- const scale = stageScale();
254
-
255
- items
256
- .slice()
257
- .sort((a, b) => (a.z ?? 0) - (b.z ?? 0))
258
- .forEach(item => {
259
- const wrap = document.createElement('div');
260
- wrap.className = 'absolute';
261
- wrap.style.left = `${item.x * scale}px`;
262
- wrap.style.top = `${item.y * scale}px`;
263
- wrap.style.width = `${item.w * scale}px`;
264
- wrap.style.height = `${item.h * scale}px`;
265
- wrap.style.zIndex = String(item.z ?? 0);
266
-
267
- if (item.type === 'text') {
268
- const d = document.createElement('div');
269
- d.className = 'w-full h-full p-2 overflow-hidden';
270
- d.style.whiteSpace = 'pre-wrap';
271
- d.style.outline = 'none';
272
-
273
- const fontSize = (item.style?.fontSize ?? 14) * scale;
274
- d.style.fontSize = `${fontSize}px`;
275
- d.style.fontWeight = item.style?.bold ? '700' : '400';
276
- d.style.fontStyle = item.style?.italic ? 'italic' : 'normal';
277
- d.style.textDecoration = item.style?.underline ? 'underline' : 'none';
278
- d.style.color = item.style?.color ?? '#111827';
279
- d.style.textAlign = item.style?.align ?? 'left';
280
- d.textContent = item.content ?? '';
281
-
282
- wrap.appendChild(d);
283
- }
284
-
285
- if (item.type === 'image') {
286
- const img = document.createElement('img');
287
- img.className = 'w-full h-full object-contain bg-white';
288
- img.src = item.src;
289
- img.alt = item.name ?? 'Image';
290
- img.loading = 'eager';
291
- wrap.appendChild(img);
292
- }
293
-
294
- if (item.type === 'rect') {
295
- const box = document.createElement('div');
296
- box.className = 'w-full h-full';
297
- box.style.background = item.style?.fill ?? '#ffffff';
298
- box.style.borderStyle = 'solid';
299
- box.style.borderColor = item.style?.stroke ?? '#111827';
300
- box.style.borderWidth = `${(item.style?.strokeWidth ?? 1) * scale}px`;
301
- wrap.appendChild(box);
302
- }
303
-
304
- pageStage.appendChild(wrap);
305
- });
306
- }
307
-
308
- async function fetchSession() {
309
- const session = await window.REPEX.request(`/sessions/${sessionId}`);
310
- state.session = session;
311
- state.totalPages = session.page_count || state.totalPages;
312
- }
313
-
314
- async function fetchPages() {
315
- try {
316
- const result = await window.REPEX.request(`/sessions/${sessionId}/pages`);
317
- state.pages = Array.isArray(result?.pages) ? result.pages : [];
318
- if (state.pages.length) {
319
- state.totalPages = Math.max(state.totalPages, state.pages.length);
320
- }
321
- } catch {
322
- state.pages = [];
323
- }
324
- }
325
-
326
- function renderPage() {
327
- renderControls();
328
- const pageObj = state.pages?.[state.pageIndex] ?? null;
329
- renderStageFromPage(pageObj);
330
- setMeta();
331
- }
332
-
333
- function nextPage() {
334
- if (state.pageIndex < state.totalPages - 1) {
335
- state.pageIndex += 1;
336
- renderPage();
337
- }
338
- }
339
-
340
- function prevPage() {
341
- if (state.pageIndex > 0) {
342
- state.pageIndex -= 1;
343
- renderPage();
344
- }
345
- }
346
-
347
- function openEditor() {
348
- state.editMode = true;
349
- editor.style.display = 'block';
350
- viewerSection.classList.add('hidden');
351
-
352
- editor.open({
353
- payload: state.session,
354
- pageIndex: state.pageIndex,
355
- totalPages: state.totalPages,
356
- sessionId,
357
- });
358
-
359
- editBtn.classList.add('bg-gray-900', 'text-white', 'border-gray-900');
360
- editBtn.classList.remove('bg-white', 'text-gray-800', 'border-gray-200');
361
-
362
- if (window.feather && typeof window.feather.replace === 'function') {
363
- window.feather.replace();
364
- }
365
- }
366
-
367
- async function closeEditor() {
368
- state.editMode = false;
369
- editor.style.display = 'none';
370
- viewerSection.classList.remove('hidden');
371
-
372
- editBtn.classList.remove('bg-gray-900', 'text-white', 'border-gray-900');
373
- editBtn.classList.add('bg-white', 'text-gray-800', 'border-gray-200');
374
-
375
- await fetchPages();
376
- renderPage();
377
- }
378
-
379
- prevPageBtn.addEventListener('click', prevPage);
380
- nextPageBtn.addEventListener('click', nextPage);
381
-
382
- editBtn.addEventListener('click', () => {
383
- if (!state.editMode) openEditor();
384
- });
385
-
386
- editor.addEventListener('editor-closed', () => {
387
- closeEditor();
388
- });
389
-
390
- window.addEventListener('keydown', (e) => {
391
- if (state.editMode) return;
392
- if (e.key === 'ArrowRight') nextPage();
393
- if (e.key === 'ArrowLeft') prevPage();
394
- });
395
-
396
- window.addEventListener('resize', () => {
397
- if (!state.editMode) renderPage();
398
- });
399
-
400
- window.addEventListener('beforeprint', () => {
401
- document.querySelectorAll('img').forEach(img => (img.loading = 'eager'));
402
- });
403
-
404
- (async () => {
405
- await fetchSession();
406
- await fetchPages();
407
- renderPage();
408
- })();
409
- });
410
- </script>
411
- </body>
412
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
legacy/static-site/review-setup.html DELETED
@@ -1,376 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width,initial-scale=1" />
6
- <title>RepEx - Report Express | Review & Setup</title>
7
-
8
- <link rel="stylesheet" href="style.css" />
9
- <script src="https://cdn.tailwindcss.com"></script>
10
- <script defer src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
11
-
12
- <style>
13
- img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
14
- </style>
15
- </head>
16
-
17
- <body class="bg-gray-50 min-h-screen">
18
- <main class="max-w-4xl mx-auto my-8 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8">
19
- <!-- Header -->
20
- <header class="mb-8 border-b border-gray-200 pb-4">
21
- <div class="grid grid-cols-[auto,1fr,auto] items-center gap-4">
22
- <div class="flex items-center">
23
- <img
24
- src="assets/prosento-logo.png"
25
- alt="Company logo"
26
- class="h-12 w-auto object-contain"
27
- loading="eager"
28
- />
29
- </div>
30
-
31
- <div class="text-center">
32
- <h1 class="text-2xl md:text-3xl font-bold text-gray-900 whitespace-nowrap">
33
- RepEx - Report Express
34
- </h1>
35
- <p class="text-gray-600 whitespace-nowrap">
36
- Review uploads → pick examples → continue to report viewer
37
- </p>
38
- </div>
39
-
40
- <div class="flex justify-end">
41
- <span class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-xs font-semibold text-gray-700">
42
- <i data-feather="check-circle" class="h-4 w-4"></i>
43
- Uploads processed
44
- </span>
45
- </div>
46
- </div>
47
- </header>
48
-
49
- <!-- Orientation -->
50
- <section class="mb-8" aria-labelledby="what-next">
51
- <h2 id="what-next" class="text-xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-4">
52
- What happens on this page
53
- </h2>
54
-
55
- <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
56
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
57
- <div class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-blue-50 border border-blue-100 mb-3">
58
- <i data-feather="image" class="h-5 w-5 text-blue-700"></i>
59
- </div>
60
- <h3 class="font-semibold text-gray-900 mb-1">Select example photos</h3>
61
- <p class="text-sm text-gray-600">
62
- Choose which uploaded images should appear as example figures in the report.
63
- </p>
64
- </div>
65
-
66
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
67
- <div class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-emerald-50 border border-emerald-100 mb-3">
68
- <i data-feather="file-text" class="h-5 w-5 text-emerald-700"></i>
69
- </div>
70
- <h3 class="font-semibold text-gray-900 mb-1">Confirm documents</h3>
71
- <p class="text-sm text-gray-600">
72
- Ensure supporting PDFs/DOCX are correct (and later attach to export if needed).
73
- </p>
74
- </div>
75
-
76
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
77
- <div class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-amber-50 border border-amber-100 mb-3">
78
- <i data-feather="table" class="h-5 w-5 text-amber-700"></i>
79
- </div>
80
- <h3 class="font-semibold text-gray-900 mb-1">Use Excel/CSV data</h3>
81
- <p class="text-sm text-gray-600">
82
- If Excel/CSV exists, it will populate report data areas automatically in later steps.
83
- </p>
84
- </div>
85
- </div>
86
- </section>
87
-
88
- <!-- Review uploads -->
89
- <section class="mb-8" aria-labelledby="review-uploads">
90
- <h2 id="review-uploads" class="text-xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-6">
91
- Review uploaded files
92
- </h2>
93
-
94
- <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
95
- <!-- Photos -->
96
- <div class="lg:col-span-2">
97
- <div class="flex items-center justify-between mb-3">
98
- <h3 class="text-lg font-semibold text-gray-900">Photos</h3>
99
- <span id="photoCount" class="text-sm font-semibold text-gray-600">0 files</span>
100
- </div>
101
-
102
- <div class="rounded-lg border border-gray-200 bg-white p-4">
103
- <p class="text-sm text-gray-600 mb-3">
104
- Select images to use as example figures in the report.
105
- <span class="font-semibold text-gray-800">Recommended:</span> 2–6 images.
106
- </p>
107
-
108
- <div id="photoGrid" class="grid grid-cols-2 md:grid-cols-3 gap-3">
109
- <!-- populated by JS -->
110
- </div>
111
-
112
- <div class="mt-4 flex flex-wrap items-center justify-between gap-3">
113
- <div class="text-sm text-gray-600">
114
- Selected for report: <span id="photoSelected" class="font-semibold text-gray-900">0</span>
115
- </div>
116
- <div class="flex gap-2">
117
- <button id="selectAllPhotos" type="button"
118
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition">
119
- <i data-feather="check-square" class="h-4 w-4"></i>
120
- Select all
121
- </button>
122
- <button id="clearPhotos" type="button"
123
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition">
124
- <i data-feather="square" class="h-4 w-4"></i>
125
- Clear
126
- </button>
127
- </div>
128
- </div>
129
- </div>
130
- </div>
131
-
132
- <!-- Documents + Data files -->
133
- <div class="space-y-6">
134
- <!-- Documents -->
135
- <div>
136
- <div class="flex items-center justify-between mb-3">
137
- <h3 class="text-lg font-semibold text-gray-900">Documents</h3>
138
- <span id="docCount" class="text-sm font-semibold text-gray-600">0 files</span>
139
- </div>
140
-
141
- <div class="rounded-lg border border-gray-200 bg-white p-4">
142
- <ul id="docList" class="space-y-2 text-sm text-gray-700">
143
- <!-- populated by JS -->
144
- </ul>
145
- <p id="docHint" class="text-xs text-gray-500 mt-3">
146
- PDFs/DOCX appear here after processing.
147
- </p>
148
- </div>
149
- </div>
150
-
151
- <!-- Data files -->
152
- <div>
153
- <div class="flex items-center justify-between mb-3">
154
- <h3 class="text-lg font-semibold text-gray-900">Data files</h3>
155
- <span id="dataCount" class="text-sm font-semibold text-gray-600">0 files</span>
156
- </div>
157
-
158
- <div class="rounded-lg border border-gray-200 bg-white p-4">
159
- <div id="dataBox" class="text-sm text-gray-700">
160
- <!-- populated by JS -->
161
- </div>
162
- <p class="text-xs text-gray-500 mt-3">
163
- If present, these files will populate report tables/fields automatically.
164
- </p>
165
- </div>
166
- </div>
167
- </div>
168
- </div>
169
- </section>
170
-
171
- <!-- Continue -->
172
- <section class="mb-4" aria-label="Continue to report viewer">
173
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-4 flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
174
- <div class="text-sm text-gray-600">
175
- Next step:
176
- <span id="readyStatus" class="font-semibold text-amber-700">Choose report example images to continue…</span>
177
- </div>
178
-
179
- <button
180
- id="continueBtn"
181
- type="button"
182
- class="inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-5 py-2.5 text-white font-semibold hover:bg-emerald-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
183
- disabled
184
- >
185
- <i data-feather="arrow-right" class="h-5 w-5"></i>
186
- Continue to Report Viewer
187
- </button>
188
- </div>
189
-
190
- <p class="text-xs text-gray-500 mt-3">
191
- Note: This page assumes uploads were completed on a previous “Processing” page.
192
- </p>
193
- </section>
194
-
195
- <!-- Footer -->
196
- <footer class="mt-12 text-center text-xs text-gray-500">
197
- <p>Prosento - © 2026 All Rights Reserved</p>
198
- <p class="mt-1">Workflow: Processing → Review uploads → Report viewer → Edit → Export</p>
199
- </footer>
200
- </main>
201
-
202
- <script src="script.js"></script>
203
- <script>
204
- document.addEventListener('DOMContentLoaded', () => {
205
- if (window.feather && typeof window.feather.replace === 'function') {
206
- window.feather.replace();
207
- }
208
-
209
- const photoGrid = document.getElementById('photoGrid');
210
- const photoCount = document.getElementById('photoCount');
211
- const photoSelected = document.getElementById('photoSelected');
212
- const selectAllPhotosBtn = document.getElementById('selectAllPhotos');
213
- const clearPhotosBtn = document.getElementById('clearPhotos');
214
-
215
- const docList = document.getElementById('docList');
216
- const docCount = document.getElementById('docCount');
217
- const docHint = document.getElementById('docHint');
218
-
219
- const dataBox = document.getElementById('dataBox');
220
- const dataCount = document.getElementById('dataCount');
221
-
222
- const readyStatus = document.getElementById('readyStatus');
223
- const continueBtn = document.getElementById('continueBtn');
224
-
225
- const sessionId = window.REPEX.getSessionId();
226
- window.REPEX.setSessionId(sessionId);
227
- if (!sessionId) {
228
- readyStatus.textContent = 'No active session found. Return to upload to continue.';
229
- readyStatus.className = 'font-semibold text-red-600';
230
- continueBtn.disabled = true;
231
- return;
232
- }
233
-
234
- const state = {
235
- selectedPhotoIds: new Set(),
236
- };
237
-
238
- function updateSelectionState() {
239
- photoSelected.textContent = String(state.selectedPhotoIds.size);
240
- const canContinue = state.selectedPhotoIds.size > 0;
241
- continueBtn.disabled = !canContinue;
242
-
243
- if (!canContinue) {
244
- readyStatus.textContent = 'Choose report example images to continue...';
245
- readyStatus.className = 'font-semibold text-amber-700';
246
- } else {
247
- readyStatus.textContent = 'Ready. Continue to report viewer.';
248
- readyStatus.className = 'font-semibold text-emerald-700';
249
- }
250
- }
251
-
252
- function renderUploads(uploads, selectedIds) {
253
- const photos = uploads.photos || [];
254
- photoCount.textContent = `${photos.length} file${photos.length === 1 ? '' : 's'}`;
255
-
256
- photoGrid.innerHTML = photos.length
257
- ? photos.map((p) => {
258
- const checked = selectedIds.includes(p.id) ? 'checked' : '';
259
- if (checked) state.selectedPhotoIds.add(p.id);
260
- return `
261
- <label class="group cursor-pointer">
262
- <input type="checkbox" class="sr-only photoCheck" data-photo-id="${p.id}" ${checked}>
263
- <div class="rounded-lg border border-gray-200 bg-gray-50 overflow-hidden group-has-[:checked]:ring-2 group-has-[:checked]:ring-emerald-200 group-has-[:checked]:border-emerald-300 transition">
264
- <div class="relative">
265
- <img src="${p.url}" alt="${p.name}" class="h-28 w-full object-cover" loading="eager">
266
- <div class="absolute top-2 right-2 inline-flex items-center justify-center rounded-full bg-white/90 border border-gray-200 p-1.5 text-gray-700 group-has-[:checked]:bg-emerald-50 group-has-[:checked]:border-emerald-200 group-has-[:checked]:text-emerald-700">
267
- <i data-feather="check" class="h-4 w-4"></i>
268
- </div>
269
- </div>
270
- <div class="p-2">
271
- <div class="text-xs font-semibold text-gray-900 truncate">${p.name}</div>
272
- <div class="text-xs text-gray-500">Click to select for report</div>
273
- </div>
274
- </div>
275
- </label>
276
- `;
277
- }).join('')
278
- : `<div class="col-span-full text-sm text-gray-500">No photos were uploaded.</div>`;
279
-
280
- const docs = uploads.documents || [];
281
- docCount.textContent = `${docs.length} file${docs.length === 1 ? '' : 's'}`;
282
- docList.innerHTML = docs.length
283
- ? docs.map(d => `
284
- <li class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
285
- <div class="flex items-center gap-2 min-w-0">
286
- <i data-feather="file-text" class="h-4 w-4 text-gray-600"></i>
287
- <span class="truncate text-gray-800">${d.name}</span>
288
- </div>
289
- <span class="text-xs font-semibold text-gray-600">${d.content_type || 'File'}</span>
290
- </li>
291
- `).join('')
292
- : `<li class="text-sm text-gray-500">No supporting documents detected.</li>`;
293
- docHint.style.display = docs.length ? 'none' : 'block';
294
-
295
- const dataFiles = uploads.data_files || [];
296
- dataCount.textContent = `${dataFiles.length} file${dataFiles.length === 1 ? '' : 's'}`;
297
- dataBox.innerHTML = dataFiles.length
298
- ? dataFiles.map(f => `
299
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700 mb-2 last:mb-0">
300
- <div class="flex items-center justify-between gap-3">
301
- <div class="flex items-center gap-2 min-w-0">
302
- <i data-feather="table" class="h-4 w-4 text-amber-700"></i>
303
- <span class="truncate font-semibold text-gray-900">${f.name}</span>
304
- </div>
305
- <span class="text-xs font-semibold text-gray-600">${f.content_type || 'Data'}</span>
306
- </div>
307
- <div class="text-xs text-gray-600 mt-1">
308
- Will populate report data areas (tables/fields).
309
- </div>
310
- </div>
311
- `).join('')
312
- : `
313
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm text-gray-600">
314
- <div class="font-semibold text-gray-800 mb-1">No Excel/CSV detected</div>
315
- If you upload a CSV or Excel file, RepEx can auto-populate report data fields.
316
- </div>
317
- `;
318
-
319
- if (window.feather && typeof window.feather.replace === 'function') {
320
- window.feather.replace();
321
- }
322
- }
323
-
324
- async function loadSession() {
325
- try {
326
- const session = await window.REPEX.request(`/sessions/${sessionId}`);
327
- renderUploads(session.uploads || {}, session.selected_photo_ids || []);
328
- updateSelectionState();
329
- } catch (err) {
330
- readyStatus.textContent = err.message || 'Failed to load session.';
331
- readyStatus.className = 'font-semibold text-red-600';
332
- }
333
- }
334
-
335
- photoGrid.addEventListener('change', (e) => {
336
- const cb = e.target.closest('.photoCheck');
337
- if (!cb) return;
338
- const id = cb.dataset.photoId;
339
- if (cb.checked) state.selectedPhotoIds.add(id);
340
- else state.selectedPhotoIds.delete(id);
341
- updateSelectionState();
342
- });
343
-
344
- selectAllPhotosBtn.addEventListener('click', () => {
345
- photoGrid.querySelectorAll('.photoCheck').forEach(cb => {
346
- cb.checked = true;
347
- state.selectedPhotoIds.add(cb.dataset.photoId);
348
- });
349
- updateSelectionState();
350
- });
351
-
352
- clearPhotosBtn.addEventListener('click', () => {
353
- photoGrid.querySelectorAll('.photoCheck').forEach(cb => (cb.checked = false));
354
- state.selectedPhotoIds.clear();
355
- updateSelectionState();
356
- });
357
-
358
- continueBtn.addEventListener('click', async () => {
359
- if (state.selectedPhotoIds.size === 0) return;
360
- const selectedIds = Array.from(state.selectedPhotoIds);
361
- try {
362
- await window.REPEX.putJson(`/sessions/${sessionId}/selection`, {
363
- selected_photo_ids: selectedIds,
364
- });
365
- window.location.href = `report-viewer.html?session=${encodeURIComponent(sessionId)}`;
366
- } catch (err) {
367
- readyStatus.textContent = err.message || 'Failed to save selection.';
368
- readyStatus.className = 'font-semibold text-red-600';
369
- }
370
- });
371
-
372
- loadSession();
373
- });
374
- </script>
375
- </body>
376
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
legacy/static-site/script.js DELETED
@@ -1,107 +0,0 @@
1
- "use strict";
2
-
3
- (function () {
4
- const SESSION_KEY = "repex_session_id";
5
- const apiBase =
6
- window.REPEX_API_BASE ||
7
- new URL("/api", window.location.origin).toString().replace(/\/$/, "");
8
-
9
- function getSessionId() {
10
- const params = new URLSearchParams(window.location.search);
11
- return params.get("session") || localStorage.getItem(SESSION_KEY) || "";
12
- }
13
-
14
- function setSessionId(id) {
15
- if (!id) return;
16
- localStorage.setItem(SESSION_KEY, id);
17
- }
18
-
19
- function clearSessionId() {
20
- localStorage.removeItem(SESSION_KEY);
21
- }
22
-
23
- function formatBytes(bytes) {
24
- if (!Number.isFinite(bytes)) return "0 B";
25
- const units = ["B", "KB", "MB", "GB"];
26
- let value = bytes;
27
- let idx = 0;
28
- while (value >= 1024 && idx < units.length - 1) {
29
- value /= 1024;
30
- idx += 1;
31
- }
32
- return `${value.toFixed(value >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`;
33
- }
34
-
35
- async function request(path, options = {}) {
36
- const url = `${apiBase}${path}`;
37
- const res = await fetch(url, {
38
- credentials: "same-origin",
39
- ...options,
40
- });
41
- if (!res.ok) {
42
- let detail = res.statusText;
43
- try {
44
- const data = await res.json();
45
- detail = data?.detail || detail;
46
- } catch {}
47
- throw new Error(detail);
48
- }
49
- if (res.status === 204) return null;
50
- const contentType = res.headers.get("content-type") || "";
51
- if (contentType.includes("application/json")) {
52
- return res.json();
53
- }
54
- return res.text();
55
- }
56
-
57
- async function postForm(path, formData) {
58
- return request(path, { method: "POST", body: formData });
59
- }
60
-
61
- async function postJson(path, body) {
62
- return request(path, {
63
- method: "POST",
64
- headers: { "Content-Type": "application/json" },
65
- body: JSON.stringify(body),
66
- });
67
- }
68
-
69
- async function putJson(path, body) {
70
- return request(path, {
71
- method: "PUT",
72
- headers: { "Content-Type": "application/json" },
73
- body: JSON.stringify(body),
74
- });
75
- }
76
-
77
- function applySessionToLinks() {
78
- const sessionId = getSessionId();
79
- if (!sessionId) return;
80
- document.querySelectorAll('a[href$=".html"]').forEach((link) => {
81
- const href = link.getAttribute("href");
82
- if (!href || href.startsWith("http")) return;
83
- const url = new URL(href, window.location.origin);
84
- if (!url.searchParams.get("session")) {
85
- url.searchParams.set("session", sessionId);
86
- link.setAttribute("href", `${url.pathname}${url.search}`);
87
- }
88
- });
89
- }
90
-
91
- window.REPEX = {
92
- apiBase,
93
- getSessionId,
94
- setSessionId,
95
- clearSessionId,
96
- request,
97
- postForm,
98
- postJson,
99
- putJson,
100
- formatBytes,
101
- applySessionToLinks,
102
- };
103
-
104
- document.addEventListener("DOMContentLoaded", () => {
105
- applySessionToLinks();
106
- });
107
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
legacy/static-site/style.css DELETED
@@ -1 +0,0 @@
1
- /* Global overrides for RepEx pages. */
 
 
legacy/static-site/templates/job-sheet-template.html DELETED
@@ -1,286 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>SIMM Inspection Job Sheet</title>
7
-
8
- <!-- Optional project stylesheet -->
9
- <link rel="stylesheet" href="../style.css" />
10
-
11
- <!-- Tailwind (CDN OK for prototypes; compile for production) -->
12
- <script src="https://cdn.tailwindcss.com"></script>
13
-
14
- <!-- Feather icons -->
15
- <script defer src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
16
-
17
- <style>
18
- @page { size: A4; margin: 10mm; }
19
-
20
- @media print {
21
- html, body { background: #fff !important; }
22
- .no-print { display: none !important; }
23
- main { box-shadow: none !important; border: none !important; margin: 0 !important; padding: 0 !important; }
24
- }
25
-
26
- .avoid-break { break-inside: avoid; page-break-inside: avoid; }
27
- img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
28
- </style>
29
- </head>
30
-
31
- <body class="bg-gray-50 print:bg-white">
32
- <!-- A4-focused container -->
33
- <main class="mx-auto max-w-[210mm] bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-5 print:ring-0 print:shadow-none print:rounded-none">
34
- <!-- Header -->
35
- <header class="mb-4 border-b border-gray-200 pb-3">
36
- <div class="grid grid-cols-[auto,1fr,auto] items-center gap-3">
37
- <div class="flex items-center">
38
- <img
39
- src="../assets/prosento-logo.png"
40
- alt="Prosento logo"
41
- class="h-10 w-auto object-contain"
42
- loading="eager"
43
- />
44
- </div>
45
-
46
- <div class="text-center leading-tight">
47
- <h1 class="text-2xl font-bold text-gray-900 whitespace-nowrap">SIMM Inspection Job Sheet</h1>
48
- <p class="text-sm text-gray-600 whitespace-nowrap">Structural Inspection and Maintenance Management</p>
49
- </div>
50
-
51
- <div class="flex items-center justify-end">
52
- <img
53
- src="../assets/client-logo.png"
54
- alt="Client logo placeholder"
55
- class="h-10 w-auto object-contain"
56
- loading="eager"
57
- />
58
- </div>
59
- </div>
60
- </header>
61
-
62
- <!-- Inspection Details -->
63
- <section class="mb-4" aria-labelledby="inspection-details-title">
64
- <h2 id="inspection-details-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
65
- Inspection Details
66
- </h2>
67
-
68
- <dl class="grid grid-cols-2 md:grid-cols-4 gap-2 bg-gray-50 border border-gray-200 rounded-lg p-3">
69
- <div class="space-y-0.5">
70
- <dt class="text-xs font-medium text-gray-500">Inspection Date</dt>
71
- <dd class="text-sm font-semibold text-gray-900">2023-11-15</dd>
72
- </div>
73
-
74
- <div class="space-y-0.5">
75
- <dt class="text-xs font-medium text-gray-500">Inspector</dt>
76
- <dd class="text-sm font-semibold text-gray-900">John Doe</dd>
77
- </div>
78
-
79
- <div class="space-y-0.5">
80
- <dt class="text-xs font-medium text-gray-500">Accompanied By</dt>
81
- <dd class="text-sm font-semibold text-gray-900">John Doe</dd>
82
- </div>
83
-
84
- <div class="space-y-0.5">
85
- <dt class="text-xs font-medium text-gray-500">Document No</dt>
86
- <dd class="text-sm font-mono font-semibold text-gray-900">SIMM-JS-2023-001</dd>
87
- </div>
88
-
89
- <div class="space-y-0.5 md:col-span-2">
90
- <dt class="text-xs font-medium text-gray-500">Project</dt>
91
- <dd class="text-sm font-semibold text-gray-900">North Pit – Sector 4 Conveyor Upgrade</dd>
92
- </div>
93
-
94
- <div class="space-y-0.5 md:col-span-2">
95
- <dt class="text-xs font-medium text-gray-500">Client / Site</dt>
96
- <dd class="text-sm font-semibold text-gray-900">Tronox</dd>
97
- </div>
98
- </dl>
99
- </section>
100
-
101
- <!-- Observations and Findings -->
102
- <section class="mb-4" aria-labelledby="observations-title">
103
- <h2 id="observations-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
104
- Observations and Findings
105
- </h2>
106
-
107
- <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
108
- <!-- Left column -->
109
- <div class="space-y-2">
110
- <div class="grid grid-cols-2 gap-2">
111
- <div class="space-y-0.5">
112
- <div class="text-xs font-medium text-gray-500">Reference</div>
113
- <div class="text-sm font-semibold text-gray-900">REF-7821</div>
114
- </div>
115
-
116
- <div class="space-y-0.5">
117
- <div class="text-xs font-medium text-gray-500">Action Type</div>
118
- <div class="text-sm font-semibold text-gray-900">Structural Repair</div>
119
- </div>
120
-
121
- <div class="space-y-0.5 col-span-2">
122
- <div class="text-xs font-medium text-gray-500">Item Description</div>
123
- <div class="text-sm font-semibold text-gray-900">Main support beam for conveyor CV-04</div>
124
- </div>
125
- </div>
126
- </div>
127
-
128
- <!-- Right column -->
129
- <div class="space-y-2">
130
- <div class="space-y-0.5">
131
- <div class="text-xs font-medium text-gray-500">Functional Location</div>
132
- <div class="text-sm font-semibold text-gray-900">Conveyor Support - CV-04-SUP-02</div>
133
- </div>
134
- </div>
135
-
136
- <!-- Centered Category + Priority (must be direct child of the grid) -->
137
- <div class="md:col-span-2 flex justify-center">
138
- <div class="inline-flex items-center gap-10">
139
- <div class="text-center space-y-1">
140
- <div class="text-xs font-medium text-gray-500">Category</div>
141
- <span
142
- id="condition-rating"
143
- class="badge inline-flex items-center justify-center rounded-md border px-4 py-1 text-sm font-semibold"
144
- aria-label="Category rating"
145
- >
146
- <!-- populated by JS -->
147
- </span>
148
- </div>
149
-
150
- <div class="text-center space-y-1">
151
- <div class="text-xs font-medium text-gray-500">Priority</div>
152
- <span
153
- id="priority-rating"
154
- class="badge inline-flex items-center justify-center rounded-md border px-4 py-1 text-sm font-semibold"
155
- aria-label="Priority rating"
156
- >
157
- <!-- populated by JS -->
158
- </span>
159
- </div>
160
- </div>
161
- </div>
162
-
163
- <!-- Full-width: Condition Description -->
164
- <div class="md:col-span-2 space-y-1">
165
- <div class="text-xs font-medium text-gray-500">Condition Description</div>
166
- <div class="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
167
- <p class="text-amber-800 text-sm font-semibold leading-snug">
168
- Visible corrosion on lower flange with ~15% material loss. Surface pitting along entire length.
169
- </p>
170
- </div>
171
- </div>
172
-
173
- <!-- Full-width: Required Action -->
174
- <div class="md:col-span-2 space-y-1">
175
- <div class="text-xs font-medium text-gray-500">Required Action</div>
176
- <div class="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
177
- <p class="text-blue-800 text-sm font-semibold leading-snug">
178
- Clean exposed rebar; apply corrosion protection; use wet-to-dry epoxy; reinstate with concrete repair.
179
- Complete within next 12 months to limit further degradation.
180
- </p>
181
- </div>
182
- </div>
183
- </div>
184
- </section>
185
-
186
- <!-- Photo Documentation -->
187
- <section class="mb-4 avoid-break" aria-labelledby="photo-doc-title">
188
- <h2 id="photo-doc-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
189
- Photo Documentation
190
- </h2>
191
-
192
- <div class="grid grid-cols-2 gap-3">
193
- <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50">
194
- <img
195
- src="https://huggingface.co/spaces/ChristopherJKoen/minefix-simm-inspector/resolve/main/images/Screenshot%202026-02-02%20100102.png"
196
- alt="Photo 1"
197
- class="w-full h-40 object-contain mx-auto"
198
- loading="eager"
199
- />
200
- <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
201
- Fig 1: Ref 1.1 – Concrete spalling (example)
202
- </figcaption>
203
- </figure>
204
-
205
- <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50 relative">
206
- <img
207
- src="https://huggingface.co/spaces/ChristopherJKoen/minefix-simm-inspector/resolve/main/images/Picture2.png"
208
- alt="Photo 2"
209
- class="w-full h-40 object-contain mx-auto"
210
- loading="eager"
211
- />
212
- <div class="absolute top-2 left-2 bg-white/95 text-black text-[11px] font-bold px-2 py-1 rounded border border-gray-300">
213
- #1.2 [C3, P3] Moderate Corrosion
214
- </div>
215
- <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
216
- Fig 2: Ref 1.2 – Walkway corrosion (example)
217
- </figcaption>
218
- </figure>
219
- </div>
220
- </section>
221
-
222
- <!-- Signatures -->
223
- <section class="mt-6" aria-label="Signatures">
224
- <div class="grid grid-cols-3 gap-4">
225
- <div class="border-t pt-2">
226
- <div class="text-xs font-medium text-gray-500">Inspected By</div>
227
- <div class="h-10 mt-1 border-b border-gray-300"></div>
228
- <p class="text-[11px] text-gray-500 mt-1">Name / Signature / Date</p>
229
- </div>
230
-
231
- <div class="border-t pt-2">
232
- <div class="text-xs font-medium text-gray-500">Approved By</div>
233
- <div class="h-10 mt-1 border-b border-gray-300"></div>
234
- <p class="text-[11px] text-gray-500 mt-1">Name / Signature / Date</p>
235
- </div>
236
-
237
- <div class="border-t pt-2">
238
- <div class="text-xs font-medium text-gray-500">Completed By</div>
239
- <div class="h-10 mt-1 border-b border-gray-300"></div>
240
- <p class="text-[11px] text-gray-500 mt-1">Name / Signature / Date</p>
241
- </div>
242
- </div>
243
- </section>
244
-
245
- <!-- Footer -->
246
- <footer class="mt-4 text-center text-[11px] text-gray-500">
247
- <p>Prosento - © 2026 All Rights Reserved</p>
248
- <p class="mt-0.5">Automatically generated job sheet</p>
249
- </footer>
250
- </main>
251
-
252
- <script>
253
- document.addEventListener('DOMContentLoaded', () => {
254
- // Badge tone system
255
- const TONES = {
256
- amber: ['bg-amber-50', 'text-amber-800', 'border-amber-200'],
257
- emerald: ['bg-emerald-50', 'text-emerald-800', 'border-emerald-200'],
258
- gray: ['bg-gray-50', 'text-gray-700', 'border-gray-200'],
259
- };
260
-
261
- const BASE_BADGE = 'inline-flex items-center justify-center rounded-md border px-4 py-1 text-sm font-semibold';
262
-
263
- function setBadge(id, text, toneKey) {
264
- const el = document.getElementById(id);
265
- if (!el) return;
266
- const tone = TONES[toneKey] || TONES.gray;
267
- el.className = `${BASE_BADGE} ${tone.join(' ')}`;
268
- el.textContent = text;
269
- }
270
-
271
- setBadge('condition-rating', '3 - Poor', 'amber');
272
- setBadge('priority-rating', '3 - 3 Years', 'emerald');
273
-
274
- // Icons
275
- if (window.feather && typeof window.feather.replace === 'function') {
276
- window.feather.replace();
277
- }
278
-
279
- // Ensure images are loaded before printing
280
- window.addEventListener('beforeprint', () => {
281
- document.querySelectorAll('img').forEach(img => (img.loading = 'eager'));
282
- });
283
- });
284
- </script>
285
- </body>
286
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
processing.html DELETED
@@ -1,80 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width,initial-scale=1" />
6
- <title>Processing - RepEx</title>
7
-
8
- <link rel="stylesheet" href="style.css" />
9
- <script src="https://cdn.tailwindcss.com"></script>
10
- <script defer src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
11
- </head>
12
-
13
- <body class="bg-gray-50 min-h-screen">
14
- <main class="max-w-2xl mx-auto my-10 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8">
15
- <header class="flex items-center gap-4 border-b border-gray-200 pb-4 mb-6">
16
- <img
17
- src="assets/prosento-logo.png"
18
- alt="Prosento logo"
19
- class="h-10 w-auto object-contain"
20
- loading="eager"
21
- />
22
- <div>
23
- <h1 class="text-xl font-semibold text-gray-900">Prosento RepEx</h1>
24
- <p class="text-sm text-gray-600">Report processing</p>
25
- </div>
26
- </header>
27
-
28
- <section class="text-center">
29
- <div class="mx-auto mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 border border-blue-100">
30
- <i data-feather="loader" class="h-5 w-5 text-blue-700"></i>
31
- </div>
32
- <h2 class="text-2xl font-semibold text-gray-900 mb-2">Generating your report</h2>
33
- <p class="text-gray-600 mb-6">
34
- We are preparing your files and building the final report. This may take a few minutes.
35
- </p>
36
-
37
- <div class="w-full rounded-full bg-gray-200 h-3 overflow-hidden">
38
- <div class="h-full bg-blue-600 w-1/2 animate-pulse"></div>
39
- </div>
40
-
41
- <p class="text-xs text-gray-500 mt-4">
42
- You can leave this tab open while we process your submission.
43
- </p>
44
- </section>
45
- </main>
46
-
47
- <script src="script.js"></script>
48
- <script>
49
- document.addEventListener('DOMContentLoaded', () => {
50
- if (window.feather && typeof window.feather.replace === 'function') {
51
- window.feather.replace();
52
- }
53
-
54
- const sessionId = window.REPEX.getSessionId();
55
- window.REPEX.setSessionId(sessionId);
56
- if (!sessionId) {
57
- const message = document.createElement('p');
58
- message.className = 'text-sm text-red-600 mt-6 text-center';
59
- message.textContent = 'No active session found. Return to the upload page.';
60
- document.querySelector('main').appendChild(message);
61
- return;
62
- }
63
-
64
- const poll = async () => {
65
- try {
66
- const status = await window.REPEX.request(`/sessions/${sessionId}/status`);
67
- if (status?.status === 'ready') {
68
- window.location.href = `review-setup.html?session=${encodeURIComponent(sessionId)}`;
69
- }
70
- } catch {
71
- // keep polling on transient errors
72
- }
73
- };
74
-
75
- poll();
76
- setInterval(poll, 2000);
77
- });
78
- </script>
79
- </body>
80
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
report-viewer.html DELETED
@@ -1,412 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width,initial-scale=1" />
6
- <title>RepEx - Report Express | Report Viewer</title>
7
-
8
- <link rel="stylesheet" href="style.css" />
9
- <script src="https://cdn.tailwindcss.com"></script>
10
- <script src="components/report-editor.js"></script>
11
- <script defer src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
12
-
13
- <style>
14
- @page { size: A4; margin: 0; }
15
-
16
- /* A4 canvas container */
17
- .a4-page {
18
- aspect-ratio: 210 / 297;
19
- width: min(100%, 560px);
20
- border-radius: 12px;
21
- }
22
-
23
- /* Viewer uses white page unless empty */
24
- .page-white {
25
- background: #ffffff;
26
- border: 1px solid #e5e7eb; /* gray-200 */
27
- }
28
-
29
- /* If a page has no items, show placeholder red */
30
- .page-empty {
31
- background: #fee2e2; /* red-200-ish */
32
- border: 1px solid #fca5a5; /* red-300-ish */
33
- }
34
-
35
- @media print {
36
- body { background: #fff !important; }
37
- .print-a4 {
38
- width: 210mm !important;
39
- height: 297mm !important;
40
- aspect-ratio: auto !important;
41
- border-radius: 0 !important;
42
- box-shadow: none !important;
43
- }
44
- .no-print { display: none !important; }
45
- main { box-shadow: none !important; border: none !important; margin: 0 !important; padding: 0 !important; }
46
- }
47
-
48
- img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
49
- </style>
50
- </head>
51
-
52
- <body class="bg-gray-50 min-h-screen">
53
- <main class="max-w-4xl mx-auto my-8 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8 print:ring-0 print:shadow-none print:rounded-none">
54
- <!-- Header -->
55
- <header class="mb-8 border-b border-gray-200 pb-4">
56
- <div class="grid grid-cols-[auto,1fr,auto] items-center gap-4">
57
- <div class="flex items-center">
58
- <img
59
- src="assets/prosento-logo.png"
60
- alt="Company logo"
61
- class="h-12 w-auto object-contain"
62
- loading="eager"
63
- />
64
- </div>
65
-
66
- <div class="text-center">
67
- <h1 class="text-2xl md:text-3xl font-bold text-gray-900 whitespace-nowrap">
68
- RepEx - Report Express
69
- </h1>
70
- <p class="text-gray-600 whitespace-nowrap">Report Viewer</p>
71
- </div>
72
-
73
- <div class="flex justify-end gap-2 no-print">
74
- <a
75
- href="review-setup.html"
76
- class="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"
77
- >
78
- <i data-feather="arrow-left" class="h-4 w-4"></i>
79
- Back
80
- </a>
81
- </div>
82
- </div>
83
- </header>
84
-
85
- <!-- Workflow buttons -->
86
- <nav class="mb-6 no-print" aria-label="Report workflow navigation">
87
- <div class="flex flex-wrap gap-2">
88
- <span
89
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-900 px-4 py-2 text-sm font-semibold text-white"
90
- >
91
- <i data-feather="layout" class="h-4 w-4"></i>
92
- Report Viewer
93
- </span>
94
-
95
- <button
96
- type="button"
97
- id="edit-report"
98
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
99
- >
100
- <i data-feather="edit-3" class="h-4 w-4"></i>
101
- Edit Report
102
- </button>
103
-
104
- <a
105
- href="edit-layouts.html"
106
- class="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"
107
- >
108
- <i data-feather="grid" class="h-4 w-4"></i>
109
- Edit Page Layouts
110
- </a>
111
-
112
- <a
113
- href="export.html"
114
- class="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"
115
- >
116
- <i data-feather="download" class="h-4 w-4"></i>
117
- Export
118
- </a>
119
- </div>
120
- </nav>
121
-
122
- <!-- Viewer -->
123
- <section id="viewerSection" aria-label="Report viewer">
124
- <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
125
- <div>
126
- <h2 class="text-xl font-semibold text-gray-800">Report Pages</h2>
127
- <p id="viewerMeta" class="text-sm text-gray-600">Loading…</p>
128
- </div>
129
-
130
- <div class="flex items-center gap-2 no-print">
131
- <button
132
- id="prevPage"
133
- type="button"
134
- class="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"
135
- >
136
- <i data-feather="chevron-left" class="h-4 w-4"></i>
137
- Prev
138
- </button>
139
-
140
- <div class="text-sm font-semibold text-gray-700">
141
- Page <span id="pageNumber">1</span> / <span id="pageTotal">1</span>
142
- </div>
143
-
144
- <button
145
- id="nextPage"
146
- type="button"
147
- class="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"
148
- >
149
- Next
150
- <i data-feather="chevron-right" class="h-4 w-4"></i>
151
- </button>
152
- </div>
153
- </div>
154
-
155
- <!-- Page stage -->
156
- <div class="flex justify-center">
157
- <div
158
- id="pageStage"
159
- class="a4-page print-a4 relative overflow-hidden shadow-sm page-empty"
160
- aria-label="Rendered A4 page"
161
- ></div>
162
- </div>
163
-
164
- <p class="mt-4 text-xs text-gray-500 no-print">
165
- Tip: Use keyboard arrows (← / →) to change pages.
166
- </p>
167
- </section>
168
-
169
- <!-- Footer -->
170
- <footer class="mt-12 text-center text-xs text-gray-500 no-print">
171
- <p>Prosento - © 2026 All Rights Reserved</p>
172
- <p class="mt-1">Viewer now renders saved edits from the editor.</p>
173
- </footer>
174
- </main>
175
-
176
- <script src="script.js"></script>
177
- <script>
178
- document.addEventListener('DOMContentLoaded', () => {
179
- if (window.feather && typeof window.feather.replace === 'function') {
180
- window.feather.replace();
181
- }
182
-
183
- const sessionId = window.REPEX.getSessionId();
184
- window.REPEX.setSessionId(sessionId);
185
- if (!sessionId) {
186
- const meta = document.getElementById('viewerMeta');
187
- if (meta) meta.textContent = 'No active session found. Return to upload to continue.';
188
- return;
189
- }
190
-
191
- const editor = document.createElement('report-editor');
192
- document.body.appendChild(editor);
193
- editor.style.display = 'none';
194
-
195
- const viewerSection = document.getElementById('viewerSection');
196
- const pageStage = document.getElementById('pageStage');
197
- const viewerMeta = document.getElementById('viewerMeta');
198
-
199
- const prevPageBtn = document.getElementById('prevPage');
200
- const nextPageBtn = document.getElementById('nextPage');
201
- const pageNumber = document.getElementById('pageNumber');
202
- const pageTotal = document.getElementById('pageTotal');
203
- const editBtn = document.getElementById('edit-report');
204
-
205
- const BASE_W = 595;
206
- const BASE_H = 842;
207
-
208
- const state = {
209
- pageIndex: 0,
210
- totalPages: 6,
211
- editMode: false,
212
- pages: [],
213
- session: null,
214
- };
215
-
216
- function setMeta() {
217
- const selected = state.session?.selected_photo_ids?.length ?? 0;
218
- const docs = state.session?.uploads?.documents?.length ?? 0;
219
- const dataFiles = state.session?.uploads?.data_files?.length ?? 0;
220
- const hasEdits = state.pages.length > 0;
221
- viewerMeta.textContent =
222
- `Selected example photos: ${selected} ? Documents: ${docs} ? Data files: ${dataFiles}` +
223
- (hasEdits ? ' ? Edited pages loaded' : ' ? No saved edits yet');
224
- }
225
-
226
- function renderControls() {
227
- pageTotal.textContent = String(state.totalPages || 1);
228
- pageNumber.textContent = String(state.pageIndex + 1);
229
- prevPageBtn.disabled = state.pageIndex === 0;
230
- nextPageBtn.disabled = state.pageIndex >= state.totalPages - 1;
231
- }
232
-
233
- function stageScale() {
234
- const rect = pageStage.getBoundingClientRect();
235
- return rect.width / BASE_W;
236
- }
237
-
238
- function clearStage() {
239
- pageStage.innerHTML = '';
240
- }
241
-
242
- function renderStageFromPage(pageObj) {
243
- clearStage();
244
-
245
- const items = pageObj?.items ?? [];
246
- const hasItems = items.length > 0;
247
-
248
- pageStage.classList.toggle('page-empty', !hasItems);
249
- pageStage.classList.toggle('page-white', hasItems);
250
-
251
- if (!hasItems) return;
252
-
253
- const scale = stageScale();
254
-
255
- items
256
- .slice()
257
- .sort((a, b) => (a.z ?? 0) - (b.z ?? 0))
258
- .forEach(item => {
259
- const wrap = document.createElement('div');
260
- wrap.className = 'absolute';
261
- wrap.style.left = `${item.x * scale}px`;
262
- wrap.style.top = `${item.y * scale}px`;
263
- wrap.style.width = `${item.w * scale}px`;
264
- wrap.style.height = `${item.h * scale}px`;
265
- wrap.style.zIndex = String(item.z ?? 0);
266
-
267
- if (item.type === 'text') {
268
- const d = document.createElement('div');
269
- d.className = 'w-full h-full p-2 overflow-hidden';
270
- d.style.whiteSpace = 'pre-wrap';
271
- d.style.outline = 'none';
272
-
273
- const fontSize = (item.style?.fontSize ?? 14) * scale;
274
- d.style.fontSize = `${fontSize}px`;
275
- d.style.fontWeight = item.style?.bold ? '700' : '400';
276
- d.style.fontStyle = item.style?.italic ? 'italic' : 'normal';
277
- d.style.textDecoration = item.style?.underline ? 'underline' : 'none';
278
- d.style.color = item.style?.color ?? '#111827';
279
- d.style.textAlign = item.style?.align ?? 'left';
280
- d.textContent = item.content ?? '';
281
-
282
- wrap.appendChild(d);
283
- }
284
-
285
- if (item.type === 'image') {
286
- const img = document.createElement('img');
287
- img.className = 'w-full h-full object-contain bg-white';
288
- img.src = item.src;
289
- img.alt = item.name ?? 'Image';
290
- img.loading = 'eager';
291
- wrap.appendChild(img);
292
- }
293
-
294
- if (item.type === 'rect') {
295
- const box = document.createElement('div');
296
- box.className = 'w-full h-full';
297
- box.style.background = item.style?.fill ?? '#ffffff';
298
- box.style.borderStyle = 'solid';
299
- box.style.borderColor = item.style?.stroke ?? '#111827';
300
- box.style.borderWidth = `${(item.style?.strokeWidth ?? 1) * scale}px`;
301
- wrap.appendChild(box);
302
- }
303
-
304
- pageStage.appendChild(wrap);
305
- });
306
- }
307
-
308
- async function fetchSession() {
309
- const session = await window.REPEX.request(`/sessions/${sessionId}`);
310
- state.session = session;
311
- state.totalPages = session.page_count || state.totalPages;
312
- }
313
-
314
- async function fetchPages() {
315
- try {
316
- const result = await window.REPEX.request(`/sessions/${sessionId}/pages`);
317
- state.pages = Array.isArray(result?.pages) ? result.pages : [];
318
- if (state.pages.length) {
319
- state.totalPages = Math.max(state.totalPages, state.pages.length);
320
- }
321
- } catch {
322
- state.pages = [];
323
- }
324
- }
325
-
326
- function renderPage() {
327
- renderControls();
328
- const pageObj = state.pages?.[state.pageIndex] ?? null;
329
- renderStageFromPage(pageObj);
330
- setMeta();
331
- }
332
-
333
- function nextPage() {
334
- if (state.pageIndex < state.totalPages - 1) {
335
- state.pageIndex += 1;
336
- renderPage();
337
- }
338
- }
339
-
340
- function prevPage() {
341
- if (state.pageIndex > 0) {
342
- state.pageIndex -= 1;
343
- renderPage();
344
- }
345
- }
346
-
347
- function openEditor() {
348
- state.editMode = true;
349
- editor.style.display = 'block';
350
- viewerSection.classList.add('hidden');
351
-
352
- editor.open({
353
- payload: state.session,
354
- pageIndex: state.pageIndex,
355
- totalPages: state.totalPages,
356
- sessionId,
357
- });
358
-
359
- editBtn.classList.add('bg-gray-900', 'text-white', 'border-gray-900');
360
- editBtn.classList.remove('bg-white', 'text-gray-800', 'border-gray-200');
361
-
362
- if (window.feather && typeof window.feather.replace === 'function') {
363
- window.feather.replace();
364
- }
365
- }
366
-
367
- async function closeEditor() {
368
- state.editMode = false;
369
- editor.style.display = 'none';
370
- viewerSection.classList.remove('hidden');
371
-
372
- editBtn.classList.remove('bg-gray-900', 'text-white', 'border-gray-900');
373
- editBtn.classList.add('bg-white', 'text-gray-800', 'border-gray-200');
374
-
375
- await fetchPages();
376
- renderPage();
377
- }
378
-
379
- prevPageBtn.addEventListener('click', prevPage);
380
- nextPageBtn.addEventListener('click', nextPage);
381
-
382
- editBtn.addEventListener('click', () => {
383
- if (!state.editMode) openEditor();
384
- });
385
-
386
- editor.addEventListener('editor-closed', () => {
387
- closeEditor();
388
- });
389
-
390
- window.addEventListener('keydown', (e) => {
391
- if (state.editMode) return;
392
- if (e.key === 'ArrowRight') nextPage();
393
- if (e.key === 'ArrowLeft') prevPage();
394
- });
395
-
396
- window.addEventListener('resize', () => {
397
- if (!state.editMode) renderPage();
398
- });
399
-
400
- window.addEventListener('beforeprint', () => {
401
- document.querySelectorAll('img').forEach(img => (img.loading = 'eager'));
402
- });
403
-
404
- (async () => {
405
- await fetchSession();
406
- await fetchPages();
407
- renderPage();
408
- })();
409
- });
410
- </script>
411
- </body>
412
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
review-setup.html DELETED
@@ -1,376 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width,initial-scale=1" />
6
- <title>RepEx - Report Express | Review & Setup</title>
7
-
8
- <link rel="stylesheet" href="style.css" />
9
- <script src="https://cdn.tailwindcss.com"></script>
10
- <script defer src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
11
-
12
- <style>
13
- img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
14
- </style>
15
- </head>
16
-
17
- <body class="bg-gray-50 min-h-screen">
18
- <main class="max-w-4xl mx-auto my-8 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8">
19
- <!-- Header -->
20
- <header class="mb-8 border-b border-gray-200 pb-4">
21
- <div class="grid grid-cols-[auto,1fr,auto] items-center gap-4">
22
- <div class="flex items-center">
23
- <img
24
- src="assets/prosento-logo.png"
25
- alt="Company logo"
26
- class="h-12 w-auto object-contain"
27
- loading="eager"
28
- />
29
- </div>
30
-
31
- <div class="text-center">
32
- <h1 class="text-2xl md:text-3xl font-bold text-gray-900 whitespace-nowrap">
33
- RepEx - Report Express
34
- </h1>
35
- <p class="text-gray-600 whitespace-nowrap">
36
- Review uploads → pick examples → continue to report viewer
37
- </p>
38
- </div>
39
-
40
- <div class="flex justify-end">
41
- <span class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-xs font-semibold text-gray-700">
42
- <i data-feather="check-circle" class="h-4 w-4"></i>
43
- Uploads processed
44
- </span>
45
- </div>
46
- </div>
47
- </header>
48
-
49
- <!-- Orientation -->
50
- <section class="mb-8" aria-labelledby="what-next">
51
- <h2 id="what-next" class="text-xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-4">
52
- What happens on this page
53
- </h2>
54
-
55
- <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
56
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
57
- <div class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-blue-50 border border-blue-100 mb-3">
58
- <i data-feather="image" class="h-5 w-5 text-blue-700"></i>
59
- </div>
60
- <h3 class="font-semibold text-gray-900 mb-1">Select example photos</h3>
61
- <p class="text-sm text-gray-600">
62
- Choose which uploaded images should appear as example figures in the report.
63
- </p>
64
- </div>
65
-
66
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
67
- <div class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-emerald-50 border border-emerald-100 mb-3">
68
- <i data-feather="file-text" class="h-5 w-5 text-emerald-700"></i>
69
- </div>
70
- <h3 class="font-semibold text-gray-900 mb-1">Confirm documents</h3>
71
- <p class="text-sm text-gray-600">
72
- Ensure supporting PDFs/DOCX are correct (and later attach to export if needed).
73
- </p>
74
- </div>
75
-
76
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
77
- <div class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-amber-50 border border-amber-100 mb-3">
78
- <i data-feather="table" class="h-5 w-5 text-amber-700"></i>
79
- </div>
80
- <h3 class="font-semibold text-gray-900 mb-1">Use Excel/CSV data</h3>
81
- <p class="text-sm text-gray-600">
82
- If Excel/CSV exists, it will populate report data areas automatically in later steps.
83
- </p>
84
- </div>
85
- </div>
86
- </section>
87
-
88
- <!-- Review uploads -->
89
- <section class="mb-8" aria-labelledby="review-uploads">
90
- <h2 id="review-uploads" class="text-xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-6">
91
- Review uploaded files
92
- </h2>
93
-
94
- <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
95
- <!-- Photos -->
96
- <div class="lg:col-span-2">
97
- <div class="flex items-center justify-between mb-3">
98
- <h3 class="text-lg font-semibold text-gray-900">Photos</h3>
99
- <span id="photoCount" class="text-sm font-semibold text-gray-600">0 files</span>
100
- </div>
101
-
102
- <div class="rounded-lg border border-gray-200 bg-white p-4">
103
- <p class="text-sm text-gray-600 mb-3">
104
- Select images to use as example figures in the report.
105
- <span class="font-semibold text-gray-800">Recommended:</span> 2–6 images.
106
- </p>
107
-
108
- <div id="photoGrid" class="grid grid-cols-2 md:grid-cols-3 gap-3">
109
- <!-- populated by JS -->
110
- </div>
111
-
112
- <div class="mt-4 flex flex-wrap items-center justify-between gap-3">
113
- <div class="text-sm text-gray-600">
114
- Selected for report: <span id="photoSelected" class="font-semibold text-gray-900">0</span>
115
- </div>
116
- <div class="flex gap-2">
117
- <button id="selectAllPhotos" type="button"
118
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition">
119
- <i data-feather="check-square" class="h-4 w-4"></i>
120
- Select all
121
- </button>
122
- <button id="clearPhotos" type="button"
123
- class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition">
124
- <i data-feather="square" class="h-4 w-4"></i>
125
- Clear
126
- </button>
127
- </div>
128
- </div>
129
- </div>
130
- </div>
131
-
132
- <!-- Documents + Data files -->
133
- <div class="space-y-6">
134
- <!-- Documents -->
135
- <div>
136
- <div class="flex items-center justify-between mb-3">
137
- <h3 class="text-lg font-semibold text-gray-900">Documents</h3>
138
- <span id="docCount" class="text-sm font-semibold text-gray-600">0 files</span>
139
- </div>
140
-
141
- <div class="rounded-lg border border-gray-200 bg-white p-4">
142
- <ul id="docList" class="space-y-2 text-sm text-gray-700">
143
- <!-- populated by JS -->
144
- </ul>
145
- <p id="docHint" class="text-xs text-gray-500 mt-3">
146
- PDFs/DOCX appear here after processing.
147
- </p>
148
- </div>
149
- </div>
150
-
151
- <!-- Data files -->
152
- <div>
153
- <div class="flex items-center justify-between mb-3">
154
- <h3 class="text-lg font-semibold text-gray-900">Data files</h3>
155
- <span id="dataCount" class="text-sm font-semibold text-gray-600">0 files</span>
156
- </div>
157
-
158
- <div class="rounded-lg border border-gray-200 bg-white p-4">
159
- <div id="dataBox" class="text-sm text-gray-700">
160
- <!-- populated by JS -->
161
- </div>
162
- <p class="text-xs text-gray-500 mt-3">
163
- If present, these files will populate report tables/fields automatically.
164
- </p>
165
- </div>
166
- </div>
167
- </div>
168
- </div>
169
- </section>
170
-
171
- <!-- Continue -->
172
- <section class="mb-4" aria-label="Continue to report viewer">
173
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-4 flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
174
- <div class="text-sm text-gray-600">
175
- Next step:
176
- <span id="readyStatus" class="font-semibold text-amber-700">Choose report example images to continue…</span>
177
- </div>
178
-
179
- <button
180
- id="continueBtn"
181
- type="button"
182
- class="inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-5 py-2.5 text-white font-semibold hover:bg-emerald-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
183
- disabled
184
- >
185
- <i data-feather="arrow-right" class="h-5 w-5"></i>
186
- Continue to Report Viewer
187
- </button>
188
- </div>
189
-
190
- <p class="text-xs text-gray-500 mt-3">
191
- Note: This page assumes uploads were completed on a previous “Processing” page.
192
- </p>
193
- </section>
194
-
195
- <!-- Footer -->
196
- <footer class="mt-12 text-center text-xs text-gray-500">
197
- <p>Prosento - © 2026 All Rights Reserved</p>
198
- <p class="mt-1">Workflow: Processing → Review uploads → Report viewer → Edit → Export</p>
199
- </footer>
200
- </main>
201
-
202
- <script src="script.js"></script>
203
- <script>
204
- document.addEventListener('DOMContentLoaded', () => {
205
- if (window.feather && typeof window.feather.replace === 'function') {
206
- window.feather.replace();
207
- }
208
-
209
- const photoGrid = document.getElementById('photoGrid');
210
- const photoCount = document.getElementById('photoCount');
211
- const photoSelected = document.getElementById('photoSelected');
212
- const selectAllPhotosBtn = document.getElementById('selectAllPhotos');
213
- const clearPhotosBtn = document.getElementById('clearPhotos');
214
-
215
- const docList = document.getElementById('docList');
216
- const docCount = document.getElementById('docCount');
217
- const docHint = document.getElementById('docHint');
218
-
219
- const dataBox = document.getElementById('dataBox');
220
- const dataCount = document.getElementById('dataCount');
221
-
222
- const readyStatus = document.getElementById('readyStatus');
223
- const continueBtn = document.getElementById('continueBtn');
224
-
225
- const sessionId = window.REPEX.getSessionId();
226
- window.REPEX.setSessionId(sessionId);
227
- if (!sessionId) {
228
- readyStatus.textContent = 'No active session found. Return to upload to continue.';
229
- readyStatus.className = 'font-semibold text-red-600';
230
- continueBtn.disabled = true;
231
- return;
232
- }
233
-
234
- const state = {
235
- selectedPhotoIds: new Set(),
236
- };
237
-
238
- function updateSelectionState() {
239
- photoSelected.textContent = String(state.selectedPhotoIds.size);
240
- const canContinue = state.selectedPhotoIds.size > 0;
241
- continueBtn.disabled = !canContinue;
242
-
243
- if (!canContinue) {
244
- readyStatus.textContent = 'Choose report example images to continue...';
245
- readyStatus.className = 'font-semibold text-amber-700';
246
- } else {
247
- readyStatus.textContent = 'Ready. Continue to report viewer.';
248
- readyStatus.className = 'font-semibold text-emerald-700';
249
- }
250
- }
251
-
252
- function renderUploads(uploads, selectedIds) {
253
- const photos = uploads.photos || [];
254
- photoCount.textContent = `${photos.length} file${photos.length === 1 ? '' : 's'}`;
255
-
256
- photoGrid.innerHTML = photos.length
257
- ? photos.map((p) => {
258
- const checked = selectedIds.includes(p.id) ? 'checked' : '';
259
- if (checked) state.selectedPhotoIds.add(p.id);
260
- return `
261
- <label class="group cursor-pointer">
262
- <input type="checkbox" class="sr-only photoCheck" data-photo-id="${p.id}" ${checked}>
263
- <div class="rounded-lg border border-gray-200 bg-gray-50 overflow-hidden group-has-[:checked]:ring-2 group-has-[:checked]:ring-emerald-200 group-has-[:checked]:border-emerald-300 transition">
264
- <div class="relative">
265
- <img src="${p.url}" alt="${p.name}" class="h-28 w-full object-cover" loading="eager">
266
- <div class="absolute top-2 right-2 inline-flex items-center justify-center rounded-full bg-white/90 border border-gray-200 p-1.5 text-gray-700 group-has-[:checked]:bg-emerald-50 group-has-[:checked]:border-emerald-200 group-has-[:checked]:text-emerald-700">
267
- <i data-feather="check" class="h-4 w-4"></i>
268
- </div>
269
- </div>
270
- <div class="p-2">
271
- <div class="text-xs font-semibold text-gray-900 truncate">${p.name}</div>
272
- <div class="text-xs text-gray-500">Click to select for report</div>
273
- </div>
274
- </div>
275
- </label>
276
- `;
277
- }).join('')
278
- : `<div class="col-span-full text-sm text-gray-500">No photos were uploaded.</div>`;
279
-
280
- const docs = uploads.documents || [];
281
- docCount.textContent = `${docs.length} file${docs.length === 1 ? '' : 's'}`;
282
- docList.innerHTML = docs.length
283
- ? docs.map(d => `
284
- <li class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
285
- <div class="flex items-center gap-2 min-w-0">
286
- <i data-feather="file-text" class="h-4 w-4 text-gray-600"></i>
287
- <span class="truncate text-gray-800">${d.name}</span>
288
- </div>
289
- <span class="text-xs font-semibold text-gray-600">${d.content_type || 'File'}</span>
290
- </li>
291
- `).join('')
292
- : `<li class="text-sm text-gray-500">No supporting documents detected.</li>`;
293
- docHint.style.display = docs.length ? 'none' : 'block';
294
-
295
- const dataFiles = uploads.data_files || [];
296
- dataCount.textContent = `${dataFiles.length} file${dataFiles.length === 1 ? '' : 's'}`;
297
- dataBox.innerHTML = dataFiles.length
298
- ? dataFiles.map(f => `
299
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700 mb-2 last:mb-0">
300
- <div class="flex items-center justify-between gap-3">
301
- <div class="flex items-center gap-2 min-w-0">
302
- <i data-feather="table" class="h-4 w-4 text-amber-700"></i>
303
- <span class="truncate font-semibold text-gray-900">${f.name}</span>
304
- </div>
305
- <span class="text-xs font-semibold text-gray-600">${f.content_type || 'Data'}</span>
306
- </div>
307
- <div class="text-xs text-gray-600 mt-1">
308
- Will populate report data areas (tables/fields).
309
- </div>
310
- </div>
311
- `).join('')
312
- : `
313
- <div class="rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm text-gray-600">
314
- <div class="font-semibold text-gray-800 mb-1">No Excel/CSV detected</div>
315
- If you upload a CSV or Excel file, RepEx can auto-populate report data fields.
316
- </div>
317
- `;
318
-
319
- if (window.feather && typeof window.feather.replace === 'function') {
320
- window.feather.replace();
321
- }
322
- }
323
-
324
- async function loadSession() {
325
- try {
326
- const session = await window.REPEX.request(`/sessions/${sessionId}`);
327
- renderUploads(session.uploads || {}, session.selected_photo_ids || []);
328
- updateSelectionState();
329
- } catch (err) {
330
- readyStatus.textContent = err.message || 'Failed to load session.';
331
- readyStatus.className = 'font-semibold text-red-600';
332
- }
333
- }
334
-
335
- photoGrid.addEventListener('change', (e) => {
336
- const cb = e.target.closest('.photoCheck');
337
- if (!cb) return;
338
- const id = cb.dataset.photoId;
339
- if (cb.checked) state.selectedPhotoIds.add(id);
340
- else state.selectedPhotoIds.delete(id);
341
- updateSelectionState();
342
- });
343
-
344
- selectAllPhotosBtn.addEventListener('click', () => {
345
- photoGrid.querySelectorAll('.photoCheck').forEach(cb => {
346
- cb.checked = true;
347
- state.selectedPhotoIds.add(cb.dataset.photoId);
348
- });
349
- updateSelectionState();
350
- });
351
-
352
- clearPhotosBtn.addEventListener('click', () => {
353
- photoGrid.querySelectorAll('.photoCheck').forEach(cb => (cb.checked = false));
354
- state.selectedPhotoIds.clear();
355
- updateSelectionState();
356
- });
357
-
358
- continueBtn.addEventListener('click', async () => {
359
- if (state.selectedPhotoIds.size === 0) return;
360
- const selectedIds = Array.from(state.selectedPhotoIds);
361
- try {
362
- await window.REPEX.putJson(`/sessions/${sessionId}/selection`, {
363
- selected_photo_ids: selectedIds,
364
- });
365
- window.location.href = `report-viewer.html?session=${encodeURIComponent(sessionId)}`;
366
- } catch (err) {
367
- readyStatus.textContent = err.message || 'Failed to save selection.';
368
- readyStatus.className = 'font-semibold text-red-600';
369
- }
370
- });
371
-
372
- loadSession();
373
- });
374
- </script>
375
- </body>
376
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
script.js DELETED
@@ -1,107 +0,0 @@
1
- "use strict";
2
-
3
- (function () {
4
- const SESSION_KEY = "repex_session_id";
5
- const apiBase =
6
- window.REPEX_API_BASE ||
7
- new URL("/api", window.location.origin).toString().replace(/\/$/, "");
8
-
9
- function getSessionId() {
10
- const params = new URLSearchParams(window.location.search);
11
- return params.get("session") || localStorage.getItem(SESSION_KEY) || "";
12
- }
13
-
14
- function setSessionId(id) {
15
- if (!id) return;
16
- localStorage.setItem(SESSION_KEY, id);
17
- }
18
-
19
- function clearSessionId() {
20
- localStorage.removeItem(SESSION_KEY);
21
- }
22
-
23
- function formatBytes(bytes) {
24
- if (!Number.isFinite(bytes)) return "0 B";
25
- const units = ["B", "KB", "MB", "GB"];
26
- let value = bytes;
27
- let idx = 0;
28
- while (value >= 1024 && idx < units.length - 1) {
29
- value /= 1024;
30
- idx += 1;
31
- }
32
- return `${value.toFixed(value >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`;
33
- }
34
-
35
- async function request(path, options = {}) {
36
- const url = `${apiBase}${path}`;
37
- const res = await fetch(url, {
38
- credentials: "same-origin",
39
- ...options,
40
- });
41
- if (!res.ok) {
42
- let detail = res.statusText;
43
- try {
44
- const data = await res.json();
45
- detail = data?.detail || detail;
46
- } catch {}
47
- throw new Error(detail);
48
- }
49
- if (res.status === 204) return null;
50
- const contentType = res.headers.get("content-type") || "";
51
- if (contentType.includes("application/json")) {
52
- return res.json();
53
- }
54
- return res.text();
55
- }
56
-
57
- async function postForm(path, formData) {
58
- return request(path, { method: "POST", body: formData });
59
- }
60
-
61
- async function postJson(path, body) {
62
- return request(path, {
63
- method: "POST",
64
- headers: { "Content-Type": "application/json" },
65
- body: JSON.stringify(body),
66
- });
67
- }
68
-
69
- async function putJson(path, body) {
70
- return request(path, {
71
- method: "PUT",
72
- headers: { "Content-Type": "application/json" },
73
- body: JSON.stringify(body),
74
- });
75
- }
76
-
77
- function applySessionToLinks() {
78
- const sessionId = getSessionId();
79
- if (!sessionId) return;
80
- document.querySelectorAll('a[href$=".html"]').forEach((link) => {
81
- const href = link.getAttribute("href");
82
- if (!href || href.startsWith("http")) return;
83
- const url = new URL(href, window.location.origin);
84
- if (!url.searchParams.get("session")) {
85
- url.searchParams.set("session", sessionId);
86
- link.setAttribute("href", `${url.pathname}${url.search}`);
87
- }
88
- });
89
- }
90
-
91
- window.REPEX = {
92
- apiBase,
93
- getSessionId,
94
- setSessionId,
95
- clearSessionId,
96
- request,
97
- postForm,
98
- postJson,
99
- putJson,
100
- formatBytes,
101
- applySessionToLinks,
102
- };
103
-
104
- document.addEventListener("DOMContentLoaded", () => {
105
- applySessionToLinks();
106
- });
107
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
style.css DELETED
@@ -1 +0,0 @@
1
- /* Global overrides for RepEx pages. */
 
 
templates/job-sheet-template.html DELETED
@@ -1,286 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>SIMM Inspection Job Sheet</title>
7
-
8
- <!-- Optional project stylesheet -->
9
- <link rel="stylesheet" href="../style.css" />
10
-
11
- <!-- Tailwind (CDN OK for prototypes; compile for production) -->
12
- <script src="https://cdn.tailwindcss.com"></script>
13
-
14
- <!-- Feather icons -->
15
- <script defer src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
16
-
17
- <style>
18
- @page { size: A4; margin: 10mm; }
19
-
20
- @media print {
21
- html, body { background: #fff !important; }
22
- .no-print { display: none !important; }
23
- main { box-shadow: none !important; border: none !important; margin: 0 !important; padding: 0 !important; }
24
- }
25
-
26
- .avoid-break { break-inside: avoid; page-break-inside: avoid; }
27
- img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
28
- </style>
29
- </head>
30
-
31
- <body class="bg-gray-50 print:bg-white">
32
- <!-- A4-focused container -->
33
- <main class="mx-auto max-w-[210mm] bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-5 print:ring-0 print:shadow-none print:rounded-none">
34
- <!-- Header -->
35
- <header class="mb-4 border-b border-gray-200 pb-3">
36
- <div class="grid grid-cols-[auto,1fr,auto] items-center gap-3">
37
- <div class="flex items-center">
38
- <img
39
- src="../assets/prosento-logo.png"
40
- alt="Prosento logo"
41
- class="h-10 w-auto object-contain"
42
- loading="eager"
43
- />
44
- </div>
45
-
46
- <div class="text-center leading-tight">
47
- <h1 class="text-2xl font-bold text-gray-900 whitespace-nowrap">SIMM Inspection Job Sheet</h1>
48
- <p class="text-sm text-gray-600 whitespace-nowrap">Structural Inspection and Maintenance Management</p>
49
- </div>
50
-
51
- <div class="flex items-center justify-end">
52
- <img
53
- src="../assets/client-logo.png"
54
- alt="Client logo placeholder"
55
- class="h-10 w-auto object-contain"
56
- loading="eager"
57
- />
58
- </div>
59
- </div>
60
- </header>
61
-
62
- <!-- Inspection Details -->
63
- <section class="mb-4" aria-labelledby="inspection-details-title">
64
- <h2 id="inspection-details-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
65
- Inspection Details
66
- </h2>
67
-
68
- <dl class="grid grid-cols-2 md:grid-cols-4 gap-2 bg-gray-50 border border-gray-200 rounded-lg p-3">
69
- <div class="space-y-0.5">
70
- <dt class="text-xs font-medium text-gray-500">Inspection Date</dt>
71
- <dd class="text-sm font-semibold text-gray-900">2023-11-15</dd>
72
- </div>
73
-
74
- <div class="space-y-0.5">
75
- <dt class="text-xs font-medium text-gray-500">Inspector</dt>
76
- <dd class="text-sm font-semibold text-gray-900">John Doe</dd>
77
- </div>
78
-
79
- <div class="space-y-0.5">
80
- <dt class="text-xs font-medium text-gray-500">Accompanied By</dt>
81
- <dd class="text-sm font-semibold text-gray-900">John Doe</dd>
82
- </div>
83
-
84
- <div class="space-y-0.5">
85
- <dt class="text-xs font-medium text-gray-500">Document No</dt>
86
- <dd class="text-sm font-mono font-semibold text-gray-900">SIMM-JS-2023-001</dd>
87
- </div>
88
-
89
- <div class="space-y-0.5 md:col-span-2">
90
- <dt class="text-xs font-medium text-gray-500">Project</dt>
91
- <dd class="text-sm font-semibold text-gray-900">North Pit – Sector 4 Conveyor Upgrade</dd>
92
- </div>
93
-
94
- <div class="space-y-0.5 md:col-span-2">
95
- <dt class="text-xs font-medium text-gray-500">Client / Site</dt>
96
- <dd class="text-sm font-semibold text-gray-900">Tronox</dd>
97
- </div>
98
- </dl>
99
- </section>
100
-
101
- <!-- Observations and Findings -->
102
- <section class="mb-4" aria-labelledby="observations-title">
103
- <h2 id="observations-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
104
- Observations and Findings
105
- </h2>
106
-
107
- <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
108
- <!-- Left column -->
109
- <div class="space-y-2">
110
- <div class="grid grid-cols-2 gap-2">
111
- <div class="space-y-0.5">
112
- <div class="text-xs font-medium text-gray-500">Reference</div>
113
- <div class="text-sm font-semibold text-gray-900">REF-7821</div>
114
- </div>
115
-
116
- <div class="space-y-0.5">
117
- <div class="text-xs font-medium text-gray-500">Action Type</div>
118
- <div class="text-sm font-semibold text-gray-900">Structural Repair</div>
119
- </div>
120
-
121
- <div class="space-y-0.5 col-span-2">
122
- <div class="text-xs font-medium text-gray-500">Item Description</div>
123
- <div class="text-sm font-semibold text-gray-900">Main support beam for conveyor CV-04</div>
124
- </div>
125
- </div>
126
- </div>
127
-
128
- <!-- Right column -->
129
- <div class="space-y-2">
130
- <div class="space-y-0.5">
131
- <div class="text-xs font-medium text-gray-500">Functional Location</div>
132
- <div class="text-sm font-semibold text-gray-900">Conveyor Support - CV-04-SUP-02</div>
133
- </div>
134
- </div>
135
-
136
- <!-- Centered Category + Priority (must be direct child of the grid) -->
137
- <div class="md:col-span-2 flex justify-center">
138
- <div class="inline-flex items-center gap-10">
139
- <div class="text-center space-y-1">
140
- <div class="text-xs font-medium text-gray-500">Category</div>
141
- <span
142
- id="condition-rating"
143
- class="badge inline-flex items-center justify-center rounded-md border px-4 py-1 text-sm font-semibold"
144
- aria-label="Category rating"
145
- >
146
- <!-- populated by JS -->
147
- </span>
148
- </div>
149
-
150
- <div class="text-center space-y-1">
151
- <div class="text-xs font-medium text-gray-500">Priority</div>
152
- <span
153
- id="priority-rating"
154
- class="badge inline-flex items-center justify-center rounded-md border px-4 py-1 text-sm font-semibold"
155
- aria-label="Priority rating"
156
- >
157
- <!-- populated by JS -->
158
- </span>
159
- </div>
160
- </div>
161
- </div>
162
-
163
- <!-- Full-width: Condition Description -->
164
- <div class="md:col-span-2 space-y-1">
165
- <div class="text-xs font-medium text-gray-500">Condition Description</div>
166
- <div class="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
167
- <p class="text-amber-800 text-sm font-semibold leading-snug">
168
- Visible corrosion on lower flange with ~15% material loss. Surface pitting along entire length.
169
- </p>
170
- </div>
171
- </div>
172
-
173
- <!-- Full-width: Required Action -->
174
- <div class="md:col-span-2 space-y-1">
175
- <div class="text-xs font-medium text-gray-500">Required Action</div>
176
- <div class="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
177
- <p class="text-blue-800 text-sm font-semibold leading-snug">
178
- Clean exposed rebar; apply corrosion protection; use wet-to-dry epoxy; reinstate with concrete repair.
179
- Complete within next 12 months to limit further degradation.
180
- </p>
181
- </div>
182
- </div>
183
- </div>
184
- </section>
185
-
186
- <!-- Photo Documentation -->
187
- <section class="mb-4 avoid-break" aria-labelledby="photo-doc-title">
188
- <h2 id="photo-doc-title" class="text-base font-semibold text-gray-800 border-b border-gray-200 pb-1 mb-2">
189
- Photo Documentation
190
- </h2>
191
-
192
- <div class="grid grid-cols-2 gap-3">
193
- <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50">
194
- <img
195
- src="https://huggingface.co/spaces/ChristopherJKoen/minefix-simm-inspector/resolve/main/images/Screenshot%202026-02-02%20100102.png"
196
- alt="Photo 1"
197
- class="w-full h-40 object-contain mx-auto"
198
- loading="eager"
199
- />
200
- <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
201
- Fig 1: Ref 1.1 – Concrete spalling (example)
202
- </figcaption>
203
- </figure>
204
-
205
- <figure class="border border-gray-200 rounded-lg p-2 bg-gray-50 relative">
206
- <img
207
- src="https://huggingface.co/spaces/ChristopherJKoen/minefix-simm-inspector/resolve/main/images/Picture2.png"
208
- alt="Photo 2"
209
- class="w-full h-40 object-contain mx-auto"
210
- loading="eager"
211
- />
212
- <div class="absolute top-2 left-2 bg-white/95 text-black text-[11px] font-bold px-2 py-1 rounded border border-gray-300">
213
- #1.2 [C3, P3] Moderate Corrosion
214
- </div>
215
- <figcaption class="text-[11px] text-gray-600 mt-1 text-center leading-tight">
216
- Fig 2: Ref 1.2 – Walkway corrosion (example)
217
- </figcaption>
218
- </figure>
219
- </div>
220
- </section>
221
-
222
- <!-- Signatures -->
223
- <section class="mt-6" aria-label="Signatures">
224
- <div class="grid grid-cols-3 gap-4">
225
- <div class="border-t pt-2">
226
- <div class="text-xs font-medium text-gray-500">Inspected By</div>
227
- <div class="h-10 mt-1 border-b border-gray-300"></div>
228
- <p class="text-[11px] text-gray-500 mt-1">Name / Signature / Date</p>
229
- </div>
230
-
231
- <div class="border-t pt-2">
232
- <div class="text-xs font-medium text-gray-500">Approved By</div>
233
- <div class="h-10 mt-1 border-b border-gray-300"></div>
234
- <p class="text-[11px] text-gray-500 mt-1">Name / Signature / Date</p>
235
- </div>
236
-
237
- <div class="border-t pt-2">
238
- <div class="text-xs font-medium text-gray-500">Completed By</div>
239
- <div class="h-10 mt-1 border-b border-gray-300"></div>
240
- <p class="text-[11px] text-gray-500 mt-1">Name / Signature / Date</p>
241
- </div>
242
- </div>
243
- </section>
244
-
245
- <!-- Footer -->
246
- <footer class="mt-4 text-center text-[11px] text-gray-500">
247
- <p>Prosento - © 2026 All Rights Reserved</p>
248
- <p class="mt-0.5">Automatically generated job sheet</p>
249
- </footer>
250
- </main>
251
-
252
- <script>
253
- document.addEventListener('DOMContentLoaded', () => {
254
- // Badge tone system
255
- const TONES = {
256
- amber: ['bg-amber-50', 'text-amber-800', 'border-amber-200'],
257
- emerald: ['bg-emerald-50', 'text-emerald-800', 'border-emerald-200'],
258
- gray: ['bg-gray-50', 'text-gray-700', 'border-gray-200'],
259
- };
260
-
261
- const BASE_BADGE = 'inline-flex items-center justify-center rounded-md border px-4 py-1 text-sm font-semibold';
262
-
263
- function setBadge(id, text, toneKey) {
264
- const el = document.getElementById(id);
265
- if (!el) return;
266
- const tone = TONES[toneKey] || TONES.gray;
267
- el.className = `${BASE_BADGE} ${tone.join(' ')}`;
268
- el.textContent = text;
269
- }
270
-
271
- setBadge('condition-rating', '3 - Poor', 'amber');
272
- setBadge('priority-rating', '3 - 3 Years', 'emerald');
273
-
274
- // Icons
275
- if (window.feather && typeof window.feather.replace === 'function') {
276
- window.feather.replace();
277
- }
278
-
279
- // Ensure images are loaded before printing
280
- window.addEventListener('beforeprint', () => {
281
- document.querySelectorAll('img').forEach(img => (img.loading = 'eager'));
282
- });
283
- });
284
- </script>
285
- </body>
286
- </html>