mirror of
https://github.com/willchen96/mike.git
synced 2026-06-14 20:55:13 +02:00
1104 lines
45 KiB
TypeScript
1104 lines
45 KiB
TypeScript
"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<TabularReview | null>(null);
|
|
const [project, setProject] = useState<Project | null>(null);
|
|
const [cells, setCells] = useState<TabularCell[]>([]);
|
|
const [documents, setDocuments] = useState<Document[]>([]);
|
|
const [columns, setColumns] = useState<ColumnConfig[]>([]);
|
|
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<string | null>(null);
|
|
const { user } = useAuth();
|
|
const [expandedCell, setExpandedCell] = useState<TabularCell | null>(null);
|
|
const [expandedCellCitation, setExpandedCellCitation] = useState<
|
|
{ quote: string; page: number } | undefined
|
|
>(undefined);
|
|
const [selectedDocIds, setSelectedDocIds] = useState<string[]>([]);
|
|
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<string | null>(
|
|
searchParams.get("chat"),
|
|
);
|
|
const [chatOpen, setChatOpen] = useState(!!initialChatParamRef.current);
|
|
const [selectedChatId, setSelectedChatId] = useState<string | null>(
|
|
initialChatParamRef.current && initialChatParamRef.current !== "new"
|
|
? initialChatParamRef.current
|
|
: null,
|
|
);
|
|
const [highlightedCell, setHighlightedCell] = useState<{ colIdx: number; rowIdx: number } | null>(null);
|
|
const [apiKeyModalProvider, setApiKeyModalProvider] =
|
|
useState<ModelProvider | null>(null);
|
|
const actionsRef = useRef<HTMLDivElement>(null);
|
|
const tableRef = useRef<TRTableHandle>(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<unknown>[] = [
|
|
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 (
|
|
<div className="flex h-full overflow-hidden">
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
{/* Header */}
|
|
<PageHeader
|
|
align="start"
|
|
shrink
|
|
className="gap-4"
|
|
breadcrumbs={[
|
|
...(projectId
|
|
? [
|
|
{
|
|
label: "Projects",
|
|
onClick: () => 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 ? (
|
|
<span className="ml-1 text-gray-400">
|
|
(#{project.cm_number})
|
|
</span>
|
|
) : 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: <Users className="h-4 w-4" />,
|
|
}
|
|
: null,
|
|
{
|
|
type: "custom",
|
|
render: (
|
|
<HeaderActionsMenu
|
|
items={[
|
|
{
|
|
label: "Rename",
|
|
icon: Pencil,
|
|
onSelect: requestReviewRename,
|
|
},
|
|
{
|
|
label: "Apply preset workflow",
|
|
icon: WandSparkles,
|
|
onSelect:
|
|
requestWorkflowPreset,
|
|
},
|
|
{
|
|
label: "Delete",
|
|
icon: Trash2,
|
|
onSelect: requestReviewDelete,
|
|
variant: "danger",
|
|
},
|
|
]}
|
|
/>
|
|
),
|
|
},
|
|
],
|
|
[
|
|
{
|
|
onClick: () =>
|
|
exportTabularReviewToExcel({
|
|
reviewTitle:
|
|
review?.title || "Tabular Review",
|
|
columns,
|
|
documents,
|
|
cells,
|
|
}),
|
|
disabled:
|
|
columns.length === 0 ||
|
|
documents.length === 0,
|
|
title: "Export to Excel",
|
|
icon: <Download className="h-4 w-4" />,
|
|
label: (
|
|
<span className="hidden sm:inline">
|
|
Export
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
onClick: handleGenerate,
|
|
disabled:
|
|
generating ||
|
|
columns.length === 0 ||
|
|
documents.length === 0 ||
|
|
savingColumnsConfig,
|
|
icon: generating ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Play className="h-4 w-4" />
|
|
),
|
|
label: (
|
|
<span className="hidden sm:inline">
|
|
{generating ? "Running…" : "Run"}
|
|
</span>
|
|
),
|
|
},
|
|
],
|
|
]}
|
|
/>
|
|
|
|
{/* Toolbar */}
|
|
<div className="flex items-center h-10 px-4 md:px-10 border-b border-gray-200 gap-4">
|
|
<button
|
|
onClick={() => {
|
|
if (!chatOpen) setSidebarOpen(false);
|
|
if (chatOpen) setSelectedChatId(null);
|
|
setChatOpen((v) => !v);
|
|
}}
|
|
disabled={loading || columns.length === 0 || documents.length === 0}
|
|
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
|
|
loading || columns.length === 0 || documents.length === 0
|
|
? "text-gray-300 cursor-default"
|
|
: "text-gray-700 hover:text-gray-900"
|
|
}`}
|
|
>
|
|
{chatOpen ? (
|
|
<X className="h-3.5 w-3.5" />
|
|
) : (
|
|
<MessageSquare className="h-3.5 w-3.5" />
|
|
)}
|
|
Assistant
|
|
</button>
|
|
<div className="ml-auto flex items-center gap-5">
|
|
{loading ? (
|
|
<>
|
|
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
|
|
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
|
|
</>
|
|
) : null}
|
|
{!loading && selectedDocIds.length > 0 && (
|
|
<div ref={actionsRef} className="relative">
|
|
<button
|
|
onClick={() => setActionsOpen((v) => !v)}
|
|
className="flex items-center gap-1 text-xs font-medium text-gray-600 hover:text-gray-900 transition-colors"
|
|
>
|
|
Actions
|
|
<ChevronDown className="h-3.5 w-3.5" />
|
|
</button>
|
|
{actionsOpen && (
|
|
<div className="absolute top-full right-0 mt-1 w-36 rounded-lg border border-gray-100 bg-white shadow-lg z-50 overflow-hidden">
|
|
<button
|
|
onClick={handleClearResults}
|
|
className="w-full px-3 py-1.5 text-left text-xs text-gray-700 hover:bg-gray-50 transition-colors"
|
|
>
|
|
Clear results
|
|
</button>
|
|
<button
|
|
onClick={handleDeleteDocuments}
|
|
className="w-full px-3 py-1.5 text-left text-xs text-red-600 hover:bg-red-50 transition-colors"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{!loading && (
|
|
<>
|
|
<button
|
|
onClick={() => setAddDocsOpen(true)}
|
|
disabled={savingColumnsConfig}
|
|
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
|
|
savingColumnsConfig
|
|
? "text-gray-300 cursor-default"
|
|
: "text-gray-700 hover:text-gray-900"
|
|
}`}
|
|
>
|
|
<Upload className="h-3.5 w-3.5" />
|
|
Add Documents
|
|
</button>
|
|
<button
|
|
onClick={() => setAddColOpen(true)}
|
|
disabled={savingColumn || savingColumnsConfig}
|
|
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
|
|
savingColumn || savingColumnsConfig
|
|
? "text-gray-300 cursor-default"
|
|
: "text-gray-700 hover:text-gray-900"
|
|
}`}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
Add Columns
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table area */}
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{chatOpen && (
|
|
<TRChatPanel
|
|
reviewId={reviewId}
|
|
reviewTitle={review?.title ?? null}
|
|
projectName={project?.name ?? null}
|
|
columns={columns}
|
|
documents={documents}
|
|
onCitationClick={handleTabularCitationClick}
|
|
onClose={() => {
|
|
setSelectedChatId(null);
|
|
setChatOpen(false);
|
|
}}
|
|
initialChatId={selectedChatId}
|
|
onChatIdChange={setSelectedChatId}
|
|
/>
|
|
)}
|
|
<div
|
|
className="relative flex flex-1 overflow-hidden"
|
|
onDragOver={(e) => {
|
|
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),
|
|
);
|
|
}}
|
|
>
|
|
<TRTable
|
|
ref={tableRef}
|
|
loading={loading}
|
|
columns={columns}
|
|
documents={filteredDocuments}
|
|
cells={cells}
|
|
highlightedCell={highlightedCell}
|
|
savingColumn={savingColumn}
|
|
savingColumnsConfig={savingColumnsConfig}
|
|
selectedDocIds={selectedDocIds}
|
|
uploadingFilenames={uploadingDroppedFilenames}
|
|
dragOverFiles={dragOverReviewFiles}
|
|
onSelectionChange={setSelectedDocIds}
|
|
onExpand={(cell) => {
|
|
setExpandedCell(cell);
|
|
setExpandedCellCitation(undefined);
|
|
}}
|
|
onCitationClick={(cell, page, quote) => {
|
|
setExpandedCell(cell);
|
|
setExpandedCellCitation({ quote, page });
|
|
}}
|
|
onUpdateColumn={handleUpdateColumn}
|
|
onDeleteColumn={handleDeleteColumn}
|
|
onAddColumn={() => setAddColOpen(true)}
|
|
onAddDocuments={() => setAddDocsOpen(true)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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 (
|
|
<TRSidePanel
|
|
cell={expandedCell}
|
|
document={expandedDoc}
|
|
column={expandedCol}
|
|
columns={columns}
|
|
onClose={() => {
|
|
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}
|
|
/>
|
|
);
|
|
})()}
|
|
|
|
<AddColumnModal
|
|
open={addColOpen}
|
|
existingCount={columns.length}
|
|
onClose={() => setAddColOpen(false)}
|
|
onAdd={handleAddColumn}
|
|
/>
|
|
|
|
{project ? (
|
|
<AddProjectDocsModal
|
|
open={addDocsOpen}
|
|
onClose={() => 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))}
|
|
/>
|
|
) : (
|
|
<AddDocumentsModal
|
|
open={addDocsOpen}
|
|
onClose={() => setAddDocsOpen(false)}
|
|
onSelect={(docs: Document[]) =>
|
|
handleAddDocuments(docs)
|
|
}
|
|
breadcrumb={[
|
|
"Tabular Reviews",
|
|
...(review ? [review.title || "Untitled Review"] : []),
|
|
"Add Documents",
|
|
]}
|
|
/>
|
|
)}
|
|
|
|
<PeopleModal
|
|
open={peopleModalOpen}
|
|
onClose={() => 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,
|
|
);
|
|
}
|
|
}
|
|
/>
|
|
|
|
<ApplyWorkflowPresetModal
|
|
open={workflowPresetModalOpen}
|
|
applying={applyingWorkflowPreset}
|
|
onClose={() => {
|
|
if (applyingWorkflowPreset) return;
|
|
setWorkflowPresetModalOpen(false);
|
|
}}
|
|
onApply={handleApplyWorkflowPreset}
|
|
/>
|
|
|
|
<ConfirmPopup
|
|
open={deleteReviewConfirmOpen}
|
|
title="Delete tabular review?"
|
|
message="This will permanently delete the tabular review and its generated cells."
|
|
confirmLabel="Delete"
|
|
confirmStatus={
|
|
deleteReviewStatus === "deleting"
|
|
? "loading"
|
|
: deleteReviewStatus === "deleted"
|
|
? "complete"
|
|
: "idle"
|
|
}
|
|
cancelLabel="Cancel"
|
|
onCancel={() => {
|
|
if (deleteReviewStatus === "deleting") return;
|
|
setDeleteReviewConfirmOpen(false);
|
|
setDeleteReviewStatus("idle");
|
|
}}
|
|
onConfirm={() => void confirmReviewDelete()}
|
|
/>
|
|
|
|
<OwnerOnlyModal
|
|
open={!!ownerOnlyAction}
|
|
action={ownerOnlyAction ?? undefined}
|
|
onClose={() => setOwnerOnlyAction(null)}
|
|
/>
|
|
|
|
<ApiKeyMissingModal
|
|
open={apiKeyModalProvider !== null}
|
|
provider={apiKeyModalProvider}
|
|
onClose={() => setApiKeyModalProvider(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|