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