Prosento_RepEx / frontend /src /pages /EditLayoutsPage.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,
ChevronDown,
ChevronUp,
Download,
Edit3,
Grid,
Layout,
Image,
Plus,
Table,
Trash2,
} from "react-feather";
import { putJson, request } from "../lib/api";
import {
applyPageTemplateToPage,
inferPageTemplateId,
mergePageTemplates,
} from "../lib/pageTemplates";
import { BASE_W } from "../lib/report";
import { ensureSections, flattenSections, replacePage } from "../lib/sections";
import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
import type {
JobsheetSection,
PageTemplateDefinition,
Session,
} from "../types/session";
import { PageFooter } from "../components/PageFooter";
import { PageHeader } from "../components/PageHeader";
import { PageShell } from "../components/PageShell";
import { InfoMenu } from "../components/InfoMenu";
import { ReportPageCanvas } from "../components/ReportPageCanvas";
import { SaveBeforeLeaveDialog } from "../components/SaveBeforeLeaveDialog";
export default function EditLayoutsPage() {
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 [pageTemplates, setPageTemplates] = useState<PageTemplateDefinition[]>(
mergePageTemplates(undefined),
);
const [newPageTemplateBySection, setNewPageTemplateBySection] = useState<
Record<string, string>
>({});
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>(
{},
);
const [pendingNavigationTarget, setPendingNavigationTarget] = useState<
string | null
>(null);
const canModify = Boolean(sessionId) && !isSaving;
const saveTimerRef = useRef<number | null>(null);
const savePromiseRef = useRef<Promise<void> | null>(null);
const lastSavedRef = useRef<string>("");
const pendingSaveRef = useRef<string>("");
useEffect(() => {
if (!sessionId) {
setStatus("No active session found.");
return;
}
setStoredSessionId(sessionId);
async function load() {
try {
const data = await request<Session>(`/sessions/${sessionId}`);
setSession(data);
setPageTemplates(mergePageTemplates(data.page_templates));
const sectionResp = await request<{ sections: JobsheetSection[] }>(
`/sessions/${sessionId}/sections`,
);
const normalized = ensureSections(sectionResp.sections);
setSections(normalized);
setNewPageTemplateBySection((prev) => {
const next = { ...prev };
normalized.forEach((section) => {
if (!next[section.id]) {
next[section.id] = "repex:blank";
}
});
return next;
});
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]);
const flatPages = useMemo(
() => flattenSections(ensureSections(sections)),
[sections],
);
const totalPages = useMemo(
() => Math.max(1, flatPages.length),
[flatPages.length],
);
const totalSections = sections.length;
const previewWidth = 220;
const previewScale = previewWidth / BASE_W;
function findTemplate(templateId?: string) {
const fallback = mergePageTemplates(undefined)[0];
const selectedId = (templateId || "").trim();
if (selectedId) {
const match = pageTemplates.find((template) => template.id === selectedId);
if (match) return match;
}
return (
pageTemplates.find((template) => template.id === "repex:standard") ??
pageTemplates[0] ??
fallback
);
}
function hasExplicitPhotos(source: JobsheetSection[]) {
return source.some((section) =>
(section.pages ?? []).some((page) => (page.photo_ids ?? []).length > 0),
);
}
function flattenPhotoIds(source: JobsheetSection[]) {
const seen = new Set<string>();
const result: string[] = [];
source.forEach((section) => {
(section.pages ?? []).forEach((page) => {
(page.photo_ids ?? []).forEach((photoId) => {
if (!seen.has(photoId)) {
seen.add(photoId);
result.push(photoId);
}
});
});
});
return result;
}
async function triggerAutoSave() {
if (!sessionId || savePromiseRef.current) return;
const snapshot = pendingSaveRef.current;
if (!snapshot || snapshot === lastSavedRef.current) return;
await saveLayout(sections, undefined, true);
}
useEffect(() => {
if (!sessionId) return;
const snapshot = JSON.stringify(sections);
if (snapshot === lastSavedRef.current) {
if (!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, isSaving]);
useEffect(() => {
if (!sections.length) return;
setNewPageTemplateBySection((prev) => {
const next: Record<string, string> = {};
sections.forEach((section) => {
next[section.id] = prev[section.id] ?? "repex:blank";
});
return next;
});
}, [sections]);
async function saveLayout(
next: JobsheetSection[],
nextSelectedIds?: string[],
silent = false,
) {
if (!sessionId) return;
if (savePromiseRef.current) {
await savePromiseRef.current;
}
const snapshot = JSON.stringify(next);
if (snapshot === lastSavedRef.current && nextSelectedIds === undefined) {
if (!isSaving) setSaveState("saved");
return;
}
const promise = (async () => {
setIsSaving(true);
setSaveState("saving");
if (!silent) {
setStatus("Saving layout changes...");
}
try {
const requests: Promise<unknown>[] = [
putJson<{ sections: JobsheetSection[] }>(
`/sessions/${sessionId}/sections`,
{ sections: next },
),
];
if (nextSelectedIds !== undefined) {
requests.push(
putJson<Session>(`/sessions/${sessionId}/selection`, {
selected_photo_ids: nextSelectedIds,
}),
);
}
const [pagesResp, sessionResp] = await Promise.all(requests);
const updatedSections =
(pagesResp as { sections?: JobsheetSection[] }).sections ?? next;
const updatedSession = sessionResp as Session | undefined;
const updated = ensureSections(updatedSections);
setSections(updated);
lastSavedRef.current = JSON.stringify(updated);
pendingSaveRef.current = lastSavedRef.current;
setSaveState("saved");
if (updatedSession) {
setSession(updatedSession);
setPageTemplates(mergePageTemplates(updatedSession.page_templates));
}
if (!silent) {
setStatus("Layout saved.");
}
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to save layout.";
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 handleAddSection() {
const baseTemplate = findTemplate("repex:standard");
const next = [
...sections,
{
id: crypto.randomUUID(),
title: `Section ${sections.length + 1}`,
pages: [applyPageTemplateToPage({ items: [] }, baseTemplate)],
},
];
await saveLayout(next);
}
function toggleSection(sectionId: string) {
setCollapsedSections((prev) => ({
...prev,
[sectionId]: !prev[sectionId],
}));
}
function setAllSectionsCollapsed(value: boolean) {
const next: Record<string, boolean> = {};
sections.forEach((section) => {
next[section.id] = value;
});
setCollapsedSections(next);
}
async function handleAddPage(sectionIndex: number) {
const section = sections[sectionIndex];
const templateId = section ? newPageTemplateBySection[section.id] : undefined;
const selectedTemplate = findTemplate(templateId);
const next = sections.map((section, idx) => {
if (idx !== sectionIndex) return section;
const baseTemplate = {
...(section.pages?.[section.pages.length - 1]?.template ?? {}),
};
const nextPage = applyPageTemplateToPage(
{ items: [], template: baseTemplate },
selectedTemplate,
);
return {
...section,
pages: [...(section.pages ?? []), nextPage],
};
});
if (session) {
const nextSelected = hasExplicitPhotos(next)
? flattenPhotoIds(next)
: session.selected_photo_ids ?? [];
await saveLayout(next, nextSelected);
return;
}
await saveLayout(next);
}
function handleChangePageTemplate(
sectionIndex: number,
pageIndex: number,
templateId: string,
) {
const selectedTemplate = findTemplate(templateId);
if (!selectedTemplate) return;
setSections((prev) => {
const section = prev[sectionIndex];
const page = section?.pages?.[pageIndex];
if (!section || !page) return prev;
const nextPage = applyPageTemplateToPage(
{ ...page, template: { ...(page.template ?? {}) } },
selectedTemplate,
);
return replacePage(prev, sectionIndex, pageIndex, nextPage);
});
const label = selectedTemplate.name || selectedTemplate.id;
setStatus(`Applied "${label}" template to page ${pageIndex + 1}.`);
}
async function handleRemovePage(sectionIndex: number, pageIndex: number) {
const next = sections.map((section, idx) => {
if (idx !== sectionIndex) return section;
const pages = [...(section.pages ?? [])];
if (pages.length <= 1) return section;
pages.splice(pageIndex, 1);
return { ...section, pages: pages.length ? pages : [{ items: [] }] };
});
if (session) {
const nextSelected = hasExplicitPhotos(next)
? flattenPhotoIds(next)
: session.selected_photo_ids ?? [];
await saveLayout(next, nextSelected);
return;
}
await saveLayout(next);
}
async function handleMovePage(sectionIndex: number, pageIndex: number, direction: number) {
const next = sections.map((section, idx) => {
if (idx !== sectionIndex) return section;
const pages = [...(section.pages ?? [])];
const target = pageIndex + direction;
if (target < 0 || target >= pages.length) return section;
[pages[pageIndex], pages[target]] = [pages[target], pages[pageIndex]];
return { ...section, pages };
});
if (session) {
const nextSelected = hasExplicitPhotos(next)
? flattenPhotoIds(next)
: session.selected_photo_ids ?? [];
await saveLayout(next, nextSelected);
return;
}
await saveLayout(next);
}
async function saveAndNavigate(target: string) {
if (!sessionId) {
navigate(target);
return;
}
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
const snapshot = JSON.stringify(sections);
pendingSaveRef.current = snapshot;
if (snapshot !== lastSavedRef.current || savePromiseRef.current) {
await saveLayout(sections, undefined, true);
}
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>
<PageHeader
title="RepEx - Report Express"
subtitle="Edit Page Layouts"
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">
<Link
to={`/input-data${sessionQuery}`}
onClick={(event) => {
event.preventDefault();
requestNavigation(`/input-data${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"
>
<Table className="h-4 w-4" />
Input Data
</Link>
<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>
<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">
<Grid className="h-4 w-4" />
Edit Page Layouts
</span>
<Link
to={`/edit-layouts/templates${sessionQuery}`}
onClick={(event) => {
event.preventDefault();
requestNavigation(`/edit-layouts/templates${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"
>
Templates
</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-6 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">Page Layouts</h2>
<p className="text-sm text-gray-600">
Add, remove, or reorder pages, then return to the report viewer to edit content.
</p>
<div className="mt-2 text-xs text-gray-500">
{totalSections} section{totalSections === 1 ? "" : "s"} -{" "}
{totalPages} page{totalPages === 1 ? "" : "s"}
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setAllSectionsCollapsed(true)}
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"
>
Collapse all
</button>
<button
type="button"
onClick={() => setAllSectionsCollapsed(false)}
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"
>
Expand all
</button>
<button
type="button"
onClick={handleAddSection}
disabled={!canModify}
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"
>
<Plus className="h-4 w-4" />
Add section
</button>
</div>
</div>
{status ? (
<p className="text-sm text-gray-600 mt-3">{status}</p>
) : null}
</section>
<section className="space-y-6">
{sections.map((section, sectionIndex) => {
const isCollapsed = collapsedSections[section.id] ?? false;
const pageCount = section.pages?.length ?? 0;
const start =
sections
.slice(0, sectionIndex)
.reduce((sum, item) => sum + (item.pages?.length ?? 0), 0) + 1;
const end = Math.max(start, start + pageCount - 1);
return (
<div key={section.id} className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div>
<div className="text-xs font-semibold text-gray-500">
Section {sectionIndex + 1}
</div>
<input
type="text"
value={section.title ?? ""}
onChange={(event) =>
setSections((prev) =>
prev.map((item, idx) =>
idx === sectionIndex
? { ...item, title: event.target.value }
: item,
),
)
}
placeholder={`Section ${sectionIndex + 1}`}
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"
/>
<div className="text-xs text-gray-500">
{pageCount} page{pageCount === 1 ? "" : "s"} - Pages {start}
{pageCount > 1 ? `-${end}` : ""}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => toggleSection(section.id)}
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"
>
{isCollapsed ? (
<>
<ChevronDown className="h-4 w-4" />
Expand
</>
) : (
<>
<ChevronUp className="h-4 w-4" />
Collapse
</>
)}
</button>
<select
value={newPageTemplateBySection[section.id] ?? "repex:blank"}
onChange={(event) =>
setNewPageTemplateBySection((prev) => ({
...prev,
[section.id]: event.target.value,
}))
}
className="rounded-lg border border-gray-200 bg-white px-2 py-2 text-xs font-semibold text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-200"
title="Template used when adding a page to this section"
>
{pageTemplates.map((template) => (
<option key={`section-${section.id}-template-${template.id}`} value={template.id}>
{template.name}
</option>
))}
</select>
<button
type="button"
onClick={() => handleAddPage(sectionIndex)}
disabled={!canModify}
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"
>
<Plus className="h-4 w-4" />
Add page
</button>
<Link
to={`/edit-layouts/templates${sessionQuery}`}
onClick={(event) => {
event.preventDefault();
requestNavigation(`/edit-layouts/templates${sessionQuery}`);
}}
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"
>
Manage templates
</Link>
</div>
</div>
{!isCollapsed ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{(section.pages ?? []).map((page, pageIndex) => {
const currentTemplateId = inferPageTemplateId(page);
const currentTemplate = findTemplate(currentTemplateId);
return (
<div
key={`${section.id}-page-${pageIndex}`}
className="rounded-lg border border-gray-200 bg-white p-3"
>
<div className="flex items-center justify-between mb-2">
<div>
<div className="text-sm font-semibold text-gray-900">
Page {pageIndex + 1}
</div>
<div className="text-xs text-gray-500">
{page.items?.length ?? 0} items - Global page {start + pageIndex} of {totalPages}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleMovePage(sectionIndex, pageIndex, -1)}
disabled={pageIndex === 0 || !canModify}
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"
aria-label="Move page up"
>
<ChevronUp className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => handleMovePage(sectionIndex, pageIndex, 1)}
disabled={pageIndex === (section.pages?.length ?? 1) - 1 || !canModify}
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"
aria-label="Move page down"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => handleRemovePage(sectionIndex, pageIndex)}
disabled={(section.pages?.length ?? 1) <= 1 || !canModify}
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"
>
<Trash2 className="h-3.5 w-3.5" />
Remove
</button>
</div>
</div>
<div className="mb-2 rounded-md border border-gray-200 bg-gray-50 p-2">
<label className="block text-[11px] font-semibold uppercase tracking-wide text-gray-500">
Page Template
</label>
<select
value={currentTemplateId}
onChange={(event) =>
handleChangePageTemplate(
sectionIndex,
pageIndex,
event.target.value,
)
}
className="mt-1 w-full rounded-md border border-gray-200 bg-white px-2 py-1 text-xs font-semibold text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-200"
>
{pageTemplates.map((template) => (
<option
key={`page-${section.id}-${pageIndex}-${template.id}`}
value={template.id}
>
{template.name}
</option>
))}
</select>
<div className="mt-1 text-[11px] text-gray-500">
{currentTemplate.description || "No description."}
</div>
</div>
<div
className="mx-auto rounded-lg border border-gray-200 bg-white"
style={{ width: previewWidth }}
>
<ReportPageCanvas
session={session}
page={page}
pageIndex={pageIndex}
pageCount={totalPages}
scale={previewScale}
template={page?.template}
sectionLabel={
section.title
? `Section ${sectionIndex + 1} - ${section.title}`
: `Section ${sectionIndex + 1}`
}
adaptive
/>
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-md border border-dashed border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500">
Section collapsed. Expand to edit pages and apply layout presets.
</div>
)}
</div>
);
})}
</section>
<PageFooter note="Tip: reorder pages here and remove empty pages before export." />
<SaveBeforeLeaveDialog
open={Boolean(pendingNavigationTarget)}
onCancel={() => setPendingNavigationTarget(null)}
onProceed={leaveWithoutWaiting}
onWait={waitForSaveAndContinue}
/>
</PageShell>
);
}