Prosento_RepEx / frontend /src /pages /ReportViewerPage.tsx
ChristopherJKoen's picture
Refresh sections UI and templates
15a4294
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
Layout,
Edit3,
Grid,
Download,
Table,
Info,
Image,
} from "react-feather";
import { request } from "../lib/api";
import { BASE_W } from "../lib/report";
import { ensureSections, flattenSections } from "../lib/sections";
import { buildSessionQuery, getSessionId, setStoredSessionId } from "../lib/session";
import { APP_VERSION } from "../lib/version";
import type { JobsheetSection, Session } from "../types/session";
import { ReportPageCanvas } from "../components/ReportPageCanvas";
import { InfoMenu } from "../components/InfoMenu";
export default function ReportViewerPage() {
const [searchParams] = useSearchParams();
const sessionId = getSessionId(searchParams.toString());
const [session, setSession] = useState<Session | null>(null);
const [sections, setSections] = useState<JobsheetSection[]>([]);
const [pageIndex, setPageIndex] = useState(0);
const [scale, setScale] = useState(1);
const [error, setError] = useState("");
const stageRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!sessionId) {
setError("No active session found. Return to upload to continue.");
return;
}
setStoredSessionId(sessionId);
}, [sessionId]);
useEffect(() => {
const handleResize = () => {
if (!stageRef.current) return;
const width = stageRef.current.clientWidth;
if (width > 0) setScale(width / BASE_W);
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
useEffect(() => {
if (!sessionId) return;
async function load() {
try {
const data = await request<Session>(`/sessions/${sessionId}`);
setSession(data);
const sectionResp = await request<{ sections: JobsheetSection[] }>(
`/sessions/${sessionId}/sections`,
);
const loaded = ensureSections(sectionResp.sections);
setSections(loaded);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to load session.";
setError(message);
}
}
load();
}, [sessionId]);
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(() => {
setPageIndex((idx) => Math.min(Math.max(0, idx), totalPages - 1));
}, [totalPages]);
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.key === "ArrowRight") {
setPageIndex((idx) => Math.min(totalPages - 1, idx + 1));
}
if (event.key === "ArrowLeft") {
setPageIndex((idx) => Math.max(0, idx - 1));
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [totalPages]);
const page = flatPages[pageIndex]?.page ?? null;
const sectionLabel = flatPages[pageIndex]?.sectionTitle
? `Section ${flatPages[pageIndex].sectionIndex + 1} - ${flatPages[pageIndex].sectionTitle}`
: flatPages[pageIndex]
? `Section ${flatPages[pageIndex].sectionIndex + 1}`
: "";
const template = page?.template;
const sessionQuery = buildSessionQuery(sessionId || "");
const editReportQuery = useMemo(() => {
if (!sessionId) return "";
const params = new URLSearchParams();
params.set("session", sessionId);
params.set("page", String(pageIndex + 1));
return `?${params.toString()}`;
}, [sessionId, pageIndex]);
const viewerMeta = useMemo(() => {
if (!session) return "Loading...";
const selected = session.selected_photo_ids?.length ?? 0;
const docs = session.uploads?.documents?.length ?? 0;
const dataFiles = session.uploads?.data_files?.length ?? 0;
const hasEdits = flatPages.length > 0;
return (
`Selected example photos: ${selected} - Documents: ${docs} - Data files: ${dataFiles}` +
(hasEdits ? " - Edited pages loaded" : " - No saved edits yet")
);
}, [flatPages.length, session]);
return (
<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">
<header className="mb-8 border-b border-gray-200 pb-4">
<div className="grid grid-cols-[auto,1fr,auto] items-center gap-4">
<div className="flex items-center">
<img
src="/assets/prosento-logo.png"
alt="Company logo"
className="h-12 w-auto object-contain"
loading="eager"
/>
</div>
<div className="text-center">
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 whitespace-nowrap">
RepEx - Report Express
</h1>
<p className="text-gray-600 whitespace-nowrap">Report Viewer</p>
</div>
<div className="flex justify-end gap-2 no-print">
<Link
to={`/review-setup${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>
</div>
</header>
<nav className="mb-6 no-print" aria-label="Report workflow navigation">
<div className="flex flex-wrap gap-2">
<Link
to={`/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>
<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">
<Layout className="h-4 w-4" />
Report Viewer
</span>
<Link
to={`/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${editReportQuery}`}
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}`}
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}`}
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>
<Link
to="/info/ratings"
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"
>
<Info className="h-4 w-4" />
Rating Scales
</Link>
</div>
</nav>
<section
id="viewerSection"
aria-label="Report viewer"
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div>
<h2 className="text-xl font-semibold text-gray-800">Report Pages</h2>
<p className="text-sm text-gray-600">{viewerMeta}</p>
</div>
<div className="flex items-center gap-2 no-print">
<button
type="button"
onClick={() => setPageIndex((idx) => Math.max(0, idx - 1))}
disabled={pageIndex === 0}
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"
>
<ChevronLeft className="h-4 w-4" />
Prev
</button>
<div className="text-sm font-semibold text-gray-700">
Page <span>{pageIndex + 1}</span> / <span>{totalPages}</span>
</div>
<button
type="button"
onClick={() =>
setPageIndex((idx) => Math.min(totalPages - 1, idx + 1))
}
disabled={pageIndex >= totalPages - 1}
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"
>
Next
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex justify-center">
<div
ref={stageRef}
className="relative shadow-sm rounded-xl bg-white border border-gray-200"
style={{
width: "min(100%, 560px)",
}}
>
<ReportPageCanvas
session={session}
page={page}
pageIndex={pageIndex}
pageCount={totalPages}
scale={scale}
template={template}
sectionLabel={sectionLabel}
adaptive
/>
</div>
</div>
<p className="mt-4 text-xs text-gray-500 no-print">
Tip: Use keyboard arrows (left / right) to change pages.
</p>
{error ? <p className="text-sm text-red-600 mt-2">{error}</p> : null}
</section>
<footer className="mt-12 text-center text-xs text-gray-500 no-print">
<p>Prosento - (c) 2026 All Rights Reserved</p>
<p className="mt-1">Viewer now renders saved edits from the editor.</p>
<p className="mt-1">Version {APP_VERSION}</p>
</footer>
</main>
);
}