Spaces:
Sleeping
Sleeping
Commit ·
15a4294
1
Parent(s): 6842961
Refresh sections UI and templates
Browse filesNormalize sections/headings storage and guard autosave to prevent headings flicker.
Improve Edit Layouts UX (collapsible sections, page counts, clearer controls).
Align job sheet templates in viewer/editor/PDF and remove the redundant 'Document No' label.
Add ImagePlacementPage scaffold.
- DataInputTemplate-populated-sr.xlsx +0 -0
- examples/DataInputTemplate-populated-sr.xlsx +0 -0
- frontend/public/templates/job-sheet-template.html +31 -9
- frontend/src/App.tsx +2 -0
- frontend/src/components/JobSheetTemplate.tsx +36 -27
- frontend/src/components/ReportPageCanvas.tsx +13 -12
- frontend/src/components/report-editor.js +240 -59
- frontend/src/lib/sections.ts +1 -0
- frontend/src/pages/EditLayoutsPage.tsx +469 -130
- frontend/src/pages/EditReportPage.tsx +109 -4
- frontend/src/pages/ExportPage.tsx +9 -1
- frontend/src/pages/ImagePlacementPage.tsx +605 -0
- frontend/src/pages/InputDataPage.tsx +368 -67
- frontend/src/pages/PrintReportPage.tsx +5 -7
- frontend/src/pages/ReportViewerPage.tsx +9 -0
- frontend/src/pages/ReviewSetupPage.tsx +120 -10
- frontend/src/pages/UploadPage.tsx +12 -27
- frontend/src/types/custom-elements.d.ts +1 -0
- frontend/src/types/session.ts +3 -7
- server/app/api/routes/sessions.py +48 -30
- server/app/api/schemas.py +9 -2
- server/app/services/data_import.py +67 -28
- server/app/services/pdf_reportlab.py +41 -13
- server/app/services/session_store.py +199 -10
DataInputTemplate-populated-sr.xlsx
ADDED
|
Binary file (15.5 kB). View file
|
|
|
examples/DataInputTemplate-populated-sr.xlsx
ADDED
|
Binary file (15.5 kB). View file
|
|
|
frontend/public/templates/job-sheet-template.html
CHANGED
|
@@ -58,8 +58,15 @@
|
|
| 58 |
</div>
|
| 59 |
|
| 60 |
<div class="text-center leading-tight">
|
| 61 |
-
<
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
</div>
|
| 64 |
|
| 65 |
<div class="flex items-center justify-end">
|
|
@@ -128,7 +135,6 @@
|
|
| 128 |
<div class="text-xs font-medium text-gray-500">Condition Description</div>
|
| 129 |
<div class="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
|
| 130 |
<p class="template-field template-field-multiline text-amber-800 text-sm font-semibold leading-snug" contenteditable="true" data-placeholder="Item description"></p>
|
| 131 |
-
<p class="template-field template-field-multiline text-amber-800 text-sm font-semibold leading-snug" contenteditable="true" data-placeholder="Condition description"></p>
|
| 132 |
</div>
|
| 133 |
</div>
|
| 134 |
|
|
@@ -136,7 +142,6 @@
|
|
| 136 |
<div class="md:col-span-2 space-y-1">
|
| 137 |
<div class="text-xs font-medium text-gray-500">Action Required</div>
|
| 138 |
<div class="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
|
| 139 |
-
<p class="template-field template-field-multiline text-blue-800 text-sm font-semibold leading-snug" contenteditable="true" data-placeholder="Action type"></p>
|
| 140 |
<p class="template-field template-field-multiline text-blue-800 text-sm font-semibold leading-snug" contenteditable="true" data-placeholder="Required action"></p>
|
| 141 |
</div>
|
| 142 |
</div>
|
|
@@ -207,11 +212,28 @@
|
|
| 207 |
</section>
|
| 208 |
|
| 209 |
<!-- Footer -->
|
| 210 |
-
<footer class="mt-2 text-[10px] text-gray-500 flex flex-
|
| 211 |
-
<
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
</footer>
|
| 216 |
</main>
|
| 217 |
</body>
|
|
|
|
| 58 |
</div>
|
| 59 |
|
| 60 |
<div class="text-center leading-tight">
|
| 61 |
+
<div class="text-base font-semibold text-gray-900 whitespace-nowrap">
|
| 62 |
+
<span
|
| 63 |
+
class="template-field"
|
| 64 |
+
contenteditable="true"
|
| 65 |
+
data-placeholder="Document No"
|
| 66 |
+
style="display:inline-block; min-width: 180px;"
|
| 67 |
+
></span>
|
| 68 |
+
</div>
|
| 69 |
+
<div class="text-[10px] text-gray-500 whitespace-nowrap">Document No</div>
|
| 70 |
</div>
|
| 71 |
|
| 72 |
<div class="flex items-center justify-end">
|
|
|
|
| 135 |
<div class="text-xs font-medium text-gray-500">Condition Description</div>
|
| 136 |
<div class="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
|
| 137 |
<p class="template-field template-field-multiline text-amber-800 text-sm font-semibold leading-snug" contenteditable="true" data-placeholder="Item description"></p>
|
|
|
|
| 138 |
</div>
|
| 139 |
</div>
|
| 140 |
|
|
|
|
| 142 |
<div class="md:col-span-2 space-y-1">
|
| 143 |
<div class="text-xs font-medium text-gray-500">Action Required</div>
|
| 144 |
<div class="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
|
|
|
|
| 145 |
<p class="template-field template-field-multiline text-blue-800 text-sm font-semibold leading-snug" contenteditable="true" data-placeholder="Required action"></p>
|
| 146 |
</div>
|
| 147 |
</div>
|
|
|
|
| 212 |
</section>
|
| 213 |
|
| 214 |
<!-- Footer -->
|
| 215 |
+
<footer class="mt-2 text-[10px] text-gray-500 flex flex-col items-center gap-1">
|
| 216 |
+
<div class="flex flex-wrap items-center justify-center gap-3">
|
| 217 |
+
<span>Date: <span class="template-field" contenteditable="true" data-placeholder="YYYY-MM-DD"></span></span>
|
| 218 |
+
<span>Inspector: <span class="template-field" contenteditable="true" data-placeholder="Inspector name"></span></span>
|
| 219 |
+
<span>Doc: <span class="template-field" contenteditable="true" data-placeholder="Document no"></span></span>
|
| 220 |
+
</div>
|
| 221 |
+
<div class="text-[10px] font-semibold text-gray-600">RepEx Inspection Job Sheet</div>
|
| 222 |
+
<div class="text-[10px] text-gray-500">
|
| 223 |
+
<span
|
| 224 |
+
class="template-field"
|
| 225 |
+
contenteditable="true"
|
| 226 |
+
data-placeholder="Section 1"
|
| 227 |
+
style="display:inline-block; min-width: 90px;"
|
| 228 |
+
></span>
|
| 229 |
+
<span class="mx-1">-</span>
|
| 230 |
+
<span
|
| 231 |
+
class="template-field"
|
| 232 |
+
contenteditable="true"
|
| 233 |
+
data-placeholder="Page 1 of 1"
|
| 234 |
+
style="display:inline-block; min-width: 90px;"
|
| 235 |
+
></span>
|
| 236 |
+
</div>
|
| 237 |
</footer>
|
| 238 |
</main>
|
| 239 |
</body>
|
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 InputDataPage from "./pages/InputDataPage";
|
| 8 |
import EditReportPage from "./pages/EditReportPage";
|
| 9 |
import EditLayoutsPage from "./pages/EditLayoutsPage";
|
|
@@ -18,6 +19,7 @@ export default function App() {
|
|
| 18 |
<Route path="/processing" element={<ProcessingPage />} />
|
| 19 |
<Route path="/review-setup" element={<ReviewSetupPage />} />
|
| 20 |
<Route path="/report-viewer" element={<ReportViewerPage />} />
|
|
|
|
| 21 |
<Route path="/input-data" element={<InputDataPage />} />
|
| 22 |
<Route path="/edit-report" element={<EditReportPage />} />
|
| 23 |
<Route path="/edit-layouts" element={<EditLayoutsPage />} />
|
|
|
|
| 4 |
import ProcessingPage from "./pages/ProcessingPage";
|
| 5 |
import ReviewSetupPage from "./pages/ReviewSetupPage";
|
| 6 |
import ReportViewerPage from "./pages/ReportViewerPage";
|
| 7 |
+
import ImagePlacementPage from "./pages/ImagePlacementPage";
|
| 8 |
import InputDataPage from "./pages/InputDataPage";
|
| 9 |
import EditReportPage from "./pages/EditReportPage";
|
| 10 |
import EditLayoutsPage from "./pages/EditLayoutsPage";
|
|
|
|
| 19 |
<Route path="/processing" element={<ProcessingPage />} />
|
| 20 |
<Route path="/review-setup" element={<ReviewSetupPage />} />
|
| 21 |
<Route path="/report-viewer" element={<ReportViewerPage />} />
|
| 22 |
+
<Route path="/image-placement" element={<ImagePlacementPage />} />
|
| 23 |
<Route path="/input-data" element={<InputDataPage />} />
|
| 24 |
<Route path="/edit-report" element={<EditReportPage />} />
|
| 25 |
<Route path="/edit-layouts" element={<EditLayoutsPage />} />
|
frontend/src/components/JobSheetTemplate.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import { useEffect, useMemo, useState } from "react";
|
| 2 |
|
| 3 |
import type { FileMeta, Session, TemplateFields } from "../types/session";
|
| 4 |
-
import { formatDocNumber
|
| 5 |
|
| 6 |
type JobSheetTemplateProps = {
|
| 7 |
session: Session | null;
|
|
@@ -12,6 +12,7 @@ type JobSheetTemplateProps = {
|
|
| 12 |
orderLocked?: boolean;
|
| 13 |
variant?: "full" | "photos";
|
| 14 |
sectionLabel?: string;
|
|
|
|
| 15 |
};
|
| 16 |
|
| 17 |
type PhotoSlotProps = {
|
|
@@ -264,37 +265,32 @@ export function JobSheetTemplate({
|
|
| 264 |
orderLocked = false,
|
| 265 |
variant = "full",
|
| 266 |
sectionLabel,
|
|
|
|
| 267 |
}: JobSheetTemplateProps) {
|
| 268 |
const inspectionDate =
|
| 269 |
template?.inspection_date ?? session?.inspection_date ?? "";
|
| 270 |
const inspector = template?.inspector ?? "";
|
| 271 |
const docNumber =
|
| 272 |
-
template?.document_no ??
|
| 273 |
-
|
|
|
|
| 274 |
const companyLogo = template?.company_logo ?? "";
|
|
|
|
| 275 |
|
| 276 |
const reference = template?.reference ?? "";
|
| 277 |
const area = template?.area ?? "";
|
| 278 |
-
const actionType = template?.action_type ?? "";
|
| 279 |
const itemDescription = template?.item_description ?? "";
|
| 280 |
const functionalLocation = template?.functional_location ?? "";
|
| 281 |
const category = template?.category ?? "";
|
| 282 |
const priority = template?.priority ?? "";
|
| 283 |
-
const conditionDescription =
|
| 284 |
-
template?.condition_description ?? session?.notes ?? "";
|
| 285 |
const requiredAction = template?.required_action ?? "";
|
| 286 |
|
| 287 |
-
const conditionText =
|
| 288 |
-
|
| 289 |
-
.join(" - ");
|
| 290 |
-
const actionText = [actionType, requiredAction]
|
| 291 |
-
.filter((value) => value && value.trim())
|
| 292 |
-
.join(" - ");
|
| 293 |
const categoryBadge = formatRating(category, CATEGORY_SCALE);
|
| 294 |
const priorityBadge = formatRating(priority, PRIORITY_SCALE);
|
| 295 |
|
| 296 |
-
const resolvedPhotos =
|
| 297 |
-
photos && photos.length ? photos : getPhotosForPage(session, pageIndex, 1);
|
| 298 |
const limitedPhotos = resolvedPhotos.slice(0, 6);
|
| 299 |
const logoUrl = resolveLogoUrl(session, companyLogo);
|
| 300 |
const [ratios, setRatios] = useState<Record<string, number>>({});
|
|
@@ -335,10 +331,19 @@ export function JobSheetTemplate({
|
|
| 335 |
const displayedPhotos =
|
| 336 |
variant === "full" ? orderedPhotos.slice(0, 2) : orderedPhotos;
|
| 337 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
const photoGridClass =
|
| 339 |
-
|
| 340 |
? "grid grid-cols-1 gap-3"
|
| 341 |
-
:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
|
| 343 |
return (
|
| 344 |
<div className="w-full h-full p-5 text-[11px] text-gray-700 flex flex-col">
|
|
@@ -351,10 +356,7 @@ export function JobSheetTemplate({
|
|
| 351 |
/>
|
| 352 |
<div className="text-center leading-tight">
|
| 353 |
<div className="text-base font-semibold text-gray-900">
|
| 354 |
-
|
| 355 |
-
</div>
|
| 356 |
-
<div className="text-[11px] text-gray-500">
|
| 357 |
-
Page {pageIndex + 1} of {pageCount}
|
| 358 |
</div>
|
| 359 |
</div>
|
| 360 |
<img
|
|
@@ -468,7 +470,7 @@ export function JobSheetTemplate({
|
|
| 468 |
<PhotoSlot
|
| 469 |
key={photo?.id || `${index}`}
|
| 470 |
url={photo?.url}
|
| 471 |
-
label={photo?.name || `Figure ${index + 1}`}
|
| 472 |
className="h-full"
|
| 473 |
imageClassName="h-full"
|
| 474 |
/>
|
|
@@ -477,12 +479,19 @@ export function JobSheetTemplate({
|
|
| 477 |
</div>
|
| 478 |
</section>
|
| 479 |
|
| 480 |
-
<footer className="mt-2 text-[10px] text-gray-500 flex flex-
|
| 481 |
-
<
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
</footer>
|
| 487 |
</div>
|
| 488 |
);
|
|
|
|
| 1 |
import { useEffect, useMemo, useState } from "react";
|
| 2 |
|
| 3 |
import type { FileMeta, Session, TemplateFields } from "../types/session";
|
| 4 |
+
import { formatDocNumber } from "../lib/report";
|
| 5 |
|
| 6 |
type JobSheetTemplateProps = {
|
| 7 |
session: Session | null;
|
|
|
|
| 12 |
orderLocked?: boolean;
|
| 13 |
variant?: "full" | "photos";
|
| 14 |
sectionLabel?: string;
|
| 15 |
+
photoLayout?: "auto" | "two-column" | "stacked";
|
| 16 |
};
|
| 17 |
|
| 18 |
type PhotoSlotProps = {
|
|
|
|
| 265 |
orderLocked = false,
|
| 266 |
variant = "full",
|
| 267 |
sectionLabel,
|
| 268 |
+
photoLayout = "auto",
|
| 269 |
}: JobSheetTemplateProps) {
|
| 270 |
const inspectionDate =
|
| 271 |
template?.inspection_date ?? session?.inspection_date ?? "";
|
| 272 |
const inspector = template?.inspector ?? "";
|
| 273 |
const docNumber =
|
| 274 |
+
template?.document_no ??
|
| 275 |
+
session?.document_no ??
|
| 276 |
+
(session?.id ? formatDocNumber(session) : "");
|
| 277 |
const companyLogo = template?.company_logo ?? "";
|
| 278 |
+
const figureCaption = template?.figure_caption ?? "";
|
| 279 |
|
| 280 |
const reference = template?.reference ?? "";
|
| 281 |
const area = template?.area ?? "";
|
|
|
|
| 282 |
const itemDescription = template?.item_description ?? "";
|
| 283 |
const functionalLocation = template?.functional_location ?? "";
|
| 284 |
const category = template?.category ?? "";
|
| 285 |
const priority = template?.priority ?? "";
|
|
|
|
|
|
|
| 286 |
const requiredAction = template?.required_action ?? "";
|
| 287 |
|
| 288 |
+
const conditionText = itemDescription;
|
| 289 |
+
const actionText = requiredAction;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
const categoryBadge = formatRating(category, CATEGORY_SCALE);
|
| 291 |
const priorityBadge = formatRating(priority, PRIORITY_SCALE);
|
| 292 |
|
| 293 |
+
const resolvedPhotos = photos && photos.length ? photos : [];
|
|
|
|
| 294 |
const limitedPhotos = resolvedPhotos.slice(0, 6);
|
| 295 |
const logoUrl = resolveLogoUrl(session, companyLogo);
|
| 296 |
const [ratios, setRatios] = useState<Record<string, number>>({});
|
|
|
|
| 331 |
const displayedPhotos =
|
| 332 |
variant === "full" ? orderedPhotos.slice(0, 2) : orderedPhotos;
|
| 333 |
|
| 334 |
+
const normalizedLayout = (photoLayout || "auto").toLowerCase();
|
| 335 |
+
const layoutMode =
|
| 336 |
+
normalizedLayout === "stacked" || normalizedLayout === "two-column"
|
| 337 |
+
? normalizedLayout
|
| 338 |
+
: "auto";
|
| 339 |
const photoGridClass =
|
| 340 |
+
layoutMode === "stacked"
|
| 341 |
? "grid grid-cols-1 gap-3"
|
| 342 |
+
: layoutMode === "two-column"
|
| 343 |
+
? "grid grid-cols-2 gap-3"
|
| 344 |
+
: displayedPhotos.length <= 1
|
| 345 |
+
? "grid grid-cols-1 gap-3"
|
| 346 |
+
: "grid grid-cols-2 gap-3";
|
| 347 |
|
| 348 |
return (
|
| 349 |
<div className="w-full h-full p-5 text-[11px] text-gray-700 flex flex-col">
|
|
|
|
| 356 |
/>
|
| 357 |
<div className="text-center leading-tight">
|
| 358 |
<div className="text-base font-semibold text-gray-900">
|
| 359 |
+
{docNumber || "-"}
|
|
|
|
|
|
|
|
|
|
| 360 |
</div>
|
| 361 |
</div>
|
| 362 |
<img
|
|
|
|
| 470 |
<PhotoSlot
|
| 471 |
key={photo?.id || `${index}`}
|
| 472 |
url={photo?.url}
|
| 473 |
+
label={figureCaption || photo?.name || `Figure ${index + 1}`}
|
| 474 |
className="h-full"
|
| 475 |
imageClassName="h-full"
|
| 476 |
/>
|
|
|
|
| 479 |
</div>
|
| 480 |
</section>
|
| 481 |
|
| 482 |
+
<footer className="mt-2 text-[10px] text-gray-500 flex flex-col items-center gap-1">
|
| 483 |
+
<div className="flex flex-wrap items-center justify-center gap-3">
|
| 484 |
+
<span>Date: {inspectionDate || "-"}</span>
|
| 485 |
+
<span>Inspector: {inspector || "-"}</span>
|
| 486 |
+
<span>Doc: {docNumber || "-"}</span>
|
| 487 |
+
</div>
|
| 488 |
+
<div className="text-[10px] font-semibold text-gray-600">
|
| 489 |
+
RepEx Inspection Job Sheet
|
| 490 |
+
</div>
|
| 491 |
+
<div className="text-[10px] text-gray-500">
|
| 492 |
+
{sectionLabel ? `${sectionLabel} - ` : ""}
|
| 493 |
+
Page {pageIndex + 1} of {pageCount}
|
| 494 |
+
</div>
|
| 495 |
</footer>
|
| 496 |
</div>
|
| 497 |
);
|
frontend/src/components/ReportPageCanvas.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
| 2 |
import type { CSSProperties } from "react";
|
| 3 |
|
| 4 |
import type { FileMeta, Page, Session, TemplateFields } from "../types/session";
|
| 5 |
-
import { BASE_H, BASE_W
|
| 6 |
import { JobSheetTemplate } from "./JobSheetTemplate";
|
| 7 |
|
| 8 |
type ReportPageCanvasProps = {
|
|
@@ -116,16 +116,17 @@ export function ReportPageCanvas({
|
|
| 116 |
{page?.blank ? (
|
| 117 |
<div className="w-full h-full bg-white" />
|
| 118 |
) : (
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
| 129 |
)}
|
| 130 |
</div>
|
| 131 |
</div>
|
|
@@ -209,5 +210,5 @@ function resolvePagePhotos(
|
|
| 209 |
if (explicit.length) {
|
| 210 |
return explicit.map((id) => byId.get(id)).filter(Boolean) as FileMeta[];
|
| 211 |
}
|
| 212 |
-
return
|
| 213 |
}
|
|
|
|
| 2 |
import type { CSSProperties } from "react";
|
| 3 |
|
| 4 |
import type { FileMeta, Page, Session, TemplateFields } from "../types/session";
|
| 5 |
+
import { BASE_H, BASE_W } from "../lib/report";
|
| 6 |
import { JobSheetTemplate } from "./JobSheetTemplate";
|
| 7 |
|
| 8 |
type ReportPageCanvasProps = {
|
|
|
|
| 116 |
{page?.blank ? (
|
| 117 |
<div className="w-full h-full bg-white" />
|
| 118 |
) : (
|
| 119 |
+
<JobSheetTemplate
|
| 120 |
+
session={session}
|
| 121 |
+
pageIndex={pageIndex}
|
| 122 |
+
pageCount={pageCount}
|
| 123 |
+
template={template}
|
| 124 |
+
photos={sheetPhotos}
|
| 125 |
+
orderLocked={page?.photo_order_locked ?? false}
|
| 126 |
+
variant={pageVariant}
|
| 127 |
+
sectionLabel={sectionLabel}
|
| 128 |
+
photoLayout={page?.photo_layout}
|
| 129 |
+
/>
|
| 130 |
)}
|
| 131 |
</div>
|
| 132 |
</div>
|
|
|
|
| 210 |
if (explicit.length) {
|
| 211 |
return explicit.map((id) => byId.get(id)).filter(Boolean) as FileMeta[];
|
| 212 |
}
|
| 213 |
+
return [];
|
| 214 |
}
|
frontend/src/components/report-editor.js
CHANGED
|
@@ -62,6 +62,7 @@ class ReportEditor extends HTMLElement {
|
|
| 62 |
this.sessionId = null;
|
| 63 |
this.apiBase = null;
|
| 64 |
this._saveTimer = null;
|
|
|
|
| 65 |
this._photoRatios = new Map();
|
| 66 |
this._indexMap = [];
|
| 67 |
}
|
|
@@ -164,6 +165,14 @@ class ReportEditor extends HTMLElement {
|
|
| 164 |
this.dispatchEvent(new CustomEvent("editor-closed", { bubbles: true }));
|
| 165 |
}
|
| 166 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
// ---------- Rendering ----------
|
| 168 |
render() {
|
| 169 |
this.innerHTML = `
|
|
@@ -194,10 +203,9 @@ class ReportEditor extends HTMLElement {
|
|
| 194 |
<i data-feather="rotate-cw" class="h-4 w-4"></i> Redo
|
| 195 |
</button>
|
| 196 |
|
| 197 |
-
<
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
</button>
|
| 201 |
|
| 202 |
<button data-btn="close"
|
| 203 |
class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition">
|
|
@@ -445,7 +453,6 @@ class ReportEditor extends HTMLElement {
|
|
| 445 |
|
| 446 |
// header buttons
|
| 447 |
this.querySelector('[data-btn="close"]').addEventListener("click", () => this.close());
|
| 448 |
-
this.querySelector('[data-btn="save"]').addEventListener("click", () => this._savePages(true));
|
| 449 |
this.querySelector('[data-btn="undo"]').addEventListener("click", () => this.undo());
|
| 450 |
this.querySelector('[data-btn="redo"]').addEventListener("click", () => this.redo());
|
| 451 |
|
|
@@ -605,14 +612,61 @@ class ReportEditor extends HTMLElement {
|
|
| 605 |
this.$canvas.querySelectorAll("[data-template-field]").forEach((el) => {
|
| 606 |
const key = el.dataset.templateField;
|
| 607 |
if (!key) return;
|
| 608 |
-
const
|
| 609 |
-
|
| 610 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 611 |
}
|
| 612 |
-
|
| 613 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
this._savePages();
|
| 615 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 616 |
el.onpointerdown = (e) => {
|
| 617 |
e.stopPropagation();
|
| 618 |
};
|
|
@@ -622,6 +676,28 @@ class ReportEditor extends HTMLElement {
|
|
| 622 |
}
|
| 623 |
};
|
| 624 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
}
|
| 626 |
|
| 627 |
_escape(value) {
|
|
@@ -767,10 +843,7 @@ class ReportEditor extends HTMLElement {
|
|
| 767 |
if (explicit.length) {
|
| 768 |
return explicit.map((id) => byId.get(id)).filter(Boolean);
|
| 769 |
}
|
| 770 |
-
|
| 771 |
-
const perPage = 1;
|
| 772 |
-
const start = this.state.activePage * perPage;
|
| 773 |
-
return selected.slice(start, start + perPage);
|
| 774 |
}
|
| 775 |
|
| 776 |
_photoSlot(photo, fallbackLabel) {
|
|
@@ -782,21 +855,43 @@ class ReportEditor extends HTMLElement {
|
|
| 782 |
</div>
|
| 783 |
`;
|
| 784 |
}
|
| 785 |
-
const label = this._escape(photo.name ||
|
| 786 |
const safeUrl = this._escape(url);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 787 |
return `
|
| 788 |
<figure class="rounded-lg border border-gray-200 bg-gray-50 p-2 break-inside-avoid mb-3">
|
| 789 |
<img src="${safeUrl}" alt="${label}" class="w-full h-auto object-contain" />
|
| 790 |
-
<figcaption class="mt-1 text-[10px] text-gray-600 text-center">${
|
| 791 |
</figure>
|
| 792 |
`;
|
| 793 |
}
|
| 794 |
|
| 795 |
-
_tplField(key, value, placeholder, className = "", multiline = false) {
|
| 796 |
const safeValue = this._escape(value || "");
|
| 797 |
const safePlaceholder = this._escape(placeholder || "");
|
| 798 |
const multiAttr = multiline ? ' data-multiline="true"' : "";
|
| 799 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 800 |
}
|
| 801 |
|
| 802 |
_templateMarkup() {
|
|
@@ -809,19 +904,19 @@ class ReportEditor extends HTMLElement {
|
|
| 809 |
const inspector = template.inspector || "";
|
| 810 |
const docNumber =
|
| 811 |
template.document_no ||
|
|
|
|
| 812 |
(session.id ? `REP-${String(session.id).slice(0, 8).toUpperCase()}` : "");
|
| 813 |
-
const clientSite = template.client_site || "";
|
| 814 |
const companyLogo = template.company_logo || "";
|
|
|
|
| 815 |
|
| 816 |
const reference = template.reference || "";
|
| 817 |
const area = template.area || "";
|
| 818 |
-
const actionType = template.action_type || "";
|
| 819 |
const itemDescription = template.item_description || "";
|
| 820 |
const functionalLocation = template.functional_location || "";
|
| 821 |
-
const
|
| 822 |
-
const
|
| 823 |
-
const
|
| 824 |
-
|
| 825 |
const requiredAction = template.required_action || "";
|
| 826 |
|
| 827 |
const categoryScale = {
|
|
@@ -841,6 +936,14 @@ class ReportEditor extends HTMLElement {
|
|
| 841 |
};
|
| 842 |
const categoryBadge = this._ratingBadge(category, categoryScale);
|
| 843 |
const priorityBadge = this._ratingBadge(priority, priorityScale);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 844 |
|
| 845 |
const variant =
|
| 846 |
(this.activePage && this.activePage.variant) || "full";
|
|
@@ -852,10 +955,26 @@ class ReportEditor extends HTMLElement {
|
|
| 852 |
: this._computePhotoLayout(photos).map((entry) => entry.photo);
|
| 853 |
const displayedPhotos =
|
| 854 |
variant === "full" ? orderedPhotos.slice(0, 2) : orderedPhotos;
|
| 855 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 856 |
const photoSlots = displayedPhotos.length
|
| 857 |
? displayedPhotos
|
| 858 |
-
.map((photo, idx) =>
|
|
|
|
|
|
|
| 859 |
.join("")
|
| 860 |
: this._photoSlot(null, "No photo selected");
|
| 861 |
const pageNum = this.state.activePage + 1;
|
|
@@ -891,21 +1010,21 @@ class ReportEditor extends HTMLElement {
|
|
| 891 |
<div class="inline-flex items-center gap-6">
|
| 892 |
<div class="text-center space-y-1">
|
| 893 |
<div class="text-xs font-medium text-gray-500">Category</div>
|
| 894 |
-
${this.
|
| 895 |
"category",
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
`
|
| 899 |
)}
|
| 900 |
</div>
|
| 901 |
|
| 902 |
<div class="text-center space-y-1">
|
| 903 |
<div class="text-xs font-medium text-gray-500">Priority</div>
|
| 904 |
-
${this.
|
| 905 |
"priority",
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
`
|
| 909 |
)}
|
| 910 |
</div>
|
| 911 |
|
|
@@ -914,19 +1033,17 @@ class ReportEditor extends HTMLElement {
|
|
| 914 |
|
| 915 |
<div class="md:col-span-2 space-y-1">
|
| 916 |
<div class="text-xs font-medium text-gray-500">Condition Description</div>
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 921 |
</div>
|
| 922 |
-
</div>
|
| 923 |
</div>
|
| 924 |
|
| 925 |
<div class="md:col-span-2 space-y-1">
|
| 926 |
<div class="text-xs font-medium text-gray-500">Action Required</div>
|
| 927 |
<div class="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
|
| 928 |
<div class="text-blue-800 text-sm font-semibold leading-snug">
|
| 929 |
-
${this._tplField("action_type", actionType, "Action type", "template-field-multiline text-blue-800 text-sm font-semibold leading-snug", true)}
|
| 930 |
${this._tplField("required_action", requiredAction, "Required action", "template-field-multiline text-blue-800 text-sm font-semibold leading-snug", true)}
|
| 931 |
</div>
|
| 932 |
</div>
|
|
@@ -947,8 +1064,12 @@ class ReportEditor extends HTMLElement {
|
|
| 947 |
</div>
|
| 948 |
|
| 949 |
<div class="text-center leading-tight">
|
| 950 |
-
|
| 951 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 952 |
</div>
|
| 953 |
|
| 954 |
<div class="flex items-center justify-end">
|
|
@@ -969,12 +1090,48 @@ class ReportEditor extends HTMLElement {
|
|
| 969 |
</div>
|
| 970 |
</section>
|
| 971 |
|
| 972 |
-
<footer class="mt-2 text-[10px] text-gray-500 flex flex-
|
| 973 |
-
<
|
| 974 |
-
|
| 975 |
-
|
| 976 |
-
|
| 977 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 978 |
</footer>
|
| 979 |
</div>
|
| 980 |
`;
|
|
@@ -1037,23 +1194,46 @@ class ReportEditor extends HTMLElement {
|
|
| 1037 |
this._saveTimer = setTimeout(() => {
|
| 1038 |
this._savePagesToServer();
|
| 1039 |
}, 800);
|
|
|
|
| 1040 |
}
|
| 1041 |
|
| 1042 |
async _savePagesToServer() {
|
| 1043 |
const base = this._apiRoot();
|
| 1044 |
if (!base || !this.sessionId) return;
|
| 1045 |
-
|
| 1046 |
-
this.
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1054 |
}
|
| 1055 |
-
|
| 1056 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1057 |
}
|
| 1058 |
}
|
| 1059 |
|
|
@@ -1091,6 +1271,7 @@ class ReportEditor extends HTMLElement {
|
|
| 1091 |
items: [],
|
| 1092 |
template: source.template ? { ...source.template } : undefined,
|
| 1093 |
photo_ids: photoIds,
|
|
|
|
| 1094 |
photo_order_locked: source.photo_order_locked,
|
| 1095 |
variant: "photos",
|
| 1096 |
};
|
|
|
|
| 62 |
this.sessionId = null;
|
| 63 |
this.apiBase = null;
|
| 64 |
this._saveTimer = null;
|
| 65 |
+
this._savingPromise = null;
|
| 66 |
this._photoRatios = new Map();
|
| 67 |
this._indexMap = [];
|
| 68 |
}
|
|
|
|
| 165 |
this.dispatchEvent(new CustomEvent("editor-closed", { bubbles: true }));
|
| 166 |
}
|
| 167 |
|
| 168 |
+
async flushSave() {
|
| 169 |
+
if (this._saveTimer) {
|
| 170 |
+
clearTimeout(this._saveTimer);
|
| 171 |
+
this._saveTimer = null;
|
| 172 |
+
}
|
| 173 |
+
return this._savePagesToServer();
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
// ---------- Rendering ----------
|
| 177 |
render() {
|
| 178 |
this.innerHTML = `
|
|
|
|
| 203 |
<i data-feather="rotate-cw" class="h-4 w-4"></i> Redo
|
| 204 |
</button>
|
| 205 |
|
| 206 |
+
<div class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm font-semibold text-gray-600">
|
| 207 |
+
<i data-feather="check-circle" class="h-4 w-4"></i> Auto-saved
|
| 208 |
+
</div>
|
|
|
|
| 209 |
|
| 210 |
<button data-btn="close"
|
| 211 |
class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition">
|
|
|
|
| 453 |
|
| 454 |
// header buttons
|
| 455 |
this.querySelector('[data-btn="close"]').addEventListener("click", () => this.close());
|
|
|
|
| 456 |
this.querySelector('[data-btn="undo"]').addEventListener("click", () => this.undo());
|
| 457 |
this.querySelector('[data-btn="redo"]').addEventListener("click", () => this.redo());
|
| 458 |
|
|
|
|
| 612 |
this.$canvas.querySelectorAll("[data-template-field]").forEach((el) => {
|
| 613 |
const key = el.dataset.templateField;
|
| 614 |
if (!key) return;
|
| 615 |
+
const isScale = key === "category" || key === "priority";
|
| 616 |
+
const rawValue = template[key] || "";
|
| 617 |
+
const displayValue = isScale
|
| 618 |
+
? buildScaleBadge(
|
| 619 |
+
rawValue,
|
| 620 |
+
key === "category" ? CATEGORY_SCALE : PRIORITY_SCALE,
|
| 621 |
+
).text
|
| 622 |
+
: rawValue;
|
| 623 |
+
|
| 624 |
+
if (document.activeElement !== el) {
|
| 625 |
+
if (displayValue) {
|
| 626 |
+
if (el.textContent !== displayValue) {
|
| 627 |
+
el.textContent = displayValue;
|
| 628 |
+
}
|
| 629 |
+
}
|
| 630 |
}
|
| 631 |
+
|
| 632 |
+
const commitValue = () => {
|
| 633 |
+
const nextText = el.textContent || "";
|
| 634 |
+
if (isScale) {
|
| 635 |
+
const code = parseScaleCode(nextText);
|
| 636 |
+
template[key] = code || nextText;
|
| 637 |
+
} else {
|
| 638 |
+
template[key] = nextText;
|
| 639 |
+
}
|
| 640 |
+
if (this.$canvas) {
|
| 641 |
+
const nextDisplay = isScale
|
| 642 |
+
? buildScaleBadge(
|
| 643 |
+
template[key],
|
| 644 |
+
key === "category" ? CATEGORY_SCALE : PRIORITY_SCALE,
|
| 645 |
+
).text
|
| 646 |
+
: template[key] || "";
|
| 647 |
+
if (nextDisplay) {
|
| 648 |
+
this.$canvas
|
| 649 |
+
.querySelectorAll(`[data-template-field="${key}"]`)
|
| 650 |
+
.forEach((node) => {
|
| 651 |
+
if (node === el || document.activeElement === node) return;
|
| 652 |
+
if (node.textContent !== nextDisplay) {
|
| 653 |
+
node.textContent = nextDisplay;
|
| 654 |
+
}
|
| 655 |
+
});
|
| 656 |
+
}
|
| 657 |
+
}
|
| 658 |
this._savePages();
|
| 659 |
};
|
| 660 |
+
|
| 661 |
+
el.oninput = () => {
|
| 662 |
+
commitValue();
|
| 663 |
+
};
|
| 664 |
+
el.onblur = () => {
|
| 665 |
+
commitValue();
|
| 666 |
+
if (isScale) {
|
| 667 |
+
this.renderCanvas();
|
| 668 |
+
}
|
| 669 |
+
};
|
| 670 |
el.onpointerdown = (e) => {
|
| 671 |
e.stopPropagation();
|
| 672 |
};
|
|
|
|
| 676 |
}
|
| 677 |
};
|
| 678 |
});
|
| 679 |
+
this._bindTemplateSelects();
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
_bindTemplateSelects() {
|
| 683 |
+
if (!this.$canvas) return;
|
| 684 |
+
const template = this._getTemplate();
|
| 685 |
+
this.$canvas.querySelectorAll("[data-template-select]").forEach((el) => {
|
| 686 |
+
const key = el.dataset.templateSelect;
|
| 687 |
+
if (!key) return;
|
| 688 |
+
const current = template[key] || "";
|
| 689 |
+
if (el.value !== String(current)) {
|
| 690 |
+
el.value = String(current);
|
| 691 |
+
}
|
| 692 |
+
el.onchange = () => {
|
| 693 |
+
template[key] = el.value;
|
| 694 |
+
this._savePages();
|
| 695 |
+
this.renderCanvas();
|
| 696 |
+
};
|
| 697 |
+
el.onpointerdown = (e) => {
|
| 698 |
+
e.stopPropagation();
|
| 699 |
+
};
|
| 700 |
+
});
|
| 701 |
}
|
| 702 |
|
| 703 |
_escape(value) {
|
|
|
|
| 843 |
if (explicit.length) {
|
| 844 |
return explicit.map((id) => byId.get(id)).filter(Boolean);
|
| 845 |
}
|
| 846 |
+
return [];
|
|
|
|
|
|
|
|
|
|
| 847 |
}
|
| 848 |
|
| 849 |
_photoSlot(photo, fallbackLabel) {
|
|
|
|
| 855 |
</div>
|
| 856 |
`;
|
| 857 |
}
|
| 858 |
+
const label = this._escape(fallbackLabel || photo.name || "");
|
| 859 |
const safeUrl = this._escape(url);
|
| 860 |
+
const caption = this._tplField(
|
| 861 |
+
"figure_caption",
|
| 862 |
+
fallbackLabel || photo.name || "",
|
| 863 |
+
"Figure caption",
|
| 864 |
+
"text-[10px] text-gray-600 text-center w-full",
|
| 865 |
+
false,
|
| 866 |
+
true,
|
| 867 |
+
);
|
| 868 |
return `
|
| 869 |
<figure class="rounded-lg border border-gray-200 bg-gray-50 p-2 break-inside-avoid mb-3">
|
| 870 |
<img src="${safeUrl}" alt="${label}" class="w-full h-auto object-contain" />
|
| 871 |
+
<figcaption class="mt-1 text-[10px] text-gray-600 text-center">${caption}</figcaption>
|
| 872 |
</figure>
|
| 873 |
`;
|
| 874 |
}
|
| 875 |
|
| 876 |
+
_tplField(key, value, placeholder, className = "", multiline = false, inline = false) {
|
| 877 |
const safeValue = this._escape(value || "");
|
| 878 |
const safePlaceholder = this._escape(placeholder || "");
|
| 879 |
const multiAttr = multiline ? ' data-multiline="true"' : "";
|
| 880 |
+
const inlineAttr = inline ? ' style="display:inline-block;"' : "";
|
| 881 |
+
return `<div class="template-field ${className}" data-template-field="${key}" contenteditable="true" data-placeholder="${safePlaceholder}"${multiAttr}${inlineAttr}>${safeValue}</div>`;
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
_tplSelectField(key, value, options, className = "") {
|
| 885 |
+
const safeValue = this._escape(value || "");
|
| 886 |
+
const optionHtml = options
|
| 887 |
+
.map((option) => {
|
| 888 |
+
const optValue = this._escape(option.value);
|
| 889 |
+
const optLabel = this._escape(option.label);
|
| 890 |
+
const selected = optValue === safeValue ? " selected" : "";
|
| 891 |
+
return `<option value="${optValue}"${selected}>${optLabel}</option>`;
|
| 892 |
+
})
|
| 893 |
+
.join("");
|
| 894 |
+
return `<select class="template-select ${className}" data-template-select="${key}"><option value="">Select</option>${optionHtml}</select>`;
|
| 895 |
}
|
| 896 |
|
| 897 |
_templateMarkup() {
|
|
|
|
| 904 |
const inspector = template.inspector || "";
|
| 905 |
const docNumber =
|
| 906 |
template.document_no ||
|
| 907 |
+
session.document_no ||
|
| 908 |
(session.id ? `REP-${String(session.id).slice(0, 8).toUpperCase()}` : "");
|
|
|
|
| 909 |
const companyLogo = template.company_logo || "";
|
| 910 |
+
const figureCaption = template.figure_caption || "";
|
| 911 |
|
| 912 |
const reference = template.reference || "";
|
| 913 |
const area = template.area || "";
|
|
|
|
| 914 |
const itemDescription = template.item_description || "";
|
| 915 |
const functionalLocation = template.functional_location || "";
|
| 916 |
+
const categoryRaw = template.category || "";
|
| 917 |
+
const priorityRaw = template.priority || "";
|
| 918 |
+
const category = parseScaleCode(categoryRaw) || categoryRaw;
|
| 919 |
+
const priority = parseScaleCode(priorityRaw) || priorityRaw;
|
| 920 |
const requiredAction = template.required_action || "";
|
| 921 |
|
| 922 |
const categoryScale = {
|
|
|
|
| 936 |
};
|
| 937 |
const categoryBadge = this._ratingBadge(category, categoryScale);
|
| 938 |
const priorityBadge = this._ratingBadge(priority, priorityScale);
|
| 939 |
+
const categoryOptions = ["0", "1", "2", "3", "4", "5"].map((key) => ({
|
| 940 |
+
value: key,
|
| 941 |
+
label: `${key} - ${categoryScale[key].label}`,
|
| 942 |
+
}));
|
| 943 |
+
const priorityOptions = ["1", "2", "3", "X", "M"].map((key) => ({
|
| 944 |
+
value: key,
|
| 945 |
+
label: `${key} - ${priorityScale[key].label}`,
|
| 946 |
+
}));
|
| 947 |
|
| 948 |
const variant =
|
| 949 |
(this.activePage && this.activePage.variant) || "full";
|
|
|
|
| 955 |
: this._computePhotoLayout(photos).map((entry) => entry.photo);
|
| 956 |
const displayedPhotos =
|
| 957 |
variant === "full" ? orderedPhotos.slice(0, 2) : orderedPhotos;
|
| 958 |
+
const photoLayout =
|
| 959 |
+
(this.activePage && this.activePage.photo_layout) || "auto";
|
| 960 |
+
const normalizedLayout = String(photoLayout).toLowerCase();
|
| 961 |
+
const layoutMode =
|
| 962 |
+
normalizedLayout === "stacked" || normalizedLayout === "two-column"
|
| 963 |
+
? normalizedLayout
|
| 964 |
+
: "auto";
|
| 965 |
+
const photoColumnsClass =
|
| 966 |
+
layoutMode === "stacked"
|
| 967 |
+
? "columns-1"
|
| 968 |
+
: layoutMode === "two-column"
|
| 969 |
+
? "columns-2"
|
| 970 |
+
: displayedPhotos.length <= 1
|
| 971 |
+
? "columns-1"
|
| 972 |
+
: "columns-2";
|
| 973 |
const photoSlots = displayedPhotos.length
|
| 974 |
? displayedPhotos
|
| 975 |
+
.map((photo, idx) =>
|
| 976 |
+
this._photoSlot(photo, figureCaption || `Figure ${idx + 1}`),
|
| 977 |
+
)
|
| 978 |
.join("")
|
| 979 |
: this._photoSlot(null, "No photo selected");
|
| 980 |
const pageNum = this.state.activePage + 1;
|
|
|
|
| 1010 |
<div class="inline-flex items-center gap-6">
|
| 1011 |
<div class="text-center space-y-1">
|
| 1012 |
<div class="text-xs font-medium text-gray-500">Category</div>
|
| 1013 |
+
${this._tplSelectField(
|
| 1014 |
"category",
|
| 1015 |
+
category,
|
| 1016 |
+
categoryOptions,
|
| 1017 |
+
`min-w-[140px] rounded-md border px-3 py-1 text-sm font-semibold text-center ${categoryBadge.className}`,
|
| 1018 |
)}
|
| 1019 |
</div>
|
| 1020 |
|
| 1021 |
<div class="text-center space-y-1">
|
| 1022 |
<div class="text-xs font-medium text-gray-500">Priority</div>
|
| 1023 |
+
${this._tplSelectField(
|
| 1024 |
"priority",
|
| 1025 |
+
priority,
|
| 1026 |
+
priorityOptions,
|
| 1027 |
+
`min-w-[140px] rounded-md border px-3 py-1 text-sm font-semibold text-center ${priorityBadge.className}`,
|
| 1028 |
)}
|
| 1029 |
</div>
|
| 1030 |
|
|
|
|
| 1033 |
|
| 1034 |
<div class="md:col-span-2 space-y-1">
|
| 1035 |
<div class="text-xs font-medium text-gray-500">Condition Description</div>
|
| 1036 |
+
<div class="bg-amber-50 border-l-4 border-amber-300 p-2 rounded-sm">
|
| 1037 |
+
<div class="text-amber-800 text-sm font-semibold leading-snug">
|
| 1038 |
+
${this._tplField("item_description", itemDescription, "Item description", "template-field-multiline text-amber-800 text-sm font-semibold leading-snug", true)}
|
| 1039 |
+
</div>
|
| 1040 |
</div>
|
|
|
|
| 1041 |
</div>
|
| 1042 |
|
| 1043 |
<div class="md:col-span-2 space-y-1">
|
| 1044 |
<div class="text-xs font-medium text-gray-500">Action Required</div>
|
| 1045 |
<div class="bg-blue-50 border-l-4 border-blue-300 p-2 rounded-sm">
|
| 1046 |
<div class="text-blue-800 text-sm font-semibold leading-snug">
|
|
|
|
| 1047 |
${this._tplField("required_action", requiredAction, "Required action", "template-field-multiline text-blue-800 text-sm font-semibold leading-snug", true)}
|
| 1048 |
</div>
|
| 1049 |
</div>
|
|
|
|
| 1064 |
</div>
|
| 1065 |
|
| 1066 |
<div class="text-center leading-tight">
|
| 1067 |
+
${this._tplField(
|
| 1068 |
+
"document_no",
|
| 1069 |
+
docNumber,
|
| 1070 |
+
"Document No",
|
| 1071 |
+
"text-base font-semibold text-gray-900 whitespace-nowrap",
|
| 1072 |
+
)}
|
| 1073 |
</div>
|
| 1074 |
|
| 1075 |
<div class="flex items-center justify-end">
|
|
|
|
| 1090 |
</div>
|
| 1091 |
</section>
|
| 1092 |
|
| 1093 |
+
<footer class="mt-2 text-[10px] text-gray-500 flex flex-col items-center gap-1">
|
| 1094 |
+
<div class="flex flex-wrap items-center justify-center gap-3">
|
| 1095 |
+
<div class="flex items-center gap-1">
|
| 1096 |
+
<span>Date:</span>
|
| 1097 |
+
${this._tplField(
|
| 1098 |
+
"inspection_date",
|
| 1099 |
+
inspectionDate,
|
| 1100 |
+
"Date",
|
| 1101 |
+
"text-[10px] text-gray-500",
|
| 1102 |
+
false,
|
| 1103 |
+
true,
|
| 1104 |
+
)}
|
| 1105 |
+
</div>
|
| 1106 |
+
<div class="flex items-center gap-1">
|
| 1107 |
+
<span>Inspector:</span>
|
| 1108 |
+
${this._tplField(
|
| 1109 |
+
"inspector",
|
| 1110 |
+
inspector,
|
| 1111 |
+
"Inspector",
|
| 1112 |
+
"text-[10px] text-gray-500",
|
| 1113 |
+
false,
|
| 1114 |
+
true,
|
| 1115 |
+
)}
|
| 1116 |
+
</div>
|
| 1117 |
+
<div class="flex items-center gap-1">
|
| 1118 |
+
<span>Doc:</span>
|
| 1119 |
+
${this._tplField(
|
| 1120 |
+
"document_no",
|
| 1121 |
+
docNumber,
|
| 1122 |
+
"Document No",
|
| 1123 |
+
"text-[10px] text-gray-500",
|
| 1124 |
+
false,
|
| 1125 |
+
true,
|
| 1126 |
+
)}
|
| 1127 |
+
</div>
|
| 1128 |
+
</div>
|
| 1129 |
+
<div class="text-[10px] font-semibold text-gray-600">
|
| 1130 |
+
RepEx Inspection Job Sheet
|
| 1131 |
+
</div>
|
| 1132 |
+
<div class="text-[10px] text-gray-500">
|
| 1133 |
+
${sectionLabel ? `${this._escape(sectionLabel)} - ` : ""}Page ${pageNum} of ${pageCount}
|
| 1134 |
+
</div>
|
| 1135 |
</footer>
|
| 1136 |
</div>
|
| 1137 |
`;
|
|
|
|
| 1194 |
this._saveTimer = setTimeout(() => {
|
| 1195 |
this._savePagesToServer();
|
| 1196 |
}, 800);
|
| 1197 |
+
this.dispatchEvent(new CustomEvent("editor-save-queued", { bubbles: true }));
|
| 1198 |
}
|
| 1199 |
|
| 1200 |
async _savePagesToServer() {
|
| 1201 |
const base = this._apiRoot();
|
| 1202 |
if (!base || !this.sessionId) return;
|
| 1203 |
+
if (this._savingPromise) {
|
| 1204 |
+
return this._savingPromise;
|
| 1205 |
+
}
|
| 1206 |
+
const promise = (async () => {
|
| 1207 |
+
this.dispatchEvent(new CustomEvent("editor-save-start", { bubbles: true }));
|
| 1208 |
+
let ok = false;
|
| 1209 |
+
try {
|
| 1210 |
+
this._syncSectionsFromPages();
|
| 1211 |
+
const res = await fetch(`${base}/sessions/${this.sessionId}/sections`, {
|
| 1212 |
+
method: "PUT",
|
| 1213 |
+
headers: { "Content-Type": "application/json" },
|
| 1214 |
+
body: JSON.stringify({ sections: this.state.sections }),
|
| 1215 |
+
});
|
| 1216 |
+
if (!res.ok) {
|
| 1217 |
+
throw new Error("Failed");
|
| 1218 |
+
}
|
| 1219 |
+
ok = true;
|
| 1220 |
+
} catch {
|
| 1221 |
+
this._toast("Sync failed");
|
| 1222 |
+
} finally {
|
| 1223 |
+
this.dispatchEvent(
|
| 1224 |
+
new CustomEvent("editor-save-end", {
|
| 1225 |
+
bubbles: true,
|
| 1226 |
+
detail: { ok },
|
| 1227 |
+
}),
|
| 1228 |
+
);
|
| 1229 |
}
|
| 1230 |
+
return ok;
|
| 1231 |
+
})();
|
| 1232 |
+
this._savingPromise = promise;
|
| 1233 |
+
try {
|
| 1234 |
+
return await promise;
|
| 1235 |
+
} finally {
|
| 1236 |
+
this._savingPromise = null;
|
| 1237 |
}
|
| 1238 |
}
|
| 1239 |
|
|
|
|
| 1271 |
items: [],
|
| 1272 |
template: source.template ? { ...source.template } : undefined,
|
| 1273 |
photo_ids: photoIds,
|
| 1274 |
+
photo_layout: source.photo_layout,
|
| 1275 |
photo_order_locked: source.photo_order_locked,
|
| 1276 |
variant: "photos",
|
| 1277 |
};
|
frontend/src/lib/sections.ts
CHANGED
|
@@ -26,6 +26,7 @@ function buildPhotoContinuation(source: Page, photoIds: string[]): Page {
|
|
| 26 |
items: [],
|
| 27 |
template: cloneTemplate(source.template),
|
| 28 |
photo_ids: photoIds,
|
|
|
|
| 29 |
photo_order_locked: source.photo_order_locked,
|
| 30 |
variant: "photos",
|
| 31 |
};
|
|
|
|
| 26 |
items: [],
|
| 27 |
template: cloneTemplate(source.template),
|
| 28 |
photo_ids: photoIds,
|
| 29 |
+
photo_layout: source.photo_layout,
|
| 30 |
photo_order_locked: source.photo_order_locked,
|
| 31 |
variant: "photos",
|
| 32 |
};
|
frontend/src/pages/EditLayoutsPage.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
-
import { useEffect, useMemo, useState } from "react";
|
| 2 |
-
import { Link, useSearchParams } from "react-router-dom";
|
| 3 |
import {
|
| 4 |
ArrowLeft,
|
| 5 |
ChevronDown,
|
|
@@ -8,6 +8,7 @@ import {
|
|
| 8 |
Edit3,
|
| 9 |
Grid,
|
| 10 |
Layout,
|
|
|
|
| 11 |
Plus,
|
| 12 |
Table,
|
| 13 |
Trash2,
|
|
@@ -15,7 +16,7 @@ import {
|
|
| 15 |
|
| 16 |
import { putJson, request } from "../lib/api";
|
| 17 |
import { BASE_W } from "../lib/report";
|
| 18 |
-
import { ensureSections, flattenSections } from "../lib/sections";
|
| 19 |
import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
|
| 20 |
import type { JobsheetSection, Session } from "../types/session";
|
| 21 |
import { PageFooter } from "../components/PageFooter";
|
|
@@ -28,12 +29,23 @@ export default function EditLayoutsPage() {
|
|
| 28 |
const [searchParams] = useSearchParams();
|
| 29 |
const sessionId = getSessionId(searchParams.toString());
|
| 30 |
const sessionQuery = buildSessionQuery(sessionId);
|
|
|
|
| 31 |
|
| 32 |
const [session, setSession] = useState<Session | null>(null);
|
| 33 |
const [sections, setSections] = useState<JobsheetSection[]>([]);
|
| 34 |
const [status, setStatus] = useState("");
|
| 35 |
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
const canModify = Boolean(sessionId) && !isSaving;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
useEffect(() => {
|
| 39 |
if (!sessionId) {
|
|
@@ -48,7 +60,11 @@ export default function EditLayoutsPage() {
|
|
| 48 |
const sectionResp = await request<{ sections: JobsheetSection[] }>(
|
| 49 |
`/sessions/${sessionId}/sections`,
|
| 50 |
);
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
} catch (err) {
|
| 53 |
const message =
|
| 54 |
err instanceof Error ? err.message : "Failed to load session.";
|
|
@@ -66,9 +82,31 @@ export default function EditLayoutsPage() {
|
|
| 66 |
() => Math.max(1, flatPages.length),
|
| 67 |
[flatPages.length],
|
| 68 |
);
|
|
|
|
| 69 |
const previewWidth = 220;
|
| 70 |
const previewScale = previewWidth / BASE_W;
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
function hasExplicitPhotos(source: JobsheetSection[]) {
|
| 73 |
return source.some((section) =>
|
| 74 |
(section.pages ?? []).some((page) => (page.photo_ids ?? []).length > 0),
|
|
@@ -91,40 +129,100 @@ export default function EditLayoutsPage() {
|
|
| 91 |
return result;
|
| 92 |
}
|
| 93 |
|
| 94 |
-
async function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
if (!sessionId) return;
|
| 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 |
-
setStatus(message);
|
| 126 |
} finally {
|
| 127 |
-
|
| 128 |
}
|
| 129 |
}
|
| 130 |
|
|
@@ -140,6 +238,21 @@ export default function EditLayoutsPage() {
|
|
| 140 |
await saveLayout(next);
|
| 141 |
}
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
async function handleAddPage(sectionIndex: number) {
|
| 144 |
const next = sections.map((section, idx) => {
|
| 145 |
if (idx !== sectionIndex) return section;
|
|
@@ -195,15 +308,88 @@ export default function EditLayoutsPage() {
|
|
| 195 |
await saveLayout(next);
|
| 196 |
}
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
return (
|
| 199 |
<PageShell>
|
| 200 |
<PageHeader
|
| 201 |
title="RepEx - Report Express"
|
| 202 |
subtitle="Edit Page Layouts"
|
| 203 |
right={
|
| 204 |
-
<div className="flex items-
|
|
|
|
| 205 |
<Link
|
| 206 |
to={`/report-viewer${sessionQuery}`}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
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"
|
| 208 |
>
|
| 209 |
<ArrowLeft className="h-4 w-4" />
|
|
@@ -218,6 +404,10 @@ export default function EditLayoutsPage() {
|
|
| 218 |
<div className="flex flex-wrap gap-2">
|
| 219 |
<Link
|
| 220 |
to={`/input-data${sessionQuery}`}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
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"
|
| 222 |
>
|
| 223 |
<Table className="h-4 w-4" />
|
|
@@ -226,14 +416,34 @@ export default function EditLayoutsPage() {
|
|
| 226 |
|
| 227 |
<Link
|
| 228 |
to={`/report-viewer${sessionQuery}`}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
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"
|
| 230 |
>
|
| 231 |
<Layout className="h-4 w-4" />
|
| 232 |
Report Viewer
|
| 233 |
</Link>
|
| 234 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
<Link
|
| 236 |
to={`/edit-report${sessionQuery}`}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
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"
|
| 238 |
>
|
| 239 |
<Edit3 className="h-4 w-4" />
|
|
@@ -247,6 +457,10 @@ export default function EditLayoutsPage() {
|
|
| 247 |
|
| 248 |
<Link
|
| 249 |
to={`/export${sessionQuery}`}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
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"
|
| 251 |
>
|
| 252 |
<Download className="h-4 w-4" />
|
|
@@ -262,8 +476,26 @@ export default function EditLayoutsPage() {
|
|
| 262 |
<p className="text-sm text-gray-600">
|
| 263 |
Add, remove, or reorder pages, then return to the report viewer to edit content.
|
| 264 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
</div>
|
| 266 |
<div className="flex flex-wrap gap-2">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
<button
|
| 268 |
type="button"
|
| 269 |
onClick={handleAddSection}
|
|
@@ -280,110 +512,217 @@ export default function EditLayoutsPage() {
|
|
| 280 |
) : null}
|
| 281 |
</section>
|
| 282 |
|
| 283 |
-
<section className="
|
| 284 |
-
|
| 285 |
-
<div
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
</div>
|
| 305 |
</div>
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
disabled={!canModify}
|
| 310 |
-
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
| 311 |
-
>
|
| 312 |
-
<Plus className="h-4 w-4" />
|
| 313 |
-
Add page
|
| 314 |
-
</button>
|
| 315 |
-
</div>
|
| 316 |
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
</div>
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
onClick={() => handleMovePage(sectionIndex, pageIndex, -1)}
|
| 336 |
-
disabled={pageIndex === 0 || !canModify}
|
| 337 |
-
className="inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white p-1.5 text-gray-600 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
| 338 |
-
aria-label="Move page up"
|
| 339 |
-
>
|
| 340 |
-
<ChevronUp className="h-3.5 w-3.5" />
|
| 341 |
-
</button>
|
| 342 |
-
<button
|
| 343 |
-
type="button"
|
| 344 |
-
onClick={() => handleMovePage(sectionIndex, pageIndex, 1)}
|
| 345 |
-
disabled={pageIndex === (section.pages?.length ?? 1) - 1 || !canModify}
|
| 346 |
-
className="inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white p-1.5 text-gray-600 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
| 347 |
-
aria-label="Move page down"
|
| 348 |
>
|
| 349 |
-
<
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
>
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
</button>
|
| 360 |
</div>
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
>
|
| 367 |
-
<ReportPageCanvas
|
| 368 |
-
session={session}
|
| 369 |
-
page={page}
|
| 370 |
-
pageIndex={pageIndex}
|
| 371 |
-
pageCount={totalPages}
|
| 372 |
-
scale={previewScale}
|
| 373 |
-
template={page?.template}
|
| 374 |
-
sectionLabel={
|
| 375 |
-
section.title
|
| 376 |
-
? `Section ${sectionIndex + 1} - ${section.title}`
|
| 377 |
-
: `Section ${sectionIndex + 1}`
|
| 378 |
-
}
|
| 379 |
-
adaptive
|
| 380 |
-
/>
|
| 381 |
-
</div>
|
| 382 |
</div>
|
| 383 |
-
)
|
| 384 |
</div>
|
| 385 |
-
|
| 386 |
-
)
|
| 387 |
</section>
|
| 388 |
|
| 389 |
<PageFooter note="Tip: reorder pages here and remove empty pages before export." />
|
|
|
|
| 1 |
+
import { useEffect, useMemo, useRef, useState } from "react";
|
| 2 |
+
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
| 3 |
import {
|
| 4 |
ArrowLeft,
|
| 5 |
ChevronDown,
|
|
|
|
| 8 |
Edit3,
|
| 9 |
Grid,
|
| 10 |
Layout,
|
| 11 |
+
Image,
|
| 12 |
Plus,
|
| 13 |
Table,
|
| 14 |
Trash2,
|
|
|
|
| 16 |
|
| 17 |
import { putJson, request } from "../lib/api";
|
| 18 |
import { BASE_W } from "../lib/report";
|
| 19 |
+
import { ensureSections, flattenSections, replacePage } from "../lib/sections";
|
| 20 |
import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
|
| 21 |
import type { JobsheetSection, Session } from "../types/session";
|
| 22 |
import { PageFooter } from "../components/PageFooter";
|
|
|
|
| 29 |
const [searchParams] = useSearchParams();
|
| 30 |
const sessionId = getSessionId(searchParams.toString());
|
| 31 |
const sessionQuery = buildSessionQuery(sessionId);
|
| 32 |
+
const navigate = useNavigate();
|
| 33 |
|
| 34 |
const [session, setSession] = useState<Session | null>(null);
|
| 35 |
const [sections, setSections] = useState<JobsheetSection[]>([]);
|
| 36 |
const [status, setStatus] = useState("");
|
| 37 |
const [isSaving, setIsSaving] = useState(false);
|
| 38 |
+
const [saveState, setSaveState] = useState<
|
| 39 |
+
"saved" | "saving" | "pending" | "error"
|
| 40 |
+
>("saved");
|
| 41 |
+
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>(
|
| 42 |
+
{},
|
| 43 |
+
);
|
| 44 |
const canModify = Boolean(sessionId) && !isSaving;
|
| 45 |
+
const saveTimerRef = useRef<number | null>(null);
|
| 46 |
+
const savePromiseRef = useRef<Promise<void> | null>(null);
|
| 47 |
+
const lastSavedRef = useRef<string>("");
|
| 48 |
+
const pendingSaveRef = useRef<string>("");
|
| 49 |
|
| 50 |
useEffect(() => {
|
| 51 |
if (!sessionId) {
|
|
|
|
| 60 |
const sectionResp = await request<{ sections: JobsheetSection[] }>(
|
| 61 |
`/sessions/${sessionId}/sections`,
|
| 62 |
);
|
| 63 |
+
const normalized = ensureSections(sectionResp.sections);
|
| 64 |
+
setSections(normalized);
|
| 65 |
+
lastSavedRef.current = JSON.stringify(normalized);
|
| 66 |
+
pendingSaveRef.current = lastSavedRef.current;
|
| 67 |
+
setSaveState("saved");
|
| 68 |
} catch (err) {
|
| 69 |
const message =
|
| 70 |
err instanceof Error ? err.message : "Failed to load session.";
|
|
|
|
| 82 |
() => Math.max(1, flatPages.length),
|
| 83 |
[flatPages.length],
|
| 84 |
);
|
| 85 |
+
const totalSections = sections.length;
|
| 86 |
const previewWidth = 220;
|
| 87 |
const previewScale = previewWidth / BASE_W;
|
| 88 |
|
| 89 |
+
const PHOTO_LAYOUT_PRESETS = [
|
| 90 |
+
{
|
| 91 |
+
id: "auto",
|
| 92 |
+
label: "Auto fit",
|
| 93 |
+
description: "Let the system choose 1 or 2 columns per page.",
|
| 94 |
+
preview: "auto",
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
id: "two-column",
|
| 98 |
+
label: "Two column",
|
| 99 |
+
description: "Force a two-column photo grid.",
|
| 100 |
+
preview: "two-column",
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
id: "stacked",
|
| 104 |
+
label: "Stacked",
|
| 105 |
+
description: "Stack images vertically for tall photos.",
|
| 106 |
+
preview: "stacked",
|
| 107 |
+
},
|
| 108 |
+
] as const;
|
| 109 |
+
|
| 110 |
function hasExplicitPhotos(source: JobsheetSection[]) {
|
| 111 |
return source.some((section) =>
|
| 112 |
(section.pages ?? []).some((page) => (page.photo_ids ?? []).length > 0),
|
|
|
|
| 129 |
return result;
|
| 130 |
}
|
| 131 |
|
| 132 |
+
async function triggerAutoSave() {
|
| 133 |
+
if (!sessionId || savePromiseRef.current) return;
|
| 134 |
+
const snapshot = pendingSaveRef.current;
|
| 135 |
+
if (!snapshot || snapshot === lastSavedRef.current) return;
|
| 136 |
+
await saveLayout(sections, undefined, true);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
useEffect(() => {
|
| 140 |
if (!sessionId) return;
|
| 141 |
+
const snapshot = JSON.stringify(sections);
|
| 142 |
+
if (snapshot === lastSavedRef.current) {
|
| 143 |
+
if (!isSaving) setSaveState("saved");
|
| 144 |
+
return;
|
| 145 |
+
}
|
| 146 |
+
pendingSaveRef.current = snapshot;
|
| 147 |
+
if (!isSaving) setSaveState("pending");
|
| 148 |
+
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
| 149 |
+
saveTimerRef.current = window.setTimeout(() => {
|
| 150 |
+
void triggerAutoSave();
|
| 151 |
+
}, 800);
|
| 152 |
+
}, [sections, sessionId, isSaving]);
|
| 153 |
+
|
| 154 |
+
async function saveLayout(
|
| 155 |
+
next: JobsheetSection[],
|
| 156 |
+
nextSelectedIds?: string[],
|
| 157 |
+
silent = false,
|
| 158 |
+
) {
|
| 159 |
+
if (!sessionId) return;
|
| 160 |
+
if (savePromiseRef.current) {
|
| 161 |
+
await savePromiseRef.current;
|
| 162 |
+
}
|
| 163 |
+
const snapshot = JSON.stringify(next);
|
| 164 |
+
if (snapshot === lastSavedRef.current && nextSelectedIds === undefined) {
|
| 165 |
+
if (!isSaving) setSaveState("saved");
|
| 166 |
+
return;
|
| 167 |
+
}
|
| 168 |
+
const promise = (async () => {
|
| 169 |
+
setIsSaving(true);
|
| 170 |
+
setSaveState("saving");
|
| 171 |
+
if (!silent) {
|
| 172 |
+
setStatus("Saving layout changes...");
|
| 173 |
}
|
| 174 |
+
try {
|
| 175 |
+
const requests: Promise<unknown>[] = [
|
| 176 |
+
putJson<{ sections: JobsheetSection[] }>(
|
| 177 |
+
`/sessions/${sessionId}/sections`,
|
| 178 |
+
{ sections: next },
|
| 179 |
+
),
|
| 180 |
+
];
|
| 181 |
+
if (nextSelectedIds !== undefined) {
|
| 182 |
+
requests.push(
|
| 183 |
+
putJson<Session>(`/sessions/${sessionId}/selection`, {
|
| 184 |
+
selected_photo_ids: nextSelectedIds,
|
| 185 |
+
}),
|
| 186 |
+
);
|
| 187 |
+
}
|
| 188 |
+
const [pagesResp, sessionResp] = await Promise.all(requests);
|
| 189 |
+
const updatedSections =
|
| 190 |
+
(pagesResp as { sections?: JobsheetSection[] }).sections ?? next;
|
| 191 |
+
const updatedSession = sessionResp as Session | undefined;
|
| 192 |
+
const updated = ensureSections(updatedSections);
|
| 193 |
+
setSections(updated);
|
| 194 |
+
lastSavedRef.current = JSON.stringify(updated);
|
| 195 |
+
pendingSaveRef.current = lastSavedRef.current;
|
| 196 |
+
setSaveState("saved");
|
| 197 |
+
if (updatedSession) {
|
| 198 |
+
setSession(updatedSession);
|
| 199 |
+
}
|
| 200 |
+
if (!silent) {
|
| 201 |
+
setStatus("Layout saved.");
|
| 202 |
+
}
|
| 203 |
+
} catch (err) {
|
| 204 |
+
const message =
|
| 205 |
+
err instanceof Error ? err.message : "Failed to save layout.";
|
| 206 |
+
setStatus(message);
|
| 207 |
+
setSaveState("error");
|
| 208 |
+
} finally {
|
| 209 |
+
setIsSaving(false);
|
| 210 |
+
if (
|
| 211 |
+
pendingSaveRef.current &&
|
| 212 |
+
pendingSaveRef.current !== lastSavedRef.current
|
| 213 |
+
) {
|
| 214 |
+
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
| 215 |
+
saveTimerRef.current = window.setTimeout(() => {
|
| 216 |
+
void triggerAutoSave();
|
| 217 |
+
}, 200);
|
| 218 |
+
}
|
| 219 |
}
|
| 220 |
+
})();
|
| 221 |
+
savePromiseRef.current = promise;
|
| 222 |
+
try {
|
| 223 |
+
await promise;
|
|
|
|
| 224 |
} finally {
|
| 225 |
+
savePromiseRef.current = null;
|
| 226 |
}
|
| 227 |
}
|
| 228 |
|
|
|
|
| 238 |
await saveLayout(next);
|
| 239 |
}
|
| 240 |
|
| 241 |
+
function toggleSection(sectionId: string) {
|
| 242 |
+
setCollapsedSections((prev) => ({
|
| 243 |
+
...prev,
|
| 244 |
+
[sectionId]: !prev[sectionId],
|
| 245 |
+
}));
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
function setAllSectionsCollapsed(value: boolean) {
|
| 249 |
+
const next: Record<string, boolean> = {};
|
| 250 |
+
sections.forEach((section) => {
|
| 251 |
+
next[section.id] = value;
|
| 252 |
+
});
|
| 253 |
+
setCollapsedSections(next);
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
async function handleAddPage(sectionIndex: number) {
|
| 257 |
const next = sections.map((section, idx) => {
|
| 258 |
if (idx !== sectionIndex) return section;
|
|
|
|
| 308 |
await saveLayout(next);
|
| 309 |
}
|
| 310 |
|
| 311 |
+
function applyPhotoLayout(
|
| 312 |
+
sectionIndex: number,
|
| 313 |
+
pageIndex: number,
|
| 314 |
+
layout: "auto" | "two-column" | "stacked",
|
| 315 |
+
) {
|
| 316 |
+
if (!canModify) return;
|
| 317 |
+
setSections((prev) => {
|
| 318 |
+
const section = prev[sectionIndex];
|
| 319 |
+
const page = section?.pages?.[pageIndex];
|
| 320 |
+
if (!section || !page) return prev;
|
| 321 |
+
const nextPage = { ...page, photo_layout: layout };
|
| 322 |
+
return replacePage(prev, sectionIndex, pageIndex, nextPage);
|
| 323 |
+
});
|
| 324 |
+
setStatus(`Applied ${layout.replace("-", " ")} layout to page ${pageIndex + 1}.`);
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
async function saveAndNavigate(target: string) {
|
| 328 |
+
if (!sessionId) {
|
| 329 |
+
navigate(target);
|
| 330 |
+
return;
|
| 331 |
+
}
|
| 332 |
+
if (saveTimerRef.current) {
|
| 333 |
+
window.clearTimeout(saveTimerRef.current);
|
| 334 |
+
saveTimerRef.current = null;
|
| 335 |
+
}
|
| 336 |
+
const snapshot = JSON.stringify(sections);
|
| 337 |
+
pendingSaveRef.current = snapshot;
|
| 338 |
+
if (snapshot !== lastSavedRef.current || savePromiseRef.current) {
|
| 339 |
+
await saveLayout(sections, undefined, true);
|
| 340 |
+
}
|
| 341 |
+
navigate(target);
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
const saveIndicator = useMemo(() => {
|
| 345 |
+
const base =
|
| 346 |
+
"inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold";
|
| 347 |
+
if (saveState === "saving") {
|
| 348 |
+
return (
|
| 349 |
+
<div className={`${base} border-amber-200 bg-amber-50 text-amber-700`}>
|
| 350 |
+
<span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
|
| 351 |
+
Saving...
|
| 352 |
+
</div>
|
| 353 |
+
);
|
| 354 |
+
}
|
| 355 |
+
if (saveState === "pending") {
|
| 356 |
+
return (
|
| 357 |
+
<div className={`${base} border-amber-200 bg-amber-50 text-amber-700`}>
|
| 358 |
+
<span className="h-2 w-2 rounded-full bg-amber-500" />
|
| 359 |
+
Unsaved changes
|
| 360 |
+
</div>
|
| 361 |
+
);
|
| 362 |
+
}
|
| 363 |
+
if (saveState === "error") {
|
| 364 |
+
return (
|
| 365 |
+
<div className={`${base} border-red-200 bg-red-50 text-red-700`}>
|
| 366 |
+
<span className="h-2 w-2 rounded-full bg-red-500" />
|
| 367 |
+
Save failed
|
| 368 |
+
</div>
|
| 369 |
+
);
|
| 370 |
+
}
|
| 371 |
+
return (
|
| 372 |
+
<div className={`${base} border-emerald-200 bg-emerald-50 text-emerald-700`}>
|
| 373 |
+
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
| 374 |
+
All changes saved
|
| 375 |
+
</div>
|
| 376 |
+
);
|
| 377 |
+
}, [saveState]);
|
| 378 |
+
|
| 379 |
return (
|
| 380 |
<PageShell>
|
| 381 |
<PageHeader
|
| 382 |
title="RepEx - Report Express"
|
| 383 |
subtitle="Edit Page Layouts"
|
| 384 |
right={
|
| 385 |
+
<div className="flex flex-col items-end gap-2 sm:flex-row sm:items-center">
|
| 386 |
+
{saveIndicator}
|
| 387 |
<Link
|
| 388 |
to={`/report-viewer${sessionQuery}`}
|
| 389 |
+
onClick={(event) => {
|
| 390 |
+
event.preventDefault();
|
| 391 |
+
void saveAndNavigate(`/report-viewer${sessionQuery}`);
|
| 392 |
+
}}
|
| 393 |
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"
|
| 394 |
>
|
| 395 |
<ArrowLeft className="h-4 w-4" />
|
|
|
|
| 404 |
<div className="flex flex-wrap gap-2">
|
| 405 |
<Link
|
| 406 |
to={`/input-data${sessionQuery}`}
|
| 407 |
+
onClick={(event) => {
|
| 408 |
+
event.preventDefault();
|
| 409 |
+
void saveAndNavigate(`/input-data${sessionQuery}`);
|
| 410 |
+
}}
|
| 411 |
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"
|
| 412 |
>
|
| 413 |
<Table className="h-4 w-4" />
|
|
|
|
| 416 |
|
| 417 |
<Link
|
| 418 |
to={`/report-viewer${sessionQuery}`}
|
| 419 |
+
onClick={(event) => {
|
| 420 |
+
event.preventDefault();
|
| 421 |
+
void saveAndNavigate(`/report-viewer${sessionQuery}`);
|
| 422 |
+
}}
|
| 423 |
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"
|
| 424 |
>
|
| 425 |
<Layout className="h-4 w-4" />
|
| 426 |
Report Viewer
|
| 427 |
</Link>
|
| 428 |
|
| 429 |
+
<Link
|
| 430 |
+
to={`/image-placement${sessionQuery}`}
|
| 431 |
+
onClick={(event) => {
|
| 432 |
+
event.preventDefault();
|
| 433 |
+
void saveAndNavigate(`/image-placement${sessionQuery}`);
|
| 434 |
+
}}
|
| 435 |
+
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"
|
| 436 |
+
>
|
| 437 |
+
<Image className="h-4 w-4" />
|
| 438 |
+
Image Placement
|
| 439 |
+
</Link>
|
| 440 |
+
|
| 441 |
<Link
|
| 442 |
to={`/edit-report${sessionQuery}`}
|
| 443 |
+
onClick={(event) => {
|
| 444 |
+
event.preventDefault();
|
| 445 |
+
void saveAndNavigate(`/edit-report${sessionQuery}`);
|
| 446 |
+
}}
|
| 447 |
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"
|
| 448 |
>
|
| 449 |
<Edit3 className="h-4 w-4" />
|
|
|
|
| 457 |
|
| 458 |
<Link
|
| 459 |
to={`/export${sessionQuery}`}
|
| 460 |
+
onClick={(event) => {
|
| 461 |
+
event.preventDefault();
|
| 462 |
+
void saveAndNavigate(`/export${sessionQuery}`);
|
| 463 |
+
}}
|
| 464 |
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"
|
| 465 |
>
|
| 466 |
<Download className="h-4 w-4" />
|
|
|
|
| 476 |
<p className="text-sm text-gray-600">
|
| 477 |
Add, remove, or reorder pages, then return to the report viewer to edit content.
|
| 478 |
</p>
|
| 479 |
+
<div className="mt-2 text-xs text-gray-500">
|
| 480 |
+
{totalSections} section{totalSections === 1 ? "" : "s"} -{" "}
|
| 481 |
+
{totalPages} page{totalPages === 1 ? "" : "s"}
|
| 482 |
+
</div>
|
| 483 |
</div>
|
| 484 |
<div className="flex flex-wrap gap-2">
|
| 485 |
+
<button
|
| 486 |
+
type="button"
|
| 487 |
+
onClick={() => setAllSectionsCollapsed(true)}
|
| 488 |
+
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 transition"
|
| 489 |
+
>
|
| 490 |
+
Collapse all
|
| 491 |
+
</button>
|
| 492 |
+
<button
|
| 493 |
+
type="button"
|
| 494 |
+
onClick={() => setAllSectionsCollapsed(false)}
|
| 495 |
+
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-700 hover:bg-gray-50 transition"
|
| 496 |
+
>
|
| 497 |
+
Expand all
|
| 498 |
+
</button>
|
| 499 |
<button
|
| 500 |
type="button"
|
| 501 |
onClick={handleAddSection}
|
|
|
|
| 512 |
) : null}
|
| 513 |
</section>
|
| 514 |
|
| 515 |
+
<section className="mb-6 rounded-lg border border-gray-200 bg-white p-4">
|
| 516 |
+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
| 517 |
+
<div>
|
| 518 |
+
<h2 className="text-lg font-semibold text-gray-900">
|
| 519 |
+
Image Layout Presets
|
| 520 |
+
</h2>
|
| 521 |
+
<p className="text-sm text-gray-600">
|
| 522 |
+
Drag a preset onto a page to set how photos are arranged.
|
| 523 |
+
</p>
|
| 524 |
+
</div>
|
| 525 |
+
</div>
|
| 526 |
+
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
| 527 |
+
{PHOTO_LAYOUT_PRESETS.map((preset) => (
|
| 528 |
+
<div
|
| 529 |
+
key={preset.id}
|
| 530 |
+
draggable
|
| 531 |
+
onDragStart={(event) => {
|
| 532 |
+
event.dataTransfer.setData("text/plain", preset.id);
|
| 533 |
+
event.dataTransfer.effectAllowed = "copy";
|
| 534 |
+
}}
|
| 535 |
+
className="rounded-lg border border-gray-200 bg-gray-50 p-3 cursor-grab active:cursor-grabbing"
|
| 536 |
+
>
|
| 537 |
+
<div className="text-sm font-semibold text-gray-900">
|
| 538 |
+
{preset.label}
|
| 539 |
+
</div>
|
| 540 |
+
<div className="text-xs text-gray-600">{preset.description}</div>
|
| 541 |
+
<div className="mt-2">
|
| 542 |
+
{preset.preview === "stacked" ? (
|
| 543 |
+
<div className="grid grid-cols-1 gap-1">
|
| 544 |
+
<div className="h-6 rounded bg-gray-200" />
|
| 545 |
+
<div className="h-6 rounded bg-gray-200" />
|
| 546 |
+
</div>
|
| 547 |
+
) : (
|
| 548 |
+
<div className="grid grid-cols-2 gap-1">
|
| 549 |
+
<div className="h-6 rounded bg-gray-200" />
|
| 550 |
+
<div className="h-6 rounded bg-gray-200" />
|
| 551 |
+
</div>
|
| 552 |
+
)}
|
| 553 |
+
</div>
|
| 554 |
+
<div className="mt-2 text-[11px] text-gray-500">
|
| 555 |
+
Drag to apply
|
| 556 |
</div>
|
| 557 |
</div>
|
| 558 |
+
))}
|
| 559 |
+
</div>
|
| 560 |
+
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 561 |
|
| 562 |
+
<section className="space-y-6">
|
| 563 |
+
{sections.map((section, sectionIndex) => {
|
| 564 |
+
const isCollapsed = collapsedSections[section.id] ?? false;
|
| 565 |
+
const pageCount = section.pages?.length ?? 0;
|
| 566 |
+
const start =
|
| 567 |
+
sections
|
| 568 |
+
.slice(0, sectionIndex)
|
| 569 |
+
.reduce((sum, item) => sum + (item.pages?.length ?? 0), 0) + 1;
|
| 570 |
+
const end = Math.max(start, start + pageCount - 1);
|
| 571 |
+
return (
|
| 572 |
+
<div key={section.id} className="rounded-lg border border-gray-200 bg-white p-4">
|
| 573 |
+
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
| 574 |
+
<div>
|
| 575 |
+
<div className="text-xs font-semibold text-gray-500">
|
| 576 |
+
Section {sectionIndex + 1}
|
| 577 |
+
</div>
|
| 578 |
+
<input
|
| 579 |
+
type="text"
|
| 580 |
+
value={section.title ?? ""}
|
| 581 |
+
onChange={(event) =>
|
| 582 |
+
setSections((prev) =>
|
| 583 |
+
prev.map((item, idx) =>
|
| 584 |
+
idx === sectionIndex
|
| 585 |
+
? { ...item, title: event.target.value }
|
| 586 |
+
: item,
|
| 587 |
+
),
|
| 588 |
+
)
|
| 589 |
+
}
|
| 590 |
+
placeholder={`Section ${sectionIndex + 1}`}
|
| 591 |
+
className="mt-1 w-60 rounded-md border border-gray-200 px-2 py-1 text-sm font-semibold text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
| 592 |
+
/>
|
| 593 |
+
<div className="text-xs text-gray-500">
|
| 594 |
+
{pageCount} page{pageCount === 1 ? "" : "s"} - Pages {start}
|
| 595 |
+
{pageCount > 1 ? `-${end}` : ""}
|
| 596 |
+
</div>
|
| 597 |
+
</div>
|
| 598 |
+
<div className="flex items-center gap-2">
|
| 599 |
+
<button
|
| 600 |
+
type="button"
|
| 601 |
+
onClick={() => toggleSection(section.id)}
|
| 602 |
+
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 603 |
+
>
|
| 604 |
+
{isCollapsed ? (
|
| 605 |
+
<>
|
| 606 |
+
<ChevronDown className="h-4 w-4" />
|
| 607 |
+
Expand
|
| 608 |
+
</>
|
| 609 |
+
) : (
|
| 610 |
+
<>
|
| 611 |
+
<ChevronUp className="h-4 w-4" />
|
| 612 |
+
Collapse
|
| 613 |
+
</>
|
| 614 |
+
)}
|
| 615 |
+
</button>
|
| 616 |
+
<button
|
| 617 |
+
type="button"
|
| 618 |
+
onClick={() => handleAddPage(sectionIndex)}
|
| 619 |
+
disabled={!canModify}
|
| 620 |
+
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
| 621 |
+
>
|
| 622 |
+
<Plus className="h-4 w-4" />
|
| 623 |
+
Add page
|
| 624 |
+
</button>
|
| 625 |
+
</div>
|
| 626 |
+
</div>
|
| 627 |
+
|
| 628 |
+
{!isCollapsed ? (
|
| 629 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 630 |
+
{(section.pages ?? []).map((page, pageIndex) => (
|
| 631 |
+
<div
|
| 632 |
+
key={`${section.id}-page-${pageIndex}`}
|
| 633 |
+
className="rounded-lg border border-gray-200 bg-white p-3"
|
| 634 |
+
>
|
| 635 |
+
<div className="flex items-center justify-between mb-2">
|
| 636 |
+
<div>
|
| 637 |
+
<div className="text-sm font-semibold text-gray-900">
|
| 638 |
+
Page {pageIndex + 1}
|
| 639 |
+
</div>
|
| 640 |
+
<div className="text-xs text-gray-500">
|
| 641 |
+
{page.items?.length ?? 0} items - Global page {start + pageIndex} of {totalPages}
|
| 642 |
+
</div>
|
| 643 |
+
</div>
|
| 644 |
+
<div className="flex items-center gap-2">
|
| 645 |
+
<button
|
| 646 |
+
type="button"
|
| 647 |
+
onClick={() => handleMovePage(sectionIndex, pageIndex, -1)}
|
| 648 |
+
disabled={pageIndex === 0 || !canModify}
|
| 649 |
+
className="inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white p-1.5 text-gray-600 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
| 650 |
+
aria-label="Move page up"
|
| 651 |
+
>
|
| 652 |
+
<ChevronUp className="h-3.5 w-3.5" />
|
| 653 |
+
</button>
|
| 654 |
+
<button
|
| 655 |
+
type="button"
|
| 656 |
+
onClick={() => handleMovePage(sectionIndex, pageIndex, 1)}
|
| 657 |
+
disabled={pageIndex === (section.pages?.length ?? 1) - 1 || !canModify}
|
| 658 |
+
className="inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white p-1.5 text-gray-600 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
| 659 |
+
aria-label="Move page down"
|
| 660 |
+
>
|
| 661 |
+
<ChevronDown className="h-3.5 w-3.5" />
|
| 662 |
+
</button>
|
| 663 |
+
<button
|
| 664 |
+
type="button"
|
| 665 |
+
onClick={() => handleRemovePage(sectionIndex, pageIndex)}
|
| 666 |
+
disabled={(section.pages?.length ?? 1) <= 1 || !canModify}
|
| 667 |
+
className="inline-flex items-center gap-1 rounded-lg border border-red-200 bg-red-50 px-2.5 py-1.5 text-xs font-semibold text-red-700 hover:bg-red-100 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
| 668 |
+
>
|
| 669 |
+
<Trash2 className="h-3.5 w-3.5" />
|
| 670 |
+
Remove
|
| 671 |
+
</button>
|
| 672 |
+
</div>
|
| 673 |
</div>
|
| 674 |
+
|
| 675 |
+
<div
|
| 676 |
+
className="mx-auto rounded-lg border border-gray-200 bg-white"
|
| 677 |
+
style={{ width: previewWidth }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 678 |
>
|
| 679 |
+
<ReportPageCanvas
|
| 680 |
+
session={session}
|
| 681 |
+
page={page}
|
| 682 |
+
pageIndex={pageIndex}
|
| 683 |
+
pageCount={totalPages}
|
| 684 |
+
scale={previewScale}
|
| 685 |
+
template={page?.template}
|
| 686 |
+
sectionLabel={
|
| 687 |
+
section.title
|
| 688 |
+
? `Section ${sectionIndex + 1} - ${section.title}`
|
| 689 |
+
: `Section ${sectionIndex + 1}`
|
| 690 |
+
}
|
| 691 |
+
adaptive
|
| 692 |
+
/>
|
| 693 |
+
</div>
|
| 694 |
+
|
| 695 |
+
<div
|
| 696 |
+
className="mt-2 rounded-md border border-dashed border-gray-200 bg-gray-50 px-2 py-2 text-[11px] text-gray-600"
|
| 697 |
+
onDragOver={(event) => {
|
| 698 |
+
event.preventDefault();
|
| 699 |
+
event.dataTransfer.dropEffect = "copy";
|
| 700 |
+
}}
|
| 701 |
+
onDrop={(event) => {
|
| 702 |
+
event.preventDefault();
|
| 703 |
+
const layout = event.dataTransfer.getData("text/plain");
|
| 704 |
+
if (
|
| 705 |
+
layout === "auto" ||
|
| 706 |
+
layout === "two-column" ||
|
| 707 |
+
layout === "stacked"
|
| 708 |
+
) {
|
| 709 |
+
applyPhotoLayout(sectionIndex, pageIndex, layout);
|
| 710 |
+
}
|
| 711 |
+
}}
|
| 712 |
>
|
| 713 |
+
Drop image layout here - Current: {page.photo_layout ?? "auto"}
|
| 714 |
+
</div>
|
|
|
|
| 715 |
</div>
|
| 716 |
+
))}
|
| 717 |
+
</div>
|
| 718 |
+
) : (
|
| 719 |
+
<div className="rounded-md border border-dashed border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500">
|
| 720 |
+
Section collapsed. Expand to edit pages and apply layout presets.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 721 |
</div>
|
| 722 |
+
)}
|
| 723 |
</div>
|
| 724 |
+
);
|
| 725 |
+
})}
|
| 726 |
</section>
|
| 727 |
|
| 728 |
<PageFooter note="Tip: reorder pages here and remove empty pages before export." />
|
frontend/src/pages/EditReportPage.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 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 { ensureSections, flattenSections } from "../lib/sections";
|
|
@@ -20,6 +20,9 @@ export default function EditReportPage() {
|
|
| 20 |
const [session, setSession] = useState<Session | null>(null);
|
| 21 |
const [pageCount, setPageCount] = useState<number | null>(null);
|
| 22 |
const [error, setError] = useState("");
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
const [editorEl, setEditorEl] = useState<ReportEditorElement | null>(null);
|
| 25 |
const editorRef = useCallback((node: ReportEditorElement | null) => {
|
|
@@ -32,6 +35,16 @@ export default function EditReportPage() {
|
|
| 32 |
return Math.max(0, Math.floor(raw) - 1);
|
| 33 |
}, [searchParams]);
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
useEffect(() => {
|
| 36 |
if (!sessionId) {
|
| 37 |
setError("No active session found. Return to upload to continue.");
|
|
@@ -80,11 +93,70 @@ export default function EditReportPage() {
|
|
| 80 |
useEffect(() => {
|
| 81 |
if (!editorEl) return;
|
| 82 |
const handleClose = () => {
|
| 83 |
-
|
| 84 |
};
|
| 85 |
editorEl.addEventListener("editor-closed", handleClose);
|
| 86 |
return () => editorEl.removeEventListener("editor-closed", handleClose);
|
| 87 |
-
}, [editorEl,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
return (
|
| 90 |
<PageShell className="max-w-6xl">
|
|
@@ -92,9 +164,14 @@ export default function EditReportPage() {
|
|
| 92 |
title="RepEx - Report Express"
|
| 93 |
subtitle="Edit Report"
|
| 94 |
right={
|
| 95 |
-
<div className="flex items-
|
|
|
|
| 96 |
<Link
|
| 97 |
to={`/report-viewer${sessionQuery}`}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
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"
|
| 99 |
>
|
| 100 |
<ArrowLeft className="h-4 w-4" />
|
|
@@ -109,6 +186,10 @@ export default function EditReportPage() {
|
|
| 109 |
<div className="flex flex-wrap gap-2">
|
| 110 |
<Link
|
| 111 |
to={`/input-data${sessionQuery}`}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition"
|
| 113 |
>
|
| 114 |
<Table className="h-4 w-4" />
|
|
@@ -117,12 +198,28 @@ export default function EditReportPage() {
|
|
| 117 |
|
| 118 |
<Link
|
| 119 |
to={`/report-viewer${sessionQuery}`}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
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"
|
| 121 |
>
|
| 122 |
<Layout className="h-4 w-4" />
|
| 123 |
Report Viewer
|
| 124 |
</Link>
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
<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">
|
| 127 |
<Edit3 className="h-4 w-4" />
|
| 128 |
Edit Report
|
|
@@ -130,6 +227,10 @@ export default function EditReportPage() {
|
|
| 130 |
|
| 131 |
<Link
|
| 132 |
to={`/edit-layouts${sessionQuery}`}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
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"
|
| 134 |
>
|
| 135 |
<Grid className="h-4 w-4" />
|
|
@@ -138,6 +239,10 @@ export default function EditReportPage() {
|
|
| 138 |
|
| 139 |
<Link
|
| 140 |
to={`/export${sessionQuery}`}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
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"
|
| 142 |
>
|
| 143 |
<Download className="h-4 w-4" />
|
|
|
|
| 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, Image } from "react-feather";
|
| 4 |
|
| 5 |
import { API_BASE, request } from "../lib/api";
|
| 6 |
import { ensureSections, flattenSections } from "../lib/sections";
|
|
|
|
| 20 |
const [session, setSession] = useState<Session | null>(null);
|
| 21 |
const [pageCount, setPageCount] = useState<number | null>(null);
|
| 22 |
const [error, setError] = useState("");
|
| 23 |
+
const [saveState, setSaveState] = useState<
|
| 24 |
+
"saved" | "saving" | "pending" | "error"
|
| 25 |
+
>("saved");
|
| 26 |
|
| 27 |
const [editorEl, setEditorEl] = useState<ReportEditorElement | null>(null);
|
| 28 |
const editorRef = useCallback((node: ReportEditorElement | null) => {
|
|
|
|
| 35 |
return Math.max(0, Math.floor(raw) - 1);
|
| 36 |
}, [searchParams]);
|
| 37 |
|
| 38 |
+
const saveAndNavigate = useCallback(
|
| 39 |
+
async (target: string) => {
|
| 40 |
+
if (editorEl?.flushSave) {
|
| 41 |
+
await editorEl.flushSave();
|
| 42 |
+
}
|
| 43 |
+
navigate(target);
|
| 44 |
+
},
|
| 45 |
+
[editorEl, navigate],
|
| 46 |
+
);
|
| 47 |
+
|
| 48 |
useEffect(() => {
|
| 49 |
if (!sessionId) {
|
| 50 |
setError("No active session found. Return to upload to continue.");
|
|
|
|
| 93 |
useEffect(() => {
|
| 94 |
if (!editorEl) return;
|
| 95 |
const handleClose = () => {
|
| 96 |
+
void saveAndNavigate(`/report-viewer${sessionQuery}`);
|
| 97 |
};
|
| 98 |
editorEl.addEventListener("editor-closed", handleClose);
|
| 99 |
return () => editorEl.removeEventListener("editor-closed", handleClose);
|
| 100 |
+
}, [editorEl, saveAndNavigate, sessionQuery]);
|
| 101 |
+
|
| 102 |
+
useEffect(() => {
|
| 103 |
+
if (!editorEl) return;
|
| 104 |
+
const handleQueued = () => {
|
| 105 |
+
setSaveState((prev) => (prev === "saving" ? prev : "pending"));
|
| 106 |
+
};
|
| 107 |
+
const handleStart = () => setSaveState("saving");
|
| 108 |
+
const handleEnd = (event: Event) => {
|
| 109 |
+
const custom = event as CustomEvent<{ ok?: boolean }>;
|
| 110 |
+
if (custom.detail && custom.detail.ok === false) {
|
| 111 |
+
setSaveState("error");
|
| 112 |
+
} else {
|
| 113 |
+
setSaveState("saved");
|
| 114 |
+
}
|
| 115 |
+
};
|
| 116 |
+
editorEl.addEventListener("editor-save-queued", handleQueued);
|
| 117 |
+
editorEl.addEventListener("editor-save-start", handleStart);
|
| 118 |
+
editorEl.addEventListener("editor-save-end", handleEnd);
|
| 119 |
+
return () => {
|
| 120 |
+
editorEl.removeEventListener("editor-save-queued", handleQueued);
|
| 121 |
+
editorEl.removeEventListener("editor-save-start", handleStart);
|
| 122 |
+
editorEl.removeEventListener("editor-save-end", handleEnd);
|
| 123 |
+
};
|
| 124 |
+
}, [editorEl]);
|
| 125 |
+
|
| 126 |
+
const saveIndicator = useMemo(() => {
|
| 127 |
+
const base =
|
| 128 |
+
"inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold";
|
| 129 |
+
if (saveState === "saving") {
|
| 130 |
+
return (
|
| 131 |
+
<div className={`${base} border-amber-200 bg-amber-50 text-amber-700`}>
|
| 132 |
+
<span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
|
| 133 |
+
Saving...
|
| 134 |
+
</div>
|
| 135 |
+
);
|
| 136 |
+
}
|
| 137 |
+
if (saveState === "pending") {
|
| 138 |
+
return (
|
| 139 |
+
<div className={`${base} border-amber-200 bg-amber-50 text-amber-700`}>
|
| 140 |
+
<span className="h-2 w-2 rounded-full bg-amber-500" />
|
| 141 |
+
Unsaved changes
|
| 142 |
+
</div>
|
| 143 |
+
);
|
| 144 |
+
}
|
| 145 |
+
if (saveState === "error") {
|
| 146 |
+
return (
|
| 147 |
+
<div className={`${base} border-red-200 bg-red-50 text-red-700`}>
|
| 148 |
+
<span className="h-2 w-2 rounded-full bg-red-500" />
|
| 149 |
+
Save failed
|
| 150 |
+
</div>
|
| 151 |
+
);
|
| 152 |
+
}
|
| 153 |
+
return (
|
| 154 |
+
<div className={`${base} border-emerald-200 bg-emerald-50 text-emerald-700`}>
|
| 155 |
+
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
| 156 |
+
All changes saved
|
| 157 |
+
</div>
|
| 158 |
+
);
|
| 159 |
+
}, [saveState]);
|
| 160 |
|
| 161 |
return (
|
| 162 |
<PageShell className="max-w-6xl">
|
|
|
|
| 164 |
title="RepEx - Report Express"
|
| 165 |
subtitle="Edit Report"
|
| 166 |
right={
|
| 167 |
+
<div className="flex flex-col items-end gap-2 sm:flex-row sm:items-center">
|
| 168 |
+
{saveIndicator}
|
| 169 |
<Link
|
| 170 |
to={`/report-viewer${sessionQuery}`}
|
| 171 |
+
onClick={(event) => {
|
| 172 |
+
event.preventDefault();
|
| 173 |
+
void saveAndNavigate(`/report-viewer${sessionQuery}`);
|
| 174 |
+
}}
|
| 175 |
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"
|
| 176 |
>
|
| 177 |
<ArrowLeft className="h-4 w-4" />
|
|
|
|
| 186 |
<div className="flex flex-wrap gap-2">
|
| 187 |
<Link
|
| 188 |
to={`/input-data${sessionQuery}`}
|
| 189 |
+
onClick={(event) => {
|
| 190 |
+
event.preventDefault();
|
| 191 |
+
void saveAndNavigate(`/input-data${sessionQuery}`);
|
| 192 |
+
}}
|
| 193 |
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"
|
| 194 |
>
|
| 195 |
<Table className="h-4 w-4" />
|
|
|
|
| 198 |
|
| 199 |
<Link
|
| 200 |
to={`/report-viewer${sessionQuery}`}
|
| 201 |
+
onClick={(event) => {
|
| 202 |
+
event.preventDefault();
|
| 203 |
+
void saveAndNavigate(`/report-viewer${sessionQuery}`);
|
| 204 |
+
}}
|
| 205 |
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"
|
| 206 |
>
|
| 207 |
<Layout className="h-4 w-4" />
|
| 208 |
Report Viewer
|
| 209 |
</Link>
|
| 210 |
|
| 211 |
+
<Link
|
| 212 |
+
to={`/image-placement${sessionQuery}`}
|
| 213 |
+
onClick={(event) => {
|
| 214 |
+
event.preventDefault();
|
| 215 |
+
void saveAndNavigate(`/image-placement${sessionQuery}`);
|
| 216 |
+
}}
|
| 217 |
+
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"
|
| 218 |
+
>
|
| 219 |
+
<Image className="h-4 w-4" />
|
| 220 |
+
Image Placement
|
| 221 |
+
</Link>
|
| 222 |
+
|
| 223 |
<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">
|
| 224 |
<Edit3 className="h-4 w-4" />
|
| 225 |
Edit Report
|
|
|
|
| 227 |
|
| 228 |
<Link
|
| 229 |
to={`/edit-layouts${sessionQuery}`}
|
| 230 |
+
onClick={(event) => {
|
| 231 |
+
event.preventDefault();
|
| 232 |
+
void saveAndNavigate(`/edit-layouts${sessionQuery}`);
|
| 233 |
+
}}
|
| 234 |
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"
|
| 235 |
>
|
| 236 |
<Grid className="h-4 w-4" />
|
|
|
|
| 239 |
|
| 240 |
<Link
|
| 241 |
to={`/export${sessionQuery}`}
|
| 242 |
+
onClick={(event) => {
|
| 243 |
+
event.preventDefault();
|
| 244 |
+
void saveAndNavigate(`/export${sessionQuery}`);
|
| 245 |
+
}}
|
| 246 |
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"
|
| 247 |
>
|
| 248 |
<Download className="h-4 w-4" />
|
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, Table } from "react-feather";
|
| 4 |
|
| 5 |
import { API_BASE, request } from "../lib/api";
|
| 6 |
import { BASE_W } from "../lib/report";
|
|
@@ -159,6 +159,14 @@ export default function ExportPage() {
|
|
| 159 |
Report Viewer
|
| 160 |
</Link>
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
<Link
|
| 163 |
to={`/edit-report${sessionQuery}`}
|
| 164 |
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 { useEffect, useMemo, useRef, useState } from "react";
|
| 2 |
import { Link, useSearchParams } from "react-router-dom";
|
| 3 |
+
import { ArrowLeft, Download, Edit3, Grid, Layout, Table, Image } from "react-feather";
|
| 4 |
|
| 5 |
import { API_BASE, request } from "../lib/api";
|
| 6 |
import { BASE_W } from "../lib/report";
|
|
|
|
| 159 |
Report Viewer
|
| 160 |
</Link>
|
| 161 |
|
| 162 |
+
<Link
|
| 163 |
+
to={`/image-placement${sessionQuery}`}
|
| 164 |
+
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"
|
| 165 |
+
>
|
| 166 |
+
<Image className="h-4 w-4" />
|
| 167 |
+
Image Placement
|
| 168 |
+
</Link>
|
| 169 |
+
|
| 170 |
<Link
|
| 171 |
to={`/edit-report${sessionQuery}`}
|
| 172 |
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/ImagePlacementPage.tsx
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useMemo, useRef, useState } from "react";
|
| 2 |
+
import { Link, useSearchParams } from "react-router-dom";
|
| 3 |
+
import {
|
| 4 |
+
ArrowLeft,
|
| 5 |
+
ChevronLeft,
|
| 6 |
+
ChevronRight,
|
| 7 |
+
Layout,
|
| 8 |
+
Edit3,
|
| 9 |
+
Grid,
|
| 10 |
+
Download,
|
| 11 |
+
Table,
|
| 12 |
+
Info,
|
| 13 |
+
Image,
|
| 14 |
+
} from "react-feather";
|
| 15 |
+
|
| 16 |
+
import { postForm, putJson, request } from "../lib/api";
|
| 17 |
+
import { BASE_W } from "../lib/report";
|
| 18 |
+
import { ensureSections, flattenSections, replacePage } from "../lib/sections";
|
| 19 |
+
import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
|
| 20 |
+
import { APP_VERSION } from "../lib/version";
|
| 21 |
+
import type { JobsheetSection, Session } from "../types/session";
|
| 22 |
+
import { ReportPageCanvas } from "../components/ReportPageCanvas";
|
| 23 |
+
import { InfoMenu } from "../components/InfoMenu";
|
| 24 |
+
|
| 25 |
+
export default function ImagePlacementPage() {
|
| 26 |
+
const [searchParams] = useSearchParams();
|
| 27 |
+
const sessionId = getSessionId(searchParams.toString());
|
| 28 |
+
|
| 29 |
+
const [session, setSession] = useState<Session | null>(null);
|
| 30 |
+
const [sections, setSections] = useState<JobsheetSection[]>([]);
|
| 31 |
+
const [pageIndex, setPageIndex] = useState(0);
|
| 32 |
+
const [scale, setScale] = useState(1);
|
| 33 |
+
const [error, setError] = useState("");
|
| 34 |
+
const [photoSelection, setPhotoSelection] = useState("");
|
| 35 |
+
const [isSaving, setIsSaving] = useState(false);
|
| 36 |
+
const [saveState, setSaveState] = useState<"saved" | "saving" | "error">(
|
| 37 |
+
"saved",
|
| 38 |
+
);
|
| 39 |
+
const [status, setStatus] = useState("");
|
| 40 |
+
const [isUploading, setIsUploading] = useState(false);
|
| 41 |
+
|
| 42 |
+
const stageRef = useRef<HTMLDivElement | null>(null);
|
| 43 |
+
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
| 44 |
+
|
| 45 |
+
useEffect(() => {
|
| 46 |
+
if (!sessionId) {
|
| 47 |
+
setError("No active session found. Return to upload to continue.");
|
| 48 |
+
return;
|
| 49 |
+
}
|
| 50 |
+
setStoredSessionId(sessionId);
|
| 51 |
+
}, [sessionId]);
|
| 52 |
+
|
| 53 |
+
useEffect(() => {
|
| 54 |
+
const handleResize = () => {
|
| 55 |
+
if (!stageRef.current) return;
|
| 56 |
+
const width = stageRef.current.clientWidth;
|
| 57 |
+
if (width > 0) setScale(width / BASE_W);
|
| 58 |
+
};
|
| 59 |
+
handleResize();
|
| 60 |
+
window.addEventListener("resize", handleResize);
|
| 61 |
+
return () => window.removeEventListener("resize", handleResize);
|
| 62 |
+
}, []);
|
| 63 |
+
|
| 64 |
+
useEffect(() => {
|
| 65 |
+
if (!sessionId) return;
|
| 66 |
+
async function load() {
|
| 67 |
+
try {
|
| 68 |
+
const data = await request<Session>(`/sessions/${sessionId}`);
|
| 69 |
+
setSession(data);
|
| 70 |
+
const sectionResp = await request<{ sections: JobsheetSection[] }>(
|
| 71 |
+
`/sessions/${sessionId}/sections`,
|
| 72 |
+
);
|
| 73 |
+
const loaded = ensureSections(sectionResp.sections);
|
| 74 |
+
setSections(loaded);
|
| 75 |
+
} catch (err) {
|
| 76 |
+
const message =
|
| 77 |
+
err instanceof Error ? err.message : "Failed to load session.";
|
| 78 |
+
setError(message);
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
load();
|
| 82 |
+
}, [sessionId]);
|
| 83 |
+
|
| 84 |
+
const flatPages = useMemo(
|
| 85 |
+
() => flattenSections(ensureSections(sections)),
|
| 86 |
+
[sections],
|
| 87 |
+
);
|
| 88 |
+
const totalPages = useMemo(() => {
|
| 89 |
+
if (flatPages.length > 0) return flatPages.length;
|
| 90 |
+
return Math.max(1, session?.page_count ?? 0);
|
| 91 |
+
}, [flatPages.length, session?.page_count]);
|
| 92 |
+
|
| 93 |
+
useEffect(() => {
|
| 94 |
+
setPageIndex((idx) => Math.min(Math.max(0, idx), totalPages - 1));
|
| 95 |
+
}, [totalPages]);
|
| 96 |
+
|
| 97 |
+
useEffect(() => {
|
| 98 |
+
setPhotoSelection("");
|
| 99 |
+
}, [pageIndex]);
|
| 100 |
+
|
| 101 |
+
useEffect(() => {
|
| 102 |
+
const handler = (event: KeyboardEvent) => {
|
| 103 |
+
if (event.key === "ArrowRight") {
|
| 104 |
+
setPageIndex((idx) => Math.min(totalPages - 1, idx + 1));
|
| 105 |
+
}
|
| 106 |
+
if (event.key === "ArrowLeft") {
|
| 107 |
+
setPageIndex((idx) => Math.max(0, idx - 1));
|
| 108 |
+
}
|
| 109 |
+
};
|
| 110 |
+
window.addEventListener("keydown", handler);
|
| 111 |
+
return () => window.removeEventListener("keydown", handler);
|
| 112 |
+
}, [totalPages]);
|
| 113 |
+
|
| 114 |
+
const page = flatPages[pageIndex]?.page ?? null;
|
| 115 |
+
const pageEntry = flatPages[pageIndex] ?? null;
|
| 116 |
+
const sectionLabel = flatPages[pageIndex]?.sectionTitle
|
| 117 |
+
? `Section ${flatPages[pageIndex].sectionIndex + 1} - ${flatPages[pageIndex].sectionTitle}`
|
| 118 |
+
: flatPages[pageIndex]
|
| 119 |
+
? `Section ${flatPages[pageIndex].sectionIndex + 1}`
|
| 120 |
+
: "";
|
| 121 |
+
const template = page?.template;
|
| 122 |
+
const orderLocked = page?.photo_order_locked ?? false;
|
| 123 |
+
const sessionQuery = buildSessionQuery(sessionId || "");
|
| 124 |
+
const editReportQuery = useMemo(() => {
|
| 125 |
+
if (!sessionId) return "";
|
| 126 |
+
const params = new URLSearchParams();
|
| 127 |
+
params.set("session", sessionId);
|
| 128 |
+
params.set("page", String(pageIndex + 1));
|
| 129 |
+
return `?${params.toString()}`;
|
| 130 |
+
}, [sessionId, pageIndex]);
|
| 131 |
+
|
| 132 |
+
const viewerMeta = useMemo(() => {
|
| 133 |
+
if (!session) return "Loading...";
|
| 134 |
+
const selected = session.selected_photo_ids?.length ?? 0;
|
| 135 |
+
const docs = session.uploads?.documents?.length ?? 0;
|
| 136 |
+
const dataFiles = session.uploads?.data_files?.length ?? 0;
|
| 137 |
+
const hasEdits = flatPages.length > 0;
|
| 138 |
+
return (
|
| 139 |
+
`Selected example photos: ${selected} - Documents: ${docs} - Data files: ${dataFiles}` +
|
| 140 |
+
(hasEdits ? " - Edited pages loaded" : " - No saved edits yet")
|
| 141 |
+
);
|
| 142 |
+
}, [flatPages.length, session]);
|
| 143 |
+
|
| 144 |
+
const linkedPhotoIds = useMemo(() => {
|
| 145 |
+
const ids = new Set<string>();
|
| 146 |
+
sections.forEach((section) => {
|
| 147 |
+
(section.pages ?? []).forEach((sectionPage) => {
|
| 148 |
+
(sectionPage.photo_ids ?? []).forEach((photoId) => ids.add(photoId));
|
| 149 |
+
});
|
| 150 |
+
});
|
| 151 |
+
return ids;
|
| 152 |
+
}, [sections]);
|
| 153 |
+
|
| 154 |
+
const pagePhotoIds = page?.photo_ids ?? [];
|
| 155 |
+
const photoLookup = useMemo(
|
| 156 |
+
() => new Map((session?.uploads?.photos ?? []).map((photo) => [photo.id, photo])),
|
| 157 |
+
[session?.uploads?.photos],
|
| 158 |
+
);
|
| 159 |
+
const availablePhotos = useMemo(() => {
|
| 160 |
+
const all = session?.uploads?.photos ?? [];
|
| 161 |
+
if (!all.length) return [];
|
| 162 |
+
return all.filter((photo) => !linkedPhotoIds.has(photo.id));
|
| 163 |
+
}, [linkedPhotoIds, session?.uploads?.photos]);
|
| 164 |
+
|
| 165 |
+
const saveIndicator = useMemo(() => {
|
| 166 |
+
const base =
|
| 167 |
+
"inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold";
|
| 168 |
+
if (saveState === "saving") {
|
| 169 |
+
return (
|
| 170 |
+
<div className={`${base} border-amber-200 bg-amber-50 text-amber-700`}>
|
| 171 |
+
<span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
|
| 172 |
+
Saving...
|
| 173 |
+
</div>
|
| 174 |
+
);
|
| 175 |
+
}
|
| 176 |
+
if (saveState === "error") {
|
| 177 |
+
return (
|
| 178 |
+
<div className={`${base} border-red-200 bg-red-50 text-red-700`}>
|
| 179 |
+
<span className="h-2 w-2 rounded-full bg-red-500" />
|
| 180 |
+
Save failed
|
| 181 |
+
</div>
|
| 182 |
+
);
|
| 183 |
+
}
|
| 184 |
+
return (
|
| 185 |
+
<div className={`${base} border-emerald-200 bg-emerald-50 text-emerald-700`}>
|
| 186 |
+
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
| 187 |
+
All changes saved
|
| 188 |
+
</div>
|
| 189 |
+
);
|
| 190 |
+
}, [saveState]);
|
| 191 |
+
|
| 192 |
+
async function persistSections(next: JobsheetSection[]) {
|
| 193 |
+
if (!sessionId) return;
|
| 194 |
+
setIsSaving(true);
|
| 195 |
+
setSaveState("saving");
|
| 196 |
+
setStatus("Saving image changes...");
|
| 197 |
+
try {
|
| 198 |
+
const resp = await putJson<{ sections: JobsheetSection[] }>(
|
| 199 |
+
`/sessions/${sessionId}/sections`,
|
| 200 |
+
{ sections: next },
|
| 201 |
+
);
|
| 202 |
+
const normalized = ensureSections(resp.sections ?? next);
|
| 203 |
+
setSections(normalized);
|
| 204 |
+
setSaveState("saved");
|
| 205 |
+
setStatus("Image changes saved.");
|
| 206 |
+
} catch (err) {
|
| 207 |
+
const message =
|
| 208 |
+
err instanceof Error ? err.message : "Failed to save image changes.";
|
| 209 |
+
setStatus(message);
|
| 210 |
+
setSaveState("error");
|
| 211 |
+
} finally {
|
| 212 |
+
setIsSaving(false);
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
function updatePagePhotos(nextIds: string[]) {
|
| 217 |
+
if (!pageEntry) return;
|
| 218 |
+
const nextPage = { ...pageEntry.page, photo_ids: nextIds };
|
| 219 |
+
const nextSections = replacePage(
|
| 220 |
+
sections,
|
| 221 |
+
pageEntry.sectionIndex,
|
| 222 |
+
pageEntry.pageIndex,
|
| 223 |
+
nextPage,
|
| 224 |
+
);
|
| 225 |
+
setSections(nextSections);
|
| 226 |
+
void persistSections(nextSections);
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
function addPhotoToPage(photoId: string) {
|
| 230 |
+
if (!photoId || !pageEntry) return;
|
| 231 |
+
const ids = [...pagePhotoIds];
|
| 232 |
+
if (!ids.includes(photoId)) ids.push(photoId);
|
| 233 |
+
updatePagePhotos(ids);
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
function removePhotoAt(index: number) {
|
| 237 |
+
const ids = [...pagePhotoIds];
|
| 238 |
+
ids.splice(index, 1);
|
| 239 |
+
updatePagePhotos(ids);
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
function movePhoto(from: number, to: number) {
|
| 243 |
+
const ids = [...pagePhotoIds];
|
| 244 |
+
if (from < 0 || from >= ids.length || to < 0 || to >= ids.length) return;
|
| 245 |
+
const [moved] = ids.splice(from, 1);
|
| 246 |
+
ids.splice(to, 0, moved);
|
| 247 |
+
updatePagePhotos(ids);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
function toggleManualOrder() {
|
| 251 |
+
if (!pageEntry) return;
|
| 252 |
+
const nextPage = {
|
| 253 |
+
...pageEntry.page,
|
| 254 |
+
photo_order_locked: !pageEntry.page.photo_order_locked,
|
| 255 |
+
};
|
| 256 |
+
const nextSections = replacePage(
|
| 257 |
+
sections,
|
| 258 |
+
pageEntry.sectionIndex,
|
| 259 |
+
pageEntry.pageIndex,
|
| 260 |
+
nextPage,
|
| 261 |
+
);
|
| 262 |
+
setSections(nextSections);
|
| 263 |
+
void persistSections(nextSections);
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
async function uploadPhoto(file: File) {
|
| 267 |
+
if (!sessionId) return;
|
| 268 |
+
setIsUploading(true);
|
| 269 |
+
setStatus(`Uploading ${file.name}...`);
|
| 270 |
+
const existing = new Set(
|
| 271 |
+
(session?.uploads?.photos ?? []).map((photo) => photo.id),
|
| 272 |
+
);
|
| 273 |
+
try {
|
| 274 |
+
const form = new FormData();
|
| 275 |
+
form.append("file", file);
|
| 276 |
+
const updated = await postForm<Session>(
|
| 277 |
+
`/sessions/${sessionId}/uploads`,
|
| 278 |
+
form,
|
| 279 |
+
);
|
| 280 |
+
setSession(updated);
|
| 281 |
+
const newPhoto =
|
| 282 |
+
(updated.uploads?.photos ?? []).find((photo) => !existing.has(photo.id)) ??
|
| 283 |
+
(updated.uploads?.photos ?? []).find((photo) => photo.name === file.name);
|
| 284 |
+
if (newPhoto) {
|
| 285 |
+
addPhotoToPage(newPhoto.id);
|
| 286 |
+
setStatus(`Added ${newPhoto.name} to this page.`);
|
| 287 |
+
} else {
|
| 288 |
+
setStatus("Uploaded image. Select it from the list to add.");
|
| 289 |
+
}
|
| 290 |
+
} catch (err) {
|
| 291 |
+
const message =
|
| 292 |
+
err instanceof Error ? err.message : "Failed to upload image.";
|
| 293 |
+
setStatus(message);
|
| 294 |
+
} finally {
|
| 295 |
+
setIsUploading(false);
|
| 296 |
+
}
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
return (
|
| 300 |
+
<main className="max-w-4xl mx-auto my-8 bg-white shadow-sm ring-1 ring-gray-200 rounded-xl p-6 md:p-8 print:ring-0 print:shadow-none print:rounded-none">
|
| 301 |
+
<header className="mb-8 border-b border-gray-200 pb-4">
|
| 302 |
+
<div className="grid grid-cols-[auto,1fr,auto] items-center gap-4">
|
| 303 |
+
<div className="flex items-center">
|
| 304 |
+
<img
|
| 305 |
+
src="/assets/prosento-logo.png"
|
| 306 |
+
alt="Company logo"
|
| 307 |
+
className="h-12 w-auto object-contain"
|
| 308 |
+
loading="eager"
|
| 309 |
+
/>
|
| 310 |
+
</div>
|
| 311 |
+
|
| 312 |
+
<div className="text-center">
|
| 313 |
+
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 whitespace-nowrap">
|
| 314 |
+
RepEx - Report Express
|
| 315 |
+
</h1>
|
| 316 |
+
<p className="text-gray-600 whitespace-nowrap">Image Placement</p>
|
| 317 |
+
</div>
|
| 318 |
+
|
| 319 |
+
<div className="flex justify-end gap-2 no-print">
|
| 320 |
+
<Link
|
| 321 |
+
to={`/report-viewer${sessionQuery}`}
|
| 322 |
+
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"
|
| 323 |
+
>
|
| 324 |
+
<ArrowLeft className="h-4 w-4" />
|
| 325 |
+
Back
|
| 326 |
+
</Link>
|
| 327 |
+
<InfoMenu sessionQuery={sessionQuery} />
|
| 328 |
+
</div>
|
| 329 |
+
</div>
|
| 330 |
+
</header>
|
| 331 |
+
|
| 332 |
+
<nav className="mb-6 no-print" aria-label="Report workflow navigation">
|
| 333 |
+
<div className="flex flex-wrap gap-2">
|
| 334 |
+
<Link
|
| 335 |
+
to={`/input-data${sessionQuery}`}
|
| 336 |
+
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"
|
| 337 |
+
>
|
| 338 |
+
<Table className="h-4 w-4" />
|
| 339 |
+
Input Data
|
| 340 |
+
</Link>
|
| 341 |
+
|
| 342 |
+
<Link
|
| 343 |
+
to={`/report-viewer${sessionQuery}`}
|
| 344 |
+
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"
|
| 345 |
+
>
|
| 346 |
+
<Layout className="h-4 w-4" />
|
| 347 |
+
Report Viewer
|
| 348 |
+
</Link>
|
| 349 |
+
|
| 350 |
+
<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">
|
| 351 |
+
<Image className="h-4 w-4" />
|
| 352 |
+
Image Placement
|
| 353 |
+
</span>
|
| 354 |
+
|
| 355 |
+
<Link
|
| 356 |
+
to={`/edit-report${editReportQuery}`}
|
| 357 |
+
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"
|
| 358 |
+
>
|
| 359 |
+
<Edit3 className="h-4 w-4" />
|
| 360 |
+
Edit Report
|
| 361 |
+
</Link>
|
| 362 |
+
|
| 363 |
+
<Link
|
| 364 |
+
to={`/edit-layouts${sessionQuery}`}
|
| 365 |
+
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"
|
| 366 |
+
>
|
| 367 |
+
<Grid className="h-4 w-4" />
|
| 368 |
+
Edit Page Layouts
|
| 369 |
+
</Link>
|
| 370 |
+
|
| 371 |
+
<Link
|
| 372 |
+
to={`/export${sessionQuery}`}
|
| 373 |
+
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"
|
| 374 |
+
>
|
| 375 |
+
<Download className="h-4 w-4" />
|
| 376 |
+
Export
|
| 377 |
+
</Link>
|
| 378 |
+
|
| 379 |
+
<Link
|
| 380 |
+
to="/info/ratings"
|
| 381 |
+
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"
|
| 382 |
+
>
|
| 383 |
+
<Info className="h-4 w-4" />
|
| 384 |
+
Rating Scales
|
| 385 |
+
</Link>
|
| 386 |
+
</div>
|
| 387 |
+
</nav>
|
| 388 |
+
|
| 389 |
+
<section
|
| 390 |
+
id="viewerSection"
|
| 391 |
+
aria-label="Image placement preview"
|
| 392 |
+
>
|
| 393 |
+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
| 394 |
+
<div>
|
| 395 |
+
<h2 className="text-xl font-semibold text-gray-800">Page Preview</h2>
|
| 396 |
+
<p className="text-sm text-gray-600">{viewerMeta}</p>
|
| 397 |
+
</div>
|
| 398 |
+
|
| 399 |
+
<div className="flex items-center gap-2 no-print">
|
| 400 |
+
<button
|
| 401 |
+
type="button"
|
| 402 |
+
onClick={() => setPageIndex((idx) => Math.max(0, idx - 1))}
|
| 403 |
+
disabled={pageIndex === 0}
|
| 404 |
+
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
| 405 |
+
>
|
| 406 |
+
<ChevronLeft className="h-4 w-4" />
|
| 407 |
+
Prev
|
| 408 |
+
</button>
|
| 409 |
+
|
| 410 |
+
<div className="text-sm font-semibold text-gray-700">
|
| 411 |
+
Page <span>{pageIndex + 1}</span> / <span>{totalPages}</span>
|
| 412 |
+
</div>
|
| 413 |
+
|
| 414 |
+
<button
|
| 415 |
+
type="button"
|
| 416 |
+
onClick={() =>
|
| 417 |
+
setPageIndex((idx) => Math.min(totalPages - 1, idx + 1))
|
| 418 |
+
}
|
| 419 |
+
disabled={pageIndex >= totalPages - 1}
|
| 420 |
+
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
| 421 |
+
>
|
| 422 |
+
Next
|
| 423 |
+
<ChevronRight className="h-4 w-4" />
|
| 424 |
+
</button>
|
| 425 |
+
</div>
|
| 426 |
+
</div>
|
| 427 |
+
|
| 428 |
+
<div className="flex justify-center">
|
| 429 |
+
<div
|
| 430 |
+
ref={stageRef}
|
| 431 |
+
className="relative shadow-sm rounded-xl bg-white border border-gray-200"
|
| 432 |
+
style={{
|
| 433 |
+
width: "min(100%, 560px)",
|
| 434 |
+
}}
|
| 435 |
+
>
|
| 436 |
+
<ReportPageCanvas
|
| 437 |
+
session={session}
|
| 438 |
+
page={page}
|
| 439 |
+
pageIndex={pageIndex}
|
| 440 |
+
pageCount={totalPages}
|
| 441 |
+
scale={scale}
|
| 442 |
+
template={template}
|
| 443 |
+
sectionLabel={sectionLabel}
|
| 444 |
+
adaptive
|
| 445 |
+
/>
|
| 446 |
+
</div>
|
| 447 |
+
</div>
|
| 448 |
+
|
| 449 |
+
<p className="mt-4 text-xs text-gray-500 no-print">
|
| 450 |
+
Tip: Use keyboard arrows (left / right) to change pages.
|
| 451 |
+
</p>
|
| 452 |
+
{error ? <p className="text-sm text-red-600 mt-2">{error}</p> : null}
|
| 453 |
+
</section>
|
| 454 |
+
|
| 455 |
+
{pageEntry ? (
|
| 456 |
+
<section className="mt-6 no-print" aria-label="Page images">
|
| 457 |
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
| 458 |
+
<div>
|
| 459 |
+
<h3 className="text-lg font-semibold text-gray-900">Page Images</h3>
|
| 460 |
+
<p className="text-sm text-gray-600">
|
| 461 |
+
Images only appear once they are linked to a page. Unlinked uploads
|
| 462 |
+
stay hidden until you place them.
|
| 463 |
+
</p>
|
| 464 |
+
</div>
|
| 465 |
+
{saveIndicator}
|
| 466 |
+
</div>
|
| 467 |
+
|
| 468 |
+
<div className="mt-3 grid gap-4 lg:grid-cols-[1.6fr,1fr]">
|
| 469 |
+
<div className="rounded-lg border border-gray-200 bg-white p-3">
|
| 470 |
+
<div className="text-xs font-semibold text-gray-600 uppercase">
|
| 471 |
+
Linked images
|
| 472 |
+
</div>
|
| 473 |
+
<div className="mt-2 space-y-2">
|
| 474 |
+
{pagePhotoIds.length ? (
|
| 475 |
+
pagePhotoIds.map((photoId, idx) => {
|
| 476 |
+
const photo = photoLookup.get(photoId);
|
| 477 |
+
return (
|
| 478 |
+
<div
|
| 479 |
+
key={`${photoId}-${idx}`}
|
| 480 |
+
className="flex flex-wrap items-center gap-2 text-xs"
|
| 481 |
+
>
|
| 482 |
+
<span className="font-semibold text-gray-800">
|
| 483 |
+
{photo?.name || photoId}
|
| 484 |
+
</span>
|
| 485 |
+
<button
|
| 486 |
+
type="button"
|
| 487 |
+
onClick={() => movePhoto(idx, idx - 1)}
|
| 488 |
+
disabled={idx === 0 || isSaving}
|
| 489 |
+
className="rounded border border-gray-200 px-2 py-0.5 text-[11px] text-gray-600 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 490 |
+
>
|
| 491 |
+
Up
|
| 492 |
+
</button>
|
| 493 |
+
<button
|
| 494 |
+
type="button"
|
| 495 |
+
onClick={() => movePhoto(idx, idx + 1)}
|
| 496 |
+
disabled={idx === pagePhotoIds.length - 1 || isSaving}
|
| 497 |
+
className="rounded border border-gray-200 px-2 py-0.5 text-[11px] text-gray-600 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 498 |
+
>
|
| 499 |
+
Down
|
| 500 |
+
</button>
|
| 501 |
+
<button
|
| 502 |
+
type="button"
|
| 503 |
+
onClick={() => removePhotoAt(idx)}
|
| 504 |
+
disabled={isSaving}
|
| 505 |
+
className="rounded border border-red-200 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700 hover:bg-red-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 506 |
+
>
|
| 507 |
+
Remove
|
| 508 |
+
</button>
|
| 509 |
+
</div>
|
| 510 |
+
);
|
| 511 |
+
})
|
| 512 |
+
) : (
|
| 513 |
+
<div className="text-xs text-gray-500">No images linked.</div>
|
| 514 |
+
)}
|
| 515 |
+
</div>
|
| 516 |
+
<div className="mt-3 flex flex-wrap items-center gap-2">
|
| 517 |
+
<button
|
| 518 |
+
type="button"
|
| 519 |
+
onClick={toggleManualOrder}
|
| 520 |
+
disabled={!pagePhotoIds.length || isSaving}
|
| 521 |
+
className="rounded border border-gray-200 px-2.5 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 522 |
+
>
|
| 523 |
+
{orderLocked ? "Use auto order" : "Apply order"}
|
| 524 |
+
</button>
|
| 525 |
+
<span className="text-[11px] text-gray-500">
|
| 526 |
+
{orderLocked ? "Manual order locked" : "Auto ordering enabled"}
|
| 527 |
+
</span>
|
| 528 |
+
</div>
|
| 529 |
+
<p className="mt-2 text-[11px] text-gray-500">
|
| 530 |
+
Adding more than 2 images will automatically create a continuation
|
| 531 |
+
page for the overflow.
|
| 532 |
+
</p>
|
| 533 |
+
</div>
|
| 534 |
+
|
| 535 |
+
<div className="rounded-lg border border-gray-200 bg-white p-3">
|
| 536 |
+
<div className="text-xs font-semibold text-gray-600 uppercase">
|
| 537 |
+
Add image
|
| 538 |
+
</div>
|
| 539 |
+
<div className="mt-2 flex flex-col gap-2">
|
| 540 |
+
<select
|
| 541 |
+
value={photoSelection}
|
| 542 |
+
onChange={(event) => setPhotoSelection(event.target.value)}
|
| 543 |
+
className="w-full rounded-md border border-gray-200 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
|
| 544 |
+
>
|
| 545 |
+
<option value="">Select an unlinked image</option>
|
| 546 |
+
{availablePhotos.map((photo) => (
|
| 547 |
+
<option key={`viewer-photo-${photo.id}`} value={photo.id}>
|
| 548 |
+
{photo.name}
|
| 549 |
+
</option>
|
| 550 |
+
))}
|
| 551 |
+
</select>
|
| 552 |
+
<button
|
| 553 |
+
type="button"
|
| 554 |
+
onClick={() => {
|
| 555 |
+
addPhotoToPage(photoSelection);
|
| 556 |
+
setPhotoSelection("");
|
| 557 |
+
}}
|
| 558 |
+
disabled={!photoSelection || isSaving}
|
| 559 |
+
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 560 |
+
>
|
| 561 |
+
Add selected image
|
| 562 |
+
</button>
|
| 563 |
+
<button
|
| 564 |
+
type="button"
|
| 565 |
+
onClick={() => uploadInputRef.current?.click()}
|
| 566 |
+
disabled={isUploading || isSaving}
|
| 567 |
+
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 568 |
+
>
|
| 569 |
+
Upload new image
|
| 570 |
+
</button>
|
| 571 |
+
<input
|
| 572 |
+
ref={uploadInputRef}
|
| 573 |
+
type="file"
|
| 574 |
+
accept="image/*"
|
| 575 |
+
className="hidden"
|
| 576 |
+
onChange={(event) => {
|
| 577 |
+
const file = event.target.files?.[0];
|
| 578 |
+
if (file) void uploadPhoto(file);
|
| 579 |
+
event.target.value = "";
|
| 580 |
+
}}
|
| 581 |
+
/>
|
| 582 |
+
{availablePhotos.length === 0 ? (
|
| 583 |
+
<p className="text-[11px] text-gray-500">
|
| 584 |
+
All uploaded images are already linked.
|
| 585 |
+
</p>
|
| 586 |
+
) : null}
|
| 587 |
+
</div>
|
| 588 |
+
</div>
|
| 589 |
+
</div>
|
| 590 |
+
|
| 591 |
+
{status ? (
|
| 592 |
+
<p className="mt-2 text-xs text-gray-500">{status}</p>
|
| 593 |
+
) : null}
|
| 594 |
+
</section>
|
| 595 |
+
) : null}
|
| 596 |
+
|
| 597 |
+
<footer className="mt-12 text-center text-xs text-gray-500 no-print">
|
| 598 |
+
<p>Prosento - (c) 2026 All Rights Reserved</p>
|
| 599 |
+
<p className="mt-1">Images linked here appear in the report output.</p>
|
| 600 |
+
<p className="mt-1">Version {APP_VERSION}</p>
|
| 601 |
+
</footer>
|
| 602 |
+
|
| 603 |
+
</main>
|
| 604 |
+
);
|
| 605 |
+
}
|
frontend/src/pages/InputDataPage.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,
|
| 4 |
|
| 5 |
import { API_BASE, postForm, putJson, request } from "../lib/api";
|
| 6 |
import { formatDocNumber } from "../lib/report";
|
|
@@ -12,7 +12,7 @@ import {
|
|
| 12 |
replacePage,
|
| 13 |
} from "../lib/sections";
|
| 14 |
import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
|
| 15 |
-
import type { JobsheetSection, Session, TemplateFields } from "../types/session";
|
| 16 |
import { PageFooter } from "../components/PageFooter";
|
| 17 |
import { PageHeader } from "../components/PageHeader";
|
| 18 |
import { PageShell } from "../components/PageShell";
|
|
@@ -24,49 +24,75 @@ type FieldDef = {
|
|
| 24 |
multiline?: boolean;
|
| 25 |
};
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
const GENERAL_FIELDS: FieldDef[] = [
|
| 28 |
{ key: "inspection_date", label: "Inspection Date" },
|
| 29 |
{ key: "inspector", label: "Inspector" },
|
| 30 |
-
{ key: "accompanied_by", label: "Accompanied By" },
|
| 31 |
{ key: "document_no", label: "Document No" },
|
| 32 |
-
{ key: "project", label: "Project" },
|
| 33 |
-
{ key: "client_site", label: "Client / Site" },
|
| 34 |
{ key: "company_logo", label: "Company Logo" },
|
| 35 |
];
|
| 36 |
|
| 37 |
const ITEM_FIELDS: FieldDef[] = [
|
| 38 |
{ key: "area", label: "Area" },
|
| 39 |
{ key: "reference", label: "Reference" },
|
| 40 |
-
{ key: "
|
| 41 |
{ key: "item_description", label: "Item Description", multiline: true },
|
| 42 |
-
{ key: "functional_location", label: "Functional Location" },
|
| 43 |
{ key: "category", label: "Category" },
|
| 44 |
{ key: "priority", label: "Priority" },
|
| 45 |
-
{ key: "condition_description", label: "Condition Description", multiline: true },
|
| 46 |
{ key: "required_action", label: "Required Action", multiline: true },
|
|
|
|
| 47 |
];
|
| 48 |
|
| 49 |
export default function InputDataPage() {
|
| 50 |
const [searchParams] = useSearchParams();
|
| 51 |
const sessionId = getSessionId(searchParams.toString());
|
| 52 |
const sessionQuery = buildSessionQuery(sessionId);
|
|
|
|
| 53 |
|
| 54 |
const [session, setSession] = useState<Session | null>(null);
|
| 55 |
const [sections, setSections] = useState<JobsheetSection[]>([]);
|
| 56 |
const [status, setStatus] = useState("");
|
| 57 |
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
|
|
|
|
|
|
| 58 |
const [isUploading, setIsUploading] = useState(false);
|
| 59 |
const [copySourceIndex, setCopySourceIndex] = useState(0);
|
| 60 |
const [copyTargets, setCopyTargets] = useState("");
|
| 61 |
const [addSectionId, setAddSectionId] = useState<string>("");
|
| 62 |
const [sectionsCollapsed, setSectionsCollapsed] = useState(true);
|
|
|
|
| 63 |
const [showGeneralColumns, setShowGeneralColumns] = useState(false);
|
| 64 |
const [generalDirty, setGeneralDirty] = useState(false);
|
| 65 |
const [generalTemplate, setGeneralTemplate] = useState<TemplateFields>({});
|
| 66 |
const [photoSelections, setPhotoSelections] = useState<Record<number, string>>(
|
| 67 |
{},
|
| 68 |
);
|
| 69 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
const excelInputRef = useRef<HTMLInputElement | null>(null);
|
| 71 |
const jsonInputRef = useRef<HTMLInputElement | null>(null);
|
| 72 |
const uploadInputRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
|
@@ -84,7 +110,15 @@ export default function InputDataPage() {
|
|
| 84 |
const sectionResp = await request<{ sections: JobsheetSection[] }>(
|
| 85 |
`/sessions/${sessionId}/sections`,
|
| 86 |
);
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
} catch (err) {
|
| 89 |
const message =
|
| 90 |
err instanceof Error ? err.message : "Failed to load session.";
|
|
@@ -101,7 +135,15 @@ export default function InputDataPage() {
|
|
| 101 |
const sectionResp = await request<{ sections: JobsheetSection[] }>(
|
| 102 |
`/sessions/${sessionId}/sections`,
|
| 103 |
);
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
}
|
| 106 |
|
| 107 |
const flatPages = useMemo(
|
|
@@ -158,6 +200,41 @@ export default function InputDataPage() {
|
|
| 158 |
setGeneralDirty(true);
|
| 159 |
}
|
| 160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
function applyRowToAll(pageIndex: number) {
|
| 162 |
const entry = flatPages[pageIndex];
|
| 163 |
if (!entry) return;
|
|
@@ -173,25 +250,13 @@ export default function InputDataPage() {
|
|
| 173 |
);
|
| 174 |
}
|
| 175 |
|
| 176 |
-
function applyGeneralToAll() {
|
| 177 |
if (!flatPages.length) return;
|
| 178 |
-
setSections((prev) =>
|
| 179 |
-
prev.map((section) => ({
|
| 180 |
-
...section,
|
| 181 |
-
pages: (section.pages ?? []).map((page) => {
|
| 182 |
-
const template = { ...(page.template ?? {}) };
|
| 183 |
-
GENERAL_FIELDS.forEach((field) => {
|
| 184 |
-
const value = generalTemplate[field.key];
|
| 185 |
-
if (value !== undefined) {
|
| 186 |
-
template[field.key] = value;
|
| 187 |
-
}
|
| 188 |
-
});
|
| 189 |
-
return { ...page, template };
|
| 190 |
-
}),
|
| 191 |
-
})),
|
| 192 |
-
);
|
| 193 |
setGeneralDirty(false);
|
| 194 |
-
|
|
|
|
|
|
|
| 195 |
}
|
| 196 |
|
| 197 |
function insertPageAt(index: number, templateSource?: TemplateFields) {
|
|
@@ -380,34 +445,147 @@ export default function InputDataPage() {
|
|
| 380 |
case "inspection_date":
|
| 381 |
return session.inspection_date || "";
|
| 382 |
case "document_no":
|
| 383 |
-
return formatDocNumber(session);
|
| 384 |
-
case "project":
|
| 385 |
-
return session.project_name || "";
|
| 386 |
-
case "condition_description":
|
| 387 |
-
return session.notes || "";
|
| 388 |
default:
|
| 389 |
return "";
|
| 390 |
}
|
| 391 |
}
|
| 392 |
|
| 393 |
-
async function saveAll(
|
|
|
|
|
|
|
|
|
|
| 394 |
if (!sessionId) return;
|
| 395 |
-
|
| 396 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
try {
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
);
|
| 402 |
-
const
|
| 403 |
-
|
| 404 |
-
|
|
|
|
|
|
|
|
|
|
| 405 |
} catch (err) {
|
| 406 |
const message =
|
| 407 |
-
err instanceof Error ? err.message : "Failed to save
|
| 408 |
setStatus(message);
|
| 409 |
-
} finally {
|
| 410 |
-
setIsSaving(false);
|
| 411 |
}
|
| 412 |
}
|
| 413 |
|
|
@@ -484,15 +662,82 @@ export default function InputDataPage() {
|
|
| 484 |
}
|
| 485 |
}
|
| 486 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
return (
|
| 488 |
<PageShell className="max-w-6xl">
|
| 489 |
<PageHeader
|
| 490 |
title="RepEx - Report Express"
|
| 491 |
subtitle="Input Data"
|
| 492 |
right={
|
| 493 |
-
<div className="flex items-
|
|
|
|
| 494 |
<Link
|
| 495 |
to={`/report-viewer${sessionQuery}`}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
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"
|
| 497 |
>
|
| 498 |
<ArrowLeft className="h-4 w-4" />
|
|
@@ -512,14 +757,34 @@ export default function InputDataPage() {
|
|
| 512 |
|
| 513 |
<Link
|
| 514 |
to={`/report-viewer${sessionQuery}`}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
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"
|
| 516 |
>
|
| 517 |
<Layout className="h-4 w-4" />
|
| 518 |
Report Viewer
|
| 519 |
</Link>
|
| 520 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
<Link
|
| 522 |
to={`/edit-report${sessionQuery}`}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
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"
|
| 524 |
>
|
| 525 |
<Edit3 className="h-4 w-4" />
|
|
@@ -528,6 +793,10 @@ export default function InputDataPage() {
|
|
| 528 |
|
| 529 |
<Link
|
| 530 |
to={`/edit-layouts${sessionQuery}`}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
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"
|
| 532 |
>
|
| 533 |
<Grid className="h-4 w-4" />
|
|
@@ -536,6 +805,10 @@ export default function InputDataPage() {
|
|
| 536 |
|
| 537 |
<Link
|
| 538 |
to={`/export${sessionQuery}`}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
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"
|
| 540 |
>
|
| 541 |
<Download className="h-4 w-4" />
|
|
@@ -584,15 +857,6 @@ export default function InputDataPage() {
|
|
| 584 |
>
|
| 585 |
Add section
|
| 586 |
</button>
|
| 587 |
-
<button
|
| 588 |
-
type="button"
|
| 589 |
-
onClick={saveAll}
|
| 590 |
-
disabled={!canSave}
|
| 591 |
-
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"
|
| 592 |
-
>
|
| 593 |
-
<Save className="h-4 w-4" />
|
| 594 |
-
Save changes
|
| 595 |
-
</button>
|
| 596 |
</div>
|
| 597 |
</div>
|
| 598 |
{status ? <p className="text-sm text-gray-600 mt-3">{status}</p> : null}
|
|
@@ -777,12 +1041,23 @@ export default function InputDataPage() {
|
|
| 777 |
</div>
|
| 778 |
|
| 779 |
<div className="w-full lg:w-[320px] shrink-0">
|
| 780 |
-
<
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 784 |
<div className="mt-3 rounded-lg border border-gray-200 bg-white overflow-x-auto">
|
| 785 |
-
<table className="min-w-[
|
| 786 |
<thead className="bg-gray-50 border-b border-gray-200">
|
| 787 |
<tr>
|
| 788 |
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-600">
|
|
@@ -791,24 +1066,50 @@ export default function InputDataPage() {
|
|
| 791 |
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-600">
|
| 792 |
Heading
|
| 793 |
</th>
|
|
|
|
|
|
|
|
|
|
| 794 |
</tr>
|
| 795 |
</thead>
|
| 796 |
<tbody>
|
| 797 |
-
{
|
| 798 |
-
|
| 799 |
<tr key={`heading-${idx}`} className="border-b border-gray-100">
|
| 800 |
<td className="px-3 py-2 text-xs font-semibold text-gray-700">
|
| 801 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 802 |
</td>
|
| 803 |
<td className="px-3 py-2 text-sm text-gray-700">
|
| 804 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 805 |
</td>
|
| 806 |
</tr>
|
| 807 |
))
|
| 808 |
) : (
|
| 809 |
<tr>
|
| 810 |
<td
|
| 811 |
-
colSpan={
|
| 812 |
className="px-3 py-4 text-sm text-gray-500 text-center"
|
| 813 |
>
|
| 814 |
No headings found.
|
|
@@ -1180,7 +1481,7 @@ export default function InputDataPage() {
|
|
| 1180 |
</button>
|
| 1181 |
</div>
|
| 1182 |
|
| 1183 |
-
<PageFooter note="Tip:
|
| 1184 |
</PageShell>
|
| 1185 |
);
|
| 1186 |
}
|
|
|
|
| 1 |
import { useEffect, useMemo, useRef, useState } from "react";
|
| 2 |
+
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
| 3 |
+
import { ArrowLeft, Download, Edit3, Grid, Layout, Table, Image } from "react-feather";
|
| 4 |
|
| 5 |
import { API_BASE, postForm, putJson, request } from "../lib/api";
|
| 6 |
import { formatDocNumber } from "../lib/report";
|
|
|
|
| 12 |
replacePage,
|
| 13 |
} from "../lib/sections";
|
| 14 |
import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
|
| 15 |
+
import type { Heading, JobsheetSection, Session, TemplateFields } from "../types/session";
|
| 16 |
import { PageFooter } from "../components/PageFooter";
|
| 17 |
import { PageHeader } from "../components/PageHeader";
|
| 18 |
import { PageShell } from "../components/PageShell";
|
|
|
|
| 24 |
multiline?: boolean;
|
| 25 |
};
|
| 26 |
|
| 27 |
+
const normalizeHeadings = (raw?: Heading[] | Record<string, string>): Heading[] => {
|
| 28 |
+
if (Array.isArray(raw)) {
|
| 29 |
+
return raw
|
| 30 |
+
.filter(Boolean)
|
| 31 |
+
.map((heading) => ({
|
| 32 |
+
number: String(heading.number ?? "").trim(),
|
| 33 |
+
name: String(heading.name ?? "").trim(),
|
| 34 |
+
}));
|
| 35 |
+
}
|
| 36 |
+
if (raw && typeof raw === "object") {
|
| 37 |
+
return Object.entries(raw).map(([number, name]) => ({
|
| 38 |
+
number: String(number).trim(),
|
| 39 |
+
name: String(name ?? "").trim(),
|
| 40 |
+
}));
|
| 41 |
+
}
|
| 42 |
+
return [];
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
const GENERAL_FIELDS: FieldDef[] = [
|
| 46 |
{ key: "inspection_date", label: "Inspection Date" },
|
| 47 |
{ key: "inspector", label: "Inspector" },
|
|
|
|
| 48 |
{ key: "document_no", label: "Document No" },
|
|
|
|
|
|
|
| 49 |
{ key: "company_logo", label: "Company Logo" },
|
| 50 |
];
|
| 51 |
|
| 52 |
const ITEM_FIELDS: FieldDef[] = [
|
| 53 |
{ key: "area", label: "Area" },
|
| 54 |
{ key: "reference", label: "Reference" },
|
| 55 |
+
{ key: "functional_location", label: "Location" },
|
| 56 |
{ key: "item_description", label: "Item Description", multiline: true },
|
|
|
|
| 57 |
{ key: "category", label: "Category" },
|
| 58 |
{ key: "priority", label: "Priority" },
|
|
|
|
| 59 |
{ key: "required_action", label: "Required Action", multiline: true },
|
| 60 |
+
{ key: "figure_caption", label: "Figure Caption" },
|
| 61 |
];
|
| 62 |
|
| 63 |
export default function InputDataPage() {
|
| 64 |
const [searchParams] = useSearchParams();
|
| 65 |
const sessionId = getSessionId(searchParams.toString());
|
| 66 |
const sessionQuery = buildSessionQuery(sessionId);
|
| 67 |
+
const navigate = useNavigate();
|
| 68 |
|
| 69 |
const [session, setSession] = useState<Session | null>(null);
|
| 70 |
const [sections, setSections] = useState<JobsheetSection[]>([]);
|
| 71 |
const [status, setStatus] = useState("");
|
| 72 |
const [isSaving, setIsSaving] = useState(false);
|
| 73 |
+
const [saveState, setSaveState] = useState<
|
| 74 |
+
"saved" | "saving" | "pending" | "error"
|
| 75 |
+
>("saved");
|
| 76 |
const [isUploading, setIsUploading] = useState(false);
|
| 77 |
const [copySourceIndex, setCopySourceIndex] = useState(0);
|
| 78 |
const [copyTargets, setCopyTargets] = useState("");
|
| 79 |
const [addSectionId, setAddSectionId] = useState<string>("");
|
| 80 |
const [sectionsCollapsed, setSectionsCollapsed] = useState(true);
|
| 81 |
+
const [headings, setHeadings] = useState<Heading[]>([]);
|
| 82 |
const [showGeneralColumns, setShowGeneralColumns] = useState(false);
|
| 83 |
const [generalDirty, setGeneralDirty] = useState(false);
|
| 84 |
const [generalTemplate, setGeneralTemplate] = useState<TemplateFields>({});
|
| 85 |
const [photoSelections, setPhotoSelections] = useState<Record<number, string>>(
|
| 86 |
{},
|
| 87 |
);
|
| 88 |
+
const saveTimerRef = useRef<number | null>(null);
|
| 89 |
+
const generalApplyTimerRef = useRef<number | null>(null);
|
| 90 |
+
const headingsSaveTimerRef = useRef<number | null>(null);
|
| 91 |
+
const lastSavedHeadingsRef = useRef<string>(JSON.stringify([]));
|
| 92 |
+
const headingsLoadedRef = useRef(false);
|
| 93 |
+
const lastSavedRef = useRef<string>("");
|
| 94 |
+
const pendingSaveRef = useRef<string>("");
|
| 95 |
+
const savePromiseRef = useRef<Promise<void> | null>(null);
|
| 96 |
const excelInputRef = useRef<HTMLInputElement | null>(null);
|
| 97 |
const jsonInputRef = useRef<HTMLInputElement | null>(null);
|
| 98 |
const uploadInputRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
|
|
|
| 110 |
const sectionResp = await request<{ sections: JobsheetSection[] }>(
|
| 111 |
`/sessions/${sessionId}/sections`,
|
| 112 |
);
|
| 113 |
+
const normalized = ensureSections(sectionResp.sections);
|
| 114 |
+
setSections(normalized);
|
| 115 |
+
const initialHeadings = normalizeHeadings(data.headings);
|
| 116 |
+
setHeadings(initialHeadings);
|
| 117 |
+
headingsLoadedRef.current = true;
|
| 118 |
+
lastSavedHeadingsRef.current = JSON.stringify(initialHeadings);
|
| 119 |
+
lastSavedRef.current = JSON.stringify(normalized);
|
| 120 |
+
pendingSaveRef.current = lastSavedRef.current;
|
| 121 |
+
setSaveState("saved");
|
| 122 |
} catch (err) {
|
| 123 |
const message =
|
| 124 |
err instanceof Error ? err.message : "Failed to load session.";
|
|
|
|
| 135 |
const sectionResp = await request<{ sections: JobsheetSection[] }>(
|
| 136 |
`/sessions/${sessionId}/sections`,
|
| 137 |
);
|
| 138 |
+
const normalized = ensureSections(sectionResp.sections);
|
| 139 |
+
setSections(normalized);
|
| 140 |
+
const nextHeadings = normalizeHeadings(data.headings);
|
| 141 |
+
setHeadings(nextHeadings);
|
| 142 |
+
headingsLoadedRef.current = true;
|
| 143 |
+
lastSavedHeadingsRef.current = JSON.stringify(nextHeadings);
|
| 144 |
+
lastSavedRef.current = JSON.stringify(normalized);
|
| 145 |
+
pendingSaveRef.current = lastSavedRef.current;
|
| 146 |
+
setSaveState("saved");
|
| 147 |
}
|
| 148 |
|
| 149 |
const flatPages = useMemo(
|
|
|
|
| 200 |
setGeneralDirty(true);
|
| 201 |
}
|
| 202 |
|
| 203 |
+
function updateHeadingField(index: number, key: keyof Heading, value: string) {
|
| 204 |
+
setHeadings((prev) =>
|
| 205 |
+
prev.map((heading, idx) =>
|
| 206 |
+
idx === index ? { ...heading, [key]: value } : heading,
|
| 207 |
+
),
|
| 208 |
+
);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
function addHeadingRow() {
|
| 212 |
+
setHeadings((prev) => [...prev, { number: "", name: "" }]);
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
function removeHeadingRow(index: number) {
|
| 216 |
+
setHeadings((prev) => prev.filter((_, idx) => idx !== index));
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
function applyGeneralToSections(
|
| 220 |
+
source: JobsheetSection[],
|
| 221 |
+
template: TemplateFields,
|
| 222 |
+
): JobsheetSection[] {
|
| 223 |
+
return source.map((section) => ({
|
| 224 |
+
...section,
|
| 225 |
+
pages: (section.pages ?? []).map((page) => {
|
| 226 |
+
const nextTemplate = { ...(page.template ?? {}) };
|
| 227 |
+
GENERAL_FIELDS.forEach((field) => {
|
| 228 |
+
const value = template[field.key];
|
| 229 |
+
if (value !== undefined) {
|
| 230 |
+
nextTemplate[field.key] = value;
|
| 231 |
+
}
|
| 232 |
+
});
|
| 233 |
+
return { ...page, template: nextTemplate };
|
| 234 |
+
}),
|
| 235 |
+
}));
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
function applyRowToAll(pageIndex: number) {
|
| 239 |
const entry = flatPages[pageIndex];
|
| 240 |
if (!entry) return;
|
|
|
|
| 250 |
);
|
| 251 |
}
|
| 252 |
|
| 253 |
+
function applyGeneralToAll(silent = false) {
|
| 254 |
if (!flatPages.length) return;
|
| 255 |
+
setSections((prev) => applyGeneralToSections(prev, generalTemplate));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
setGeneralDirty(false);
|
| 257 |
+
if (!silent) {
|
| 258 |
+
setStatus("Applied general info to all pages.");
|
| 259 |
+
}
|
| 260 |
}
|
| 261 |
|
| 262 |
function insertPageAt(index: number, templateSource?: TemplateFields) {
|
|
|
|
| 445 |
case "inspection_date":
|
| 446 |
return session.inspection_date || "";
|
| 447 |
case "document_no":
|
| 448 |
+
return session.document_no || formatDocNumber(session);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
default:
|
| 450 |
return "";
|
| 451 |
}
|
| 452 |
}
|
| 453 |
|
| 454 |
+
async function saveAll(
|
| 455 |
+
silent = false,
|
| 456 |
+
overrideSections?: JobsheetSection[],
|
| 457 |
+
) {
|
| 458 |
if (!sessionId) return;
|
| 459 |
+
if (savePromiseRef.current) {
|
| 460 |
+
await savePromiseRef.current;
|
| 461 |
+
}
|
| 462 |
+
const toSave = overrideSections ?? sections;
|
| 463 |
+
const snapshot = JSON.stringify(toSave);
|
| 464 |
+
if (!overrideSections && snapshot === lastSavedRef.current) {
|
| 465 |
+
if (!isSaving) setSaveState("saved");
|
| 466 |
+
return;
|
| 467 |
+
}
|
| 468 |
+
const promise = (async () => {
|
| 469 |
+
setIsSaving(true);
|
| 470 |
+
setSaveState("saving");
|
| 471 |
+
if (!silent) {
|
| 472 |
+
setStatus("Saving input data...");
|
| 473 |
+
}
|
| 474 |
+
try {
|
| 475 |
+
const resp = await putJson<{ sections: JobsheetSection[] }>(
|
| 476 |
+
`/sessions/${sessionId}/sections`,
|
| 477 |
+
{ sections: toSave },
|
| 478 |
+
);
|
| 479 |
+
const updated = ensureSections(resp.sections ?? toSave);
|
| 480 |
+
setSections(updated);
|
| 481 |
+
lastSavedRef.current = JSON.stringify(updated);
|
| 482 |
+
pendingSaveRef.current = lastSavedRef.current;
|
| 483 |
+
setSaveState("saved");
|
| 484 |
+
if (!silent) {
|
| 485 |
+
setStatus("Input data saved.");
|
| 486 |
+
} else {
|
| 487 |
+
setStatus("All changes saved.");
|
| 488 |
+
}
|
| 489 |
+
} catch (err) {
|
| 490 |
+
const message =
|
| 491 |
+
err instanceof Error ? err.message : "Failed to save input data.";
|
| 492 |
+
setStatus(message);
|
| 493 |
+
setSaveState("error");
|
| 494 |
+
} finally {
|
| 495 |
+
setIsSaving(false);
|
| 496 |
+
if (
|
| 497 |
+
pendingSaveRef.current &&
|
| 498 |
+
pendingSaveRef.current !== lastSavedRef.current
|
| 499 |
+
) {
|
| 500 |
+
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
| 501 |
+
saveTimerRef.current = window.setTimeout(() => {
|
| 502 |
+
void triggerAutoSave();
|
| 503 |
+
}, 200);
|
| 504 |
+
}
|
| 505 |
+
}
|
| 506 |
+
})();
|
| 507 |
+
savePromiseRef.current = promise;
|
| 508 |
try {
|
| 509 |
+
await promise;
|
| 510 |
+
} finally {
|
| 511 |
+
savePromiseRef.current = null;
|
| 512 |
+
}
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
async function triggerAutoSave() {
|
| 516 |
+
if (!sessionId || savePromiseRef.current) return;
|
| 517 |
+
const snapshot = pendingSaveRef.current;
|
| 518 |
+
if (!snapshot || snapshot === lastSavedRef.current) return;
|
| 519 |
+
await saveAll(true);
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
useEffect(() => {
|
| 523 |
+
if (!sessionId) return;
|
| 524 |
+
const snapshot = JSON.stringify(sections);
|
| 525 |
+
if (snapshot === lastSavedRef.current) {
|
| 526 |
+
if (!generalDirty && !isSaving) {
|
| 527 |
+
setSaveState("saved");
|
| 528 |
+
}
|
| 529 |
+
return;
|
| 530 |
+
}
|
| 531 |
+
pendingSaveRef.current = snapshot;
|
| 532 |
+
if (!isSaving) {
|
| 533 |
+
setSaveState("pending");
|
| 534 |
+
}
|
| 535 |
+
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
| 536 |
+
saveTimerRef.current = window.setTimeout(() => {
|
| 537 |
+
void triggerAutoSave();
|
| 538 |
+
}, 800);
|
| 539 |
+
}, [sections, sessionId, generalDirty, isSaving]);
|
| 540 |
+
|
| 541 |
+
useEffect(() => {
|
| 542 |
+
if (!generalDirty) return;
|
| 543 |
+
if (!isSaving) {
|
| 544 |
+
setSaveState("pending");
|
| 545 |
+
}
|
| 546 |
+
if (generalApplyTimerRef.current) {
|
| 547 |
+
window.clearTimeout(generalApplyTimerRef.current);
|
| 548 |
+
}
|
| 549 |
+
generalApplyTimerRef.current = window.setTimeout(() => {
|
| 550 |
+
applyGeneralToAll(true);
|
| 551 |
+
}, 800);
|
| 552 |
+
}, [generalDirty, generalTemplate, isSaving]);
|
| 553 |
+
|
| 554 |
+
useEffect(() => {
|
| 555 |
+
if (!sessionId) return;
|
| 556 |
+
if (!headingsLoadedRef.current) return;
|
| 557 |
+
const snapshot = JSON.stringify(headings);
|
| 558 |
+
if (snapshot === lastSavedHeadingsRef.current) return;
|
| 559 |
+
if (headingsSaveTimerRef.current) {
|
| 560 |
+
window.clearTimeout(headingsSaveTimerRef.current);
|
| 561 |
+
}
|
| 562 |
+
headingsSaveTimerRef.current = window.setTimeout(() => {
|
| 563 |
+
void saveHeadings(true);
|
| 564 |
+
}, 800);
|
| 565 |
+
}, [headings, sessionId]);
|
| 566 |
+
|
| 567 |
+
async function saveHeadings(silent = false) {
|
| 568 |
+
if (!sessionId) return;
|
| 569 |
+
const snapshot = JSON.stringify(headings);
|
| 570 |
+
if (snapshot === lastSavedHeadingsRef.current) return;
|
| 571 |
+
try {
|
| 572 |
+
if (!silent) {
|
| 573 |
+
setStatus("Saving headings...");
|
| 574 |
+
}
|
| 575 |
+
const resp = await putJson<{ headings: Heading[] }>(
|
| 576 |
+
`/sessions/${sessionId}/headings`,
|
| 577 |
+
{ headings },
|
| 578 |
);
|
| 579 |
+
const normalized = resp.headings ?? headings;
|
| 580 |
+
setHeadings(normalized);
|
| 581 |
+
lastSavedHeadingsRef.current = JSON.stringify(normalized);
|
| 582 |
+
if (!silent) {
|
| 583 |
+
setStatus("Headings saved.");
|
| 584 |
+
}
|
| 585 |
} catch (err) {
|
| 586 |
const message =
|
| 587 |
+
err instanceof Error ? err.message : "Failed to save headings.";
|
| 588 |
setStatus(message);
|
|
|
|
|
|
|
| 589 |
}
|
| 590 |
}
|
| 591 |
|
|
|
|
| 662 |
}
|
| 663 |
}
|
| 664 |
|
| 665 |
+
async function saveAndNavigate(target: string) {
|
| 666 |
+
if (!sessionId) {
|
| 667 |
+
navigate(target);
|
| 668 |
+
return;
|
| 669 |
+
}
|
| 670 |
+
if (generalApplyTimerRef.current) {
|
| 671 |
+
window.clearTimeout(generalApplyTimerRef.current);
|
| 672 |
+
generalApplyTimerRef.current = null;
|
| 673 |
+
}
|
| 674 |
+
let overrideSections: JobsheetSection[] | undefined;
|
| 675 |
+
if (generalDirty) {
|
| 676 |
+
overrideSections = applyGeneralToSections(sections, generalTemplate);
|
| 677 |
+
setSections(overrideSections);
|
| 678 |
+
setGeneralDirty(false);
|
| 679 |
+
}
|
| 680 |
+
if (saveTimerRef.current) {
|
| 681 |
+
window.clearTimeout(saveTimerRef.current);
|
| 682 |
+
saveTimerRef.current = null;
|
| 683 |
+
}
|
| 684 |
+
const snapshot = JSON.stringify(overrideSections ?? sections);
|
| 685 |
+
pendingSaveRef.current = snapshot;
|
| 686 |
+
if (snapshot !== lastSavedRef.current || savePromiseRef.current) {
|
| 687 |
+
await saveAll(true, overrideSections);
|
| 688 |
+
}
|
| 689 |
+
navigate(target);
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
const saveIndicator = useMemo(() => {
|
| 693 |
+
const base =
|
| 694 |
+
"inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold";
|
| 695 |
+
if (saveState === "saving") {
|
| 696 |
+
return (
|
| 697 |
+
<div className={`${base} border-amber-200 bg-amber-50 text-amber-700`}>
|
| 698 |
+
<span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
|
| 699 |
+
Saving...
|
| 700 |
+
</div>
|
| 701 |
+
);
|
| 702 |
+
}
|
| 703 |
+
if (saveState === "pending") {
|
| 704 |
+
return (
|
| 705 |
+
<div className={`${base} border-amber-200 bg-amber-50 text-amber-700`}>
|
| 706 |
+
<span className="h-2 w-2 rounded-full bg-amber-500" />
|
| 707 |
+
Unsaved changes
|
| 708 |
+
</div>
|
| 709 |
+
);
|
| 710 |
+
}
|
| 711 |
+
if (saveState === "error") {
|
| 712 |
+
return (
|
| 713 |
+
<div className={`${base} border-red-200 bg-red-50 text-red-700`}>
|
| 714 |
+
<span className="h-2 w-2 rounded-full bg-red-500" />
|
| 715 |
+
Save failed
|
| 716 |
+
</div>
|
| 717 |
+
);
|
| 718 |
+
}
|
| 719 |
+
return (
|
| 720 |
+
<div className={`${base} border-emerald-200 bg-emerald-50 text-emerald-700`}>
|
| 721 |
+
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
| 722 |
+
All changes saved
|
| 723 |
+
</div>
|
| 724 |
+
);
|
| 725 |
+
}, [saveState]);
|
| 726 |
+
|
| 727 |
return (
|
| 728 |
<PageShell className="max-w-6xl">
|
| 729 |
<PageHeader
|
| 730 |
title="RepEx - Report Express"
|
| 731 |
subtitle="Input Data"
|
| 732 |
right={
|
| 733 |
+
<div className="flex flex-col items-end gap-2 sm:flex-row sm:items-center">
|
| 734 |
+
{saveIndicator}
|
| 735 |
<Link
|
| 736 |
to={`/report-viewer${sessionQuery}`}
|
| 737 |
+
onClick={(event) => {
|
| 738 |
+
event.preventDefault();
|
| 739 |
+
void saveAndNavigate(`/report-viewer${sessionQuery}`);
|
| 740 |
+
}}
|
| 741 |
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"
|
| 742 |
>
|
| 743 |
<ArrowLeft className="h-4 w-4" />
|
|
|
|
| 757 |
|
| 758 |
<Link
|
| 759 |
to={`/report-viewer${sessionQuery}`}
|
| 760 |
+
onClick={(event) => {
|
| 761 |
+
event.preventDefault();
|
| 762 |
+
void saveAndNavigate(`/report-viewer${sessionQuery}`);
|
| 763 |
+
}}
|
| 764 |
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"
|
| 765 |
>
|
| 766 |
<Layout className="h-4 w-4" />
|
| 767 |
Report Viewer
|
| 768 |
</Link>
|
| 769 |
|
| 770 |
+
<Link
|
| 771 |
+
to={`/image-placement${sessionQuery}`}
|
| 772 |
+
onClick={(event) => {
|
| 773 |
+
event.preventDefault();
|
| 774 |
+
void saveAndNavigate(`/image-placement${sessionQuery}`);
|
| 775 |
+
}}
|
| 776 |
+
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"
|
| 777 |
+
>
|
| 778 |
+
<Image className="h-4 w-4" />
|
| 779 |
+
Image Placement
|
| 780 |
+
</Link>
|
| 781 |
+
|
| 782 |
<Link
|
| 783 |
to={`/edit-report${sessionQuery}`}
|
| 784 |
+
onClick={(event) => {
|
| 785 |
+
event.preventDefault();
|
| 786 |
+
void saveAndNavigate(`/edit-report${sessionQuery}`);
|
| 787 |
+
}}
|
| 788 |
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"
|
| 789 |
>
|
| 790 |
<Edit3 className="h-4 w-4" />
|
|
|
|
| 793 |
|
| 794 |
<Link
|
| 795 |
to={`/edit-layouts${sessionQuery}`}
|
| 796 |
+
onClick={(event) => {
|
| 797 |
+
event.preventDefault();
|
| 798 |
+
void saveAndNavigate(`/edit-layouts${sessionQuery}`);
|
| 799 |
+
}}
|
| 800 |
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"
|
| 801 |
>
|
| 802 |
<Grid className="h-4 w-4" />
|
|
|
|
| 805 |
|
| 806 |
<Link
|
| 807 |
to={`/export${sessionQuery}`}
|
| 808 |
+
onClick={(event) => {
|
| 809 |
+
event.preventDefault();
|
| 810 |
+
void saveAndNavigate(`/export${sessionQuery}`);
|
| 811 |
+
}}
|
| 812 |
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"
|
| 813 |
>
|
| 814 |
<Download className="h-4 w-4" />
|
|
|
|
| 857 |
>
|
| 858 |
Add section
|
| 859 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 860 |
</div>
|
| 861 |
</div>
|
| 862 |
{status ? <p className="text-sm text-gray-600 mt-3">{status}</p> : null}
|
|
|
|
| 1041 |
</div>
|
| 1042 |
|
| 1043 |
<div className="w-full lg:w-[320px] shrink-0">
|
| 1044 |
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
| 1045 |
+
<div>
|
| 1046 |
+
<h3 className="text-base font-semibold text-gray-900">Headings</h3>
|
| 1047 |
+
<p className="text-sm text-gray-600">
|
| 1048 |
+
Edit heading numbers or add new rows.
|
| 1049 |
+
</p>
|
| 1050 |
+
</div>
|
| 1051 |
+
<button
|
| 1052 |
+
type="button"
|
| 1053 |
+
onClick={addHeadingRow}
|
| 1054 |
+
className="inline-flex items-center gap-2 rounded-md border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 transition"
|
| 1055 |
+
>
|
| 1056 |
+
Add
|
| 1057 |
+
</button>
|
| 1058 |
+
</div>
|
| 1059 |
<div className="mt-3 rounded-lg border border-gray-200 bg-white overflow-x-auto">
|
| 1060 |
+
<table className="min-w-[360px] w-full text-sm">
|
| 1061 |
<thead className="bg-gray-50 border-b border-gray-200">
|
| 1062 |
<tr>
|
| 1063 |
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-600">
|
|
|
|
| 1066 |
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-600">
|
| 1067 |
Heading
|
| 1068 |
</th>
|
| 1069 |
+
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-600 w-20">
|
| 1070 |
+
Actions
|
| 1071 |
+
</th>
|
| 1072 |
</tr>
|
| 1073 |
</thead>
|
| 1074 |
<tbody>
|
| 1075 |
+
{headings.length ? (
|
| 1076 |
+
headings.map((heading, idx) => (
|
| 1077 |
<tr key={`heading-${idx}`} className="border-b border-gray-100">
|
| 1078 |
<td className="px-3 py-2 text-xs font-semibold text-gray-700">
|
| 1079 |
+
<input
|
| 1080 |
+
type="text"
|
| 1081 |
+
className="w-full rounded-md border border-gray-200 px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-200"
|
| 1082 |
+
value={heading.number}
|
| 1083 |
+
onChange={(event) =>
|
| 1084 |
+
updateHeadingField(idx, "number", event.target.value)
|
| 1085 |
+
}
|
| 1086 |
+
/>
|
| 1087 |
</td>
|
| 1088 |
<td className="px-3 py-2 text-sm text-gray-700">
|
| 1089 |
+
<input
|
| 1090 |
+
type="text"
|
| 1091 |
+
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"
|
| 1092 |
+
value={heading.name}
|
| 1093 |
+
onChange={(event) =>
|
| 1094 |
+
updateHeadingField(idx, "name", event.target.value)
|
| 1095 |
+
}
|
| 1096 |
+
/>
|
| 1097 |
+
</td>
|
| 1098 |
+
<td className="px-3 py-2 text-xs">
|
| 1099 |
+
<button
|
| 1100 |
+
type="button"
|
| 1101 |
+
onClick={() => removeHeadingRow(idx)}
|
| 1102 |
+
className="inline-flex items-center gap-1 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-[11px] font-semibold text-red-700 hover:bg-red-100"
|
| 1103 |
+
>
|
| 1104 |
+
Remove
|
| 1105 |
+
</button>
|
| 1106 |
</td>
|
| 1107 |
</tr>
|
| 1108 |
))
|
| 1109 |
) : (
|
| 1110 |
<tr>
|
| 1111 |
<td
|
| 1112 |
+
colSpan={3}
|
| 1113 |
className="px-3 py-4 text-sm text-gray-500 text-center"
|
| 1114 |
>
|
| 1115 |
No headings found.
|
|
|
|
| 1481 |
</button>
|
| 1482 |
</div>
|
| 1483 |
|
| 1484 |
+
<PageFooter note="Tip: changes save automatically. Use apply row to keep pages consistent." />
|
| 1485 |
</PageShell>
|
| 1486 |
);
|
| 1487 |
}
|
frontend/src/pages/PrintReportPage.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import type { CSSProperties } from "react";
|
|
| 3 |
import { useSearchParams } from "react-router-dom";
|
| 4 |
|
| 5 |
import { request } from "../lib/api";
|
| 6 |
-
import { BASE_W
|
| 7 |
import { getSessionId } from "../lib/session";
|
| 8 |
import type { FileMeta, Page, PageItem, Session, TemplateFields } from "../types/session";
|
| 9 |
import { JobSheetTemplate } from "../components/JobSheetTemplate";
|
|
@@ -38,7 +38,7 @@ function resolvePagePhotos(
|
|
| 38 |
if (explicit.length) {
|
| 39 |
return explicit.map((id) => byId.get(id)).filter(Boolean) as FileMeta[];
|
| 40 |
}
|
| 41 |
-
return
|
| 42 |
}
|
| 43 |
|
| 44 |
function chunkPhotos(photos: FileMeta[], perSheet: number) {
|
|
@@ -207,11 +207,8 @@ export default function PrintReportPage() {
|
|
| 207 |
|
| 208 |
const basePages = useMemo(() => {
|
| 209 |
if (pages.length) return pages;
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
const count = Math.max(1, selected.length || session.page_count || 1);
|
| 213 |
-
return Array.from({ length: count }, () => ({ items: [] as PageItem[] }));
|
| 214 |
-
}, [pages, session]);
|
| 215 |
|
| 216 |
const totalPages = basePages.length || 1;
|
| 217 |
|
|
@@ -259,6 +256,7 @@ export default function PrintReportPage() {
|
|
| 259 |
photos={sheet.photos}
|
| 260 |
orderLocked={sheet.page.photo_order_locked ?? false}
|
| 261 |
variant={sheet.variant}
|
|
|
|
| 262 |
/>
|
| 263 |
{sheet.sheetIndex === 0
|
| 264 |
? renderItems(sheet.page.items ?? [], scale)
|
|
|
|
| 3 |
import { useSearchParams } from "react-router-dom";
|
| 4 |
|
| 5 |
import { request } from "../lib/api";
|
| 6 |
+
import { BASE_W } from "../lib/report";
|
| 7 |
import { getSessionId } from "../lib/session";
|
| 8 |
import type { FileMeta, Page, PageItem, Session, TemplateFields } from "../types/session";
|
| 9 |
import { JobSheetTemplate } from "../components/JobSheetTemplate";
|
|
|
|
| 38 |
if (explicit.length) {
|
| 39 |
return explicit.map((id) => byId.get(id)).filter(Boolean) as FileMeta[];
|
| 40 |
}
|
| 41 |
+
return [];
|
| 42 |
}
|
| 43 |
|
| 44 |
function chunkPhotos(photos: FileMeta[], perSheet: number) {
|
|
|
|
| 207 |
|
| 208 |
const basePages = useMemo(() => {
|
| 209 |
if (pages.length) return pages;
|
| 210 |
+
return [];
|
| 211 |
+
}, [pages]);
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
const totalPages = basePages.length || 1;
|
| 214 |
|
|
|
|
| 256 |
photos={sheet.photos}
|
| 257 |
orderLocked={sheet.page.photo_order_locked ?? false}
|
| 258 |
variant={sheet.variant}
|
| 259 |
+
photoLayout={sheet.page.photo_layout}
|
| 260 |
/>
|
| 261 |
{sheet.sheetIndex === 0
|
| 262 |
? renderItems(sheet.page.items ?? [], scale)
|
frontend/src/pages/ReportViewerPage.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
| 10 |
Download,
|
| 11 |
Table,
|
| 12 |
Info,
|
|
|
|
| 13 |
} from "react-feather";
|
| 14 |
|
| 15 |
import { request } from "../lib/api";
|
|
@@ -174,6 +175,14 @@ export default function ReportViewerPage() {
|
|
| 174 |
Report Viewer
|
| 175 |
</span>
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
<Link
|
| 178 |
to={`/edit-report${editReportQuery}`}
|
| 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"
|
|
|
|
| 10 |
Download,
|
| 11 |
Table,
|
| 12 |
Info,
|
| 13 |
+
Image,
|
| 14 |
} from "react-feather";
|
| 15 |
|
| 16 |
import { request } from "../lib/api";
|
|
|
|
| 175 |
Report Viewer
|
| 176 |
</span>
|
| 177 |
|
| 178 |
+
<Link
|
| 179 |
+
to={`/image-placement${sessionQuery}`}
|
| 180 |
+
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"
|
| 181 |
+
>
|
| 182 |
+
<Image className="h-4 w-4" />
|
| 183 |
+
Image Placement
|
| 184 |
+
</Link>
|
| 185 |
+
|
| 186 |
<Link
|
| 187 |
to={`/edit-report${editReportQuery}`}
|
| 188 |
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/ReviewSetupPage.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useEffect, useMemo, useState } from "react";
|
| 2 |
import { useNavigate, useSearchParams } from "react-router-dom";
|
| 3 |
import {
|
| 4 |
CheckCircle,
|
|
@@ -8,9 +8,10 @@ import {
|
|
| 8 |
CheckSquare,
|
| 9 |
Square,
|
| 10 |
ArrowRight,
|
|
|
|
| 11 |
} from "react-feather";
|
| 12 |
|
| 13 |
-
import { putJson, request } from "../lib/api";
|
| 14 |
import { getSessionId, setStoredSessionId } from "../lib/session";
|
| 15 |
import type { Session } from "../types/session";
|
| 16 |
import { PageFooter } from "../components/PageFooter";
|
|
@@ -22,12 +23,16 @@ export default function ReviewSetupPage() {
|
|
| 22 |
const [searchParams] = useSearchParams();
|
| 23 |
const sessionId = getSessionId(searchParams.toString());
|
| 24 |
|
|
|
|
|
|
|
| 25 |
const [session, setSession] = useState<Session | null>(null);
|
| 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(() => {
|
| 33 |
if (!sessionId) {
|
|
@@ -40,6 +45,11 @@ export default function ReviewSetupPage() {
|
|
| 40 |
const data = await request<Session>(`/sessions/${sessionId}`);
|
| 41 |
setSession(data);
|
| 42 |
const initial = new Set(data.selected_photo_ids || []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
setSelectedPhotoIds(initial);
|
| 44 |
} catch (err) {
|
| 45 |
const message =
|
|
@@ -68,6 +78,69 @@ export default function ReviewSetupPage() {
|
|
| 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;
|
|
@@ -151,11 +224,30 @@ export default function ReviewSetupPage() {
|
|
| 151 |
|
| 152 |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 153 |
<div className="lg:col-span-2">
|
| 154 |
-
<div className="flex items-center justify-between mb-3">
|
| 155 |
<h3 className="text-lg font-semibold text-gray-900">Photos</h3>
|
| 156 |
-
<
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
</div>
|
| 160 |
|
| 161 |
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
|
@@ -317,11 +409,29 @@ export default function ReviewSetupPage() {
|
|
| 317 |
</div>
|
| 318 |
|
| 319 |
<div>
|
| 320 |
-
<div className="flex items-center justify-between mb-3">
|
| 321 |
<h3 className="text-lg font-semibold text-gray-900">Data files</h3>
|
| 322 |
-
<
|
| 323 |
-
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
</div>
|
| 326 |
|
| 327 |
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
|
|
|
| 1 |
+
import { useEffect, useMemo, useRef, useState } from "react";
|
| 2 |
import { useNavigate, useSearchParams } from "react-router-dom";
|
| 3 |
import {
|
| 4 |
CheckCircle,
|
|
|
|
| 8 |
CheckSquare,
|
| 9 |
Square,
|
| 10 |
ArrowRight,
|
| 11 |
+
UploadCloud,
|
| 12 |
} from "react-feather";
|
| 13 |
|
| 14 |
+
import { postForm, putJson, request } from "../lib/api";
|
| 15 |
import { getSessionId, setStoredSessionId } from "../lib/session";
|
| 16 |
import type { Session } from "../types/session";
|
| 17 |
import { PageFooter } from "../components/PageFooter";
|
|
|
|
| 23 |
const [searchParams] = useSearchParams();
|
| 24 |
const sessionId = getSessionId(searchParams.toString());
|
| 25 |
|
| 26 |
+
const photoUploadRef = useRef<HTMLInputElement | null>(null);
|
| 27 |
+
const dataUploadRef = useRef<HTMLInputElement | null>(null);
|
| 28 |
const [session, setSession] = useState<Session | null>(null);
|
| 29 |
const [selectedPhotoIds, setSelectedPhotoIds] = useState<Set<string>>(
|
| 30 |
new Set(),
|
| 31 |
);
|
| 32 |
const [showAllPhotos, setShowAllPhotos] = useState(false);
|
| 33 |
const [statusMessage, setStatusMessage] = useState("");
|
| 34 |
+
const [isUploadingPhotos, setIsUploadingPhotos] = useState(false);
|
| 35 |
+
const [isUploadingData, setIsUploadingData] = useState(false);
|
| 36 |
|
| 37 |
useEffect(() => {
|
| 38 |
if (!sessionId) {
|
|
|
|
| 45 |
const data = await request<Session>(`/sessions/${sessionId}`);
|
| 46 |
setSession(data);
|
| 47 |
const initial = new Set(data.selected_photo_ids || []);
|
| 48 |
+
const photoIds = (data.uploads?.photos ?? []).map((photo) => photo.id);
|
| 49 |
+
const hasDataFiles = (data.uploads?.data_files ?? []).length > 0;
|
| 50 |
+
if (!hasDataFiles && photoIds.length > 0 && initial.size < photoIds.length) {
|
| 51 |
+
photoIds.forEach((photoId) => initial.add(photoId));
|
| 52 |
+
}
|
| 53 |
setSelectedPhotoIds(initial);
|
| 54 |
} catch (err) {
|
| 55 |
const message =
|
|
|
|
| 78 |
return "Ready. Continue to report viewer.";
|
| 79 |
}, [canContinue, selectedPhotoIds.size, session?.uploads?.data_files?.length, sessionId]);
|
| 80 |
|
| 81 |
+
async function handleUploadPhotos(files: FileList | null) {
|
| 82 |
+
if (!sessionId || !files || files.length === 0) return;
|
| 83 |
+
setIsUploadingPhotos(true);
|
| 84 |
+
setStatusMessage("");
|
| 85 |
+
const priorPhotoIds = new Set(
|
| 86 |
+
(session?.uploads?.photos ?? []).map((photo) => photo.id),
|
| 87 |
+
);
|
| 88 |
+
try {
|
| 89 |
+
let updatedSession: Session | null = session;
|
| 90 |
+
for (const file of Array.from(files)) {
|
| 91 |
+
const formData = new FormData();
|
| 92 |
+
formData.append("file", file);
|
| 93 |
+
updatedSession = await postForm<Session>(
|
| 94 |
+
`/sessions/${sessionId}/uploads`,
|
| 95 |
+
formData,
|
| 96 |
+
);
|
| 97 |
+
}
|
| 98 |
+
if (updatedSession) {
|
| 99 |
+
setSession(updatedSession);
|
| 100 |
+
const next = new Set(selectedPhotoIds);
|
| 101 |
+
for (const photo of updatedSession.uploads?.photos ?? []) {
|
| 102 |
+
if (!priorPhotoIds.has(photo.id)) {
|
| 103 |
+
next.add(photo.id);
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
setSelectedPhotoIds(next);
|
| 107 |
+
}
|
| 108 |
+
} catch (err) {
|
| 109 |
+
const message =
|
| 110 |
+
err instanceof Error ? err.message : "Failed to upload photos.";
|
| 111 |
+
setStatusMessage(message);
|
| 112 |
+
} finally {
|
| 113 |
+
setIsUploadingPhotos(false);
|
| 114 |
+
if (photoUploadRef.current) photoUploadRef.current.value = "";
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
async function handleUploadDataFile(files: FileList | null) {
|
| 119 |
+
if (!sessionId || !files || files.length === 0) return;
|
| 120 |
+
setIsUploadingData(true);
|
| 121 |
+
setStatusMessage("");
|
| 122 |
+
try {
|
| 123 |
+
const file = files[0];
|
| 124 |
+
const formData = new FormData();
|
| 125 |
+
formData.append("file", file);
|
| 126 |
+
const updated = await postForm<Session>(
|
| 127 |
+
`/sessions/${sessionId}/data-files`,
|
| 128 |
+
formData,
|
| 129 |
+
);
|
| 130 |
+
setSession(updated);
|
| 131 |
+
if (updated.selected_photo_ids) {
|
| 132 |
+
setSelectedPhotoIds(new Set(updated.selected_photo_ids));
|
| 133 |
+
}
|
| 134 |
+
} catch (err) {
|
| 135 |
+
const message =
|
| 136 |
+
err instanceof Error ? err.message : "Failed to upload data file.";
|
| 137 |
+
setStatusMessage(message);
|
| 138 |
+
} finally {
|
| 139 |
+
setIsUploadingData(false);
|
| 140 |
+
if (dataUploadRef.current) dataUploadRef.current.value = "";
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
async function handleContinue() {
|
| 145 |
if (!sessionId) return;
|
| 146 |
if (selectedPhotoIds.size === 0 && dataFiles.length === 0) return;
|
|
|
|
| 224 |
|
| 225 |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 226 |
<div className="lg:col-span-2">
|
| 227 |
+
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
| 228 |
<h3 className="text-lg font-semibold text-gray-900">Photos</h3>
|
| 229 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 230 |
+
<span className="text-sm font-semibold text-gray-600">
|
| 231 |
+
{photos.length} file{photos.length === 1 ? "" : "s"}
|
| 232 |
+
</span>
|
| 233 |
+
<button
|
| 234 |
+
type="button"
|
| 235 |
+
onClick={() => photoUploadRef.current?.click()}
|
| 236 |
+
disabled={!sessionId || isUploadingPhotos}
|
| 237 |
+
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50"
|
| 238 |
+
>
|
| 239 |
+
<UploadCloud className="h-4 w-4" />
|
| 240 |
+
{isUploadingPhotos ? "Uploading..." : "Add photos"}
|
| 241 |
+
</button>
|
| 242 |
+
<input
|
| 243 |
+
ref={photoUploadRef}
|
| 244 |
+
type="file"
|
| 245 |
+
className="hidden"
|
| 246 |
+
multiple
|
| 247 |
+
accept=".jpg,.jpeg,.png,.webp"
|
| 248 |
+
onChange={(event) => handleUploadPhotos(event.target.files)}
|
| 249 |
+
/>
|
| 250 |
+
</div>
|
| 251 |
</div>
|
| 252 |
|
| 253 |
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
|
|
|
| 409 |
</div>
|
| 410 |
|
| 411 |
<div>
|
| 412 |
+
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
| 413 |
<h3 className="text-lg font-semibold text-gray-900">Data files</h3>
|
| 414 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 415 |
+
<span className="text-sm font-semibold text-gray-600">
|
| 416 |
+
{dataFiles.length} file{dataFiles.length === 1 ? "" : "s"}
|
| 417 |
+
</span>
|
| 418 |
+
<button
|
| 419 |
+
type="button"
|
| 420 |
+
onClick={() => dataUploadRef.current?.click()}
|
| 421 |
+
disabled={!sessionId || isUploadingData}
|
| 422 |
+
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50"
|
| 423 |
+
>
|
| 424 |
+
<UploadCloud className="h-4 w-4" />
|
| 425 |
+
{isUploadingData ? "Uploading..." : "Upload data file"}
|
| 426 |
+
</button>
|
| 427 |
+
<input
|
| 428 |
+
ref={dataUploadRef}
|
| 429 |
+
type="file"
|
| 430 |
+
className="hidden"
|
| 431 |
+
accept=".csv,.xls,.xlsx"
|
| 432 |
+
onChange={(event) => handleUploadDataFile(event.target.files)}
|
| 433 |
+
/>
|
| 434 |
+
</div>
|
| 435 |
</div>
|
| 436 |
|
| 437 |
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
frontend/src/pages/UploadPage.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
| 13 |
|
| 14 |
import { postForm, request } from "../lib/api";
|
| 15 |
import { formatBytes } from "../lib/format";
|
|
|
|
| 16 |
import { setStoredSessionId } from "../lib/session";
|
| 17 |
import { APP_VERSION } from "../lib/version";
|
| 18 |
import type { Session } from "../types/session";
|
|
@@ -23,9 +24,8 @@ export default function UploadPage() {
|
|
| 23 |
const navigate = useNavigate();
|
| 24 |
const inputRef = useRef<HTMLInputElement | null>(null);
|
| 25 |
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
| 26 |
-
const [
|
| 27 |
const [inspectionDate, setInspectionDate] = useState("");
|
| 28 |
-
const [notes, setNotes] = useState("");
|
| 29 |
const [uploadStatus, setUploadStatus] = useState("");
|
| 30 |
const [statusTone, setStatusTone] = useState<StatusTone>("idle");
|
| 31 |
const [recentSessions, setRecentSessions] = useState<Session[]>([]);
|
|
@@ -86,9 +86,8 @@ export default function UploadPage() {
|
|
| 86 |
setStatusTone("idle");
|
| 87 |
|
| 88 |
const formData = new FormData();
|
| 89 |
-
formData.append("
|
| 90 |
formData.append("inspection_date", inspectionDate);
|
| 91 |
-
formData.append("notes", notes.trim());
|
| 92 |
selectedFiles.forEach((file) => formData.append("files", file));
|
| 93 |
|
| 94 |
try {
|
|
@@ -187,7 +186,7 @@ export default function UploadPage() {
|
|
| 187 |
</div>
|
| 188 |
<h3 className="text-base font-semibold text-gray-900 mb-1">Upload documents</h3>
|
| 189 |
<p className="text-sm text-gray-600">
|
| 190 |
-
Add
|
| 191 |
</p>
|
| 192 |
</article>
|
| 193 |
|
|
@@ -259,15 +258,15 @@ export default function UploadPage() {
|
|
| 259 |
|
| 260 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
| 261 |
<div className="space-y-1">
|
| 262 |
-
<label className="block text-sm font-medium text-gray-700" htmlFor="
|
| 263 |
-
|
| 264 |
</label>
|
| 265 |
<input
|
| 266 |
-
id="
|
| 267 |
type="text"
|
| 268 |
-
value={
|
| 269 |
-
onChange={(event) =>
|
| 270 |
-
placeholder="e.g.,
|
| 271 |
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500"
|
| 272 |
/>
|
| 273 |
</div>
|
|
@@ -286,20 +285,6 @@ export default function UploadPage() {
|
|
| 286 |
</div>
|
| 287 |
</div>
|
| 288 |
|
| 289 |
-
<div className="space-y-1 mb-6">
|
| 290 |
-
<label className="block text-sm font-medium text-gray-700" htmlFor="notes">
|
| 291 |
-
Additional Notes
|
| 292 |
-
</label>
|
| 293 |
-
<textarea
|
| 294 |
-
id="notes"
|
| 295 |
-
rows={4}
|
| 296 |
-
value={notes}
|
| 297 |
-
onChange={(event) => setNotes(event.target.value)}
|
| 298 |
-
placeholder="Add any context you'd like included in the report..."
|
| 299 |
-
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500"
|
| 300 |
-
/>
|
| 301 |
-
</div>
|
| 302 |
-
|
| 303 |
<button
|
| 304 |
type="button"
|
| 305 |
onClick={handleSubmit}
|
|
@@ -340,7 +325,7 @@ export default function UploadPage() {
|
|
| 340 |
Report ID
|
| 341 |
</th>
|
| 342 |
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
| 343 |
-
|
| 344 |
</th>
|
| 345 |
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
| 346 |
Date
|
|
@@ -368,7 +353,7 @@ export default function UploadPage() {
|
|
| 368 |
#{session.id.slice(0, 8)}
|
| 369 |
</td>
|
| 370 |
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
| 371 |
-
{session.
|
| 372 |
</td>
|
| 373 |
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
| 374 |
{session.created_at
|
|
|
|
| 13 |
|
| 14 |
import { postForm, request } from "../lib/api";
|
| 15 |
import { formatBytes } from "../lib/format";
|
| 16 |
+
import { formatDocNumber } from "../lib/report";
|
| 17 |
import { setStoredSessionId } from "../lib/session";
|
| 18 |
import { APP_VERSION } from "../lib/version";
|
| 19 |
import type { Session } from "../types/session";
|
|
|
|
| 24 |
const navigate = useNavigate();
|
| 25 |
const inputRef = useRef<HTMLInputElement | null>(null);
|
| 26 |
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
| 27 |
+
const [documentNo, setDocumentNo] = useState("");
|
| 28 |
const [inspectionDate, setInspectionDate] = useState("");
|
|
|
|
| 29 |
const [uploadStatus, setUploadStatus] = useState("");
|
| 30 |
const [statusTone, setStatusTone] = useState<StatusTone>("idle");
|
| 31 |
const [recentSessions, setRecentSessions] = useState<Session[]>([]);
|
|
|
|
| 86 |
setStatusTone("idle");
|
| 87 |
|
| 88 |
const formData = new FormData();
|
| 89 |
+
formData.append("document_no", documentNo.trim());
|
| 90 |
formData.append("inspection_date", inspectionDate);
|
|
|
|
| 91 |
selectedFiles.forEach((file) => formData.append("files", file));
|
| 92 |
|
| 93 |
try {
|
|
|
|
| 186 |
</div>
|
| 187 |
<h3 className="text-base font-semibold text-gray-900 mb-1">Upload documents</h3>
|
| 188 |
<p className="text-sm text-gray-600">
|
| 189 |
+
Add supporting PDFs/DOCX to complete the context.
|
| 190 |
</p>
|
| 191 |
</article>
|
| 192 |
|
|
|
|
| 258 |
|
| 259 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
| 260 |
<div className="space-y-1">
|
| 261 |
+
<label className="block text-sm font-medium text-gray-700" htmlFor="documentNo">
|
| 262 |
+
Document No
|
| 263 |
</label>
|
| 264 |
<input
|
| 265 |
+
id="documentNo"
|
| 266 |
type="text"
|
| 267 |
+
value={documentNo}
|
| 268 |
+
onChange={(event) => setDocumentNo(event.target.value)}
|
| 269 |
+
placeholder="e.g., SIMM-JS-2025-001"
|
| 270 |
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-500"
|
| 271 |
/>
|
| 272 |
</div>
|
|
|
|
| 285 |
</div>
|
| 286 |
</div>
|
| 287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
<button
|
| 289 |
type="button"
|
| 290 |
onClick={handleSubmit}
|
|
|
|
| 325 |
Report ID
|
| 326 |
</th>
|
| 327 |
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
| 328 |
+
Document No
|
| 329 |
</th>
|
| 330 |
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
| 331 |
Date
|
|
|
|
| 353 |
#{session.id.slice(0, 8)}
|
| 354 |
</td>
|
| 355 |
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
| 356 |
+
{session.document_no || formatDocNumber(session)}
|
| 357 |
</td>
|
| 358 |
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
| 359 |
{session.created_at
|
frontend/src/types/custom-elements.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ declare global {
|
|
| 13 |
|
| 14 |
interface ReportEditorElement extends HTMLElement {
|
| 15 |
open: (options?: ReportEditorOpenOptions) => void;
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
namespace JSX {
|
|
|
|
| 13 |
|
| 14 |
interface ReportEditorElement extends HTMLElement {
|
| 15 |
open: (options?: ReportEditorOpenOptions) => void;
|
| 16 |
+
flushSave?: () => Promise<void>;
|
| 17 |
}
|
| 18 |
|
| 19 |
namespace JSX {
|
frontend/src/types/session.ts
CHANGED
|
@@ -23,9 +23,8 @@ export type Session = {
|
|
| 23 |
status: string;
|
| 24 |
created_at: string;
|
| 25 |
updated_at: string;
|
| 26 |
-
|
| 27 |
inspection_date: string;
|
| 28 |
-
notes: string;
|
| 29 |
uploads: SessionUploads;
|
| 30 |
selected_photo_ids: string[];
|
| 31 |
page_count: number;
|
|
@@ -69,26 +68,23 @@ export type PageItem = {
|
|
| 69 |
export type TemplateFields = {
|
| 70 |
inspection_date?: string;
|
| 71 |
inspector?: string;
|
| 72 |
-
accompanied_by?: string;
|
| 73 |
document_no?: string;
|
| 74 |
-
project?: string;
|
| 75 |
-
client_site?: string;
|
| 76 |
company_logo?: string;
|
| 77 |
area?: string;
|
| 78 |
reference?: string;
|
| 79 |
-
action_type?: string;
|
| 80 |
item_description?: string;
|
| 81 |
functional_location?: string;
|
| 82 |
category?: string;
|
| 83 |
priority?: string;
|
| 84 |
-
condition_description?: string;
|
| 85 |
required_action?: string;
|
|
|
|
| 86 |
};
|
| 87 |
|
| 88 |
export type Page = {
|
| 89 |
items: PageItem[];
|
| 90 |
template?: TemplateFields;
|
| 91 |
photo_ids?: string[];
|
|
|
|
| 92 |
photo_order_locked?: boolean;
|
| 93 |
blank?: boolean;
|
| 94 |
variant?: "full" | "photos";
|
|
|
|
| 23 |
status: string;
|
| 24 |
created_at: string;
|
| 25 |
updated_at: string;
|
| 26 |
+
document_no: string;
|
| 27 |
inspection_date: string;
|
|
|
|
| 28 |
uploads: SessionUploads;
|
| 29 |
selected_photo_ids: string[];
|
| 30 |
page_count: number;
|
|
|
|
| 68 |
export type TemplateFields = {
|
| 69 |
inspection_date?: string;
|
| 70 |
inspector?: string;
|
|
|
|
| 71 |
document_no?: string;
|
|
|
|
|
|
|
| 72 |
company_logo?: string;
|
| 73 |
area?: string;
|
| 74 |
reference?: string;
|
|
|
|
| 75 |
item_description?: string;
|
| 76 |
functional_location?: string;
|
| 77 |
category?: string;
|
| 78 |
priority?: string;
|
|
|
|
| 79 |
required_action?: string;
|
| 80 |
+
figure_caption?: string;
|
| 81 |
};
|
| 82 |
|
| 83 |
export type Page = {
|
| 84 |
items: PageItem[];
|
| 85 |
template?: TemplateFields;
|
| 86 |
photo_ids?: string[];
|
| 87 |
+
photo_layout?: "auto" | "two-column" | "stacked";
|
| 88 |
photo_order_locked?: boolean;
|
| 89 |
blank?: boolean;
|
| 90 |
variant?: "full" | "photos";
|
server/app/api/routes/sessions.py
CHANGED
|
@@ -10,6 +10,8 @@ from openpyxl import Workbook
|
|
| 10 |
|
| 11 |
from ..deps import get_session_store
|
| 12 |
from ..schemas import (
|
|
|
|
|
|
|
| 13 |
PagesRequest,
|
| 14 |
PagesResponse,
|
| 15 |
SectionsRequest,
|
|
@@ -52,6 +54,18 @@ def _attach_urls(session: dict) -> dict:
|
|
| 52 |
return session
|
| 53 |
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
@router.get("", response_model=List[SessionResponse])
|
| 56 |
def list_sessions(store: SessionStore = Depends(get_session_store)) -> List[SessionResponse]:
|
| 57 |
sessions = store.list_sessions()
|
|
@@ -61,16 +75,15 @@ def list_sessions(store: SessionStore = Depends(get_session_store)) -> List[Sess
|
|
| 61 |
|
| 62 |
@router.post("", response_model=SessionResponse, status_code=status.HTTP_201_CREATED)
|
| 63 |
def create_session(
|
| 64 |
-
|
| 65 |
inspection_date: str = Form(""),
|
| 66 |
-
notes: str = Form(""),
|
| 67 |
files: List[UploadFile] = File(...),
|
| 68 |
store: SessionStore = Depends(get_session_store),
|
| 69 |
) -> SessionResponse:
|
| 70 |
if not files:
|
| 71 |
raise HTTPException(status_code=400, detail="At least one file is required.")
|
| 72 |
|
| 73 |
-
session = store.create_session(
|
| 74 |
saved_files = []
|
| 75 |
for upload in files:
|
| 76 |
try:
|
|
@@ -179,6 +192,20 @@ def save_sections(
|
|
| 179 |
return SectionsResponse(sections=session.get("jobsheet_sections") or [])
|
| 180 |
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
@router.get("/{session_id}/uploads/{file_id}")
|
| 183 |
def get_upload(
|
| 184 |
session_id: str,
|
|
@@ -288,16 +315,20 @@ def import_json(
|
|
| 288 |
session = store.set_pages(session, pages)
|
| 289 |
|
| 290 |
if isinstance(imported_session, dict):
|
| 291 |
-
|
| 292 |
-
"
|
| 293 |
-
"
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
"
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
store.update_session(session)
|
| 302 |
|
| 303 |
return _attach_urls(session)
|
|
@@ -362,15 +393,10 @@ def export_excel(
|
|
| 362 |
wb = Workbook()
|
| 363 |
ws_general = wb.active
|
| 364 |
ws_general.title = "General Information"
|
| 365 |
-
ws_general.append(["
|
| 366 |
ws_general.append(["Inspection Date", session.get("inspection_date", "")])
|
| 367 |
ws_general.append(["Inspector", first_template.get("inspector", "")])
|
| 368 |
-
ws_general.append(["
|
| 369 |
-
ws_general.append(["Document No", first_template.get("document_no", "")])
|
| 370 |
-
ws_general.append(["Client / Site", first_template.get("client_site", "")])
|
| 371 |
-
ws_general.append(
|
| 372 |
-
["Client Logo Image Name", first_template.get("company_logo", "")]
|
| 373 |
-
)
|
| 374 |
|
| 375 |
ws_headings = wb.create_sheet("Headings")
|
| 376 |
ws_headings.append(["Heading Number", "Heading Name"])
|
|
@@ -389,16 +415,12 @@ def export_excel(
|
|
| 389 |
[
|
| 390 |
"REF",
|
| 391 |
"Area",
|
| 392 |
-
"
|
| 393 |
"Item Description",
|
| 394 |
"Category",
|
| 395 |
"Priority",
|
| 396 |
-
"Item Description",
|
| 397 |
-
"Condition Description",
|
| 398 |
-
"Action Type",
|
| 399 |
"Required Action",
|
| 400 |
"Figure Caption",
|
| 401 |
-
"Figure Description",
|
| 402 |
"Image Name 1",
|
| 403 |
"Image Name 2",
|
| 404 |
"Image Name 3",
|
|
@@ -430,12 +452,8 @@ def export_excel(
|
|
| 430 |
template.get("item_description", ""),
|
| 431 |
template.get("category", ""),
|
| 432 |
template.get("priority", ""),
|
| 433 |
-
template.get("item_description", ""),
|
| 434 |
-
template.get("condition_description", ""),
|
| 435 |
-
template.get("action_type", ""),
|
| 436 |
template.get("required_action", ""),
|
| 437 |
-
"",
|
| 438 |
-
"",
|
| 439 |
*photo_names[:6],
|
| 440 |
]
|
| 441 |
)
|
|
|
|
| 10 |
|
| 11 |
from ..deps import get_session_store
|
| 12 |
from ..schemas import (
|
| 13 |
+
HeadingsRequest,
|
| 14 |
+
HeadingsResponse,
|
| 15 |
PagesRequest,
|
| 16 |
PagesResponse,
|
| 17 |
SectionsRequest,
|
|
|
|
| 54 |
return session
|
| 55 |
|
| 56 |
|
| 57 |
+
def _merge_text(primary: str, secondary: str) -> str:
|
| 58 |
+
primary = (primary or "").strip()
|
| 59 |
+
secondary = (secondary or "").strip()
|
| 60 |
+
if not secondary:
|
| 61 |
+
return primary
|
| 62 |
+
if not primary:
|
| 63 |
+
return secondary
|
| 64 |
+
if secondary in primary:
|
| 65 |
+
return primary
|
| 66 |
+
return f"{primary} - {secondary}"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
@router.get("", response_model=List[SessionResponse])
|
| 70 |
def list_sessions(store: SessionStore = Depends(get_session_store)) -> List[SessionResponse]:
|
| 71 |
sessions = store.list_sessions()
|
|
|
|
| 75 |
|
| 76 |
@router.post("", response_model=SessionResponse, status_code=status.HTTP_201_CREATED)
|
| 77 |
def create_session(
|
| 78 |
+
document_no: str = Form(""),
|
| 79 |
inspection_date: str = Form(""),
|
|
|
|
| 80 |
files: List[UploadFile] = File(...),
|
| 81 |
store: SessionStore = Depends(get_session_store),
|
| 82 |
) -> SessionResponse:
|
| 83 |
if not files:
|
| 84 |
raise HTTPException(status_code=400, detail="At least one file is required.")
|
| 85 |
|
| 86 |
+
session = store.create_session(document_no, inspection_date)
|
| 87 |
saved_files = []
|
| 88 |
for upload in files:
|
| 89 |
try:
|
|
|
|
| 192 |
return SectionsResponse(sections=session.get("jobsheet_sections") or [])
|
| 193 |
|
| 194 |
|
| 195 |
+
@router.put("/{session_id}/headings", response_model=HeadingsResponse)
|
| 196 |
+
def save_headings(
|
| 197 |
+
session_id: str,
|
| 198 |
+
payload: HeadingsRequest,
|
| 199 |
+
store: SessionStore = Depends(get_session_store),
|
| 200 |
+
) -> HeadingsResponse:
|
| 201 |
+
session_id = _normalize_session_id(session_id, store)
|
| 202 |
+
session = store.get_session(session_id)
|
| 203 |
+
if not session:
|
| 204 |
+
raise HTTPException(status_code=404, detail="Session not found.")
|
| 205 |
+
session = store.set_headings(session, payload.headings)
|
| 206 |
+
return HeadingsResponse(headings=session.get("headings") or [])
|
| 207 |
+
|
| 208 |
+
|
| 209 |
@router.get("/{session_id}/uploads/{file_id}")
|
| 210 |
def get_upload(
|
| 211 |
session_id: str,
|
|
|
|
| 315 |
session = store.set_pages(session, pages)
|
| 316 |
|
| 317 |
if isinstance(imported_session, dict):
|
| 318 |
+
document_no = _merge_text(
|
| 319 |
+
imported_session.get("document_no", ""),
|
| 320 |
+
imported_session.get("project_name", ""),
|
| 321 |
+
)
|
| 322 |
+
if document_no:
|
| 323 |
+
session["document_no"] = document_no
|
| 324 |
+
if imported_session.get("inspection_date") is not None:
|
| 325 |
+
session["inspection_date"] = imported_session["inspection_date"]
|
| 326 |
+
if imported_session.get("selected_photo_ids") is not None:
|
| 327 |
+
session["selected_photo_ids"] = imported_session["selected_photo_ids"]
|
| 328 |
+
if imported_session.get("page_count") is not None:
|
| 329 |
+
session["page_count"] = imported_session["page_count"]
|
| 330 |
+
if imported_session.get("headings") is not None:
|
| 331 |
+
session["headings"] = imported_session["headings"]
|
| 332 |
store.update_session(session)
|
| 333 |
|
| 334 |
return _attach_urls(session)
|
|
|
|
| 393 |
wb = Workbook()
|
| 394 |
ws_general = wb.active
|
| 395 |
ws_general.title = "General Information"
|
| 396 |
+
ws_general.append(["Document No", session.get("document_no", "")])
|
| 397 |
ws_general.append(["Inspection Date", session.get("inspection_date", "")])
|
| 398 |
ws_general.append(["Inspector", first_template.get("inspector", "")])
|
| 399 |
+
ws_general.append(["Company Logo Image Name", first_template.get("company_logo", "")])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
|
| 401 |
ws_headings = wb.create_sheet("Headings")
|
| 402 |
ws_headings.append(["Heading Number", "Heading Name"])
|
|
|
|
| 415 |
[
|
| 416 |
"REF",
|
| 417 |
"Area",
|
| 418 |
+
"Location",
|
| 419 |
"Item Description",
|
| 420 |
"Category",
|
| 421 |
"Priority",
|
|
|
|
|
|
|
|
|
|
| 422 |
"Required Action",
|
| 423 |
"Figure Caption",
|
|
|
|
| 424 |
"Image Name 1",
|
| 425 |
"Image Name 2",
|
| 426 |
"Image Name 3",
|
|
|
|
| 452 |
template.get("item_description", ""),
|
| 453 |
template.get("category", ""),
|
| 454 |
template.get("priority", ""),
|
|
|
|
|
|
|
|
|
|
| 455 |
template.get("required_action", ""),
|
| 456 |
+
template.get("figure_caption", ""),
|
|
|
|
| 457 |
*photo_names[:6],
|
| 458 |
]
|
| 459 |
)
|
server/app/api/schemas.py
CHANGED
|
@@ -24,9 +24,8 @@ class SessionResponse(BaseModel):
|
|
| 24 |
status: str
|
| 25 |
created_at: str
|
| 26 |
updated_at: str
|
| 27 |
-
|
| 28 |
inspection_date: str
|
| 29 |
-
notes: str
|
| 30 |
uploads: Dict[str, List[FileMeta]] = Field(default_factory=dict)
|
| 31 |
selected_photo_ids: List[str] = Field(default_factory=list)
|
| 32 |
page_count: int = 0
|
|
@@ -64,3 +63,11 @@ class SectionsResponse(BaseModel):
|
|
| 64 |
|
| 65 |
class SectionsRequest(BaseModel):
|
| 66 |
sections: List[JobsheetSection] = Field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
status: str
|
| 25 |
created_at: str
|
| 26 |
updated_at: str
|
| 27 |
+
document_no: str
|
| 28 |
inspection_date: str
|
|
|
|
| 29 |
uploads: Dict[str, List[FileMeta]] = Field(default_factory=dict)
|
| 30 |
selected_photo_ids: List[str] = Field(default_factory=list)
|
| 31 |
page_count: int = 0
|
|
|
|
| 63 |
|
| 64 |
class SectionsRequest(BaseModel):
|
| 65 |
sections: List[JobsheetSection] = Field(default_factory=list)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class HeadingsRequest(BaseModel):
|
| 69 |
+
headings: List[Heading] = Field(default_factory=list)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class HeadingsResponse(BaseModel):
|
| 73 |
+
headings: List[Heading] = Field(default_factory=list)
|
server/app/services/data_import.py
CHANGED
|
@@ -34,6 +34,18 @@ def _cell_to_str(value: object) -> str:
|
|
| 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:
|
|
@@ -91,6 +103,9 @@ def _parse_headings(rows: Iterable[Iterable[object]]) -> List[Dict[str, str]]:
|
|
| 91 |
name = _cell_to_str(row[name_idx]) if name_idx is not None and name_idx < len(row) else ""
|
| 92 |
|
| 93 |
if not number and not name:
|
|
|
|
|
|
|
|
|
|
| 94 |
combined = _cell_to_str(row[0] if row else "")
|
| 95 |
match = re.match(r"^(\\d+)\\s*[-–.]?\\s*(.+)$", combined)
|
| 96 |
if match:
|
|
@@ -110,6 +125,9 @@ def _header_map(headers: List[str]) -> Dict[str, List[int]]:
|
|
| 110 |
if not name:
|
| 111 |
continue
|
| 112 |
mapping.setdefault(name, []).append(idx)
|
|
|
|
|
|
|
|
|
|
| 113 |
return mapping
|
| 114 |
|
| 115 |
|
|
@@ -172,14 +190,13 @@ def _parse_items(rows: Iterable[Iterable[object]]) -> List[Dict[str, str | List[
|
|
| 172 |
mapping = _header_map(headers)
|
| 173 |
image_indices = _image_column_indices(headers)
|
| 174 |
|
|
|
|
|
|
|
|
|
|
| 175 |
def first_index(name: str) -> Optional[int]:
|
| 176 |
-
values =
|
| 177 |
return values[0] if values else None
|
| 178 |
|
| 179 |
-
def second_index(name: str) -> Optional[int]:
|
| 180 |
-
values = mapping.get(_normalize_text(name)) or []
|
| 181 |
-
return values[1] if len(values) > 1 else None
|
| 182 |
-
|
| 183 |
def image_index(n: int) -> Optional[int]:
|
| 184 |
return image_indices.get(n) or first_index(f"image name {n}") or first_index(
|
| 185 |
f"image {n}"
|
|
@@ -196,12 +213,44 @@ def _parse_items(rows: Iterable[Iterable[object]]) -> List[Dict[str, str | List[
|
|
| 196 |
cells = list(row)
|
| 197 |
if not any(_cell_to_str(cell) for cell in cells):
|
| 198 |
continue
|
| 199 |
-
|
| 200 |
-
cells,
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
reference = _row_value(cells, ref_index)
|
| 203 |
if not reference:
|
| 204 |
reference = _find_reference_value(cells)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
image_names = [
|
| 206 |
_row_value(cells, image_index(1)),
|
| 207 |
_row_value(cells, image_index(2)),
|
|
@@ -230,16 +279,13 @@ def _parse_items(rows: Iterable[Iterable[object]]) -> List[Dict[str, str | List[
|
|
| 230 |
"reference": reference,
|
| 231 |
"area": _row_value(cells, area_index),
|
| 232 |
"functional_location": _row_value(
|
| 233 |
-
cells, first_index("functional location")
|
| 234 |
),
|
| 235 |
"item_description": item_desc,
|
| 236 |
"category": _row_value(cells, first_index("category")),
|
| 237 |
"priority": _row_value(cells, first_index("priority")),
|
| 238 |
-
"
|
| 239 |
-
|
| 240 |
-
),
|
| 241 |
-
"action_type": _row_value(cells, first_index("action type")),
|
| 242 |
-
"required_action": _row_value(cells, first_index("required action")),
|
| 243 |
"image_names": [name for name in image_names if name],
|
| 244 |
}
|
| 245 |
)
|
|
@@ -378,9 +424,9 @@ def populate_session_from_data_files(
|
|
| 378 |
items = parsed.get("items") or []
|
| 379 |
|
| 380 |
# Update session-wide fields if provided
|
| 381 |
-
|
| 382 |
-
if
|
| 383 |
-
session["
|
| 384 |
inspection_date = general.get("inspection date")
|
| 385 |
if inspection_date:
|
| 386 |
session["inspection_date"] = inspection_date
|
|
@@ -390,11 +436,8 @@ def populate_session_from_data_files(
|
|
| 390 |
)
|
| 391 |
|
| 392 |
if isinstance(headings, dict):
|
| 393 |
-
headings = [
|
| 394 |
-
|
| 395 |
-
]
|
| 396 |
-
if headings:
|
| 397 |
-
session["headings"] = headings
|
| 398 |
|
| 399 |
sections: List[dict] = []
|
| 400 |
selected_photo_ids: List[str] = []
|
|
@@ -408,10 +451,7 @@ def populate_session_from_data_files(
|
|
| 408 |
template = {
|
| 409 |
"inspection_date": inspection_date or session.get("inspection_date", ""),
|
| 410 |
"inspector": general.get("inspector", ""),
|
| 411 |
-
"
|
| 412 |
-
"document_no": general.get("document no", ""),
|
| 413 |
-
"project": general.get("project name", session.get("project_name", "")),
|
| 414 |
-
"client_site": general.get("client / site", ""),
|
| 415 |
"company_logo": company_logo,
|
| 416 |
"reference": item.get("reference", ""),
|
| 417 |
"area": item.get("area", ""),
|
|
@@ -419,9 +459,8 @@ def populate_session_from_data_files(
|
|
| 419 |
"item_description": item.get("item_description", ""),
|
| 420 |
"category": item.get("category", ""),
|
| 421 |
"priority": item.get("priority", ""),
|
| 422 |
-
"condition_description": item.get("condition_description", ""),
|
| 423 |
-
"action_type": item.get("action_type", ""),
|
| 424 |
"required_action": item.get("required_action", ""),
|
|
|
|
| 425 |
}
|
| 426 |
image_names = item.get("image_names", []) or []
|
| 427 |
photo_ids = _photo_ids_for_names(image_names, photo_lookup)
|
|
|
|
| 34 |
return str(value).strip()
|
| 35 |
|
| 36 |
|
| 37 |
+
def _merge_text(primary: str, secondary: str) -> str:
|
| 38 |
+
primary = (primary or "").strip()
|
| 39 |
+
secondary = (secondary or "").strip()
|
| 40 |
+
if not secondary:
|
| 41 |
+
return primary
|
| 42 |
+
if not primary:
|
| 43 |
+
return secondary
|
| 44 |
+
if secondary in primary:
|
| 45 |
+
return primary
|
| 46 |
+
return f"{primary} - {secondary}"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
def _parse_general_info(rows: Iterable[Iterable[object]]) -> Dict[str, str]:
|
| 50 |
info: Dict[str, str] = {}
|
| 51 |
for row in rows:
|
|
|
|
| 103 |
name = _cell_to_str(row[name_idx]) if name_idx is not None and name_idx < len(row) else ""
|
| 104 |
|
| 105 |
if not number and not name:
|
| 106 |
+
if len(row) >= 2:
|
| 107 |
+
number = _cell_to_str(row[0])
|
| 108 |
+
name = _cell_to_str(row[1])
|
| 109 |
combined = _cell_to_str(row[0] if row else "")
|
| 110 |
match = re.match(r"^(\\d+)\\s*[-–.]?\\s*(.+)$", combined)
|
| 111 |
if match:
|
|
|
|
| 125 |
if not name:
|
| 126 |
continue
|
| 127 |
mapping.setdefault(name, []).append(idx)
|
| 128 |
+
compact = re.sub(r"[^a-z0-9]", "", name)
|
| 129 |
+
if compact and compact != name:
|
| 130 |
+
mapping.setdefault(compact, []).append(idx)
|
| 131 |
return mapping
|
| 132 |
|
| 133 |
|
|
|
|
| 190 |
mapping = _header_map(headers)
|
| 191 |
image_indices = _image_column_indices(headers)
|
| 192 |
|
| 193 |
+
def indices_for(name: str) -> List[int]:
|
| 194 |
+
return mapping.get(_normalize_text(name)) or []
|
| 195 |
+
|
| 196 |
def first_index(name: str) -> Optional[int]:
|
| 197 |
+
values = indices_for(name)
|
| 198 |
return values[0] if values else None
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
def image_index(n: int) -> Optional[int]:
|
| 201 |
return image_indices.get(n) or first_index(f"image name {n}") or first_index(
|
| 202 |
f"image {n}"
|
|
|
|
| 213 |
cells = list(row)
|
| 214 |
if not any(_cell_to_str(cell) for cell in cells):
|
| 215 |
continue
|
| 216 |
+
item_desc_candidates = [
|
| 217 |
+
_row_value(cells, idx) for idx in indices_for("item description")
|
| 218 |
+
]
|
| 219 |
+
item_desc = max(item_desc_candidates, key=len) if item_desc_candidates else ""
|
| 220 |
+
condition_desc = _row_value(cells, first_index("condition description"))
|
| 221 |
+
if condition_desc and condition_desc not in item_desc:
|
| 222 |
+
item_desc = " - ".join(
|
| 223 |
+
[value for value in [item_desc, condition_desc] if value]
|
| 224 |
+
)
|
| 225 |
reference = _row_value(cells, ref_index)
|
| 226 |
if not reference:
|
| 227 |
reference = _find_reference_value(cells)
|
| 228 |
+
action_type = _row_value(cells, first_index("action type"))
|
| 229 |
+
required_action_candidates = [
|
| 230 |
+
_row_value(cells, idx) for idx in indices_for("required action")
|
| 231 |
+
]
|
| 232 |
+
required_action = (
|
| 233 |
+
max(required_action_candidates, key=len)
|
| 234 |
+
if required_action_candidates
|
| 235 |
+
else ""
|
| 236 |
+
)
|
| 237 |
+
if action_type and action_type not in required_action:
|
| 238 |
+
required_action = " - ".join(
|
| 239 |
+
[value for value in [action_type, required_action] if value]
|
| 240 |
+
)
|
| 241 |
+
figure_caption_candidates = [
|
| 242 |
+
_row_value(cells, idx) for idx in indices_for("figure caption")
|
| 243 |
+
]
|
| 244 |
+
figure_caption = (
|
| 245 |
+
max(figure_caption_candidates, key=len)
|
| 246 |
+
if figure_caption_candidates
|
| 247 |
+
else ""
|
| 248 |
+
)
|
| 249 |
+
figure_description = _row_value(cells, first_index("figure description"))
|
| 250 |
+
if figure_description and figure_description not in figure_caption:
|
| 251 |
+
figure_caption = " - ".join(
|
| 252 |
+
[value for value in [figure_caption, figure_description] if value]
|
| 253 |
+
)
|
| 254 |
image_names = [
|
| 255 |
_row_value(cells, image_index(1)),
|
| 256 |
_row_value(cells, image_index(2)),
|
|
|
|
| 279 |
"reference": reference,
|
| 280 |
"area": _row_value(cells, area_index),
|
| 281 |
"functional_location": _row_value(
|
| 282 |
+
cells, first_index("functional location") or first_index("location")
|
| 283 |
),
|
| 284 |
"item_description": item_desc,
|
| 285 |
"category": _row_value(cells, first_index("category")),
|
| 286 |
"priority": _row_value(cells, first_index("priority")),
|
| 287 |
+
"required_action": required_action,
|
| 288 |
+
"figure_caption": figure_caption,
|
|
|
|
|
|
|
|
|
|
| 289 |
"image_names": [name for name in image_names if name],
|
| 290 |
}
|
| 291 |
)
|
|
|
|
| 424 |
items = parsed.get("items") or []
|
| 425 |
|
| 426 |
# Update session-wide fields if provided
|
| 427 |
+
document_no = general.get("document no") or general.get("document number") or ""
|
| 428 |
+
if document_no:
|
| 429 |
+
session["document_no"] = document_no
|
| 430 |
inspection_date = general.get("inspection date")
|
| 431 |
if inspection_date:
|
| 432 |
session["inspection_date"] = inspection_date
|
|
|
|
| 436 |
)
|
| 437 |
|
| 438 |
if isinstance(headings, dict):
|
| 439 |
+
headings = [{"number": key, "name": value} for key, value in headings.items()]
|
| 440 |
+
session["headings"] = headings if isinstance(headings, list) else []
|
|
|
|
|
|
|
|
|
|
| 441 |
|
| 442 |
sections: List[dict] = []
|
| 443 |
selected_photo_ids: List[str] = []
|
|
|
|
| 451 |
template = {
|
| 452 |
"inspection_date": inspection_date or session.get("inspection_date", ""),
|
| 453 |
"inspector": general.get("inspector", ""),
|
| 454 |
+
"document_no": document_no or session.get("document_no", ""),
|
|
|
|
|
|
|
|
|
|
| 455 |
"company_logo": company_logo,
|
| 456 |
"reference": item.get("reference", ""),
|
| 457 |
"area": item.get("area", ""),
|
|
|
|
| 459 |
"item_description": item.get("item_description", ""),
|
| 460 |
"category": item.get("category", ""),
|
| 461 |
"priority": item.get("priority", ""),
|
|
|
|
|
|
|
| 462 |
"required_action": item.get("required_action", ""),
|
| 463 |
+
"figure_caption": item.get("figure_caption", ""),
|
| 464 |
}
|
| 465 |
image_names = item.get("image_names", []) or []
|
| 466 |
photo_ids = _photo_ids_for_names(image_names, photo_lookup)
|
server/app/services/pdf_reportlab.py
CHANGED
|
@@ -221,7 +221,7 @@ def render_report_pdf(
|
|
| 221 |
width, height = A4
|
| 222 |
margin = 10 * mm
|
| 223 |
header_h = 20 * mm
|
| 224 |
-
footer_h =
|
| 225 |
gap = 4 * mm
|
| 226 |
photo_col_gap = 6 * mm
|
| 227 |
photo_row_gap = 6 * mm
|
|
@@ -280,9 +280,11 @@ def render_report_pdf(
|
|
| 280 |
|
| 281 |
print_pages: List[dict] = []
|
| 282 |
for section_index, section in enumerate(sections):
|
|
|
|
| 283 |
section_pages = section.get("pages") or []
|
| 284 |
for page_index, page in enumerate(section_pages):
|
| 285 |
template = page.get("template") or {}
|
|
|
|
| 286 |
base_variant = (
|
| 287 |
(page.get("variant") or "").strip().lower() if isinstance(page, dict) else ""
|
| 288 |
)
|
|
@@ -296,7 +298,7 @@ def render_report_pdf(
|
|
| 296 |
continue
|
| 297 |
path = store.resolve_upload_path(session, pid)
|
| 298 |
if path and path.exists():
|
| 299 |
-
label = _safe_text(item.get("name") or path.name)
|
| 300 |
photo_entries.append({"path": path, "label": label})
|
| 301 |
if base_variant == "photos":
|
| 302 |
chunks = _chunk(photo_entries, max_photos_photos) or [[]]
|
|
@@ -308,6 +310,7 @@ def render_report_pdf(
|
|
| 308 |
"photos": chunk,
|
| 309 |
"variant": "photos",
|
| 310 |
"section_index": section_index,
|
|
|
|
| 311 |
}
|
| 312 |
)
|
| 313 |
else:
|
|
@@ -325,6 +328,7 @@ def render_report_pdf(
|
|
| 325 |
"photos": chunk,
|
| 326 |
"variant": variant,
|
| 327 |
"section_index": section_index,
|
|
|
|
| 328 |
}
|
| 329 |
)
|
| 330 |
|
|
@@ -335,6 +339,8 @@ def render_report_pdf(
|
|
| 335 |
"template": {},
|
| 336 |
"photos": [],
|
| 337 |
"variant": "full",
|
|
|
|
|
|
|
| 338 |
}
|
| 339 |
]
|
| 340 |
|
|
@@ -351,6 +357,20 @@ def render_report_pdf(
|
|
| 351 |
template = payload["template"]
|
| 352 |
photos = payload["photos"]
|
| 353 |
variant = payload["variant"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
|
| 355 |
header_y = height - margin
|
| 356 |
content_top = header_y - header_h - gap
|
|
@@ -388,10 +408,11 @@ def render_report_pdf(
|
|
| 388 |
)
|
| 389 |
pdf.setFillColor(gray_900)
|
| 390 |
pdf.setFont("Helvetica-Bold", 13)
|
| 391 |
-
pdf.drawCentredString(
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
|
|
|
| 395 |
pdf.setStrokeColor(gray_200)
|
| 396 |
pdf.line(margin, header_y - 17 * mm, width - margin, header_y - 17 * mm)
|
| 397 |
|
|
@@ -410,8 +431,6 @@ def render_report_pdf(
|
|
| 410 |
area = _safe_text(template.get("area"))
|
| 411 |
location = _safe_text(template.get("functional_location"))
|
| 412 |
item_desc = _safe_text(template.get("item_description"))
|
| 413 |
-
condition_desc = _safe_text(template.get("condition_description"))
|
| 414 |
-
action_type = _safe_text(template.get("action_type"))
|
| 415 |
required_action = _safe_text(template.get("required_action"))
|
| 416 |
|
| 417 |
left_w = (width - 2 * margin) * 0.6
|
|
@@ -522,8 +541,8 @@ def render_report_pdf(
|
|
| 522 |
pdf.drawCentredString(pr_label_x, y - 2 + badge_h / 2 - 4, pr_text)
|
| 523 |
y -= 10 * mm
|
| 524 |
|
| 525 |
-
condition =
|
| 526 |
-
action =
|
| 527 |
|
| 528 |
pdf.setFillColor(gray_500)
|
| 529 |
pdf.setFont("Helvetica", 11)
|
|
@@ -650,9 +669,18 @@ def render_report_pdf(
|
|
| 650 |
# Footer
|
| 651 |
footer_y = margin
|
| 652 |
pdf.setFillColor(gray_500)
|
| 653 |
-
pdf.setFont("Helvetica",
|
| 654 |
-
pdf.drawCentredString(width / 2, footer_y +
|
| 655 |
-
pdf.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
|
| 657 |
pdf.showPage()
|
| 658 |
|
|
|
|
| 221 |
width, height = A4
|
| 222 |
margin = 10 * mm
|
| 223 |
header_h = 20 * mm
|
| 224 |
+
footer_h = 12 * mm
|
| 225 |
gap = 4 * mm
|
| 226 |
photo_col_gap = 6 * mm
|
| 227 |
photo_row_gap = 6 * mm
|
|
|
|
| 280 |
|
| 281 |
print_pages: List[dict] = []
|
| 282 |
for section_index, section in enumerate(sections):
|
| 283 |
+
section_title = _safe_text(section.get("title")) or f"Section {section_index + 1}"
|
| 284 |
section_pages = section.get("pages") or []
|
| 285 |
for page_index, page in enumerate(section_pages):
|
| 286 |
template = page.get("template") or {}
|
| 287 |
+
figure_caption = _safe_text(template.get("figure_caption"))
|
| 288 |
base_variant = (
|
| 289 |
(page.get("variant") or "").strip().lower() if isinstance(page, dict) else ""
|
| 290 |
)
|
|
|
|
| 298 |
continue
|
| 299 |
path = store.resolve_upload_path(session, pid)
|
| 300 |
if path and path.exists():
|
| 301 |
+
label = figure_caption or _safe_text(item.get("name") or path.name)
|
| 302 |
photo_entries.append({"path": path, "label": label})
|
| 303 |
if base_variant == "photos":
|
| 304 |
chunks = _chunk(photo_entries, max_photos_photos) or [[]]
|
|
|
|
| 310 |
"photos": chunk,
|
| 311 |
"variant": "photos",
|
| 312 |
"section_index": section_index,
|
| 313 |
+
"section_title": section_title,
|
| 314 |
}
|
| 315 |
)
|
| 316 |
else:
|
|
|
|
| 328 |
"photos": chunk,
|
| 329 |
"variant": variant,
|
| 330 |
"section_index": section_index,
|
| 331 |
+
"section_title": section_title,
|
| 332 |
}
|
| 333 |
)
|
| 334 |
|
|
|
|
| 339 |
"template": {},
|
| 340 |
"photos": [],
|
| 341 |
"variant": "full",
|
| 342 |
+
"section_index": 0,
|
| 343 |
+
"section_title": "Section 1",
|
| 344 |
}
|
| 345 |
]
|
| 346 |
|
|
|
|
| 357 |
template = payload["template"]
|
| 358 |
photos = payload["photos"]
|
| 359 |
variant = payload["variant"]
|
| 360 |
+
doc_number = _safe_text(template.get("document_no")) or _safe_text(
|
| 361 |
+
session.get("document_no")
|
| 362 |
+
)
|
| 363 |
+
if not doc_number and session.get("id"):
|
| 364 |
+
doc_number = f"REP-{session['id'][:8].upper()}"
|
| 365 |
+
section_index = payload.get("section_index")
|
| 366 |
+
section_title = _safe_text(payload.get("section_title"))
|
| 367 |
+
section_label = ""
|
| 368 |
+
if isinstance(section_index, int):
|
| 369 |
+
base_label = f"Section {section_index + 1}"
|
| 370 |
+
if section_title and section_title != base_label:
|
| 371 |
+
section_label = f"{base_label} - {section_title}"
|
| 372 |
+
else:
|
| 373 |
+
section_label = base_label
|
| 374 |
|
| 375 |
header_y = height - margin
|
| 376 |
content_top = header_y - header_h - gap
|
|
|
|
| 408 |
)
|
| 409 |
pdf.setFillColor(gray_900)
|
| 410 |
pdf.setFont("Helvetica-Bold", 13)
|
| 411 |
+
pdf.drawCentredString(
|
| 412 |
+
width / 2,
|
| 413 |
+
header_y - 7 * mm,
|
| 414 |
+
doc_number or "Document No",
|
| 415 |
+
)
|
| 416 |
pdf.setStrokeColor(gray_200)
|
| 417 |
pdf.line(margin, header_y - 17 * mm, width - margin, header_y - 17 * mm)
|
| 418 |
|
|
|
|
| 431 |
area = _safe_text(template.get("area"))
|
| 432 |
location = _safe_text(template.get("functional_location"))
|
| 433 |
item_desc = _safe_text(template.get("item_description"))
|
|
|
|
|
|
|
| 434 |
required_action = _safe_text(template.get("required_action"))
|
| 435 |
|
| 436 |
left_w = (width - 2 * margin) * 0.6
|
|
|
|
| 541 |
pdf.drawCentredString(pr_label_x, y - 2 + badge_h / 2 - 4, pr_text)
|
| 542 |
y -= 10 * mm
|
| 543 |
|
| 544 |
+
condition = item_desc
|
| 545 |
+
action = required_action
|
| 546 |
|
| 547 |
pdf.setFillColor(gray_500)
|
| 548 |
pdf.setFont("Helvetica", 11)
|
|
|
|
| 669 |
# Footer
|
| 670 |
footer_y = margin
|
| 671 |
pdf.setFillColor(gray_500)
|
| 672 |
+
pdf.setFont("Helvetica-Bold", 10)
|
| 673 |
+
pdf.drawCentredString(width / 2, footer_y + 8 * mm, "RepEx Inspection Job Sheet")
|
| 674 |
+
pdf.setFont("Helvetica", 9)
|
| 675 |
+
pdf.drawCentredString(
|
| 676 |
+
width / 2,
|
| 677 |
+
footer_y + 4 * mm,
|
| 678 |
+
"Prosento - (c) 2026 All Rights Reserved - Automatically generated job sheet",
|
| 679 |
+
)
|
| 680 |
+
page_line = f"Page {output_index + 1} of {total_pages}"
|
| 681 |
+
if section_label:
|
| 682 |
+
page_line = f"{section_label} - {page_line}"
|
| 683 |
+
pdf.drawCentredString(width / 2, footer_y + 1 * mm, page_line)
|
| 684 |
|
| 685 |
pdf.showPage()
|
| 686 |
|
server/app/services/session_store.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import json
|
| 4 |
import re
|
| 5 |
from dataclasses import dataclass
|
|
@@ -60,6 +61,54 @@ def _validate_session_id(session_id: str) -> str:
|
|
| 60 |
return normalized
|
| 61 |
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
class SessionStore:
|
| 64 |
def __init__(self, base_dir: Optional[Path] = None) -> None:
|
| 65 |
settings = get_settings()
|
|
@@ -68,17 +117,35 @@ class SessionStore:
|
|
| 68 |
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
| 69 |
self.max_upload_bytes = settings.max_upload_mb * 1024 * 1024
|
| 70 |
self._lock = Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
def list_sessions(self) -> List[dict]:
|
| 73 |
sessions: List[dict] = []
|
| 74 |
for session_file in sorted(self.sessions_dir.glob("*/session.json"), reverse=True):
|
| 75 |
try:
|
| 76 |
-
|
|
|
|
|
|
|
| 77 |
except Exception:
|
| 78 |
continue
|
| 79 |
return sessions
|
| 80 |
|
| 81 |
-
def create_session(self,
|
| 82 |
session_id = uuid4().hex
|
| 83 |
now = _now_iso()
|
| 84 |
session = {
|
|
@@ -86,14 +153,14 @@ class SessionStore:
|
|
| 86 |
"status": "ready",
|
| 87 |
"created_at": now,
|
| 88 |
"updated_at": now,
|
| 89 |
-
"
|
| 90 |
"inspection_date": inspection_date,
|
| 91 |
-
"notes": notes,
|
| 92 |
"uploads": {"photos": [], "documents": [], "data_files": []},
|
| 93 |
"selected_photo_ids": [],
|
| 94 |
"page_count": 0,
|
| 95 |
"pages": [],
|
| 96 |
"jobsheet_sections": [],
|
|
|
|
| 97 |
}
|
| 98 |
self._save_session(session)
|
| 99 |
return session
|
|
@@ -106,11 +173,13 @@ class SessionStore:
|
|
| 106 |
if not session_path.exists():
|
| 107 |
return None
|
| 108 |
try:
|
| 109 |
-
|
|
|
|
| 110 |
except Exception:
|
| 111 |
return None
|
| 112 |
|
| 113 |
def update_session(self, session: dict) -> None:
|
|
|
|
| 114 |
session["updated_at"] = _now_iso()
|
| 115 |
self._save_session(session)
|
| 116 |
|
|
@@ -143,12 +212,19 @@ class SessionStore:
|
|
| 143 |
def set_pages(self, session: dict, pages: List[dict]) -> dict:
|
| 144 |
if not pages:
|
| 145 |
pages = [{"items": []}]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
# Legacy compatibility: store as a single section.
|
| 147 |
session["jobsheet_sections"] = [
|
| 148 |
-
{"id": uuid4().hex, "title": "Section 1", "pages":
|
| 149 |
]
|
| 150 |
session["pages"] = []
|
| 151 |
-
session["page_count"] = len(
|
| 152 |
self.update_session(session)
|
| 153 |
return session
|
| 154 |
|
|
@@ -179,11 +255,18 @@ class SessionStore:
|
|
| 179 |
else:
|
| 180 |
normalized_pages.append(page)
|
| 181 |
pages = normalized_pages
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
normalized.append(
|
| 183 |
{
|
| 184 |
"id": section.get("id") or uuid4().hex,
|
| 185 |
"title": section.get("title") or "Section",
|
| 186 |
-
"pages":
|
| 187 |
}
|
| 188 |
)
|
| 189 |
if not normalized:
|
|
@@ -194,14 +277,50 @@ class SessionStore:
|
|
| 194 |
self.update_session(session)
|
| 195 |
return session
|
| 196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
def ensure_sections(self, session: dict) -> List[dict]:
|
| 198 |
sections = session.get("jobsheet_sections") or []
|
| 199 |
if sections:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
session["page_count"] = sum(
|
| 201 |
-
len(section.get("pages") or []) for section in
|
| 202 |
)
|
| 203 |
self.update_session(session)
|
| 204 |
-
return
|
| 205 |
|
| 206 |
pages = session.get("pages") or []
|
| 207 |
if not pages:
|
|
@@ -217,6 +336,76 @@ class SessionStore:
|
|
| 217 |
self.update_session(session)
|
| 218 |
return sections
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
def save_upload(self, session_id: str, upload: UploadFile) -> StoredFile:
|
| 221 |
filename = _safe_name(upload.filename or "upload")
|
| 222 |
ext = Path(filename).suffix
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import copy
|
| 4 |
import json
|
| 5 |
import re
|
| 6 |
from dataclasses import dataclass
|
|
|
|
| 61 |
return normalized
|
| 62 |
|
| 63 |
|
| 64 |
+
def _merge_text(primary: str, secondary: str) -> str:
|
| 65 |
+
primary = (primary or "").strip()
|
| 66 |
+
secondary = (secondary or "").strip()
|
| 67 |
+
if not secondary:
|
| 68 |
+
return primary
|
| 69 |
+
if not primary:
|
| 70 |
+
return secondary
|
| 71 |
+
if secondary in primary:
|
| 72 |
+
return primary
|
| 73 |
+
return f"{primary} - {secondary}"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _normalize_template_fields(template: Optional[dict]) -> dict:
|
| 77 |
+
if not isinstance(template, dict):
|
| 78 |
+
return {}
|
| 79 |
+
normalized = dict(template)
|
| 80 |
+
|
| 81 |
+
item_description = _merge_text(
|
| 82 |
+
normalized.get("item_description", ""),
|
| 83 |
+
normalized.pop("condition_description", ""),
|
| 84 |
+
)
|
| 85 |
+
if item_description:
|
| 86 |
+
normalized["item_description"] = item_description
|
| 87 |
+
else:
|
| 88 |
+
normalized.pop("item_description", None)
|
| 89 |
+
|
| 90 |
+
action_type = normalized.pop("action_type", "")
|
| 91 |
+
required_action = _merge_text(action_type, normalized.get("required_action", ""))
|
| 92 |
+
if required_action:
|
| 93 |
+
normalized["required_action"] = required_action
|
| 94 |
+
else:
|
| 95 |
+
normalized.pop("required_action", None)
|
| 96 |
+
|
| 97 |
+
figure_caption = _merge_text(
|
| 98 |
+
normalized.get("figure_caption", ""),
|
| 99 |
+
normalized.pop("figure_description", ""),
|
| 100 |
+
)
|
| 101 |
+
if figure_caption:
|
| 102 |
+
normalized["figure_caption"] = figure_caption
|
| 103 |
+
else:
|
| 104 |
+
normalized.pop("figure_caption", None)
|
| 105 |
+
|
| 106 |
+
for legacy_key in ("accompanied_by", "project", "client_site"):
|
| 107 |
+
normalized.pop(legacy_key, None)
|
| 108 |
+
|
| 109 |
+
return normalized
|
| 110 |
+
|
| 111 |
+
|
| 112 |
class SessionStore:
|
| 113 |
def __init__(self, base_dir: Optional[Path] = None) -> None:
|
| 114 |
settings = get_settings()
|
|
|
|
| 117 |
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
| 118 |
self.max_upload_bytes = settings.max_upload_mb * 1024 * 1024
|
| 119 |
self._lock = Lock()
|
| 120 |
+
self._migrate_storage()
|
| 121 |
+
|
| 122 |
+
def _migrate_storage(self) -> None:
|
| 123 |
+
for session_file in self.sessions_dir.glob("*/session.json"):
|
| 124 |
+
try:
|
| 125 |
+
raw = json.loads(session_file.read_text(encoding="utf-8"))
|
| 126 |
+
except Exception:
|
| 127 |
+
continue
|
| 128 |
+
normalized = self._normalize_session(copy.deepcopy(raw))
|
| 129 |
+
if normalized != raw:
|
| 130 |
+
try:
|
| 131 |
+
session_file.write_text(
|
| 132 |
+
json.dumps(normalized, indent=2), encoding="utf-8"
|
| 133 |
+
)
|
| 134 |
+
except Exception:
|
| 135 |
+
continue
|
| 136 |
|
| 137 |
def list_sessions(self) -> List[dict]:
|
| 138 |
sessions: List[dict] = []
|
| 139 |
for session_file in sorted(self.sessions_dir.glob("*/session.json"), reverse=True):
|
| 140 |
try:
|
| 141 |
+
session = json.loads(session_file.read_text(encoding="utf-8"))
|
| 142 |
+
session = self._normalize_session(session)
|
| 143 |
+
sessions.append(session)
|
| 144 |
except Exception:
|
| 145 |
continue
|
| 146 |
return sessions
|
| 147 |
|
| 148 |
+
def create_session(self, document_no: str, inspection_date: str) -> dict:
|
| 149 |
session_id = uuid4().hex
|
| 150 |
now = _now_iso()
|
| 151 |
session = {
|
|
|
|
| 153 |
"status": "ready",
|
| 154 |
"created_at": now,
|
| 155 |
"updated_at": now,
|
| 156 |
+
"document_no": document_no,
|
| 157 |
"inspection_date": inspection_date,
|
|
|
|
| 158 |
"uploads": {"photos": [], "documents": [], "data_files": []},
|
| 159 |
"selected_photo_ids": [],
|
| 160 |
"page_count": 0,
|
| 161 |
"pages": [],
|
| 162 |
"jobsheet_sections": [],
|
| 163 |
+
"headings": [],
|
| 164 |
}
|
| 165 |
self._save_session(session)
|
| 166 |
return session
|
|
|
|
| 173 |
if not session_path.exists():
|
| 174 |
return None
|
| 175 |
try:
|
| 176 |
+
session = json.loads(session_path.read_text(encoding="utf-8"))
|
| 177 |
+
return self._normalize_session(session)
|
| 178 |
except Exception:
|
| 179 |
return None
|
| 180 |
|
| 181 |
def update_session(self, session: dict) -> None:
|
| 182 |
+
session = self._normalize_session(session)
|
| 183 |
session["updated_at"] = _now_iso()
|
| 184 |
self._save_session(session)
|
| 185 |
|
|
|
|
| 212 |
def set_pages(self, session: dict, pages: List[dict]) -> dict:
|
| 213 |
if not pages:
|
| 214 |
pages = [{"items": []}]
|
| 215 |
+
normalized_pages = []
|
| 216 |
+
for page in pages:
|
| 217 |
+
if not isinstance(page, dict):
|
| 218 |
+
normalized_pages.append({"items": []})
|
| 219 |
+
continue
|
| 220 |
+
template = _normalize_template_fields(page.get("template"))
|
| 221 |
+
normalized_pages.append({**page, "template": template})
|
| 222 |
# Legacy compatibility: store as a single section.
|
| 223 |
session["jobsheet_sections"] = [
|
| 224 |
+
{"id": uuid4().hex, "title": "Section 1", "pages": normalized_pages}
|
| 225 |
]
|
| 226 |
session["pages"] = []
|
| 227 |
+
session["page_count"] = len(normalized_pages)
|
| 228 |
self.update_session(session)
|
| 229 |
return session
|
| 230 |
|
|
|
|
| 255 |
else:
|
| 256 |
normalized_pages.append(page)
|
| 257 |
pages = normalized_pages
|
| 258 |
+
normalized_pages = []
|
| 259 |
+
for page in pages:
|
| 260 |
+
if not isinstance(page, dict):
|
| 261 |
+
normalized_pages.append({"items": []})
|
| 262 |
+
continue
|
| 263 |
+
template = _normalize_template_fields(page.get("template"))
|
| 264 |
+
normalized_pages.append({**page, "template": template})
|
| 265 |
normalized.append(
|
| 266 |
{
|
| 267 |
"id": section.get("id") or uuid4().hex,
|
| 268 |
"title": section.get("title") or "Section",
|
| 269 |
+
"pages": normalized_pages if normalized_pages else [{"items": []}],
|
| 270 |
}
|
| 271 |
)
|
| 272 |
if not normalized:
|
|
|
|
| 277 |
self.update_session(session)
|
| 278 |
return session
|
| 279 |
|
| 280 |
+
def set_headings(self, session: dict, headings: List[dict]) -> dict:
|
| 281 |
+
normalized: List[dict] = []
|
| 282 |
+
for heading in headings or []:
|
| 283 |
+
if hasattr(heading, "model_dump"):
|
| 284 |
+
heading = heading.model_dump()
|
| 285 |
+
elif hasattr(heading, "dict"):
|
| 286 |
+
heading = heading.dict()
|
| 287 |
+
if not isinstance(heading, dict):
|
| 288 |
+
continue
|
| 289 |
+
number = str(heading.get("number") or "").strip()
|
| 290 |
+
name = str(heading.get("name") or "").strip()
|
| 291 |
+
normalized.append({"number": number, "name": name})
|
| 292 |
+
session["headings"] = normalized
|
| 293 |
+
self.update_session(session)
|
| 294 |
+
return session
|
| 295 |
+
|
| 296 |
def ensure_sections(self, session: dict) -> List[dict]:
|
| 297 |
sections = session.get("jobsheet_sections") or []
|
| 298 |
if sections:
|
| 299 |
+
normalized_sections: List[dict] = []
|
| 300 |
+
for section in sections:
|
| 301 |
+
if not isinstance(section, dict):
|
| 302 |
+
continue
|
| 303 |
+
pages = section.get("pages") or []
|
| 304 |
+
normalized_pages = []
|
| 305 |
+
for page in pages:
|
| 306 |
+
if not isinstance(page, dict):
|
| 307 |
+
normalized_pages.append({"items": []})
|
| 308 |
+
continue
|
| 309 |
+
template = _normalize_template_fields(page.get("template"))
|
| 310 |
+
normalized_pages.append({**page, "template": template})
|
| 311 |
+
normalized_sections.append(
|
| 312 |
+
{
|
| 313 |
+
"id": section.get("id") or uuid4().hex,
|
| 314 |
+
"title": section.get("title") or "Section",
|
| 315 |
+
"pages": normalized_pages if normalized_pages else [{"items": []}],
|
| 316 |
+
}
|
| 317 |
+
)
|
| 318 |
+
session["jobsheet_sections"] = normalized_sections
|
| 319 |
session["page_count"] = sum(
|
| 320 |
+
len(section.get("pages") or []) for section in normalized_sections
|
| 321 |
)
|
| 322 |
self.update_session(session)
|
| 323 |
+
return normalized_sections
|
| 324 |
|
| 325 |
pages = session.get("pages") or []
|
| 326 |
if not pages:
|
|
|
|
| 336 |
self.update_session(session)
|
| 337 |
return sections
|
| 338 |
|
| 339 |
+
def _normalize_session(self, session: dict) -> dict:
|
| 340 |
+
if not isinstance(session, dict):
|
| 341 |
+
return session
|
| 342 |
+
document_no = _merge_text(
|
| 343 |
+
session.get("document_no", ""),
|
| 344 |
+
session.get("project_name", ""),
|
| 345 |
+
)
|
| 346 |
+
if document_no:
|
| 347 |
+
session["document_no"] = document_no
|
| 348 |
+
session.pop("project_name", None)
|
| 349 |
+
session.pop("notes", None)
|
| 350 |
+
|
| 351 |
+
headings = session.get("headings")
|
| 352 |
+
if isinstance(headings, dict):
|
| 353 |
+
session["headings"] = [
|
| 354 |
+
{"number": str(key).strip(), "name": str(value).strip()}
|
| 355 |
+
for key, value in headings.items()
|
| 356 |
+
]
|
| 357 |
+
elif isinstance(headings, list):
|
| 358 |
+
normalized_headings = []
|
| 359 |
+
for heading in headings:
|
| 360 |
+
if hasattr(heading, "model_dump"):
|
| 361 |
+
heading = heading.model_dump()
|
| 362 |
+
elif hasattr(heading, "dict"):
|
| 363 |
+
heading = heading.dict()
|
| 364 |
+
if not isinstance(heading, dict):
|
| 365 |
+
continue
|
| 366 |
+
number = str(heading.get("number") or "").strip()
|
| 367 |
+
name = str(heading.get("name") or "").strip()
|
| 368 |
+
normalized_headings.append({"number": number, "name": name})
|
| 369 |
+
session["headings"] = normalized_headings
|
| 370 |
+
else:
|
| 371 |
+
session["headings"] = []
|
| 372 |
+
|
| 373 |
+
pages = session.get("pages") or []
|
| 374 |
+
if pages:
|
| 375 |
+
normalized_pages = []
|
| 376 |
+
for page in pages:
|
| 377 |
+
if not isinstance(page, dict):
|
| 378 |
+
normalized_pages.append({"items": []})
|
| 379 |
+
continue
|
| 380 |
+
template = _normalize_template_fields(page.get("template"))
|
| 381 |
+
normalized_pages.append({**page, "template": template})
|
| 382 |
+
session["pages"] = normalized_pages
|
| 383 |
+
|
| 384 |
+
sections = session.get("jobsheet_sections") or []
|
| 385 |
+
if sections:
|
| 386 |
+
normalized_sections = []
|
| 387 |
+
for section in sections:
|
| 388 |
+
if not isinstance(section, dict):
|
| 389 |
+
continue
|
| 390 |
+
pages = section.get("pages") or []
|
| 391 |
+
normalized_pages = []
|
| 392 |
+
for page in pages:
|
| 393 |
+
if not isinstance(page, dict):
|
| 394 |
+
normalized_pages.append({"items": []})
|
| 395 |
+
continue
|
| 396 |
+
template = _normalize_template_fields(page.get("template"))
|
| 397 |
+
normalized_pages.append({**page, "template": template})
|
| 398 |
+
normalized_sections.append(
|
| 399 |
+
{
|
| 400 |
+
"id": section.get("id") or uuid4().hex,
|
| 401 |
+
"title": section.get("title") or "Section",
|
| 402 |
+
"pages": normalized_pages if normalized_pages else [{"items": []}],
|
| 403 |
+
}
|
| 404 |
+
)
|
| 405 |
+
session["jobsheet_sections"] = normalized_sections
|
| 406 |
+
|
| 407 |
+
return session
|
| 408 |
+
|
| 409 |
def save_upload(self, session_id: str, upload: UploadFile) -> StoredFile:
|
| 410 |
filename = _safe_name(upload.filename or "upload")
|
| 411 |
ext = Path(filename).suffix
|