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