"use client"; import { useEffect, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Plus, Loader2, Play, ChevronDown, MessageSquare, Download, Users, Upload, X, Pencil, Trash2, WandSparkles, } from "lucide-react"; import { clearTabularCells, deleteTabularReview, getTabularReview, getProject, getTabularReviewPeople, regenerateTabularCell, streamTabularGeneration, updateTabularReview, uploadReviewDocument, } from "@/app/lib/mikeApi"; import type { ColumnConfig, Document, Project, TabularCell, TabularReview, Workflow, } from "../shared/types"; import { AddColumnModal } from "./AddColumnModal"; import { ApplyWorkflowPresetModal } from "./ApplyWorkflowPresetModal"; import { AddDocumentsModal } from "../shared/AddDocumentsModal"; import { AddProjectDocsModal } from "../shared/AddProjectDocsModal"; import { PeopleModal } from "../shared/PeopleModal"; import { OwnerOnlyModal } from "../shared/OwnerOnlyModal"; import { ApiKeyMissingModal } from "../shared/ApiKeyMissingModal"; import { ConfirmPopup } from "../shared/ConfirmPopup"; import { HeaderActionsMenu } from "../shared/HeaderActionsMenu"; import { useAuth } from "@/contexts/AuthContext"; import { useUserProfile } from "@/contexts/UserProfileContext"; import { getModelProvider, isModelAvailable, type ModelProvider, } from "@/app/lib/modelAvailability"; import { TRSidePanel } from "./TRSidePanel"; import { TRTable } from "./TRTable"; import type { TRTableHandle } from "./TRTable"; import { TRChatPanel } from "./TRChatPanel"; import { exportTabularReviewToExcel } from "./exportToExcel"; import { useSidebar } from "@/app/contexts/SidebarContext"; import { PageHeader } from "../shared/PageHeader"; interface Props { reviewId: string; projectId?: string; } export function TRView({ reviewId, projectId }: Props) { const { setSidebarOpen } = useSidebar(); const [review, setReview] = useState(null); const [project, setProject] = useState(null); const [cells, setCells] = useState([]); const [documents, setDocuments] = useState([]); const [columns, setColumns] = useState([]); const [loading, setLoading] = useState(true); const [generating, setGenerating] = useState(false); const [savingColumn, setSavingColumn] = useState(false); const [savingColumnsConfig, setSavingColumnsConfig] = useState(false); const [addColOpen, setAddColOpen] = useState(false); const [addDocsOpen, setAddDocsOpen] = useState(false); const [peopleModalOpen, setPeopleModalOpen] = useState(false); const [workflowPresetModalOpen, setWorkflowPresetModalOpen] = useState(false); const [applyingWorkflowPreset, setApplyingWorkflowPreset] = useState(false); const [deleteReviewConfirmOpen, setDeleteReviewConfirmOpen] = useState(false); const [deleteReviewStatus, setDeleteReviewStatus] = useState< "idle" | "deleting" | "deleted" >("idle"); const [ownerOnlyAction, setOwnerOnlyAction] = useState(null); const { user } = useAuth(); const [expandedCell, setExpandedCell] = useState(null); const [expandedCellCitation, setExpandedCellCitation] = useState< { quote: string; page: number } | undefined >(undefined); const [selectedDocIds, setSelectedDocIds] = useState([]); const [actionsOpen, setActionsOpen] = useState(false); const [search, setSearch] = useState(""); const [dragOverReviewFiles, setDragOverReviewFiles] = useState(false); const [uploadingDroppedFilenames, setUploadingDroppedFilenames] = useState< string[] >([]); const searchParams = useSearchParams(); const initialChatParamRef = useRef( searchParams.get("chat"), ); const [chatOpen, setChatOpen] = useState(!!initialChatParamRef.current); const [selectedChatId, setSelectedChatId] = useState( initialChatParamRef.current && initialChatParamRef.current !== "new" ? initialChatParamRef.current : null, ); const [highlightedCell, setHighlightedCell] = useState<{ colIdx: number; rowIdx: number } | null>(null); const [apiKeyModalProvider, setApiKeyModalProvider] = useState(null); const actionsRef = useRef(null); const tableRef = useRef(null); const router = useRouter(); const { profile } = useUserProfile(); const apiKeys = profile?.apiKeys; const tabularModel = profile?.tabularModel ?? "gemini-3-flash-preview"; useEffect(() => { const params = new URLSearchParams(window.location.search); if (chatOpen) { params.set("chat", selectedChatId ?? "new"); } else { params.delete("chat"); } const query = params.toString(); const newUrl = `${window.location.pathname}${query ? `?${query}` : ""}`; window.history.replaceState(null, "", newUrl); }, [chatOpen, selectedChatId]); useEffect(() => { if (!actionsOpen) return; function handleClickOutside(e: MouseEvent) { if ( actionsRef.current && !actionsRef.current.contains(e.target as Node) ) setActionsOpen(false); } document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [actionsOpen]); useEffect(() => { const fetches: Promise[] = [ getTabularReview(reviewId).then(({ review, cells, documents }) => { setReview(review); setCells(cells); setDocuments(documents); setColumns(review.columns_config || []); }), ]; if (projectId) { fetches.push( getProject(projectId) .then(setProject) .catch(() => {}), ); } Promise.all(fetches).finally(() => setLoading(false)); }, [reviewId, projectId]); function getNextColumnIndex() { return ( columns.reduce((max, column) => Math.max(max, column.index), -1) + 1 ); } async function saveColumnsConfig(nextColumns: ColumnConfig[]) { setSavingColumnsConfig(true); try { const updated = await updateTabularReview(reviewId, { columns_config: nextColumns, document_ids: documents.map((document) => document.id), }); setReview(updated); setColumns(updated.columns_config || nextColumns); } finally { setSavingColumnsConfig(false); } } async function handleAddDocuments(newDocs: Document[]) { const toAdd = newDocs.filter( (d) => !documents.some((existing) => existing.id === d.id), ); if (!toAdd.length) return; const allIds = [ ...documents.map((d) => d.id), ...toAdd.map((d) => d.id), ]; await updateTabularReview(reviewId, { document_ids: allIds, columns_config: columns, }); setDocuments((prev) => [...prev, ...toAdd]); if (columns.length > 0) { setCells((prev) => [ ...prev, ...toAdd.flatMap((doc) => columns.map((col) => ({ id: `new-${doc.id}-${col.index}`, review_id: reviewId, document_id: doc.id, column_index: col.index, content: null, status: "pending" as const, created_at: new Date().toISOString(), })), ), ]); } } function hasFilePayload(dt: DataTransfer): boolean { return Array.from(dt.types).includes("Files"); } async function handleDropReviewFiles(files: File[]) { if (files.length === 0) return; setUploadingDroppedFilenames(files.map((file) => file.name)); try { const uploaded: Document[] = []; const documentIds = documents.map((document) => document.id); for (const file of files) { const document = await uploadReviewDocument(reviewId, file, { projectId, documentIds, columnsConfig: columns, }); uploaded.push(document); documentIds.push(document.id); } await handleAddDocuments(uploaded); } catch (err) { console.error("Tabular review document drop upload failed", err); } finally { setUploadingDroppedFilenames([]); } } async function handleRegenerateCell(docId: string, colIndex: number) { if (apiKeys && !isModelAvailable(tabularModel, apiKeys)) { setApiKeyModalProvider(getModelProvider(tabularModel)); return; } setCells((prev) => prev.map((c) => c.document_id === docId && c.column_index === colIndex ? { ...c, status: "generating" as const, content: null } : c, ), ); setExpandedCell((prev) => prev ? { ...prev, status: "generating" as const, content: null } : null, ); try { const result = await regenerateTabularCell( reviewId, docId, colIndex, ); setCells((prev) => prev.map((c) => c.document_id === docId && c.column_index === colIndex ? { ...c, status: "done" as const, content: result } : c, ), ); setExpandedCell((prev) => prev ? { ...prev, status: "done" as const, content: result } : null, ); } catch (err) { console.error("Regeneration failed", err); setCells((prev) => prev.map((c) => c.document_id === docId && c.column_index === colIndex ? { ...c, status: "error" as const } : c, ), ); setExpandedCell((prev) => prev ? { ...prev, status: "error" as const } : null, ); } } async function handleGenerate() { if (!review || generating) return; // If columns changed since last save, update the review first if (columns.length === 0) return; if (apiKeys && !isModelAvailable(tabularModel, apiKeys)) { setApiKeyModalProvider(getModelProvider(tabularModel)); return; } setGenerating(true); try { const response = await streamTabularGeneration(reviewId); if (!response.ok) { const payload = await response.json().catch(() => null); const provider = payload && ["claude", "gemini", "openai"].includes(payload.provider) ? (payload.provider as ModelProvider) : getModelProvider(tabularModel); if (payload?.code === "missing_api_key" && provider) { setApiKeyModalProvider(provider); } throw new Error( payload?.detail ?? `Generation failed: ${response.status}`, ); } if (!response.body) throw new Error("No body"); // Optimistically set empty/pending/error cells to generating (skip done cells) setCells((prev) => documents.flatMap((doc) => columns.map((col) => { const existing = prev.find( (c) => c.document_id === doc.id && c.column_index === col.index, ); if (existing?.status === "done" && existing?.content) { return existing; } return existing ? { ...existing, status: "generating" as const, content: null, } : { id: `${doc.id}-${col.index}`, review_id: reviewId, document_id: doc.id, column_index: col.index, content: null, status: "generating" as const, created_at: new Date().toISOString(), }; }), ), ); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { if (!line.startsWith("data:")) continue; const dataStr = line.slice(5).trim(); if (dataStr === "[DONE]") break; try { const data = JSON.parse(dataStr); if (data.type === "cell_update") { setCells((prev) => prev.map((c) => c.document_id === data.document_id && c.column_index === data.column_index ? { ...c, content: data.content, status: data.status, } : c, ), ); } } catch {} } } } catch (err) { console.error("Generation failed", err); } finally { setGenerating(false); } } async function handleAddColumn(newColumns: ColumnConfig[]) { const startIndex = getNextColumnIndex(); const normalizedColumns = newColumns.map((column, index) => ({ ...column, index: startIndex + index, })); const newCols = [...columns, ...normalizedColumns]; setSavingColumn(true); setColumns(newCols); setCells((prev) => [ ...prev, ...documents .filter((doc) => normalizedColumns.some( (column) => !prev.some( (cell) => cell.document_id === doc.id && cell.column_index === column.index, ), ), ) .flatMap((doc) => normalizedColumns .filter( (column) => !prev.some( (cell) => cell.document_id === doc.id && cell.column_index === column.index, ), ) .map((column) => ({ id: `new-${doc.id}-${column.index}`, review_id: reviewId, document_id: doc.id, column_index: column.index, content: null, status: "pending" as const, created_at: new Date().toISOString(), })), ), ]); try { await saveColumnsConfig(newCols); } catch (err) { setColumns(columns); setCells((prev) => prev.filter( (cell) => !normalizedColumns.some( (column) => column.index === cell.column_index, ), ), ); console.error("Failed to save column", err); } finally { setSavingColumn(false); } } async function handleUpdateColumn(nextColumn: ColumnConfig) { const nextColumns = columns.map((column) => column.index === nextColumn.index ? nextColumn : column, ); const previousColumns = columns; setColumns(nextColumns); try { await saveColumnsConfig(nextColumns); } catch (err) { setColumns(previousColumns); console.error("Failed to update column", err); } } async function handleDeleteColumn(columnIndex: number) { const previousColumns = columns; const nextColumns = columns.filter( (column) => column.index !== columnIndex, ); setColumns(nextColumns); try { await saveColumnsConfig(nextColumns); } catch (err) { setColumns(previousColumns); console.error("Failed to delete column", err); } } function handleTabularCitationClick(colIdx: number, rowIdx: number) { setSearch(""); setHighlightedCell({ colIdx, rowIdx }); setTimeout(() => { tableRef.current?.scrollToCell(colIdx, rowIdx); }, 50); setTimeout(() => setHighlightedCell(null), 3000); } async function handleDeleteDocuments() { const idsToDelete = [...selectedDocIds]; if (idsToDelete.length === 0) return; const previousDocuments = documents; const previousCells = cells; const remaining = documents.filter( (d) => !idsToDelete.includes(d.id), ); setDocuments(remaining); setCells((prev) => prev.filter((c) => !idsToDelete.includes(c.document_id)), ); setSelectedDocIds([]); setActionsOpen(false); try { await updateTabularReview(reviewId, { document_ids: remaining.map((d) => d.id), columns_config: columns, }); } catch (err) { setDocuments(previousDocuments); setCells(previousCells); setSelectedDocIds(idsToDelete); console.error("Failed to delete tabular review documents", err); } } async function handleClearResults() { const docIds = [...selectedDocIds]; if (docIds.length === 0) return; setCells((prev) => prev.map((c) => docIds.includes(c.document_id) ? { ...c, content: null, status: "pending" } : c, ), ); setSelectedDocIds([]); setActionsOpen(false); await clearTabularCells(reviewId, docIds); } async function handleTitleCommit(newTitle: string) { if (!newTitle || newTitle === review?.title) return; if (review?.is_owner === false) { setOwnerOnlyAction("rename this tabular review"); return; } setReview((prev) => (prev ? { ...prev, title: newTitle } : prev)); await updateTabularReview(reviewId, { title: newTitle }); } function requestReviewRename() { if (review?.is_owner === false) { setOwnerOnlyAction("rename this tabular review"); return; } const nextTitle = window.prompt( "Rename tabular review", review?.title ?? "Untitled Review", ); const trimmed = nextTitle?.trim(); if (!trimmed) return; void handleTitleCommit(trimmed); } function requestReviewDelete() { if (review?.is_owner === false) { setOwnerOnlyAction("delete this tabular review"); return; } setDeleteReviewStatus("idle"); setDeleteReviewConfirmOpen(true); } async function confirmReviewDelete() { if (deleteReviewStatus === "deleting") return; setDeleteReviewStatus("deleting"); try { await deleteTabularReview(reviewId); setDeleteReviewStatus("deleted"); setTimeout(() => { router.push( projectId ? `/projects/${projectId}?tab=reviews` : "/tabular-reviews", ); }, 250); } catch (err) { setDeleteReviewStatus("idle"); console.error("Failed to delete tabular review", err); } } function requestWorkflowPreset() { if (review?.is_owner === false) { setOwnerOnlyAction("apply a preset workflow"); return; } setWorkflowPresetModalOpen(true); } async function handleApplyWorkflowPreset(workflow: Workflow) { if (!workflow.columns_config?.length) return; const nextColumns = workflow.columns_config.map((column, index) => ({ ...column, index, })); const previousColumns = columns; const previousCells = cells; setApplyingWorkflowPreset(true); setColumns(nextColumns); setCells([]); try { await saveColumnsConfig(nextColumns); if (documents.length > 0) { try { await clearTabularCells( reviewId, documents.map((document) => document.id), ); } catch (err) { console.error("Failed to clear old tabular cells", err); } } setWorkflowPresetModalOpen(false); } catch (err) { setColumns(previousColumns); setCells(previousCells); console.error("Failed to apply workflow preset", err); } finally { setApplyingWorkflowPreset(false); } } const q = search.toLowerCase(); const filteredDocuments = q ? documents.filter((d) => d.filename.toLowerCase().includes(q)) : documents; return (
{/* Header */} router.push("/projects"), }, loading ? { loading: true, skeletonClassName: "w-32", onClick: () => router.push(`/projects/${projectId}`), title: "Back to project", } : { label: project?.name ?? "", suffix: project?.cm_number ? ( (#{project.cm_number}) ) : null, onClick: () => router.push(`/projects/${projectId}`), title: "Back to project", }, ] : [ { label: "Tabular Reviews", onClick: () => router.push("/tabular-reviews"), title: "Back to Tabular Reviews", }, ]), loading ? { loading: true, skeletonClassName: "w-40", } : { label: review?.title || "Untitled Review", }, ]} actionGroups={[ [ { type: "search", value: search, onChange: setSearch, placeholder: "Search documents…", }, !projectId ? { onClick: () => setPeopleModalOpen(true), disabled: loading, iconOnly: true, title: "People with access", icon: , } : null, { type: "custom", render: ( ), }, ], [ { onClick: () => exportTabularReviewToExcel({ reviewTitle: review?.title || "Tabular Review", columns, documents, cells, }), disabled: columns.length === 0 || documents.length === 0, title: "Export to Excel", icon: , label: ( Export ), }, { onClick: handleGenerate, disabled: generating || columns.length === 0 || documents.length === 0 || savingColumnsConfig, icon: generating ? ( ) : ( ), label: ( {generating ? "Running…" : "Run"} ), }, ], ]} /> {/* Toolbar */}
{loading ? ( <>
) : null} {!loading && selectedDocIds.length > 0 && (
{actionsOpen && (
)}
)} {!loading && ( <> )}
{/* Table area */}
{chatOpen && ( { setSelectedChatId(null); setChatOpen(false); }} initialChatId={selectedChatId} onChatIdChange={setSelectedChatId} /> )}
{ if (!hasFilePayload(e.dataTransfer)) return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; setDragOverReviewFiles(true); }} onDragLeave={(e) => { if ( !e.currentTarget.contains( e.relatedTarget as Node, ) ) { setDragOverReviewFiles(false); } }} onDrop={(e) => { if (!hasFilePayload(e.dataTransfer)) return; e.preventDefault(); e.stopPropagation(); setDragOverReviewFiles(false); void handleDropReviewFiles( Array.from(e.dataTransfer.files), ); }} > { setExpandedCell(cell); setExpandedCellCitation(undefined); }} onCitationClick={(cell, page, quote) => { setExpandedCell(cell); setExpandedCellCitation({ quote, page }); }} onUpdateColumn={handleUpdateColumn} onDeleteColumn={handleDeleteColumn} onAddColumn={() => setAddColOpen(true)} onAddDocuments={() => setAddDocsOpen(true)} />
{/* Cell detail side panel */} {expandedCell && (() => { const expandedDoc = documents.find( (d) => d.id === expandedCell.document_id, ); const expandedCol = columns.find( (c) => c.index === expandedCell.column_index, ); if (!expandedDoc || !expandedCol) return null; return ( { setExpandedCell(null); setExpandedCellCitation(undefined); }} onNavigate={(columnIndex) => { const nextCell = cells.find( (c) => c.document_id === expandedCell.document_id && c.column_index === columnIndex, ); if (nextCell) { setExpandedCell(nextCell); setExpandedCellCitation(undefined); } }} onRegenerate={() => handleRegenerateCell( expandedCell.document_id, expandedCell.column_index, ) } displayDocument={expandedCellCitation !== undefined} citationQuote={expandedCellCitation?.quote} citationPage={expandedCellCitation?.page} /> ); })()} setAddColOpen(false)} onAdd={handleAddColumn} /> {project ? ( setAddDocsOpen(false)} onSelect={(docs: Document[]) => handleAddDocuments(docs) } breadcrumb={[ "Projects", project.name + (project.cm_number ? ` (#${project.cm_number})` : ""), "Tabular Reviews", ...(review ? [review.title || "Untitled Review"] : []), "Add Documents", ]} projectId={project.id} excludeDocIds={new Set(documents.map((d) => d.id))} /> ) : ( setAddDocsOpen(false)} onSelect={(docs: Document[]) => handleAddDocuments(docs) } breadcrumb={[ "Tabular Reviews", ...(review ? [review.title || "Untitled Review"] : []), "Add Documents", ]} /> )} setPeopleModalOpen(false)} resource={review} fetchPeople={getTabularReviewPeople} currentUserEmail={user?.email ?? null} breadcrumb={[ "Tabular Reviews", review?.title || "Untitled Review", "People", ]} // Only the review owner may modify the member list. PeopleModal // hides the add/remove controls when this prop is undefined. onSharedWithChange={ review?.is_owner === false ? undefined : async (next) => { const updated = await updateTabularReview( reviewId, { shared_with: next }, ); setReview((prev) => prev ? { ...prev, shared_with: updated.shared_with, } : prev, ); } } /> { if (applyingWorkflowPreset) return; setWorkflowPresetModalOpen(false); }} onApply={handleApplyWorkflowPreset} /> { if (deleteReviewStatus === "deleting") return; setDeleteReviewConfirmOpen(false); setDeleteReviewStatus("idle"); }} onConfirm={() => void confirmReviewDelete()} /> setOwnerOnlyAction(null)} /> setApiKeyModalProvider(null)} />
); }