Spaces:
Sleeping
Sleeping
| import { useEffect, useMemo, useRef, useState } from "react"; | |
| import { useNavigate, useSearchParams } from "react-router-dom"; | |
| import { | |
| CheckCircle, | |
| Image as ImageIcon, | |
| FileText, | |
| Table, | |
| CheckSquare, | |
| Square, | |
| ArrowRight, | |
| UploadCloud, | |
| } from "react-feather"; | |
| import { postForm, putJson, request } from "../lib/api"; | |
| import { getSessionId, setStoredSessionId } from "../lib/session"; | |
| import type { Session } from "../types/session"; | |
| import { PageFooter } from "../components/PageFooter"; | |
| import { PageHeader } from "../components/PageHeader"; | |
| import { PageShell } from "../components/PageShell"; | |
| export default function ReviewSetupPage() { | |
| const navigate = useNavigate(); | |
| const [searchParams] = useSearchParams(); | |
| const sessionId = getSessionId(searchParams.toString()); | |
| const photoUploadRef = useRef<HTMLInputElement | null>(null); | |
| const dataUploadRef = useRef<HTMLInputElement | null>(null); | |
| const [session, setSession] = useState<Session | null>(null); | |
| const [selectedPhotoIds, setSelectedPhotoIds] = useState<Set<string>>( | |
| new Set(), | |
| ); | |
| const [showAllPhotos, setShowAllPhotos] = useState(false); | |
| const [statusMessage, setStatusMessage] = useState(""); | |
| const [isUploadingPhotos, setIsUploadingPhotos] = useState(false); | |
| const [isUploadingData, setIsUploadingData] = useState(false); | |
| useEffect(() => { | |
| if (!sessionId) { | |
| setStatusMessage("No active session found. Return to upload to continue."); | |
| return; | |
| } | |
| setStoredSessionId(sessionId); | |
| async function loadSession() { | |
| try { | |
| const data = await request<Session>(`/sessions/${sessionId}`); | |
| setSession(data); | |
| const initial = new Set(data.selected_photo_ids || []); | |
| const photoIds = (data.uploads?.photos ?? []).map((photo) => photo.id); | |
| if (photoIds.length > 0 && initial.size < photoIds.length) { | |
| photoIds.forEach((photoId) => initial.add(photoId)); | |
| } | |
| setSelectedPhotoIds(initial); | |
| } catch (err) { | |
| const message = | |
| err instanceof Error ? err.message : "Failed to load session."; | |
| setStatusMessage(message); | |
| } | |
| } | |
| loadSession(); | |
| }, [sessionId]); | |
| const photos = session?.uploads?.photos ?? []; | |
| const documents = session?.uploads?.documents ?? []; | |
| const dataFiles = session?.uploads?.data_files ?? []; | |
| const canContinue = | |
| selectedPhotoIds.size > 0 || (session?.uploads?.data_files?.length ?? 0) > 0; | |
| const previewCount = 9; | |
| const visiblePhotos = showAllPhotos ? photos : photos.slice(0, previewCount); | |
| const readyStatus = useMemo(() => { | |
| if (!sessionId) return "No active session found. Return to upload to continue."; | |
| if (!canContinue) return "Choose report example images to continue..."; | |
| if (selectedPhotoIds.size === 0 && (session?.uploads?.data_files?.length ?? 0) > 0) { | |
| return "Data file detected. Continue to build the report."; | |
| } | |
| return "Ready. Continue to report viewer."; | |
| }, [canContinue, selectedPhotoIds.size, session?.uploads?.data_files?.length, sessionId]); | |
| async function handleUploadPhotos(files: FileList | null) { | |
| if (!sessionId || !files || files.length === 0) return; | |
| setIsUploadingPhotos(true); | |
| setStatusMessage(""); | |
| const priorPhotoIds = new Set( | |
| (session?.uploads?.photos ?? []).map((photo) => photo.id), | |
| ); | |
| try { | |
| let updatedSession: Session | null = session; | |
| for (const file of Array.from(files)) { | |
| const formData = new FormData(); | |
| formData.append("file", file); | |
| updatedSession = await postForm<Session>( | |
| `/sessions/${sessionId}/uploads`, | |
| formData, | |
| ); | |
| } | |
| if (updatedSession) { | |
| setSession(updatedSession); | |
| const next = new Set(selectedPhotoIds); | |
| for (const photo of updatedSession.uploads?.photos ?? []) { | |
| if (!priorPhotoIds.has(photo.id)) { | |
| next.add(photo.id); | |
| } | |
| } | |
| setSelectedPhotoIds(next); | |
| } | |
| } catch (err) { | |
| const message = | |
| err instanceof Error ? err.message : "Failed to upload photos."; | |
| setStatusMessage(message); | |
| } finally { | |
| setIsUploadingPhotos(false); | |
| if (photoUploadRef.current) photoUploadRef.current.value = ""; | |
| } | |
| } | |
| async function handleUploadDataFile(files: FileList | null) { | |
| if (!sessionId || !files || files.length === 0) return; | |
| setIsUploadingData(true); | |
| setStatusMessage(""); | |
| try { | |
| const file = files[0]; | |
| const formData = new FormData(); | |
| formData.append("file", file); | |
| const updated = await postForm<Session>( | |
| `/sessions/${sessionId}/data-files`, | |
| formData, | |
| ); | |
| setSession(updated); | |
| const nextSelected = new Set(updated.selected_photo_ids || []); | |
| const allPhotoIds = (updated.uploads?.photos ?? []).map((photo) => photo.id); | |
| if (nextSelected.size < allPhotoIds.length) { | |
| allPhotoIds.forEach((photoId) => nextSelected.add(photoId)); | |
| } | |
| setSelectedPhotoIds(nextSelected); | |
| } catch (err) { | |
| const message = | |
| err instanceof Error ? err.message : "Failed to upload data file."; | |
| setStatusMessage(message); | |
| } finally { | |
| setIsUploadingData(false); | |
| if (dataUploadRef.current) dataUploadRef.current.value = ""; | |
| } | |
| } | |
| async function handleContinue() { | |
| if (!sessionId) return; | |
| if (selectedPhotoIds.size === 0 && dataFiles.length === 0) return; | |
| try { | |
| if (selectedPhotoIds.size > 0) { | |
| await putJson(`/sessions/${sessionId}/selection`, { | |
| selected_photo_ids: Array.from(selectedPhotoIds), | |
| }); | |
| } | |
| navigate(`/report-viewer?session=${encodeURIComponent(sessionId)}`); | |
| } catch (err) { | |
| const message = | |
| err instanceof Error ? err.message : "Failed to save selection."; | |
| setStatusMessage(message); | |
| } | |
| } | |
| return ( | |
| <PageShell> | |
| <PageHeader | |
| title="RepEx - Report Express" | |
| subtitle="Review uploads -> pick examples -> continue to report viewer" | |
| right={ | |
| <span className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-xs font-semibold text-gray-700"> | |
| <CheckCircle className="h-4 w-4" /> | |
| Uploads processed | |
| </span> | |
| } | |
| /> | |
| <section className="mb-8" aria-labelledby="what-next"> | |
| <h2 | |
| id="what-next" | |
| className="text-xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-4" | |
| > | |
| What happens on this page | |
| </h2> | |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> | |
| <div className="rounded-lg border border-gray-200 bg-gray-50 p-4"> | |
| <div className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-blue-50 border border-blue-100 mb-3"> | |
| <ImageIcon className="h-5 w-5 text-blue-700" /> | |
| </div> | |
| <h3 className="font-semibold text-gray-900 mb-1"> | |
| Select example photos | |
| </h3> | |
| <p className="text-sm text-gray-600"> | |
| Choose which uploaded images should appear as example figures in the report. | |
| </p> | |
| </div> | |
| <div className="rounded-lg border border-gray-200 bg-gray-50 p-4"> | |
| <div className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-emerald-50 border border-emerald-100 mb-3"> | |
| <FileText className="h-5 w-5 text-emerald-700" /> | |
| </div> | |
| <h3 className="font-semibold text-gray-900 mb-1">Confirm documents</h3> | |
| <p className="text-sm text-gray-600"> | |
| Ensure supporting PDFs/DOCX are correct (and later attach to export if needed). | |
| </p> | |
| </div> | |
| <div className="rounded-lg border border-gray-200 bg-gray-50 p-4"> | |
| <div className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-amber-50 border border-amber-100 mb-3"> | |
| <Table className="h-5 w-5 text-amber-700" /> | |
| </div> | |
| <h3 className="font-semibold text-gray-900 mb-1">Use Excel/CSV data</h3> | |
| <p className="text-sm text-gray-600"> | |
| If Excel/CSV exists, it will populate report data areas automatically in later steps. | |
| </p> | |
| </div> | |
| </div> | |
| </section> | |
| <section className="mb-8" aria-labelledby="review-uploads"> | |
| <h2 | |
| id="review-uploads" | |
| className="text-xl font-semibold text-gray-800 border-b border-gray-200 pb-2 mb-6" | |
| > | |
| Review uploaded files | |
| </h2> | |
| <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> | |
| <div className="lg:col-span-2"> | |
| <div className="flex flex-wrap items-center justify-between gap-3 mb-3"> | |
| <h3 className="text-lg font-semibold text-gray-900">Photos</h3> | |
| <div className="flex flex-wrap items-center gap-2"> | |
| <span className="text-sm font-semibold text-gray-600"> | |
| {photos.length} file{photos.length === 1 ? "" : "s"} | |
| </span> | |
| <button | |
| type="button" | |
| onClick={() => photoUploadRef.current?.click()} | |
| disabled={!sessionId || isUploadingPhotos} | |
| 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" | |
| > | |
| <UploadCloud className="h-4 w-4" /> | |
| {isUploadingPhotos ? "Uploading..." : "Add photos"} | |
| </button> | |
| <input | |
| ref={photoUploadRef} | |
| type="file" | |
| className="hidden" | |
| multiple | |
| accept=".jpg,.jpeg,.png,.webp" | |
| onChange={(event) => handleUploadPhotos(event.target.files)} | |
| /> | |
| </div> | |
| </div> | |
| <div className="rounded-lg border border-gray-200 bg-white p-4"> | |
| <p className="text-sm text-gray-600 mb-3"> | |
| Select images to use as example figures in the report.{" "} | |
| <span className="font-semibold text-gray-800">Recommended:</span>{" "} | |
| 2-6 images. | |
| </p> | |
| <div className="grid grid-cols-2 md:grid-cols-3 gap-3"> | |
| {photos.length === 0 ? ( | |
| <div className="col-span-full text-sm text-gray-500"> | |
| No photos were uploaded. | |
| </div> | |
| ) : ( | |
| visiblePhotos.map((photo) => { | |
| const isChecked = selectedPhotoIds.has(photo.id); | |
| return ( | |
| <label key={photo.id} className="cursor-pointer"> | |
| <input | |
| type="checkbox" | |
| className="sr-only" | |
| checked={isChecked} | |
| onChange={(event) => { | |
| const next = new Set(selectedPhotoIds); | |
| if (event.target.checked) { | |
| next.add(photo.id); | |
| } else { | |
| next.delete(photo.id); | |
| } | |
| setSelectedPhotoIds(next); | |
| }} | |
| /> | |
| <div | |
| className={[ | |
| "rounded-lg border bg-gray-50 overflow-hidden transition", | |
| isChecked | |
| ? "ring-2 ring-emerald-200 border-emerald-300" | |
| : "border-gray-200", | |
| ].join(" ")} | |
| > | |
| <div className="relative"> | |
| <img | |
| src={photo.url} | |
| alt={photo.name} | |
| className="h-28 w-full object-cover" | |
| loading="eager" | |
| /> | |
| <div | |
| className={[ | |
| "absolute top-2 right-2 inline-flex items-center justify-center rounded-full border p-1.5", | |
| isChecked | |
| ? "bg-emerald-50 border-emerald-200 text-emerald-700" | |
| : "bg-white/90 border-gray-200 text-gray-700", | |
| ].join(" ")} | |
| > | |
| <CheckCircle className="h-4 w-4" /> | |
| </div> | |
| </div> | |
| <div className="p-2"> | |
| <div className="text-xs font-semibold text-gray-900 truncate"> | |
| {photo.name} | |
| </div> | |
| <div className="text-xs text-gray-500"> | |
| Click to select for report | |
| </div> | |
| </div> | |
| </div> | |
| </label> | |
| ); | |
| }) | |
| )} | |
| </div> | |
| {photos.length > previewCount ? ( | |
| <div className="mt-3 flex items-center justify-between gap-3 text-sm text-gray-600"> | |
| <span> | |
| Showing {visiblePhotos.length} of {photos.length} photos | |
| </span> | |
| <button | |
| type="button" | |
| onClick={() => setShowAllPhotos((prev) => !prev)} | |
| className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition" | |
| > | |
| {showAllPhotos ? "Show fewer" : `Show all (${photos.length})`} | |
| </button> | |
| </div> | |
| ) : null} | |
| <div className="mt-4 flex flex-wrap items-center justify-between gap-3"> | |
| <div className="text-sm text-gray-600"> | |
| Selected for report:{" "} | |
| <span className="font-semibold text-gray-900"> | |
| {selectedPhotoIds.size} | |
| </span> | |
| </div> | |
| <div className="flex gap-2"> | |
| <button | |
| type="button" | |
| onClick={() => { | |
| setSelectedPhotoIds(new Set(photos.map((p) => p.id))); | |
| }} | |
| className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition" | |
| > | |
| <CheckSquare className="h-4 w-4" /> | |
| Select all | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => setSelectedPhotoIds(new Set())} | |
| className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 transition" | |
| > | |
| <Square className="h-4 w-4" /> | |
| Clear | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="space-y-6"> | |
| <div> | |
| <div className="flex items-center justify-between mb-3"> | |
| <h3 className="text-lg font-semibold text-gray-900">Documents</h3> | |
| <span className="text-sm font-semibold text-gray-600"> | |
| {documents.length} file{documents.length === 1 ? "" : "s"} | |
| </span> | |
| </div> | |
| <div className="rounded-lg border border-gray-200 bg-white p-4"> | |
| <ul className="space-y-2 text-sm text-gray-700"> | |
| {documents.length === 0 ? ( | |
| <li className="text-sm text-gray-500"> | |
| No supporting documents detected. | |
| </li> | |
| ) : ( | |
| documents.map((doc) => ( | |
| <li | |
| key={doc.id} | |
| className="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2" | |
| > | |
| <div className="flex items-center gap-2 min-w-0"> | |
| <FileText className="h-4 w-4 text-gray-600" /> | |
| <span className="truncate text-gray-800">{doc.name}</span> | |
| </div> | |
| <span className="text-xs font-semibold text-gray-600"> | |
| {doc.content_type || "File"} | |
| </span> | |
| </li> | |
| )) | |
| )} | |
| </ul> | |
| {documents.length === 0 ? ( | |
| <p className="text-xs text-gray-500 mt-3"> | |
| PDFs/DOCX appear here after processing. | |
| </p> | |
| ) : null} | |
| </div> | |
| </div> | |
| <div> | |
| <div className="flex flex-wrap items-center justify-between gap-3 mb-3"> | |
| <h3 className="text-lg font-semibold text-gray-900">Data files</h3> | |
| <div className="flex flex-wrap items-center gap-2"> | |
| <span className="text-sm font-semibold text-gray-600"> | |
| {dataFiles.length} file{dataFiles.length === 1 ? "" : "s"} | |
| </span> | |
| <button | |
| type="button" | |
| onClick={() => dataUploadRef.current?.click()} | |
| disabled={!sessionId || isUploadingData} | |
| 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" | |
| > | |
| <UploadCloud className="h-4 w-4" /> | |
| {isUploadingData ? "Uploading..." : "Upload data file"} | |
| </button> | |
| <input | |
| ref={dataUploadRef} | |
| type="file" | |
| className="hidden" | |
| accept=".csv,.xls,.xlsx" | |
| onChange={(event) => handleUploadDataFile(event.target.files)} | |
| /> | |
| </div> | |
| </div> | |
| <div className="rounded-lg border border-gray-200 bg-white p-4"> | |
| {dataFiles.length === 0 ? ( | |
| <div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm text-gray-600"> | |
| <div className="font-semibold text-gray-800 mb-1"> | |
| No Excel/CSV detected | |
| </div> | |
| If you upload a CSV or Excel file, RepEx can auto-populate report data fields. | |
| </div> | |
| ) : ( | |
| dataFiles.map((file) => ( | |
| <div | |
| key={file.id} | |
| className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700 mb-2 last:mb-0" | |
| > | |
| <div className="flex items-center justify-between gap-3"> | |
| <div className="flex items-center gap-2 min-w-0"> | |
| <Table className="h-4 w-4 text-amber-700" /> | |
| <span className="truncate font-semibold text-gray-900"> | |
| {file.name} | |
| </span> | |
| </div> | |
| <span className="text-xs font-semibold text-gray-600"> | |
| {file.content_type || "Data"} | |
| </span> | |
| </div> | |
| <div className="text-xs text-gray-600 mt-1"> | |
| Will populate report data areas (tables/fields). | |
| </div> | |
| </div> | |
| )) | |
| )} | |
| <p className="text-xs text-gray-500 mt-3"> | |
| If present, these files will populate report tables/fields automatically. | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <section className="mb-4" aria-label="Continue to report viewer"> | |
| <div className="rounded-lg border border-gray-200 bg-gray-50 p-4 flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between"> | |
| <div | |
| className={[ | |
| "text-sm", | |
| canContinue ? "text-emerald-700 font-semibold" : "text-amber-700 font-semibold", | |
| ].join(" ")} | |
| > | |
| {readyStatus} | |
| </div> | |
| <button | |
| type="button" | |
| onClick={handleContinue} | |
| disabled={!canContinue} | |
| className="inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-5 py-2.5 text-white font-semibold hover:bg-emerald-700 transition disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| <ArrowRight className="h-5 w-5" /> | |
| Continue to Report Viewer | |
| </button> | |
| </div> | |
| <p className="text-xs text-gray-500 mt-3"> | |
| Note: This page assumes uploads were completed on a previous processing step. | |
| </p> | |
| </section> | |
| {statusMessage ? ( | |
| <p className="text-sm text-red-600">{statusMessage}</p> | |
| ) : null} | |
| <PageFooter note="Workflow: Processing -> Review uploads -> Report viewer -> Edit -> Export" /> | |
| </PageShell> | |
| ); | |
| } | |