Prosento_RepEx / frontend /src /pages /EditReportPage.tsx
ChristopherJKoen's picture
V0.1.5
74b1b27
import { useCallback, useEffect, useMemo, 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, request } from "../lib/api";
import { ensureSections, flattenSections } from "../lib/sections";
import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
import type { JobsheetSection, 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 { SaveBeforeLeaveDialog } from "../components/SaveBeforeLeaveDialog";
export default function EditReportPage() {
const [searchParams] = useSearchParams();
const sessionId = getSessionId(searchParams.toString());
const sessionQuery = buildSessionQuery(sessionId);
const navigate = useNavigate();
const [session, setSession] = useState<Session | null>(null);
const [pageCount, setPageCount] = useState<number | null>(null);
const [error, setError] = useState("");
const [saveState, setSaveState] = useState<
"saved" | "saving" | "pending" | "error"
>("saved");
const [pendingNavigationTarget, setPendingNavigationTarget] = useState<
string | null
>(null);
const [editorEl, setEditorEl] = useState<ReportEditorElement | null>(null);
const editorRef = useCallback((node: ReportEditorElement | null) => {
setEditorEl(node);
}, []);
const pageIndex = useMemo(() => {
const raw = Number(searchParams.get("page") || "1");
if (!Number.isFinite(raw) || raw <= 0) return 0;
return Math.max(0, Math.floor(raw) - 1);
}, [searchParams]);
const saveAndNavigate = useCallback(
async (target: string) => {
if (editorEl?.flushSave) {
await editorEl.flushSave();
}
navigate(target);
},
[editorEl, navigate],
);
const requestNavigation = useCallback(
(target: string) => {
if (saveState === "saving" || saveState === "pending") {
setPendingNavigationTarget(target);
return;
}
void saveAndNavigate(target);
},
[saveAndNavigate, saveState],
);
const waitForSaveAndContinue = useCallback(async () => {
if (!pendingNavigationTarget) return;
const target = pendingNavigationTarget;
setPendingNavigationTarget(null);
await saveAndNavigate(target);
}, [pendingNavigationTarget, saveAndNavigate]);
const leaveWithoutWaiting = useCallback(() => {
if (!pendingNavigationTarget) return;
const target = pendingNavigationTarget;
setPendingNavigationTarget(null);
navigate(target);
}, [navigate, pendingNavigationTarget]);
useEffect(() => {
if (!sessionId) {
setError("No active session found. Return to upload to continue.");
return;
}
setStoredSessionId(sessionId);
async function load() {
try {
const [data, sectionResp] = await Promise.all([
request<Session>(`/sessions/${sessionId}`),
request<{ sections: JobsheetSection[] }>(
`/sessions/${sessionId}/sections`,
),
]);
setSession(data);
const loadedSections = ensureSections(sectionResp.sections);
const flatPages = flattenSections(loadedSections);
setPageCount(flatPages.length || null);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to load session.";
setError(message);
}
}
load();
}, [sessionId]);
useEffect(() => {
if (!sessionId || !session || !editorEl) return;
const totalPages = pageCount && pageCount > 0
? pageCount
: Math.max(
1,
session.page_count ?? 1,
);
editorEl.open({
payload: session,
pageIndex,
totalPages,
sessionId,
apiBase: API_BASE,
mode: "page",
});
}, [editorEl, sessionId, session, pageIndex, pageCount]);
useEffect(() => {
if (!editorEl) return;
const handleClose = () => {
requestNavigation(`/report-viewer${sessionQuery}`);
};
editorEl.addEventListener("editor-closed", handleClose);
return () => {
editorEl.removeEventListener("editor-closed", handleClose);
};
}, [editorEl, requestNavigation, sessionQuery]);
useEffect(() => {
if (!editorEl) return;
const handleQueued = () => {
setSaveState((prev) => (prev === "saving" ? prev : "pending"));
};
const handleStart = () => setSaveState("saving");
const handleEnd = (event: Event) => {
const custom = event as CustomEvent<{ ok?: boolean }>;
if (custom.detail && custom.detail.ok === false) {
setSaveState("error");
} else {
setSaveState("saved");
}
};
editorEl.addEventListener("editor-save-queued", handleQueued);
editorEl.addEventListener("editor-save-start", handleStart);
editorEl.addEventListener("editor-save-end", handleEnd);
return () => {
editorEl.removeEventListener("editor-save-queued", handleQueued);
editorEl.removeEventListener("editor-save-start", handleStart);
editorEl.removeEventListener("editor-save-end", handleEnd);
};
}, [editorEl]);
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="Edit Report"
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>
<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">
<Edit3 className="h-4 w-4" />
Edit Report
</span>
<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>
{error ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 mb-4">
{error}
</div>
) : null}
{!session && !error ? (
<div className="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 mb-4">
Loading editor...
</div>
) : null}
<report-editor ref={editorRef} data-mode="page" />
<PageFooter note={`Editing page ${pageIndex + 1}. Changes save automatically.`} />
<SaveBeforeLeaveDialog
open={Boolean(pendingNavigationTarget)}
onCancel={() => setPendingNavigationTarget(null)}
onProceed={leaveWithoutWaiting}
onWait={waitForSaveAndContinue}
/>
</PageShell>
);
}