Prosento_RepEx / frontend /src /pages /ReviewSetupPage.tsx
ChristopherJKoen's picture
V0.1.5
74b1b27
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>
);
}