Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| } | |