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