Prosento_RepEx / frontend /src /pages /InputDataPage.tsx
ChristopherJKoen's picture
V0.1.5
74b1b27
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { ArrowLeft, Download, Edit3, Grid, Layout, Table, Image } from "react-feather";
import { API_BASE, postForm, putJson, request } from "../lib/api";
import { formatDocNumber } from "../lib/report";
import {
ensureSections,
flattenSections,
insertPage,
removePage,
replacePage,
} from "../lib/sections";
import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
import type { Heading, JobsheetSection, Session, TemplateFields } from "../types/session";
import { PageFooter } from "../components/PageFooter";
import { PageHeader } from "../components/PageHeader";
import { PageShell } from "../components/PageShell";
import { InfoMenu } from "../components/InfoMenu";
import { LoadingBar } from "../components/LoadingBar";
import { SaveBeforeLeaveDialog } from "../components/SaveBeforeLeaveDialog";
type FieldDef = {
key: keyof TemplateFields;
label: string;
multiline?: boolean;
};
const normalizeHeadings = (raw?: Heading[] | Record<string, string>): Heading[] => {
if (Array.isArray(raw)) {
return raw
.filter(Boolean)
.map((heading) => ({
number: String(heading.number ?? "").trim(),
name: String(heading.name ?? "").trim(),
}));
}
if (raw && typeof raw === "object") {
return Object.entries(raw).map(([number, name]) => ({
number: String(number).trim(),
name: String(name ?? "").trim(),
}));
}
return [];
};
const GENERAL_FIELDS: FieldDef[] = [
{ key: "inspection_date", label: "Inspection Date" },
{ key: "inspector", label: "Inspector" },
{ key: "document_no", label: "Document No" },
{ key: "company_logo", label: "Company Logo" },
];
const ITEM_FIELDS: FieldDef[] = [
{ key: "area", label: "Area" },
{ key: "reference", label: "Reference" },
{ key: "functional_location", label: "Location" },
{ key: "item_description", label: "Item Description", multiline: true },
{ key: "category", label: "Category" },
{ key: "priority", label: "Priority" },
{ key: "required_action", label: "Required Action", multiline: true },
{ key: "figure_caption", label: "Figure Caption" },
];
export default function InputDataPage() {
const [searchParams] = useSearchParams();
const sessionId = getSessionId(searchParams.toString());
const sessionQuery = buildSessionQuery(sessionId);
const navigate = useNavigate();
const [session, setSession] = useState<Session | null>(null);
const [sections, setSections] = useState<JobsheetSection[]>([]);
const [status, setStatus] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [saveState, setSaveState] = useState<
"saved" | "saving" | "pending" | "error"
>("saved");
const [isUploading, setIsUploading] = useState(false);
const [copySourceIndex, setCopySourceIndex] = useState(0);
const [copyTargets, setCopyTargets] = useState("");
const [addSectionId, setAddSectionId] = useState<string>("");
const [sectionsCollapsed, setSectionsCollapsed] = useState(true);
const [headings, setHeadings] = useState<Heading[]>([]);
const [showGeneralColumns, setShowGeneralColumns] = useState(false);
const [generalDirty, setGeneralDirty] = useState(false);
const [generalTemplate, setGeneralTemplate] = useState<TemplateFields>({});
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [pendingNavigationTarget, setPendingNavigationTarget] = useState<
string | null
>(null);
const [photoSelections, setPhotoSelections] = useState<Record<number, string>>(
{},
);
const saveTimerRef = useRef<number | null>(null);
const generalApplyTimerRef = useRef<number | null>(null);
const headingsSaveTimerRef = useRef<number | null>(null);
const lastSavedHeadingsRef = useRef<string>(JSON.stringify([]));
const headingsLoadedRef = useRef(false);
const lastSavedRef = useRef<string>("");
const pendingSaveRef = useRef<string>("");
const savePromiseRef = useRef<Promise<void> | null>(null);
const excelInputRef = useRef<HTMLInputElement | null>(null);
const jsonInputRef = useRef<HTMLInputElement | null>(null);
const uploadInputRefs = useRef<Record<number, HTMLInputElement | null>>({});
const uploadTimerRef = useRef<number | null>(null);
function clearUploadTimer() {
if (uploadTimerRef.current !== null) {
window.clearInterval(uploadTimerRef.current);
uploadTimerRef.current = null;
}
}
function startUploadProgress() {
clearUploadTimer();
setUploadProgress(6);
uploadTimerRef.current = window.setInterval(() => {
setUploadProgress((prev) => {
const value = typeof prev === "number" ? prev : 0;
if (value >= 92) return value;
return Math.min(92, value + Math.max(1, (92 - value) * 0.08));
});
}, 350);
}
useEffect(() => {
if (!sessionId) {
setStatus("No active session found. Return to upload to continue.");
return;
}
setStoredSessionId(sessionId);
async function load() {
try {
const data = await request<Session>(`/sessions/${sessionId}`);
setSession(data);
const sectionResp = await request<{ sections: JobsheetSection[] }>(
`/sessions/${sessionId}/sections`,
);
const normalized = ensureSections(sectionResp.sections);
setSections(normalized);
const initialHeadings = normalizeHeadings(data.headings);
setHeadings(initialHeadings);
headingsLoadedRef.current = true;
lastSavedHeadingsRef.current = JSON.stringify(initialHeadings);
lastSavedRef.current = JSON.stringify(normalized);
pendingSaveRef.current = lastSavedRef.current;
setSaveState("saved");
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to load session.";
setStatus(message);
}
}
load();
}, [sessionId]);
useEffect(
() => () => {
clearUploadTimer();
},
[],
);
async function refreshSession() {
if (!sessionId) return;
const data = await request<Session>(`/sessions/${sessionId}`);
setSession(data);
const sectionResp = await request<{ sections: JobsheetSection[] }>(
`/sessions/${sessionId}/sections`,
);
const normalized = ensureSections(sectionResp.sections);
setSections(normalized);
const nextHeadings = normalizeHeadings(data.headings);
setHeadings(nextHeadings);
headingsLoadedRef.current = true;
lastSavedHeadingsRef.current = JSON.stringify(nextHeadings);
lastSavedRef.current = JSON.stringify(normalized);
pendingSaveRef.current = lastSavedRef.current;
setSaveState("saved");
}
const flatPages = useMemo(
() => flattenSections(ensureSections(sections)),
[sections],
);
const totalPages = useMemo(() => {
if (flatPages.length > 0) return flatPages.length;
return Math.max(1, session?.page_count ?? 0);
}, [flatPages.length, session?.page_count]);
useEffect(() => {
if (copySourceIndex >= flatPages.length) {
setCopySourceIndex(Math.max(0, flatPages.length - 1));
}
}, [copySourceIndex, flatPages.length]);
useEffect(() => {
if (!addSectionId && sections.length > 0) {
setAddSectionId(sections[0].id);
}
}, [addSectionId, sections]);
useEffect(() => {
if (generalDirty) return;
const source = flatPages[0]?.page?.template ?? {};
const next: TemplateFields = {};
GENERAL_FIELDS.forEach((field) => {
const value = source[field.key] ?? getFallbackValue(field.key);
if (value !== undefined) {
next[field.key] = value;
}
});
setGeneralTemplate(next);
}, [generalDirty, flatPages, session]);
function updateField(
entryIndex: number,
key: keyof TemplateFields,
value: string,
) {
const entry = flatPages[entryIndex];
if (!entry) return;
setSections((prev) => {
const template = { ...(entry.page.template ?? {}) };
template[key] = value;
const nextPage = { ...entry.page, template };
return replacePage(prev, entry.sectionIndex, entry.pageIndex, nextPage);
});
}
function updateGeneralField(key: keyof TemplateFields, value: string) {
setGeneralTemplate((prev) => ({ ...prev, [key]: value }));
setGeneralDirty(true);
}
function updateHeadingField(index: number, key: keyof Heading, value: string) {
setHeadings((prev) =>
prev.map((heading, idx) =>
idx === index ? { ...heading, [key]: value } : heading,
),
);
}
function addHeadingRow() {
setHeadings((prev) => [...prev, { number: "", name: "" }]);
}
function removeHeadingRow(index: number) {
setHeadings((prev) => prev.filter((_, idx) => idx !== index));
}
function applyGeneralToSections(
source: JobsheetSection[],
template: TemplateFields,
): JobsheetSection[] {
return source.map((section) => ({
...section,
pages: (section.pages ?? []).map((page) => {
const nextTemplate = { ...(page.template ?? {}) };
GENERAL_FIELDS.forEach((field) => {
const value = template[field.key];
if (value !== undefined) {
nextTemplate[field.key] = value;
}
});
return { ...page, template: nextTemplate };
}),
}));
}
function applyRowToAll(pageIndex: number) {
const entry = flatPages[pageIndex];
if (!entry) return;
const source = entry.page.template ?? {};
setSections((prev) =>
prev.map((section) => ({
...section,
pages: (section.pages ?? []).map((page) => ({
...page,
template: { ...source },
})),
})),
);
}
function applyGeneralToAll(silent = false) {
if (!flatPages.length) return;
setSections((prev) => applyGeneralToSections(prev, generalTemplate));
setGeneralDirty(false);
if (!silent) {
setStatus("Applied general info to all pages.");
}
}
function insertPageAt(index: number, templateSource?: TemplateFields) {
const atEnd = index >= flatPages.length;
const entry = flatPages[index] ?? flatPages[flatPages.length - 1];
if (!entry) return;
setSections((prev) => {
const fallbackTemplate =
templateSource ??
entry.page.template ??
{};
const pageIndex = atEnd ? entry.pageIndex + 1 : entry.pageIndex;
return insertPage(prev, entry.sectionIndex, pageIndex, {
items: [],
template: { ...fallbackTemplate },
});
});
}
function addSection() {
const baseTemplate = { ...generalTemplate };
setSections((prev) => [
...prev,
{
id: crypto.randomUUID(),
title: `Section ${prev.length + 1}`,
pages: [{ items: [], template: baseTemplate }],
},
]);
}
function updateSectionTitle(sectionId: string, value: string) {
setSections((prev) =>
prev.map((section) =>
section.id === sectionId ? { ...section, title: value } : section,
),
);
}
function insertPageIntoSection(sectionId: string, templateSource?: TemplateFields) {
const sectionIndex = sections.findIndex((section) => section.id === sectionId);
if (sectionIndex === -1) return;
const section = sections[sectionIndex];
const fallbackTemplate =
templateSource ??
section.pages?.[section.pages.length - 1]?.template ??
generalTemplate ??
{};
setSections((prev) =>
insertPage(prev, sectionIndex, section.pages?.length ?? 0, {
items: [],
template: { ...fallbackTemplate },
}),
);
}
function removePageAt(index: number) {
const entry = flatPages[index];
if (!entry) return;
setSections((prev) =>
removePage(prev, entry.sectionIndex, entry.pageIndex),
);
}
function updatePhotoSelection(pageIndex: number, value: string) {
setPhotoSelections((prev) => ({ ...prev, [pageIndex]: value }));
}
function updatePagePhotos(pageIndex: number, nextIds: string[]) {
const entry = flatPages[pageIndex];
if (!entry) return;
setSections((prev) => {
const nextPage = { ...entry.page, photo_ids: nextIds };
return replacePage(prev, entry.sectionIndex, entry.pageIndex, nextPage);
});
}
function setPhotoOrderLocked(pageIndex: number, locked: boolean) {
const entry = flatPages[pageIndex];
if (!entry) return;
setSections((prev) => {
const nextPage = { ...entry.page, photo_order_locked: locked };
return replacePage(prev, entry.sectionIndex, entry.pageIndex, nextPage);
});
}
function movePhoto(pageIndex: number, from: number, to: number) {
const entry = flatPages[pageIndex];
if (!entry) return;
setSections((prev) => {
const ids = [...(entry.page.photo_ids ?? [])];
if (from < 0 || from >= ids.length || to < 0 || to >= ids.length) {
return prev;
}
const [moved] = ids.splice(from, 1);
ids.splice(to, 0, moved);
const nextPage = { ...entry.page, photo_ids: ids };
return replacePage(prev, entry.sectionIndex, entry.pageIndex, nextPage);
});
}
function removePhoto(pageIndex: number, index: number) {
const entry = flatPages[pageIndex];
if (!entry) return;
setSections((prev) => {
const ids = [...(entry.page.photo_ids ?? [])];
ids.splice(index, 1);
const nextPage = { ...entry.page, photo_ids: ids };
return replacePage(prev, entry.sectionIndex, entry.pageIndex, nextPage);
});
}
function addPhotoToPage(pageIndex: number, photoId: string) {
if (!photoId) return;
const entry = flatPages[pageIndex];
if (!entry) return;
setSections((prev) => {
const ids = [...(entry.page.photo_ids ?? [])];
if (!ids.includes(photoId)) ids.push(photoId);
const nextPage = { ...entry.page, photo_ids: ids };
return replacePage(prev, entry.sectionIndex, entry.pageIndex, nextPage);
});
}
function parseTargetPages(value: string, max: number): number[] {
const results = new Set<number>();
const parts = value
.split(",")
.map((part) => part.trim())
.filter(Boolean);
parts.forEach((part) => {
if (part.includes("-")) {
const [startRaw, endRaw] = part.split("-").map((chunk) => chunk.trim());
const start = Number.parseInt(startRaw, 10);
const end = Number.parseInt(endRaw, 10);
if (Number.isNaN(start) || Number.isNaN(end)) return;
const min = Math.min(start, end);
const maxRange = Math.max(start, end);
for (let idx = min; idx <= maxRange; idx += 1) {
if (idx >= 1 && idx <= max) results.add(idx - 1);
}
} else {
const pageNum = Number.parseInt(part, 10);
if (!Number.isNaN(pageNum) && pageNum >= 1 && pageNum <= max) {
results.add(pageNum - 1);
}
}
});
return Array.from(results).sort((a, b) => a - b);
}
function copyPageToTargets() {
if (!flatPages.length) return;
const targets = parseTargetPages(copyTargets, flatPages.length).filter(
(idx) => idx !== copySourceIndex,
);
if (!targets.length) {
setStatus("No valid target pages selected for copy.");
return;
}
const sourceTemplate = flatPages[copySourceIndex]?.page?.template ?? {};
setSections((prev) =>
prev.map((section, sIdx) => ({
...section,
pages: (section.pages ?? []).map((page, pIdx) => {
const flatIndex =
flattenSections(prev).find(
(entry) =>
entry.sectionIndex === sIdx && entry.pageIndex === pIdx,
)?.flatIndex ?? -1;
if (!targets.includes(flatIndex)) return page;
return { ...page, template: { ...sourceTemplate } };
}),
})),
);
setStatus(
`Copied page ${copySourceIndex + 1} to ${targets
.map((idx) => idx + 1)
.join(", ")}.`,
);
}
function getFallbackValue(field: keyof TemplateFields): string {
if (!session) return "";
switch (field) {
case "inspection_date":
return session.inspection_date || "";
case "document_no":
return session.document_no || formatDocNumber(session);
default:
return "";
}
}
async function saveAll(
silent = false,
overrideSections?: JobsheetSection[],
) {
if (!sessionId) return;
if (savePromiseRef.current) {
await savePromiseRef.current;
}
const toSave = overrideSections ?? sections;
const snapshot = JSON.stringify(toSave);
if (!overrideSections && snapshot === lastSavedRef.current) {
if (!isSaving) setSaveState("saved");
return;
}
const promise = (async () => {
setIsSaving(true);
setSaveState("saving");
if (!silent) {
setStatus("Saving input data...");
}
try {
const resp = await putJson<{ sections: JobsheetSection[] }>(
`/sessions/${sessionId}/sections`,
{ sections: toSave },
);
const updated = ensureSections(resp.sections ?? toSave);
setSections(updated);
lastSavedRef.current = JSON.stringify(updated);
pendingSaveRef.current = lastSavedRef.current;
setSaveState("saved");
if (!silent) {
setStatus("Input data saved.");
} else {
setStatus("All changes saved.");
}
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to save input data.";
setStatus(message);
setSaveState("error");
} finally {
setIsSaving(false);
if (
pendingSaveRef.current &&
pendingSaveRef.current !== lastSavedRef.current
) {
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = window.setTimeout(() => {
void triggerAutoSave();
}, 200);
}
}
})();
savePromiseRef.current = promise;
try {
await promise;
} finally {
savePromiseRef.current = null;
}
}
async function triggerAutoSave() {
if (!sessionId || savePromiseRef.current) return;
const snapshot = pendingSaveRef.current;
if (!snapshot || snapshot === lastSavedRef.current) return;
await saveAll(true);
}
useEffect(() => {
if (!sessionId) return;
const snapshot = JSON.stringify(sections);
if (snapshot === lastSavedRef.current) {
if (!generalDirty && !isSaving) {
setSaveState("saved");
}
return;
}
pendingSaveRef.current = snapshot;
if (!isSaving) {
setSaveState("pending");
}
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = window.setTimeout(() => {
void triggerAutoSave();
}, 800);
}, [sections, sessionId, generalDirty, isSaving]);
useEffect(() => {
if (!generalDirty) return;
if (!isSaving) {
setSaveState("pending");
}
if (generalApplyTimerRef.current) {
window.clearTimeout(generalApplyTimerRef.current);
}
generalApplyTimerRef.current = window.setTimeout(() => {
applyGeneralToAll(true);
}, 800);
}, [generalDirty, generalTemplate, isSaving]);
useEffect(() => {
if (!sessionId) return;
if (!headingsLoadedRef.current) return;
const snapshot = JSON.stringify(headings);
if (snapshot === lastSavedHeadingsRef.current) return;
if (headingsSaveTimerRef.current) {
window.clearTimeout(headingsSaveTimerRef.current);
}
headingsSaveTimerRef.current = window.setTimeout(() => {
void saveHeadings(true);
}, 800);
}, [headings, sessionId]);
async function saveHeadings(silent = false) {
if (!sessionId) return;
const snapshot = JSON.stringify(headings);
if (snapshot === lastSavedHeadingsRef.current) return;
try {
if (!silent) {
setStatus("Saving headings...");
}
const resp = await putJson<{ headings: Heading[] }>(
`/sessions/${sessionId}/headings`,
{ headings },
);
const normalized = resp.headings ?? headings;
setHeadings(normalized);
lastSavedHeadingsRef.current = JSON.stringify(normalized);
if (!silent) {
setStatus("Headings saved.");
}
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to save headings.";
setStatus(message);
}
}
async function uploadDataFile(file: File) {
if (!sessionId) return;
setIsUploading(true);
setStatus("Uploading data file...");
startUploadProgress();
try {
const form = new FormData();
form.append("file", file);
await postForm<Session>(`/sessions/${sessionId}/data-files`, form);
await refreshSession();
setStatus("Data file imported.");
setGeneralDirty(false);
clearUploadTimer();
setUploadProgress(100);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to import data file.";
setStatus(message);
clearUploadTimer();
setUploadProgress(null);
} finally {
setIsUploading(false);
window.setTimeout(() => {
setUploadProgress(null);
}, 500);
}
}
async function uploadJsonFile(file: File) {
if (!sessionId) return;
setIsUploading(true);
setStatus("Uploading JSON package...");
startUploadProgress();
try {
const form = new FormData();
form.append("file", file);
await postForm<Session>(`/sessions/${sessionId}/import-json`, form);
await refreshSession();
setStatus("JSON package imported.");
setGeneralDirty(false);
clearUploadTimer();
setUploadProgress(100);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to import JSON package.";
setStatus(message);
clearUploadTimer();
setUploadProgress(null);
} finally {
setIsUploading(false);
window.setTimeout(() => {
setUploadProgress(null);
}, 500);
}
}
async function uploadPhotoForPage(pageIndex: number, file: File) {
if (!sessionId) return;
setIsUploading(true);
setStatus(`Uploading image ${file.name}...`);
startUploadProgress();
const existing = new Set(
(session?.uploads?.photos ?? []).map((photo) => photo.id),
);
try {
const form = new FormData();
form.append("file", file);
const updated = await postForm<Session>(
`/sessions/${sessionId}/uploads`,
form,
);
setSession(updated);
const newPhoto =
(updated.uploads?.photos ?? []).find((photo) => !existing.has(photo.id)) ??
(updated.uploads?.photos ?? []).find((photo) => photo.name === file.name);
if (newPhoto) {
addPhotoToPage(pageIndex, newPhoto.id);
setStatus(`Uploaded ${newPhoto.name}.`);
} else {
setStatus("Uploaded image. Refresh to see new file.");
}
clearUploadTimer();
setUploadProgress(100);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to upload image.";
setStatus(message);
clearUploadTimer();
setUploadProgress(null);
} finally {
setIsUploading(false);
window.setTimeout(() => {
setUploadProgress(null);
}, 500);
}
}
async function saveAndNavigate(target: string) {
if (!sessionId) {
navigate(target);
return;
}
if (generalApplyTimerRef.current) {
window.clearTimeout(generalApplyTimerRef.current);
generalApplyTimerRef.current = null;
}
let overrideSections: JobsheetSection[] | undefined;
if (generalDirty) {
overrideSections = applyGeneralToSections(sections, generalTemplate);
setSections(overrideSections);
setGeneralDirty(false);
}
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
const snapshot = JSON.stringify(overrideSections ?? sections);
pendingSaveRef.current = snapshot;
if (snapshot !== lastSavedRef.current || savePromiseRef.current) {
await saveAll(true, overrideSections);
}
navigate(target);
}
function requestNavigation(target: string) {
if (saveState === "saving" || saveState === "pending" || isSaving) {
setPendingNavigationTarget(target);
return;
}
void saveAndNavigate(target);
}
async function waitForSaveAndContinue() {
if (!pendingNavigationTarget) return;
const target = pendingNavigationTarget;
setPendingNavigationTarget(null);
await saveAndNavigate(target);
}
function leaveWithoutWaiting() {
if (!pendingNavigationTarget) return;
const target = pendingNavigationTarget;
setPendingNavigationTarget(null);
navigate(target);
}
const saveIndicator = useMemo(() => {
const base =
"inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold";
if (saveState === "saving") {
return (
<div className={`${base} border-amber-200 bg-amber-50 text-amber-700`}>
<span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
Saving...
</div>
);
}
if (saveState === "pending") {
return (
<div className={`${base} border-amber-200 bg-amber-50 text-amber-700`}>
<span className="h-2 w-2 rounded-full bg-amber-500" />
Unsaved changes
</div>
);
}
if (saveState === "error") {
return (
<div className={`${base} border-red-200 bg-red-50 text-red-700`}>
<span className="h-2 w-2 rounded-full bg-red-500" />
Save failed
</div>
);
}
return (
<div className={`${base} border-emerald-200 bg-emerald-50 text-emerald-700`}>
<span className="h-2 w-2 rounded-full bg-emerald-500" />
All changes saved
</div>
);
}, [saveState]);
return (
<PageShell className="max-w-6xl">
<PageHeader
title="RepEx - Report Express"
subtitle="Input Data"
right={
<div className="flex flex-col items-end gap-2 sm:flex-row sm:items-center">
{saveIndicator}
<Link
to={`/report-viewer${sessionQuery}`}
onClick={(event) => {
event.preventDefault();
requestNavigation(`/report-viewer${sessionQuery}`);
}}
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"
>
<ArrowLeft className="h-4 w-4" />
Back
</Link>
<InfoMenu sessionQuery={sessionQuery} />
</div>
}
/>
<nav className="mb-6" aria-label="Report workflow navigation">
<div className="flex flex-wrap gap-2">
<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">
<Table className="h-4 w-4" />
Input Data
</span>
<Link
to={`/report-viewer${sessionQuery}`}
onClick={(event) => {
event.preventDefault();
requestNavigation(`/report-viewer${sessionQuery}`);
}}
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"
>
<Layout className="h-4 w-4" />
Report Viewer
</Link>
<Link
to={`/image-placement${sessionQuery}`}
onClick={(event) => {
event.preventDefault();
requestNavigation(`/image-placement${sessionQuery}`);
}}
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"
>
<Image className="h-4 w-4" />
Image Placement
</Link>
<Link
to={`/edit-report${sessionQuery}`}
onClick={(event) => {
event.preventDefault();
requestNavigation(`/edit-report${sessionQuery}`);
}}
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"
>
<Edit3 className="h-4 w-4" />
Edit Report
</Link>
<Link
to={`/edit-layouts${sessionQuery}`}
onClick={(event) => {
event.preventDefault();
requestNavigation(`/edit-layouts${sessionQuery}`);
}}
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"
>
<Grid className="h-4 w-4" />
Edit Page Layouts
</Link>
<Link
to={`/export${sessionQuery}`}
onClick={(event) => {
event.preventDefault();
requestNavigation(`/export${sessionQuery}`);
}}
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"
>
<Download className="h-4 w-4" />
Export
</Link>
</div>
</nav>
<section className="mb-4 rounded-lg border border-gray-200 bg-white p-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-gray-900">Job Sheet Data</h2>
<p className="text-sm text-gray-600">
Update job sheet fields per page. Use "Apply row to all" to copy a
page's fields across every job sheet.
</p>
</div>
<div className="flex flex-wrap gap-2">
<div className="flex items-center gap-2">
<select
className="rounded-md border border-gray-200 px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
value={addSectionId}
onChange={(event) => setAddSectionId(event.target.value)}
disabled={!sections.length}
>
{sections.map((section, idx) => (
<option key={section.id} value={section.id}>
{section.title || `Section ${idx + 1}`}
</option>
))}
</select>
<button
type="button"
onClick={() => insertPageIntoSection(addSectionId, generalTemplate)}
disabled={!sessionId || !addSectionId}
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"
>
Add blank page
</button>
</div>
<button
type="button"
onClick={addSection}
disabled={!sessionId}
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"
>
Add section
</button>
</div>
</div>
{status ? <p className="text-sm text-gray-600 mt-3">{status}</p> : null}
<LoadingBar
active={isSaving}
progress={null}
label="Saving changes"
className="mt-3"
/>
<LoadingBar
active={isUploading}
progress={uploadProgress}
label={status || "Uploading files"}
className="mt-3"
/>
</section>
<section className="mb-4 rounded-lg border border-gray-200 bg-white p-4">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-gray-900">Copy page to targets</h3>
<p className="text-sm text-gray-600">
Choose a source page and the pages to overwrite (e.g. 2,4-6).
</p>
</div>
<div className="flex flex-wrap items-end gap-2">
<label className="text-xs text-gray-600">
Source page
<select
className="mt-1 w-36 rounded-md border border-gray-200 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
value={copySourceIndex}
onChange={(event) => setCopySourceIndex(Number(event.target.value))}
>
{flatPages.map((_, idx) => (
<option key={`copy-source-${idx}`} value={idx}>
Page {idx + 1}
</option>
))}
</select>
</label>
<label className="text-xs text-gray-600">
Target pages
<input
type="text"
placeholder="e.g. 2,4-6"
className="mt-1 w-44 rounded-md border border-gray-200 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
value={copyTargets}
onChange={(event) => setCopyTargets(event.target.value)}
/>
</label>
<button
type="button"
onClick={copyPageToTargets}
disabled={!flatPages.length}
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Copy page data
</button>
</div>
</div>
</section>
<section className="mb-4 rounded-lg border border-gray-200 bg-white p-4">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-gray-900">
Import / Export data files
</h3>
<p className="text-sm text-gray-600">
Upload an Excel/CSV data file or a JSON package to populate job sheets.
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => excelInputRef.current?.click()}
disabled={!sessionId || isUploading}
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"
>
Upload Excel/CSV
</button>
<button
type="button"
onClick={() => jsonInputRef.current?.click()}
disabled={!sessionId || isUploading}
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"
>
Upload JSON
</button>
<a
href={
sessionId
? `${API_BASE}/sessions/${sessionId}/export.xlsx`
: "#"
}
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"
>
Download Excel
</a>
<a
href={
sessionId
? `${API_BASE}/sessions/${sessionId}/export`
: "#"
}
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"
>
Download JSON
</a>
</div>
</div>
<input
ref={excelInputRef}
type="file"
accept=".xlsx,.xls,.csv"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) uploadDataFile(file);
event.target.value = "";
}}
/>
<input
ref={jsonInputRef}
type="file"
accept=".json,application/json"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) uploadJsonFile(file);
event.target.value = "";
}}
/>
</section>
<section className="mb-4 rounded-lg border border-gray-200 bg-white p-4">
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr),320px] gap-4 items-start">
<div className="min-w-0">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-base font-semibold text-gray-900">
General Information
</h3>
<p className="text-sm text-gray-600">
Update the global inspection details once, then apply to all pages.
</p>
</div>
<div className="flex flex-wrap gap-2">
{generalDirty ? (
<button
type="button"
onClick={applyGeneralToAll}
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"
>
Apply general info to all pages
</button>
) : null}
</div>
</div>
<div className="mt-4 rounded-lg border border-gray-200 bg-white overflow-x-auto w-full max-w-full">
<table className="min-w-[560px] w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-600 min-w-[180px]">
Field
</th>
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-600">
Value
</th>
</tr>
</thead>
<tbody>
{GENERAL_FIELDS.map((field) => (
<tr key={`general-${field.key}`} className="border-b border-gray-100">
<td className="px-3 py-2 text-xs font-semibold text-gray-700">
{field.label}
</td>
<td className="px-3 py-2">
<input
type="text"
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"
value={generalTemplate[field.key] ?? getFallbackValue(field.key)}
onChange={(event) =>
updateGeneralField(field.key, event.target.value)
}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="w-full lg:w-[320px] shrink-0">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-base font-semibold text-gray-900">Headings</h3>
<p className="text-sm text-gray-600">
Edit heading numbers or add new rows.
</p>
</div>
<button
type="button"
onClick={addHeadingRow}
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"
>
Add
</button>
</div>
<div className="mt-3 rounded-lg border border-gray-200 bg-white overflow-x-auto">
<table className="min-w-[360px] w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-600">
Number
</th>
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-600">
Heading
</th>
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-600 w-20">
Actions
</th>
</tr>
</thead>
<tbody>
{headings.length ? (
headings.map((heading, idx) => (
<tr key={`heading-${idx}`} className="border-b border-gray-100">
<td className="px-3 py-2 text-xs font-semibold text-gray-700">
<input
type="text"
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"
value={heading.number}
onChange={(event) =>
updateHeadingField(idx, "number", event.target.value)
}
/>
</td>
<td className="px-3 py-2 text-sm text-gray-700">
<input
type="text"
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"
value={heading.name}
onChange={(event) =>
updateHeadingField(idx, "name", event.target.value)
}
/>
</td>
<td className="px-3 py-2 text-xs">
<button
type="button"
onClick={() => removeHeadingRow(idx)}
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"
>
Remove
</button>
</td>
</tr>
))
) : (
<tr>
<td
colSpan={3}
className="px-3 py-4 text-sm text-gray-500 text-center"
>
No headings found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</section>
<section className="mb-4 rounded-lg border border-gray-200 bg-white p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-gray-900">Job Sheet Sections</h3>
<p className="text-xs text-gray-600">
Rename sections and add pages directly inside a section.
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setSectionsCollapsed((prev) => !prev)}
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"
>
{sectionsCollapsed ? "Expand" : "Collapse"}
</button>
<button
type="button"
onClick={addSection}
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"
>
Add section
</button>
</div>
</div>
{sectionsCollapsed ? (
<div className="mt-2 text-[11px] text-gray-500">
{sections.length} section{sections.length === 1 ? "" : "s"} ·{" "}
{sections.reduce((sum, section) => sum + (section.pages?.length ?? 0), 0)}{" "}
page{sections.length === 1 ? "" : "s"} total
</div>
) : (
<div className="mt-2 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{sections.map((section, idx) => (
<div
key={section.id}
className="rounded-md border border-gray-200 bg-gray-50 p-2"
>
<div className="flex items-center justify-between gap-2">
<div className="text-[11px] font-semibold text-gray-700">
Section {idx + 1}
</div>
<button
type="button"
onClick={() => insertPageIntoSection(section.id)}
className="inline-flex items-center gap-1 rounded-md border border-gray-200 bg-white px-2 py-0.5 text-[11px] font-semibold text-gray-700 hover:bg-gray-50 transition"
>
Add page
</button>
</div>
<div className="mt-1 text-[11px] text-gray-500">
Pages: {section.pages?.length ?? 0}
</div>
<div className="text-[11px] text-gray-500">
Page numbers:{" "}
{(() => {
const start =
sections
.slice(0, idx)
.reduce((sum, item) => sum + (item.pages?.length ?? 0), 0) +
1;
const count = section.pages?.length ?? 0;
if (count <= 0) return "-";
const end = start + count - 1;
return start === end ? `${start}` : `${start}-${end}`;
})()}
</div>
<label className="mt-1 block text-[11px] text-gray-600">
Title
<input
type="text"
className="mt-1 w-full rounded-md border border-gray-200 px-2 py-0.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-200"
value={section.title ?? ""}
onChange={(event) =>
updateSectionTitle(section.id, event.target.value)
}
placeholder={`Section ${idx + 1}`}
/>
</label>
</div>
))}
</div>
)}
</section>
<div className="rounded-lg border border-gray-200 bg-white overflow-x-auto">
<div className="px-3 py-2 text-xs text-gray-500 bg-gray-50 border-b border-gray-200">
General Info (double-click the header below to expand/collapse columns)
</div>
<table className="min-w-[2400px] w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th
rowSpan={2}
className="px-3 py-2 text-left text-xs font-semibold text-gray-600"
>
Page
</th>
<th
colSpan={showGeneralColumns ? GENERAL_FIELDS.length : 1}
onDoubleClick={() => setShowGeneralColumns((prev) => !prev)}
className="px-3 py-2 text-left text-xs font-semibold text-gray-600 cursor-pointer select-none"
title="Double-click to toggle general columns"
>
General Info
</th>
<th
colSpan={ITEM_FIELDS.length + 1}
className="px-3 py-2 text-left text-xs font-semibold text-gray-600"
>
Item Details
</th>
<th
rowSpan={2}
className="px-3 py-2 text-left text-xs font-semibold text-gray-600 min-w-[520px]"
>
Actions
</th>
</tr>
<tr>
{showGeneralColumns ? (
GENERAL_FIELDS.map((field) => (
<th
key={`general-col-${field.key}`}
className="px-3 py-2 text-left text-xs font-semibold text-gray-600 min-w-[180px]"
>
{field.label}
</th>
))
) : (
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 min-w-[180px]">
General info hidden
</th>
)}
{ITEM_FIELDS.map((field) => (
<th
key={`item-col-${field.key}`}
className="px-3 py-2 text-left text-xs font-semibold text-gray-600 min-w-[180px]"
>
{field.label}
</th>
))}
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-600 min-w-[320px]">
Images
</th>
</tr>
</thead>
<tbody>
{flatPages.map((entry) => {
const pageIndex = entry.flatIndex;
const template = entry.page.template ?? {};
const photoIds = entry.page.photo_ids ?? [];
const orderLocked = entry.page.photo_order_locked ?? false;
const photoLookup = new Map(
(session?.uploads?.photos ?? []).map((photo) => [photo.id, photo]),
);
const availablePhotos = (session?.uploads?.photos ?? []).filter(
(photo) => !photoIds.includes(photo.id),
);
return (
<tr key={`row-${entry.sectionId}-${entry.pageIndex}`} className="border-b border-gray-100">
<td className="px-3 py-2 text-xs font-semibold text-gray-700">
Page {entry.flatIndex + 1}
</td>
{showGeneralColumns ? (
GENERAL_FIELDS.map((field) => (
<td
key={`${pageIndex}-general-${field.key}`}
className="px-3 py-2 min-w-[180px]"
>
<input
type="text"
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"
value={template[field.key] ?? getFallbackValue(field.key)}
onChange={(event) =>
updateField(pageIndex, field.key, event.target.value)
}
/>
</td>
))
) : (
<td className="px-3 py-2 min-w-[180px] text-xs text-gray-500">
(hidden)
</td>
)}
{ITEM_FIELDS.map((field) => (
<td key={`${pageIndex}-${field.key}`} className="px-3 py-2 min-w-[180px]">
{field.multiline ? (
<textarea
rows={2}
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"
value={template[field.key] ?? getFallbackValue(field.key)}
onChange={(event) =>
updateField(pageIndex, field.key, event.target.value)
}
/>
) : (
<input
type="text"
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"
value={template[field.key] ?? getFallbackValue(field.key)}
onChange={(event) =>
updateField(pageIndex, field.key, event.target.value)
}
/>
)}
</td>
))}
<td className="px-3 py-2 min-w-[320px]">
<div className="space-y-2">
{photoIds.length ? (
photoIds.map((photoId, idx) => {
const photo = photoLookup.get(photoId);
return (
<div key={`${pageIndex}-photo-${photoId}`} className="flex flex-wrap items-center gap-2 text-xs">
<span className="font-semibold text-gray-700">
{photo?.name || photoId}
</span>
<button
type="button"
onClick={() => movePhoto(pageIndex, idx, idx - 1)}
disabled={idx === 0}
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"
>
Up
</button>
<button
type="button"
onClick={() => movePhoto(pageIndex, idx, idx + 1)}
disabled={idx === photoIds.length - 1}
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"
>
Down
</button>
<button
type="button"
onClick={() => removePhoto(pageIndex, idx)}
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"
>
Remove
</button>
</div>
);
})
) : (
<div className="text-xs text-gray-500">No images linked.</div>
)}
<div className="flex flex-wrap items-center gap-2">
<select
className="rounded-md border border-gray-200 px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-200"
value={photoSelections[pageIndex] ?? ""}
onChange={(event) =>
updatePhotoSelection(pageIndex, event.target.value)
}
>
<option value="">Select image</option>
{availablePhotos.map((photo) => (
<option key={`photo-option-${photo.id}`} value={photo.id}>
{photo.name}
</option>
))}
</select>
<button
type="button"
onClick={() =>
addPhotoToPage(pageIndex, photoSelections[pageIndex] ?? "")
}
disabled={!photoSelections[pageIndex]}
className="rounded border border-gray-200 px-2 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Add
</button>
<button
type="button"
onClick={() => uploadInputRefs.current[pageIndex]?.click()}
disabled={isUploading}
className="rounded border border-gray-200 px-2 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Upload
</button>
<button
type="button"
onClick={() => setPhotoOrderLocked(pageIndex, !orderLocked)}
disabled={photoIds.length === 0}
className="rounded border border-gray-200 px-2 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{orderLocked ? "Auto order" : "Apply order"}
</button>
<span className="text-[11px] text-gray-500">
{orderLocked ? "Manual order" : "Auto order"}
</span>
<input
ref={(node) => {
uploadInputRefs.current[pageIndex] = node;
}}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) uploadPhotoForPage(pageIndex, file);
event.target.value = "";
}}
/>
</div>
</div>
</td>
<td className="px-3 py-2 min-w-[520px]">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => applyRowToAll(pageIndex)}
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 transition"
>
Apply row to all
</button>
<button
type="button"
onClick={() => insertPageAt(pageIndex, template)}
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 transition"
>
Insert above
</button>
<button
type="button"
onClick={() => insertPageAt(pageIndex + 1, template)}
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 transition"
>
Insert below
</button>
<button
type="button"
onClick={() => removePageAt(pageIndex)}
disabled={flatPages.length <= 1}
className="inline-flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-semibold text-red-700 hover:bg-red-100 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Delete page
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-3 flex justify-end">
<button
type="button"
onClick={() => insertPageAt(flatPages.length, flatPages[flatPages.length - 1]?.page?.template)}
disabled={!sessionId}
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"
>
Add page at end
</button>
</div>
<PageFooter note="Tip: changes save automatically. Use apply row to keep pages consistent." />
<SaveBeforeLeaveDialog
open={Boolean(pendingNavigationTarget)}
onCancel={() => setPendingNavigationTarget(null)}
onProceed={leaveWithoutWaiting}
onWait={waitForSaveAndContinue}
/>
</PageShell>
);
}