mirror of
https://github.com/willchen96/mike.git
synced 2026-06-22 21:28:07 +02:00
Add local repo contents
This commit is contained in:
parent
65739ef1ce
commit
d9690965b5
176 changed files with 68998 additions and 0 deletions
522
frontend/src/app/components/tabular/AddColumnModal.tsx
Normal file
522
frontend/src/app/components/tabular/AddColumnModal.tsx
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChevronDown, Plus, X } from "lucide-react";
|
||||
import type { ColumnConfig, ColumnFormat } from "../shared/types";
|
||||
import { generateTabularColumnPrompt } from "@/app/lib/mikeApi";
|
||||
import { FORMAT_OPTIONS, formatLabel, formatIcon } from "./columnFormat";
|
||||
import { TAG_COLORS } from "./pillUtils";
|
||||
import { getPresetConfig, PROMPT_PRESETS } from "./columnPresets";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface ColumnDraft {
|
||||
name: string;
|
||||
prompt: string;
|
||||
format: ColumnFormat;
|
||||
tags: string[];
|
||||
tagInput: string;
|
||||
}
|
||||
|
||||
const EMPTY_DRAFT: ColumnDraft = {
|
||||
name: "",
|
||||
prompt: "",
|
||||
format: "text",
|
||||
tags: [],
|
||||
tagInput: "",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
existingCount: number;
|
||||
onClose: () => void;
|
||||
onAdd: (cols: ColumnConfig[]) => void;
|
||||
editingColumn?: ColumnConfig;
|
||||
onSave?: (col: ColumnConfig) => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export function AddColumnModal({ open, existingCount, onClose, onAdd, editingColumn, onSave, onDelete }: Props) {
|
||||
const isEditing = !!editingColumn;
|
||||
const [columns, setColumns] = useState<ColumnDraft[]>([{ ...EMPTY_DRAFT }]);
|
||||
const [generatingIndices, setGeneratingIndices] = useState<number[]>([]);
|
||||
const [presetsOpenIndex, setPresetsOpenIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const presetsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (editingColumn) {
|
||||
setColumns([{
|
||||
name: editingColumn.name,
|
||||
prompt: editingColumn.prompt,
|
||||
format: editingColumn.format ?? "text",
|
||||
tags: editingColumn.tags ?? [],
|
||||
tagInput: "",
|
||||
}]);
|
||||
} else {
|
||||
setColumns([{ ...EMPTY_DRAFT }]);
|
||||
}
|
||||
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (presetsOpenIndex === null) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (
|
||||
presetsRef.current &&
|
||||
!presetsRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setPresetsOpenIndex(null);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [presetsOpenIndex]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function resetForm() {
|
||||
setColumns([{ ...EMPTY_DRAFT }]);
|
||||
setGeneratingIndices([]);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
|
||||
function updateColumn(index: number, patch: Partial<ColumnDraft>) {
|
||||
setColumns((prev) =>
|
||||
prev.map((col, i) => (i === index ? { ...col, ...patch } : col)),
|
||||
);
|
||||
}
|
||||
|
||||
function addAnotherColumn() {
|
||||
setColumns((prev) => [...prev, { ...EMPTY_DRAFT }]);
|
||||
}
|
||||
|
||||
function removeColumn(index: number) {
|
||||
setColumns((prev) =>
|
||||
prev.length === 1
|
||||
? [{ ...EMPTY_DRAFT }]
|
||||
: prev.filter((_, i) => i !== index),
|
||||
);
|
||||
}
|
||||
|
||||
function commitTag(index: number) {
|
||||
setColumns((prev) => {
|
||||
const col = prev[index]!;
|
||||
const tag = col.tagInput.trim();
|
||||
if (!tag || col.tags.includes(tag)) {
|
||||
return prev.map((c, i) =>
|
||||
i === index ? { ...c, tagInput: "" } : c,
|
||||
);
|
||||
}
|
||||
return prev.map((c, i) =>
|
||||
i === index
|
||||
? { ...c, tags: [...c.tags, tag], tagInput: "" }
|
||||
: c,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function handleTagKeyDown(
|
||||
e: React.KeyboardEvent<HTMLInputElement>,
|
||||
index: number,
|
||||
) {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
commitTag(index);
|
||||
} else if (
|
||||
e.key === "Backspace" &&
|
||||
columns[index]!.tagInput === "" &&
|
||||
columns[index]!.tags.length > 0
|
||||
) {
|
||||
updateColumn(index, {
|
||||
tags: columns[index]!.tags.slice(0, -1),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function autoGeneratePrompt(index: number) {
|
||||
const title = columns[index]?.name?.trim() ?? "";
|
||||
if (!title) return;
|
||||
setGeneratingIndices((prev) => [...prev, index]);
|
||||
try {
|
||||
const col = columns[index]!;
|
||||
const { prompt } = await generateTabularColumnPrompt(title, {
|
||||
format: col.format,
|
||||
tags: col.format === "tag" ? col.tags : undefined,
|
||||
});
|
||||
updateColumn(index, { prompt });
|
||||
} finally {
|
||||
setGeneratingIndices((prev) => prev.filter((v) => v !== index));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (columns.some((col) => !col.name.trim() || !col.prompt.trim()))
|
||||
return;
|
||||
if (isEditing && onSave && editingColumn) {
|
||||
const col = columns[0]!;
|
||||
onSave({
|
||||
index: editingColumn.index,
|
||||
name: col.name.trim(),
|
||||
prompt: col.prompt.trim(),
|
||||
format: col.format,
|
||||
tags: col.format === "tag" ? col.tags : undefined,
|
||||
});
|
||||
} else {
|
||||
onAdd(
|
||||
columns.map((col, i) => ({
|
||||
index: existingCount + i,
|
||||
name: col.name.trim(),
|
||||
prompt: col.prompt.trim(),
|
||||
format: col.format,
|
||||
tags: col.format === "tag" ? col.tags : undefined,
|
||||
})),
|
||||
);
|
||||
}
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 pt-5 pb-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<span>Tabular Review</span>
|
||||
<span>›</span>
|
||||
<span>{isEditing ? "Edit column" : "New column"}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col min-h-0 flex-1"
|
||||
>
|
||||
{/* Body */}
|
||||
<div className="px-6 pt-3 pb-5 space-y-5 overflow-y-auto flex-1">
|
||||
{columns.map((column, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-xl border border-gray-200 p-4"
|
||||
>
|
||||
{/* Name row */}
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Input + preset dropdown anchored to this wrapper */}
|
||||
<div
|
||||
className="relative flex flex-1 items-start"
|
||||
ref={
|
||||
presetsOpenIndex === index
|
||||
? presetsRef
|
||||
: null
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={column.name}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value;
|
||||
const preset =
|
||||
getPresetConfig(name);
|
||||
updateColumn(index, {
|
||||
name,
|
||||
...(preset
|
||||
? {
|
||||
prompt: preset.prompt,
|
||||
format: preset.format,
|
||||
tags:
|
||||
preset.tags ??
|
||||
[],
|
||||
tagInput: "",
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}}
|
||||
placeholder="Column name"
|
||||
className="flex-1 text-2xl font-serif text-gray-800 placeholder-gray-400 focus:outline-none bg-transparent"
|
||||
autoFocus={index === 0}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPresetsOpenIndex(
|
||||
presetsOpenIndex === index
|
||||
? null
|
||||
: index,
|
||||
)
|
||||
}
|
||||
title="Column presets"
|
||||
className="mt-1.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${presetsOpenIndex === index ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{presetsOpenIndex === index && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-50 rounded-xl border border-gray-100 bg-white shadow-lg overflow-y-auto max-h-64">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateColumn(index, { ...EMPTY_DRAFT });
|
||||
setPresetsOpenIndex(null);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-left text-sm text-gray-400 hover:bg-gray-50 transition-colors border-b border-gray-100"
|
||||
>
|
||||
No Preset
|
||||
</button>
|
||||
{PROMPT_PRESETS.map(
|
||||
(preset) => (
|
||||
<button
|
||||
key={preset.name}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateColumn(
|
||||
index,
|
||||
{
|
||||
name: preset.name,
|
||||
prompt: preset.prompt,
|
||||
format: preset.format,
|
||||
tags:
|
||||
preset.tags ??
|
||||
[],
|
||||
tagInput:
|
||||
"",
|
||||
},
|
||||
);
|
||||
setPresetsOpenIndex(
|
||||
null,
|
||||
);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{columns.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeColumn(index)}
|
||||
className="mt-1.5 rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-gray-100 hover:text-gray-500"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Format */}
|
||||
<div className="mt-4">
|
||||
<label className="text-sm font-medium text-gray-500">
|
||||
Format
|
||||
</label>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="mt-1 flex items-center justify-between rounded-md border border-gray-200 bg-white px-2 py-1.5 text-sm text-gray-700 hover:border-gray-400 focus:outline-none">
|
||||
<span className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const Icon = formatIcon(
|
||||
column.format,
|
||||
);
|
||||
return (
|
||||
<Icon className="h-3.5 w-3.5 text-gray-400" />
|
||||
);
|
||||
})()}
|
||||
{formatLabel(column.format)}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-gray-400" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="z-[200]"
|
||||
>
|
||||
<DropdownMenuRadioGroup
|
||||
value={column.format}
|
||||
onValueChange={(v) =>
|
||||
updateColumn(index, {
|
||||
format: v as ColumnFormat,
|
||||
tags: [],
|
||||
tagInput: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
{FORMAT_OPTIONS.map((o) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={o.value}
|
||||
value={o.value}
|
||||
>
|
||||
<o.icon className="h-3.5 w-3.5 text-gray-400" />
|
||||
{o.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Tag input */}
|
||||
{column.format === "tag" && (
|
||||
<div className="mt-3">
|
||||
<label className="text-sm font-medium text-gray-500">
|
||||
Tags
|
||||
</label>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5 rounded-md border border-gray-200 px-2 py-1.5 focus-within:border-gray-400">
|
||||
{column.tags.map((tag, tagIdx) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs ${TAG_COLORS[tagIdx % TAG_COLORS.length]}`}
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updateColumn(
|
||||
index,
|
||||
{
|
||||
tags: column.tags.filter(
|
||||
(t) =>
|
||||
t !==
|
||||
tag,
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
value={column.tagInput}
|
||||
onChange={(e) =>
|
||||
updateColumn(index, {
|
||||
tagInput:
|
||||
e.target.value,
|
||||
})
|
||||
}
|
||||
onKeyDown={(e) =>
|
||||
handleTagKeyDown(e, index)
|
||||
}
|
||||
onBlur={() => commitTag(index)}
|
||||
placeholder="Add tag…"
|
||||
className="min-w-[80px] flex-1 bg-transparent text-sm text-gray-700 placeholder-gray-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
Press Enter or comma to add a tag.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-500">
|
||||
Prompt
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
autoGeneratePrompt(index)
|
||||
}
|
||||
disabled={
|
||||
!column.name.trim() ||
|
||||
generatingIndices.includes(index)
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-900 disabled:text-gray-300"
|
||||
>
|
||||
{generatingIndices.includes(index) ? (
|
||||
<span className="h-4 w-4 rounded-full border-2 border-gray-300 border-t-gray-600 animate-spin block" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
Auto-Generate Prompt
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
rows={6}
|
||||
value={column.prompt}
|
||||
onChange={(e) =>
|
||||
updateColumn(index, {
|
||||
prompt: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Write the analysis prompt — describe what Mike should extract from each document for this column…"
|
||||
className="mt-2 w-full rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-700 placeholder-gray-400 focus:border-gray-400 focus:outline-none bg-transparent resize-none leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addAnotherColumn}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-900"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add another column
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
|
||||
<div>
|
||||
{isEditing && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="rounded-lg px-4 py-2 text-sm text-red-500 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={columns.some(
|
||||
(col) => !col.name.trim() || !col.prompt.trim(),
|
||||
)}
|
||||
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{isEditing ? "Save changes" : "Add columns"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
532
frontend/src/app/components/tabular/AddNewTRModal.tsx
Normal file
532
frontend/src/app/components/tabular/AddNewTRModal.tsx
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, ChevronDown, Loader2, Upload, X } from "lucide-react";
|
||||
import type { MikeDocument, MikeProject, MikeWorkflow } from "../shared/types";
|
||||
import {
|
||||
getProject,
|
||||
listProjects,
|
||||
listStandaloneDocuments,
|
||||
listWorkflows,
|
||||
uploadProjectDocument,
|
||||
uploadStandaloneDocument,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import { FileDirectory } from "../shared/FileDirectory";
|
||||
import { BUILT_IN_WORKFLOWS } from "../workflows/builtinWorkflows";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAdd: (
|
||||
title: string,
|
||||
projectId?: string,
|
||||
documentIds?: string[],
|
||||
columnsConfig?: MikeWorkflow["columns_config"],
|
||||
) => void;
|
||||
projects?: MikeProject[];
|
||||
/** When provided, skip the project/directory picker and show only these docs */
|
||||
projectDocs?: MikeDocument[];
|
||||
projectName?: string;
|
||||
projectCmNumber?: string | null;
|
||||
}
|
||||
|
||||
export function AddNewTRModal({
|
||||
open,
|
||||
onClose,
|
||||
onAdd,
|
||||
projects = [],
|
||||
projectDocs: fixedProjectDocs,
|
||||
projectName,
|
||||
projectCmNumber,
|
||||
}: Props) {
|
||||
const isProjectMode = fixedProjectDocs !== undefined;
|
||||
const [title, setTitle] = useState("");
|
||||
const [underProject, setUnderProject] = useState(false);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState("");
|
||||
const [projectDropdownOpen, setProjectDropdownOpen] = useState(false);
|
||||
|
||||
// Project-scoped docs (when underProject is true and no fixedProjectDocs)
|
||||
const [projectDocs, setProjectDocs] = useState<MikeDocument[]>([]);
|
||||
const [loadingDocs, setLoadingDocs] = useState(false);
|
||||
|
||||
// Full directory (when underProject is false)
|
||||
const [standaloneDocs, setStandaloneDocs] = useState<MikeDocument[]>([]);
|
||||
const [directoryProjects, setDirectoryProjects] = useState<MikeProject[]>(
|
||||
[],
|
||||
);
|
||||
const [loadingDirectory, setLoadingDirectory] = useState(false);
|
||||
|
||||
const [selectedDocIds, setSelectedDocIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Workflow templates
|
||||
const [workflows, setWorkflows] = useState<MikeWorkflow[]>([]);
|
||||
const [loadingWorkflows, setLoadingWorkflows] = useState(false);
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [workflowDropdownOpen, setWorkflowDropdownOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
setLoadingWorkflows(true);
|
||||
const builtinTabular = BUILT_IN_WORKFLOWS.filter(
|
||||
(w) => w.type === "tabular",
|
||||
);
|
||||
listWorkflows("tabular")
|
||||
.then((custom) => setWorkflows([...builtinTabular, ...custom]))
|
||||
.catch(() => setWorkflows(builtinTabular))
|
||||
.finally(() => setLoadingWorkflows(false));
|
||||
|
||||
if (isProjectMode) {
|
||||
setSelectedDocIds(
|
||||
new Set((fixedProjectDocs ?? []).map((d) => d.id)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingDirectory(true);
|
||||
// /projects only returns counts, not the documents array — fetch
|
||||
// each project in parallel so FileDirectory can render the docs
|
||||
// when the user expands a folder.
|
||||
Promise.all([listStandaloneDocuments(), listProjects()])
|
||||
.then(async ([docs, projs]) => {
|
||||
setStandaloneDocs(
|
||||
[...docs].sort((a, b) =>
|
||||
(b.created_at ?? "").localeCompare(a.created_at ?? ""),
|
||||
),
|
||||
);
|
||||
const fullProjects = await Promise.all(
|
||||
projs.map((p) => getProject(p.id)),
|
||||
);
|
||||
setDirectoryProjects(fullProjects);
|
||||
})
|
||||
.catch(() => {
|
||||
setStandaloneDocs([]);
|
||||
setDirectoryProjects([]);
|
||||
})
|
||||
.finally(() => setLoadingDirectory(false));
|
||||
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function handleClose() {
|
||||
setTitle("");
|
||||
setUnderProject(false);
|
||||
setSelectedProjectId("");
|
||||
setProjectDropdownOpen(false);
|
||||
setProjectDocs([]);
|
||||
setStandaloneDocs([]);
|
||||
setDirectoryProjects([]);
|
||||
setSelectedDocIds(new Set());
|
||||
setSelectedWorkflowId(null);
|
||||
setWorkflowDropdownOpen(false);
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
if (underProject && !selectedProjectId) return;
|
||||
const selectedWorkflow = workflows.find(
|
||||
(w) => w.id === selectedWorkflowId,
|
||||
);
|
||||
onAdd(
|
||||
title.trim(),
|
||||
underProject ? selectedProjectId : undefined,
|
||||
selectedDocIds.size > 0 ? [...selectedDocIds] : undefined,
|
||||
selectedWorkflow?.columns_config ?? undefined,
|
||||
);
|
||||
handleClose();
|
||||
}
|
||||
|
||||
async function handleSelectProject(projectId: string) {
|
||||
setSelectedProjectId(projectId);
|
||||
setProjectDropdownOpen(false);
|
||||
setProjectDocs([]);
|
||||
setSelectedDocIds(new Set());
|
||||
setLoadingDocs(true);
|
||||
try {
|
||||
const proj = await getProject(projectId);
|
||||
const docs = (proj.documents ?? []).filter(
|
||||
(d) => d.status === "ready",
|
||||
);
|
||||
setProjectDocs(docs);
|
||||
setSelectedDocIds(new Set(docs.map((d) => d.id)));
|
||||
} finally {
|
||||
setLoadingDocs(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (!files.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded = await Promise.all(
|
||||
files.map((f) =>
|
||||
underProject && selectedProjectId
|
||||
? uploadProjectDocument(selectedProjectId, f)
|
||||
: uploadStandaloneDocument(f),
|
||||
),
|
||||
);
|
||||
if (underProject && selectedProjectId) {
|
||||
setProjectDocs((prev) => [...uploaded, ...prev]);
|
||||
} else {
|
||||
setStandaloneDocs((prev) => [...uploaded, ...prev]);
|
||||
}
|
||||
uploaded.forEach((d) =>
|
||||
setSelectedDocIds((prev) => new Set([...prev, d.id])),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Upload failed:", err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId);
|
||||
const selectedWorkflow = workflows.find((w) => w.id === selectedWorkflowId);
|
||||
|
||||
// What to show in the directory depends on mode and toggle state
|
||||
const directoryStandalone = isProjectMode
|
||||
? (fixedProjectDocs ?? [])
|
||||
: underProject
|
||||
? []
|
||||
: standaloneDocs;
|
||||
const directoryFolders = isProjectMode
|
||||
? []
|
||||
: underProject
|
||||
? []
|
||||
: directoryProjects;
|
||||
const flatProjectDocs: MikeDocument[] =
|
||||
!isProjectMode && underProject ? projectDocs : [];
|
||||
const directoryLoading = isProjectMode
|
||||
? false
|
||||
: underProject
|
||||
? loadingDocs
|
||||
: loadingDirectory;
|
||||
const showDirectory = isProjectMode || !underProject || !!selectedProjectId;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 pt-5 pb-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
{isProjectMode && projectName ? (
|
||||
<>
|
||||
<span>Projects</span>
|
||||
<span>›</span>
|
||||
<span>
|
||||
{projectName}
|
||||
{projectCmNumber ? ` (#${projectCmNumber})` : ""}
|
||||
</span>
|
||||
<span>›</span>
|
||||
<span>Tabular Reviews</span>
|
||||
<span>›</span>
|
||||
<span>New review</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Tabular Reviews</span>
|
||||
<span>›</span>
|
||||
<span>New review</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col min-h-0 flex-1"
|
||||
>
|
||||
<div className="px-6 pt-3 pb-4 space-y-5 overflow-y-auto flex-1">
|
||||
{/* Title */}
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Review name"
|
||||
className="w-full text-2xl font-serif text-gray-800 placeholder-gray-400 focus:outline-none bg-transparent"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Workflow template */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
Workflow Template
|
||||
</p>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setWorkflowDropdownOpen((o) => !o)
|
||||
}
|
||||
disabled={loadingWorkflows}
|
||||
className="flex items-center justify-between w-full rounded-lg border border-gray-200 px-3 py-2 text-sm hover:border-gray-400 focus:outline-none bg-white transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{loadingWorkflows && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
selectedWorkflow
|
||||
? "text-gray-800 truncate"
|
||||
: "text-gray-400"
|
||||
}
|
||||
>
|
||||
{loadingWorkflows
|
||||
? "Loading templates…"
|
||||
: selectedWorkflow
|
||||
? selectedWorkflow.title
|
||||
: "No template — start from scratch"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-gray-400 shrink-0 ml-2" />
|
||||
</button>
|
||||
{workflowDropdownOpen && !loadingWorkflows && (
|
||||
<div className="absolute left-0 top-full z-20 mt-1 w-full rounded-xl border border-gray-100 bg-white shadow-lg overflow-y-auto max-h-52">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedWorkflowId(null);
|
||||
setWorkflowDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-gray-50 ${!selectedWorkflowId ? "bg-gray-50 text-gray-900" : "text-gray-500"}`}
|
||||
>
|
||||
<span className="flex-1">
|
||||
No template — start from scratch
|
||||
</span>
|
||||
{!selectedWorkflowId && (
|
||||
<Check className="h-3.5 w-3.5 text-gray-500 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
{workflows.length > 0 && (
|
||||
<div className="border-t border-gray-100" />
|
||||
)}
|
||||
{workflows.map((wf) => (
|
||||
<button
|
||||
key={wf.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedWorkflowId(
|
||||
wf.id,
|
||||
);
|
||||
setWorkflowDropdownOpen(
|
||||
false,
|
||||
);
|
||||
}}
|
||||
className={`w-full text-left flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-gray-50 ${selectedWorkflowId === wf.id ? "bg-gray-50 text-gray-900" : "text-gray-700"}`}
|
||||
>
|
||||
<span className="flex-1 truncate">
|
||||
{wf.title}
|
||||
</span>
|
||||
{selectedWorkflowId ===
|
||||
wf.id && (
|
||||
<Check className="h-3.5 w-3.5 text-gray-500 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create under a project toggle */}
|
||||
{!isProjectMode && <div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = !underProject;
|
||||
setUnderProject(next);
|
||||
if (!next) {
|
||||
setSelectedProjectId("");
|
||||
setProjectDropdownOpen(false);
|
||||
setProjectDocs([]);
|
||||
setSelectedDocIds(new Set());
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2.5 w-fit"
|
||||
>
|
||||
<span
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors duration-200 ${underProject ? "bg-gray-900" : "bg-gray-200"}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${underProject ? "translate-x-4" : "translate-x-0"}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
Create under a project
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{underProject && (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setProjectDropdownOpen((o) => !o)
|
||||
}
|
||||
className="flex items-center justify-between w-full rounded-lg border border-gray-200 px-3 py-2 text-sm hover:border-gray-400 focus:outline-none bg-white transition-colors"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
selectedProject
|
||||
? "text-gray-800"
|
||||
: "text-gray-400"
|
||||
}
|
||||
>
|
||||
{selectedProject
|
||||
? selectedProject.name +
|
||||
(selectedProject.cm_number
|
||||
? ` (#${selectedProject.cm_number})`
|
||||
: "")
|
||||
: "Select project…"}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
</button>
|
||||
{projectDropdownOpen && (
|
||||
<div className="absolute left-0 top-full z-20 mt-1 w-full rounded-xl border border-gray-100 bg-white shadow-lg overflow-y-auto max-h-48">
|
||||
{projects.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-gray-400">
|
||||
No projects found
|
||||
</p>
|
||||
) : (
|
||||
projects.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleSelectProject(
|
||||
p.id,
|
||||
)
|
||||
}
|
||||
className={`w-full text-left flex items-center justify-between px-3 py-2 text-sm transition-colors hover:bg-gray-50 ${selectedProjectId === p.id ? "bg-gray-50 text-gray-900" : "text-gray-700"}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{p.name}
|
||||
{p.cm_number && (
|
||||
<span className="ml-1 text-gray-400">
|
||||
(#
|
||||
{
|
||||
p.cm_number
|
||||
}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{selectedProjectId ===
|
||||
p.id && (
|
||||
<Check className="h-3.5 w-3.5 text-gray-500 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
{/* File directory */}
|
||||
{showDirectory && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
Select Documents
|
||||
</p>
|
||||
<div>
|
||||
<FileDirectory
|
||||
standaloneDocs={
|
||||
isProjectMode
|
||||
? directoryStandalone
|
||||
: underProject
|
||||
? flatProjectDocs
|
||||
: directoryStandalone
|
||||
}
|
||||
directoryProjects={
|
||||
isProjectMode
|
||||
? []
|
||||
: underProject
|
||||
? []
|
||||
: directoryFolders
|
||||
}
|
||||
loading={directoryLoading}
|
||||
selectedIds={selectedDocIds}
|
||||
onChange={setSelectedDocIds}
|
||||
heading={isProjectMode ? "Project Documents" : "Documents"}
|
||||
emptyMessage={
|
||||
isProjectMode || underProject
|
||||
? "No ready documents in this project"
|
||||
: "No documents yet"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between gap-2 border-t border-gray-100 px-6 py-4 shrink-0">
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{uploading ? "Uploading…" : "Upload"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
!title.trim() ||
|
||||
(underProject && !selectedProjectId)
|
||||
}
|
||||
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
1470
frontend/src/app/components/tabular/TRChatPanel.tsx
Normal file
1470
frontend/src/app/components/tabular/TRChatPanel.tsx
Normal file
File diff suppressed because it is too large
Load diff
307
frontend/src/app/components/tabular/TREditColumnMenu.tsx
Normal file
307
frontend/src/app/components/tabular/TREditColumnMenu.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChevronDown, Loader2, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
|
||||
import type { ColumnConfig, ColumnFormat } from "../shared/types";
|
||||
import { generateTabularColumnPrompt } from "@/app/lib/mikeApi";
|
||||
import { FORMAT_OPTIONS, formatLabel, formatIcon } from "./columnFormat";
|
||||
import { TAG_COLORS } from "./pillUtils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export interface TREditColumnMenuProps {
|
||||
column: ColumnConfig;
|
||||
disabled?: boolean;
|
||||
onSave: (column: ColumnConfig) => void | Promise<void>;
|
||||
onDelete: (columnIndex: number) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function TREditColumnMenu({
|
||||
column,
|
||||
disabled,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: TREditColumnMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState(column.name);
|
||||
const [prompt, setPrompt] = useState(column.prompt);
|
||||
const [format, setFormat] = useState<ColumnFormat>(column.format ?? "text");
|
||||
const [tags, setTags] = useState<string[]>(column.tags ?? []);
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setName(column.name);
|
||||
setPrompt(column.prompt);
|
||||
setFormat(column.format ?? "text");
|
||||
setTags(column.tags ?? []);
|
||||
setTagInput("");
|
||||
}
|
||||
}, [column.name, column.prompt, column.format, column.tags, open]);
|
||||
|
||||
function commitTag() {
|
||||
const tag = tagInput.trim();
|
||||
if (!tag) {
|
||||
setTagInput("");
|
||||
return;
|
||||
}
|
||||
setTags((prev) => (prev.includes(tag) ? prev : [...prev, tag]));
|
||||
setTagInput("");
|
||||
}
|
||||
|
||||
function handleTagKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
commitTag();
|
||||
} else if (
|
||||
e.key === "Backspace" &&
|
||||
tagInput === "" &&
|
||||
tags.length > 0
|
||||
) {
|
||||
setTags((prev) => prev.slice(0, -1));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
...column,
|
||||
name: name.trim(),
|
||||
prompt: prompt.trim(),
|
||||
format,
|
||||
tags: format === "tag" ? tags : undefined,
|
||||
});
|
||||
setOpen(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
console.log(tags);
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await onDelete(column.index);
|
||||
setOpen(false);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAutoGenerate() {
|
||||
if (!name.trim()) return;
|
||||
setGenerating(true);
|
||||
try {
|
||||
const { prompt } = await generateTabularColumnPrompt(name.trim(), {
|
||||
format,
|
||||
tags: format === "tag" ? tags : undefined,
|
||||
});
|
||||
setPrompt(prompt);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={`flex h-4 w-4 items-center justify-center rounded transition-colors ${
|
||||
disabled
|
||||
? "text-gray-300 cursor-default"
|
||||
: "text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 top-full z-20 mt-1.5 w-72 rounded-xl border border-gray-100 bg-white p-3 shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm font-medium text-gray-800">
|
||||
Edit Column
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<label className="text-xs font-medium text-gray-800">
|
||||
Label
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-gray-800 text-xs font-normal focus:border-gray-400 focus:outline-none"
|
||||
/>
|
||||
|
||||
{/* Format */}
|
||||
<div className="mt-3">
|
||||
<label className="text-xs font-medium text-gray-800">
|
||||
Format
|
||||
</label>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="mt-1 flex w-full items-center justify-between rounded-md border border-gray-200 bg-white px-2 py-1 text-xs text-gray-700 hover:border-gray-400 focus:outline-none">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{(() => {
|
||||
const Icon = formatIcon(format);
|
||||
return (
|
||||
<Icon className="h-3 w-3 text-gray-400" />
|
||||
);
|
||||
})()}
|
||||
{formatLabel(format)}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 text-gray-400" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
style={{
|
||||
width: "var(--radix-dropdown-menu-trigger-width)",
|
||||
}}
|
||||
>
|
||||
<DropdownMenuRadioGroup
|
||||
value={format}
|
||||
onValueChange={(v) => {
|
||||
setFormat(v as ColumnFormat);
|
||||
setTags([]);
|
||||
setTagInput("");
|
||||
}}
|
||||
>
|
||||
{FORMAT_OPTIONS.map((o) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={o.value}
|
||||
value={o.value}
|
||||
className="text-xs"
|
||||
>
|
||||
<o.icon className="h-3 w-3 text-gray-400" />
|
||||
{o.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Tag input */}
|
||||
{format === "tag" && (
|
||||
<div className="mt-2">
|
||||
<div className="flex flex-wrap gap-1 rounded-md border border-gray-200 px-2 py-1 focus-within:border-gray-400 min-h-[28px]">
|
||||
{tags.map((tag, tagIdx) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={`inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] ${TAG_COLORS[tagIdx % TAG_COLORS.length]}`}
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setTags((prev) =>
|
||||
prev.filter(
|
||||
(t) => t !== tag,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-2 w-2" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) =>
|
||||
setTagInput(e.target.value)
|
||||
}
|
||||
onKeyDown={handleTagKeyDown}
|
||||
onBlur={commitTag}
|
||||
placeholder={
|
||||
tags.length === 0 ? "Add tags…" : ""
|
||||
}
|
||||
className="min-w-[60px] flex-1 bg-transparent text-xs text-gray-700 placeholder-gray-300 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-gray-800">
|
||||
Prompt
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAutoGenerate}
|
||||
disabled={!name.trim() || generating}
|
||||
className="inline-flex items-center gap-1 text-xs text-gray-600 transition-colors hover:text-gray-700 disabled:text-gray-300"
|
||||
>
|
||||
{generating ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-3 w-3" />
|
||||
)}
|
||||
Auto-generate
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
rows={6}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="mt-2 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-normal text-gray-800 placeholder-gray-300 focus:border-gray-400 focus:outline-none resize-none leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting || saving}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-red-500 transition-colors hover:text-red-600 disabled:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
saving ||
|
||||
deleting ||
|
||||
generating ||
|
||||
!name.trim() ||
|
||||
!prompt.trim()
|
||||
}
|
||||
className="rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-gray-700 disabled:opacity-40"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
457
frontend/src/app/components/tabular/TRSidePanel.tsx
Normal file
457
frontend/src/app/components/tabular/TRSidePanel.tsx
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { ColumnConfig, MikeDocument, TabularCell } from "../shared/types";
|
||||
import { preprocessCitations, type ParsedCitation } from "./citation-utils";
|
||||
import { getPillClass } from "./pillUtils";
|
||||
import { DocView } from "../shared/DocView";
|
||||
import { DocxView } from "../shared/DocxView";
|
||||
|
||||
function isDocxDocument(d: {
|
||||
file_type?: string | null;
|
||||
filename?: string;
|
||||
}): boolean {
|
||||
const ft = (d.file_type ?? "").toLowerCase();
|
||||
if (ft === "docx" || ft === "doc") return true;
|
||||
const ext = d.filename?.split(".").pop()?.toLowerCase();
|
||||
return ext === "docx" || ext === "doc";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
cell: TabularCell;
|
||||
document: MikeDocument;
|
||||
column: ColumnConfig;
|
||||
columns: ColumnConfig[];
|
||||
onClose: () => void;
|
||||
onNavigate: (columnIndex: number) => void;
|
||||
onRegenerate?: () => Promise<void>;
|
||||
/** If true, open the document panel immediately */
|
||||
displayDocument?: boolean;
|
||||
/** Quote to highlight when opening document panel */
|
||||
citationQuote?: string;
|
||||
/** Page to scroll to when opening document panel */
|
||||
citationPage?: number;
|
||||
}
|
||||
|
||||
const FLAG_BADGE: Record<string, string> = {
|
||||
green: "bg-emerald-600 backdrop-blur-md border border-emerald-300/20 text-white shadow-md",
|
||||
grey: "bg-slate-500 backdrop-blur-md border border-slate-300/20 text-white shadow-md",
|
||||
yellow: "bg-amber-500 backdrop-blur-md border border-amber-300/20 text-white shadow-md",
|
||||
red: "bg-red-600 backdrop-blur-md border border-red-300/20 text-white shadow-md",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TRSidePanel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TRSidePanel({
|
||||
cell,
|
||||
document: doc,
|
||||
column,
|
||||
columns,
|
||||
onClose,
|
||||
onNavigate,
|
||||
onRegenerate,
|
||||
displayDocument = false,
|
||||
citationQuote,
|
||||
citationPage,
|
||||
}: Props) {
|
||||
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
|
||||
const currentPos = sortedColumns.findIndex((c) => c.index === column.index);
|
||||
const prevColumn = currentPos > 0 ? sortedColumns[currentPos - 1] : null;
|
||||
const nextColumn =
|
||||
currentPos < sortedColumns.length - 1
|
||||
? sortedColumns[currentPos + 1]
|
||||
: null;
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const [quoteExpanded, setQuoteExpanded] = useState(false);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
const quoteParagraphRef = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
// Internal state — initialised from props, also toggled by badge clicks inside the panel
|
||||
const [docCitation, setDocCitation] = useState<
|
||||
{ quote: string; page: number } | undefined
|
||||
>(
|
||||
displayDocument && citationQuote
|
||||
? { quote: citationQuote, page: citationPage ?? 1 }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Re-sync when the panel opens for a different cell or citation
|
||||
useEffect(() => {
|
||||
setDocCitation(
|
||||
displayDocument && citationQuote
|
||||
? { quote: citationQuote, page: citationPage ?? 1 }
|
||||
: undefined,
|
||||
);
|
||||
setQuoteExpanded(false);
|
||||
}, [cell.id, displayDocument, citationQuote, citationPage]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = quoteParagraphRef.current;
|
||||
if (!el || quoteExpanded) return;
|
||||
setIsTruncated(el.scrollWidth > el.clientWidth);
|
||||
}, [docCitation?.quote, quoteExpanded]);
|
||||
|
||||
const { processed: summaryText, citations: summaryCitations } =
|
||||
preprocessCitations(cell.content?.summary ?? "");
|
||||
const { processed: reasoningText, citations: reasoningCitations } =
|
||||
preprocessCitations(cell.content?.reasoning ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
console.log("[TRSidePanel] summary:", cell.content?.summary ?? "");
|
||||
}, [cell.id, cell.content?.summary]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed right-0 top-0 bottom-0 z-100 flex flex-row shadow-md border-l border-gray-200"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.08)",
|
||||
backdropFilter: "blur(10px) saturate(50%)",
|
||||
WebkitBackdropFilter: "blur(10px) saturate(50%)",
|
||||
}}
|
||||
>
|
||||
{/* Document panel — left, 600px */}
|
||||
{docCitation !== undefined && (
|
||||
<div className="relative flex w-[600px] shrink-0 flex-col border-r border-white/30 px-3">
|
||||
{/* Doc header */}
|
||||
<div className="flex items-center gap-2 pt-3 shrink-0 border-b border-white/30">
|
||||
<p
|
||||
className="flex-1 truncate text-sm font-semibold font-sans text-slate-700 font-serif"
|
||||
title={doc.filename}
|
||||
>
|
||||
{doc.filename}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setDocCitation(undefined)}
|
||||
className="shrink-0 rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-white/40 hover:text-slate-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Quote row */}
|
||||
{docCitation.quote && (
|
||||
<div className="py-2 shrink-0">
|
||||
<div className="w-full rounded-md bg-gray-50 border border-gray-200 px-2 py-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
isTruncated || quoteExpanded
|
||||
? setQuoteExpanded((v) => !v)
|
||||
: undefined
|
||||
}
|
||||
className={`flex w-full items-start gap-1 text-left ${!(isTruncated || quoteExpanded) ? "cursor-default" : ""}`}
|
||||
>
|
||||
<p
|
||||
ref={quoteParagraphRef}
|
||||
className={`flex-1 text-sm text-gray-600 ${quoteExpanded ? "" : "truncate"}`}
|
||||
>
|
||||
"{docCitation.quote}"
|
||||
</p>
|
||||
{(isTruncated || quoteExpanded) && (
|
||||
<ChevronDown
|
||||
className={`mt-0.5 h-3 w-3 shrink-0 text-gray-500 transition-transform ${quoteExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isDocxDocument(doc) && !doc.pdf_storage_path ? (
|
||||
<DocxView
|
||||
documentId={doc.id}
|
||||
quotes={[
|
||||
{
|
||||
page: docCitation.page,
|
||||
quote: docCitation.quote,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<DocView
|
||||
doc={{ document_id: doc.id }}
|
||||
quote={docCitation.quote}
|
||||
fallbackPage={docCitation.page}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info column — right, 300px fixed */}
|
||||
<div className="flex w-[300px] shrink-0 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-end gap-3 px-5 pt-3 pb-1 shrink-0 border-b border-white/30">
|
||||
<div className="flex items-center gap-1 mr-auto">
|
||||
<button
|
||||
onClick={() =>
|
||||
prevColumn && onNavigate(prevColumn.index)
|
||||
}
|
||||
disabled={!prevColumn}
|
||||
title={prevColumn ? prevColumn.name : undefined}
|
||||
className="rounded-lg p-0.5 text-slate-600 transition-colors hover:bg-slate-200 hover:text-slate-900 disabled:opacity-30 disabled:cursor-default"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-xs text-slate-600 font-sans tabular-nums">
|
||||
{currentPos + 1} / {sortedColumns.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
nextColumn && onNavigate(nextColumn.index)
|
||||
}
|
||||
disabled={!nextColumn}
|
||||
title={nextColumn ? nextColumn.name : undefined}
|
||||
className="rounded-lg p-0.5 text-slate-600 transition-colors hover:bg-slate-200 hover:text-slate-900 disabled:opacity-30 disabled:cursor-default"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{onRegenerate && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setRegenerating(true);
|
||||
try {
|
||||
await onRegenerate();
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
}}
|
||||
disabled={regenerating}
|
||||
title="Regenerate"
|
||||
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 disabled:opacity-40"
|
||||
>
|
||||
{regenerating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Analysis panel */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="pb-2 px-5">
|
||||
{/* Column name */}
|
||||
<div className="mb-1">
|
||||
<span className="text-lg font-semibold text-slate-900">
|
||||
{column.name}
|
||||
</span>
|
||||
</div>
|
||||
{/* Document name */}
|
||||
<p className="text-xs mb-4">{doc.filename}</p>
|
||||
|
||||
{/* Flag section */}
|
||||
{cell.content?.flag && (
|
||||
<div className="mb-5">
|
||||
<h4 className="mb-2 text-sm font-semibold tracking-wider font-sans">
|
||||
Flag
|
||||
</h4>
|
||||
<span
|
||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold ${FLAG_BADGE[cell.content.flag] ?? FLAG_BADGE.grey}`}
|
||||
>
|
||||
{cell.content.flag.charAt(0).toUpperCase() +
|
||||
cell.content.flag.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
<div className="mb-6">
|
||||
<h4 className="mb-2 text-sm font-semibold tracking-wider font-sans">
|
||||
Results
|
||||
</h4>
|
||||
<div className="text-xs leading-relaxed text-slate-600">
|
||||
<MarkdownContent
|
||||
citations={summaryCitations}
|
||||
onCitationClick={setDocCitation}
|
||||
column={column}
|
||||
>
|
||||
{summaryText || "—"}
|
||||
</MarkdownContent>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reasoning */}
|
||||
{cell.content?.reasoning && (
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold tracking-wider font-sans">
|
||||
Reasoning
|
||||
</h4>
|
||||
<div className="text-xs leading-relaxed text-slate-600">
|
||||
<MarkdownContent
|
||||
citations={reasoningCitations}
|
||||
onCitationClick={setDocCitation}
|
||||
citationOffset={summaryCitations.length}
|
||||
column={column}
|
||||
inline
|
||||
>
|
||||
{reasoningText}
|
||||
</MarkdownContent>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CitationBadge({
|
||||
index,
|
||||
citation,
|
||||
onClick,
|
||||
}: {
|
||||
index: number;
|
||||
citation: ParsedCitation;
|
||||
onClick: (c: { quote: string; page: number }) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-page={citation.page}
|
||||
data-quote={citation.quote}
|
||||
title={`Page ${citation.page}: "${citation.quote}"`}
|
||||
onClick={() =>
|
||||
onClick({ quote: citation.quote, page: citation.page })
|
||||
}
|
||||
className="inline-flex items-center justify-center rounded-full bg-gray-200 w-3.5 h-3.5 text-[9px] font-medium text-gray-700 align-super cursor-pointer hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
{index + 1}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownContent({
|
||||
children,
|
||||
citations,
|
||||
onCitationClick,
|
||||
citationOffset = 0,
|
||||
column,
|
||||
inline,
|
||||
}: {
|
||||
children: string;
|
||||
citations: ParsedCitation[];
|
||||
onCitationClick: (c: { quote: string; page: number }) => void;
|
||||
inline?: boolean;
|
||||
citationOffset?: number;
|
||||
column?: ColumnConfig;
|
||||
}) {
|
||||
if (!children) return null;
|
||||
|
||||
const pills: string[] = [];
|
||||
let processed = children.replace(/\[\[([^\]]+)\]\]/g, (_, content) => {
|
||||
const idx = pills.length;
|
||||
pills.push(content);
|
||||
return `\`§p${idx}§\``;
|
||||
});
|
||||
processed = processed.replace(/§(\d+)§/g, (_, idx) => `\`§c${idx}§\``);
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ node, ...props }) =>
|
||||
inline ? (
|
||||
<span {...props} />
|
||||
) : (
|
||||
<p
|
||||
className="mb-1.5 last:mb-0 leading-relaxed"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ node, ...props }) => (
|
||||
<ul
|
||||
className="list-disc pl-4 space-y-0.5 mb-1.5 last:mb-0"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ol: ({ node, ...props }) => (
|
||||
<ol
|
||||
className="list-decimal pl-4 space-y-0.5 mb-1.5 last:mb-0"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
li: ({ node, ...props }) => <li {...props} />,
|
||||
strong: ({ node, ...props }) => (
|
||||
<strong className="font-semibold" {...props} />
|
||||
),
|
||||
em: ({ node, ...props }) => (
|
||||
<em className="italic" {...props} />
|
||||
),
|
||||
a: ({ node, href, children, ...props }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-700 underline"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ node, children: codeChildren, ...props }) => {
|
||||
const t = String(codeChildren);
|
||||
const citMatch = t.match(/^§c(\d+)§$/);
|
||||
if (citMatch) {
|
||||
const idx = parseInt(citMatch[1]);
|
||||
const citation = citations[idx];
|
||||
if (citation) {
|
||||
return (
|
||||
<CitationBadge
|
||||
index={citationOffset + idx}
|
||||
citation={citation}
|
||||
onClick={onCitationClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
const pillMatch = t.match(/^§p(\d+)§$/);
|
||||
if (pillMatch) {
|
||||
const content = pills[parseInt(pillMatch[1])];
|
||||
if (content !== undefined) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block rounded-full px-1.5 py-0.5 text-[11px] font-medium leading-none ${getPillClass(content, column)}`}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<code
|
||||
className="bg-gray-100 px-1 py-0.5 rounded text-[11px] font-mono"
|
||||
{...props}
|
||||
>
|
||||
{codeChildren}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{processed}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
326
frontend/src/app/components/tabular/TRTable.tsx
Normal file
326
frontend/src/app/components/tabular/TRTable.tsx
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
"use client";
|
||||
|
||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
import { Plus, Table2 } from "lucide-react";
|
||||
import type { ColumnConfig, MikeDocument, TabularCell } from "../shared/types";
|
||||
import { TabularCell as TabularCellComponent } from "./TabularCell";
|
||||
import { TREditColumnMenu } from "./TREditColumnMenu";
|
||||
|
||||
const SKELETON_COLS = 4;
|
||||
const SKELETON_ROWS = 5;
|
||||
|
||||
const COL_W = "w-[300px] shrink-0";
|
||||
const CHECK_W = "w-8 shrink-0";
|
||||
|
||||
// Pixel widths matching the CSS constants above
|
||||
const CHECK_W_PX = 32; // w-8 = 2rem = 32px
|
||||
const DOC_COL_W_PX = 300;
|
||||
const DATA_COL_W_PX = 300;
|
||||
const STICKY_LEFT_PX = CHECK_W_PX + DOC_COL_W_PX; // 332px
|
||||
|
||||
export interface TRTableHandle {
|
||||
scrollToCell: (colIdx: number, rowIdx: number) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
columns: ColumnConfig[];
|
||||
documents: MikeDocument[];
|
||||
cells: TabularCell[];
|
||||
savingColumn: boolean;
|
||||
savingColumnsConfig: boolean;
|
||||
selectedDocIds: string[];
|
||||
highlightedCell?: { colIdx: number; rowIdx: number } | null;
|
||||
onSelectionChange: (ids: string[]) => void;
|
||||
onExpand: (cell: TabularCell) => void;
|
||||
onCitationClick: (cell: TabularCell, page: number, quote: string) => void;
|
||||
onUpdateColumn: (col: ColumnConfig) => void;
|
||||
onDeleteColumn: (colIndex: number) => void;
|
||||
onAddColumn: () => void;
|
||||
onAddDocuments: () => void;
|
||||
}
|
||||
|
||||
export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
||||
{
|
||||
loading,
|
||||
columns,
|
||||
documents,
|
||||
cells,
|
||||
savingColumn,
|
||||
savingColumnsConfig,
|
||||
selectedDocIds,
|
||||
highlightedCell,
|
||||
onSelectionChange,
|
||||
onExpand,
|
||||
onCitationClick,
|
||||
onUpdateColumn,
|
||||
onDeleteColumn,
|
||||
onAddColumn,
|
||||
onAddDocuments,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
|
||||
const totalContentWidth =
|
||||
CHECK_W_PX + DOC_COL_W_PX + sortedColumns.length * DATA_COL_W_PX + 32;
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToCell(colIdx: number, rowIdx: number) {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Vertical: find actual row via DOM (handles variable row heights)
|
||||
const allRows = container.querySelectorAll<HTMLElement>(
|
||||
":scope > div.flex.min-w-full",
|
||||
);
|
||||
const targetRow = allRows[rowIdx];
|
||||
if (targetRow) {
|
||||
container.scrollTo({
|
||||
top: Math.max(0, targetRow.offsetTop - 40),
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
// Horizontal: fixed column widths — center the target column in view
|
||||
const targetScrollLeft =
|
||||
STICKY_LEFT_PX +
|
||||
colIdx * DATA_COL_W_PX -
|
||||
container.clientWidth / 2 +
|
||||
DATA_COL_W_PX / 2;
|
||||
container.scrollLeft = Math.max(0, targetScrollLeft);
|
||||
},
|
||||
}));
|
||||
|
||||
function getCell(docId: string, colIdx: number) {
|
||||
return cells.find(
|
||||
(c) => c.document_id === docId && c.column_index === colIdx,
|
||||
);
|
||||
}
|
||||
|
||||
const allSelected =
|
||||
documents.length > 0 &&
|
||||
documents.every((d) => selectedDocIds.includes(d.id));
|
||||
const someSelected =
|
||||
!allSelected && documents.some((d) => selectedDocIds.includes(d.id));
|
||||
|
||||
function toggleAll() {
|
||||
if (allSelected) {
|
||||
onSelectionChange([]);
|
||||
} else {
|
||||
onSelectionChange(documents.map((d) => d.id));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDoc(id: string) {
|
||||
if (selectedDocIds.includes(id)) {
|
||||
onSelectionChange(selectedDocIds.filter((x) => x !== id));
|
||||
} else {
|
||||
onSelectionChange([...selectedDocIds, id]);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex border-b border-gray-200">
|
||||
<div
|
||||
className={`${CHECK_W} border-r border-gray-200 p-2`}
|
||||
/>
|
||||
<div
|
||||
className={`${COL_W} border-r border-gray-200 p-2 text-xs font-medium text-gray-500`}
|
||||
>
|
||||
Document
|
||||
</div>
|
||||
{Array.from({ length: SKELETON_COLS }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${COL_W} border-r border-gray-200 p-2`}
|
||||
>
|
||||
<div className="h-4 w-28 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{Array.from({ length: SKELETON_ROWS }).map((_, row) => (
|
||||
<div
|
||||
key={row}
|
||||
className={`flex border-b border-gray-50 ${row % 2 === 0 ? "bg-white" : "bg-gray-50/50"}`}
|
||||
>
|
||||
<div className={`${CHECK_W} p-2`} />
|
||||
<div className={`${COL_W} p-2`}>
|
||||
<div className="h-4 w-32 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
{Array.from({ length: SKELETON_COLS }).map((_, col) => (
|
||||
<div key={col} className={`${COL_W} p-2`}>
|
||||
<div className="h-4 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (columns.length === 0 && documents.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex items-center border-b border-gray-200">
|
||||
<div className={`${CHECK_W} border-r border-gray-200`} />
|
||||
<div
|
||||
className={`${COL_W} border-r border-gray-200 p-2 text-xs font-medium text-gray-500 select-none`}
|
||||
>
|
||||
Document
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-start justify-center w-full max-w-xs mx-auto">
|
||||
<Table2 className="h-8 w-8 text-gray-300 mb-4" />
|
||||
<p className="text-2xl font-medium font-serif text-gray-900">
|
||||
Tabular Review
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-400 text-left">
|
||||
Add columns and documents to get started.
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onAddColumn}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-gray-700 shadow-md"
|
||||
>
|
||||
+ Add Columns
|
||||
</button>
|
||||
<button
|
||||
onClick={onAddDocuments}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50 transition-colors shadow-sm"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Documents
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto" ref={scrollContainerRef}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="sticky top-0 z-20 flex bg-white h-8"
|
||||
style={{ minWidth: totalContentWidth }}
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-30 ${CHECK_W} bg-white border-b border-r border-gray-200 flex justify-center items-center select-none`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={toggleAll}
|
||||
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`sticky left-8 z-30 ${COL_W} bg-white border-b border-r border-gray-200 p-2 text-left text-xs font-medium text-gray-500 select-none`}
|
||||
>
|
||||
Document
|
||||
</div>
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.index}
|
||||
className={`${COL_W} border-b border-r border-gray-200 p-2 text-left text-xs font-medium text-gray-500 select-none`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="truncate">{col.name}</span>
|
||||
<TREditColumnMenu
|
||||
column={col}
|
||||
disabled={savingColumn || savingColumnsConfig}
|
||||
onSave={onUpdateColumn}
|
||||
onDelete={onDeleteColumn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex-1 border-b border-gray-200 flex items-center justify-start p-2 min-w-8">
|
||||
<button
|
||||
onClick={onAddColumn}
|
||||
disabled={savingColumn || savingColumnsConfig}
|
||||
className="flex items-center justify-center text-gray-400 hover:text-gray-700 transition-colors disabled:text-gray-200"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{documents.map((doc, docIdx) => {
|
||||
const rowBg = selectedDocIds.includes(doc.id)
|
||||
? "bg-gray-100"
|
||||
: docIdx % 2 === 0
|
||||
? "bg-white"
|
||||
: "bg-gray-50";
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
className={`flex ${rowBg}`}
|
||||
style={{ minWidth: totalContentWidth }}
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} border-b border-r border-gray-200 p-2 flex items-center justify-center ${rowBg}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDocIds.includes(doc.id)}
|
||||
onChange={() => toggleDoc(doc.id)}
|
||||
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`sticky left-8 z-[60] ${COL_W} border-b border-r border-gray-200 p-2 text-xs text-gray-800 flex items-center ${rowBg}`}
|
||||
>
|
||||
<span className="line-clamp-1" title={doc.filename}>
|
||||
{doc.filename}
|
||||
</span>
|
||||
</div>
|
||||
{columns.map((col) => {
|
||||
const cell = getCell(doc.id, col.index);
|
||||
const colPos = sortedColumns.findIndex(
|
||||
(c) => c.index === col.index,
|
||||
);
|
||||
const isHighlighted =
|
||||
highlightedCell?.colIdx === colPos &&
|
||||
highlightedCell?.rowIdx === docIdx;
|
||||
return (
|
||||
<div
|
||||
key={col.index}
|
||||
className={`${COL_W} border-b border-r border-gray-200 transition-colors ${isHighlighted ? "bg-blue-200" : ""}`}
|
||||
>
|
||||
{cell && (
|
||||
<TabularCellComponent
|
||||
cell={cell}
|
||||
column={col}
|
||||
onExpand={() => onExpand(cell)}
|
||||
onCitationClick={(page, quote) =>
|
||||
onCitationClick(
|
||||
cell,
|
||||
page,
|
||||
quote,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex-1 border-b border-gray-200 min-h-8 min-w-8" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
268
frontend/src/app/components/tabular/TabularCell.tsx
Normal file
268
frontend/src/app/components/tabular/TabularCell.tsx
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { AlertCircle, Expand } from "lucide-react";
|
||||
import type { ColumnConfig, TabularCell as TCell } from "../shared/types";
|
||||
import { preprocessCitations, type ParsedCitation } from "./citation-utils";
|
||||
import { getPillClass } from "./pillUtils";
|
||||
|
||||
interface Props {
|
||||
cell: TCell;
|
||||
column?: ColumnConfig;
|
||||
onExpand: () => void;
|
||||
onCitationClick?: (page: number, quote: string) => void;
|
||||
}
|
||||
|
||||
const FLAG_STYLES = {
|
||||
green: "bg-green-500",
|
||||
grey: "bg-gray-400",
|
||||
yellow: "bg-amber-400",
|
||||
red: "bg-red-500",
|
||||
} as const;
|
||||
|
||||
// Replace citations and pills with inline-code tokens so ReactMarkdown passes
|
||||
// them through its `code` component, where we render the final UI.
|
||||
function preprocessCellMarkdown(text: string): {
|
||||
processed: string;
|
||||
citations: ParsedCitation[];
|
||||
pills: string[];
|
||||
} {
|
||||
const { processed: withCits, citations } = preprocessCitations(text);
|
||||
const pills: string[] = [];
|
||||
let out = withCits.replace(/\[\[([^\]]+)\]\]/g, (_, content) => {
|
||||
const idx = pills.length;
|
||||
pills.push(content);
|
||||
return `\`§p${idx}§\`\u200B`;
|
||||
});
|
||||
out = out.replace(/§(\d+)§/g, (_, idx) => `\`§c${idx}§\`\u200B`);
|
||||
return { processed: out, citations, pills };
|
||||
}
|
||||
|
||||
function CellMarkdown({
|
||||
text,
|
||||
citations,
|
||||
pills,
|
||||
column,
|
||||
onCitationClick,
|
||||
onExpand,
|
||||
inline,
|
||||
}: {
|
||||
text: string;
|
||||
citations: ParsedCitation[];
|
||||
pills: string[];
|
||||
column?: ColumnConfig;
|
||||
onCitationClick?: (page: number, quote: string) => void;
|
||||
onExpand: () => void;
|
||||
inline?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ node, ...props }) =>
|
||||
inline ? (
|
||||
<span {...props} />
|
||||
) : (
|
||||
<p className="mb-1 last:mb-0 leading-relaxed" {...props} />
|
||||
),
|
||||
ul: ({ node, ...props }) => (
|
||||
<ul className="list-disc pl-4 space-y-0.5" {...props} />
|
||||
),
|
||||
ol: ({ node, ...props }) => (
|
||||
<ol className="list-decimal pl-4 space-y-0.5" {...props} />
|
||||
),
|
||||
li: ({ node, ...props }) => <li {...props} />,
|
||||
strong: ({ node, ...props }) => (
|
||||
<strong className="font-semibold" {...props} />
|
||||
),
|
||||
em: ({ node, ...props }) => <em className="italic" {...props} />,
|
||||
a: ({ node, href, children, ...props }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-700 underline"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ node, children, ...props }) => {
|
||||
const t = String(children);
|
||||
const citMatch = t.match(/^§c(\d+)§$/);
|
||||
if (citMatch) {
|
||||
const idx = parseInt(citMatch[1]);
|
||||
const citation = citations[idx];
|
||||
if (citation) {
|
||||
return (
|
||||
<span
|
||||
title={`Page ${citation.page}: "${citation.quote}"`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onCitationClick) {
|
||||
onCitationClick(
|
||||
citation.page,
|
||||
citation.quote,
|
||||
);
|
||||
} else {
|
||||
onExpand();
|
||||
}
|
||||
}}
|
||||
className="mx-0.5 inline-flex items-center justify-center rounded-full bg-gray-200 w-3.5 h-3.5 text-[9px] font-medium text-gray-700 align-super cursor-pointer hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
{idx + 1}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
const pillMatch = t.match(/^§p(\d+)§$/);
|
||||
if (pillMatch) {
|
||||
const content = pills[parseInt(pillMatch[1])];
|
||||
if (content !== undefined) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block rounded-full px-1.5 py-0.5 text-[10px] font-medium leading-none ${getPillClass(content, column)}`}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<code
|
||||
className="bg-gray-100 px-1 py-0.5 rounded text-[11px] font-mono"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabularCell({
|
||||
cell,
|
||||
column,
|
||||
onExpand,
|
||||
onCitationClick,
|
||||
}: Props) {
|
||||
const [inlineExpanded, setInlineExpanded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inlineExpanded) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setInlineExpanded(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [inlineExpanded]);
|
||||
|
||||
if (cell.status === "generating") {
|
||||
return (
|
||||
<div className="h-10 px-2 flex items-center">
|
||||
<div className="h-4 w-full rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (cell.status === "error") {
|
||||
return (
|
||||
<div className="h-10 flex items-center justify-center text-gray-300">
|
||||
<AlertCircle className="h-4 w-4 text-red-300" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!cell.content?.summary) {
|
||||
return <div className="h-10" />;
|
||||
}
|
||||
|
||||
const { processed, citations, pills } = preprocessCellMarkdown(
|
||||
cell.content.summary,
|
||||
);
|
||||
|
||||
const firstLine = processed.split("\n").find((l) => l.trim()) ?? processed;
|
||||
const collapsedDisplay = firstLine.replace(/^[-*•]\s+/, "");
|
||||
|
||||
function handleCitationClickInOverlay(page: number, quote: string) {
|
||||
setInlineExpanded(false);
|
||||
onCitationClick?.(page, quote);
|
||||
}
|
||||
|
||||
function handleSeeDetails() {
|
||||
setInlineExpanded(false);
|
||||
onExpand();
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
{/* Normal cell row — always visible, preserves table layout */}
|
||||
<div
|
||||
className="group relative h-10 px-2 flex items-center text-xs text-gray-800 leading-relaxed cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setInlineExpanded((v) => !v)}
|
||||
>
|
||||
{cell.content.flag && (
|
||||
<span
|
||||
className={`absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full ${FLAG_STYLES[cell.content.flag]}`}
|
||||
title={cell.content.flag}
|
||||
/>
|
||||
)}
|
||||
<div className="line-clamp-1 w-full min-w-0">
|
||||
<CellMarkdown
|
||||
text={collapsedDisplay}
|
||||
citations={citations}
|
||||
pills={pills}
|
||||
column={column}
|
||||
onCitationClick={onCitationClick}
|
||||
onExpand={onExpand}
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline expanded overlay — absolutely positioned so it overlays without disrupting table layout */}
|
||||
{inlineExpanded && (
|
||||
<div className="absolute left-0 top-0 z-50 w-full bg-white border border-gray-200 shadow-lg rounded-sm">
|
||||
<div className="relative p-2 pr-4 text-xs text-gray-800 leading-relaxed">
|
||||
{cell.content.flag && (
|
||||
<span
|
||||
className={`absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full ${FLAG_STYLES[cell.content.flag]}`}
|
||||
title={cell.content.flag}
|
||||
/>
|
||||
)}
|
||||
<CellMarkdown
|
||||
text={processed}
|
||||
citations={citations}
|
||||
pills={pills}
|
||||
column={column}
|
||||
onCitationClick={handleCitationClickInOverlay}
|
||||
onExpand={handleSeeDetails}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2 py-1.5 flex items-center justify-end">
|
||||
<button
|
||||
onClick={handleSeeDetails}
|
||||
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<Expand className="h-3 w-3" />
|
||||
See details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
853
frontend/src/app/components/tabular/TabularReviewView.tsx
Normal file
853
frontend/src/app/components/tabular/TabularReviewView.tsx
Normal file
|
|
@ -0,0 +1,853 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Plus, Loader2, Play, ChevronDown, MessageSquare, Download, Users } from "lucide-react";
|
||||
import { HeaderSearchBtn } from "../shared/HeaderSearchBtn";
|
||||
|
||||
import {
|
||||
clearTabularCells,
|
||||
getTabularReview,
|
||||
getProject,
|
||||
getTabularReviewPeople,
|
||||
regenerateTabularCell,
|
||||
streamTabularGeneration,
|
||||
updateTabularReview,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import type {
|
||||
ColumnConfig,
|
||||
MikeDocument,
|
||||
MikeProject,
|
||||
TabularCell,
|
||||
TabularReview,
|
||||
} from "../shared/types";
|
||||
import { AddColumnModal } from "./AddColumnModal";
|
||||
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 { RenameableTitle } from "../shared/RenameableTitle";
|
||||
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";
|
||||
|
||||
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<MikeProject | null>(null);
|
||||
const [cells, setCells] = useState<TabularCell[]>([]);
|
||||
const [documents, setDocuments] = useState<MikeDocument[]>([]);
|
||||
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 [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 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 = {
|
||||
claudeApiKey: profile?.claudeApiKey ?? null,
|
||||
geminiApiKey: profile?.geminiApiKey ?? null,
|
||||
};
|
||||
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: MikeDocument[]) {
|
||||
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(),
|
||||
})),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegenerateCell(docId: string, colIndex: number) {
|
||||
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 (!isModelAvailable(tabularModel, apiKeys)) {
|
||||
setApiKeyModalProvider(getModelProvider(tabularModel));
|
||||
return;
|
||||
}
|
||||
|
||||
setGenerating(true);
|
||||
|
||||
// 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(),
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await streamTabularGeneration(reviewId);
|
||||
if (!response.body) throw new Error("No body");
|
||||
|
||||
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 remaining = documents.filter(
|
||||
(d) => !selectedDocIds.includes(d.id),
|
||||
);
|
||||
setDocuments(remaining);
|
||||
setCells((prev) =>
|
||||
prev.filter((c) => !selectedDocIds.includes(c.document_id)),
|
||||
);
|
||||
setSelectedDocIds([]);
|
||||
setActionsOpen(false);
|
||||
await updateTabularReview(reviewId, {
|
||||
document_ids: remaining.map((d) => d.id),
|
||||
columns_config: columns,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
setReview((prev) => (prev ? { ...prev, title: newTitle } : prev));
|
||||
await updateTabularReview(reviewId, { title: newTitle });
|
||||
}
|
||||
|
||||
const q = search.toLowerCase();
|
||||
const filteredDocuments = q
|
||||
? documents.filter((d) => d.filename.toLowerCase().includes(q))
|
||||
: documents;
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden bg-white">
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-white px-8 py-4 flex items-start justify-between shrink-0 gap-4">
|
||||
<div className="flex items-center gap-1.5 text-2xl font-medium font-serif">
|
||||
{projectId && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => router.push("/projects")}
|
||||
className="text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Projects
|
||||
</button>
|
||||
<span className="text-gray-300">›</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
router.push(`/projects/${projectId}`)
|
||||
}
|
||||
className="text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-6 w-32 rounded bg-gray-100 animate-pulse" />
|
||||
) : (
|
||||
<>
|
||||
{project?.name ?? ""}
|
||||
{project?.cm_number && (
|
||||
<span className="ml-1 text-gray-400">
|
||||
(#{project.cm_number})
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<span className="text-gray-300">›</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/projects/${projectId}?tab=reviews`,
|
||||
)
|
||||
}
|
||||
className="text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Tabular Reviews
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!projectId && (
|
||||
<button
|
||||
onClick={() => router.push("/tabular-reviews")}
|
||||
className="text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Tabular Reviews
|
||||
</button>
|
||||
)}
|
||||
<span className="text-gray-300">›</span>
|
||||
{loading ? (
|
||||
<div className="h-6 w-40 rounded bg-gray-100 animate-pulse" />
|
||||
) : (
|
||||
<RenameableTitle
|
||||
value={review?.title || "Untitled Review"}
|
||||
onCommit={handleTitleCommit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!loading && (
|
||||
<div className="flex items-center gap-2">
|
||||
<HeaderSearchBtn value={search} onChange={setSearch} placeholder="Search documents…" />
|
||||
{!projectId && (
|
||||
<button
|
||||
onClick={() => setPeopleModalOpen(true)}
|
||||
disabled={loading}
|
||||
className={`flex h-8 w-8 items-center justify-center text-sm transition-colors ${
|
||||
loading
|
||||
? "text-gray-300 cursor-default"
|
||||
: "text-gray-500 hover:text-gray-900 cursor-pointer"
|
||||
}`}
|
||||
title="People with access"
|
||||
aria-label="People with access"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() =>
|
||||
exportTabularReviewToExcel({
|
||||
reviewTitle: review?.title || "Tabular Review",
|
||||
columns,
|
||||
documents,
|
||||
cells,
|
||||
})
|
||||
}
|
||||
disabled={columns.length === 0 || documents.length === 0}
|
||||
title="Export to Excel"
|
||||
className={`flex h-8 items-center justify-center gap-1.5 px-3 text-sm transition-colors ${
|
||||
columns.length === 0 || documents.length === 0
|
||||
? "text-gray-300 cursor-default"
|
||||
: "text-gray-700 hover:text-gray-900 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={
|
||||
generating ||
|
||||
columns.length === 0 ||
|
||||
documents.length === 0 ||
|
||||
savingColumnsConfig
|
||||
}
|
||||
className={`flex h-8 items-center justify-center gap-1.5 px-3 text-sm transition-colors ${
|
||||
generating ||
|
||||
columns.length === 0 ||
|
||||
documents.length === 0 ||
|
||||
savingColumnsConfig
|
||||
? "text-gray-300 cursor-default"
|
||||
: "text-gray-700 hover:text-gray-900 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
{generating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
{generating ? "Running…" : "Run"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center h-10 px-8 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"
|
||||
}`}
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
Assistant in Tabular Review
|
||||
</button>
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
{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>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setAddDocsOpen(true)}
|
||||
disabled={loading || savingColumnsConfig}
|
||||
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
|
||||
loading || savingColumnsConfig
|
||||
? "text-gray-300 cursor-default"
|
||||
: "text-gray-700 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Documents
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAddColOpen(true)}
|
||||
disabled={
|
||||
loading || savingColumn || savingColumnsConfig
|
||||
}
|
||||
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
|
||||
loading || 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}
|
||||
/>
|
||||
)}
|
||||
<TRTable
|
||||
ref={tableRef}
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
documents={filteredDocuments}
|
||||
cells={cells}
|
||||
highlightedCell={highlightedCell}
|
||||
savingColumn={savingColumn}
|
||||
savingColumnsConfig={savingColumnsConfig}
|
||||
selectedDocIds={selectedDocIds}
|
||||
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>
|
||||
|
||||
{/* 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: MikeDocument[]) =>
|
||||
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: MikeDocument[]) =>
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
||||
<OwnerOnlyModal
|
||||
open={!!ownerOnlyAction}
|
||||
action={ownerOnlyAction ?? undefined}
|
||||
onClose={() => setOwnerOnlyAction(null)}
|
||||
/>
|
||||
|
||||
<ApiKeyMissingModal
|
||||
open={apiKeyModalProvider !== null}
|
||||
provider={apiKeyModalProvider}
|
||||
onClose={() => setApiKeyModalProvider(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
frontend/src/app/components/tabular/citation-utils.ts
Normal file
26
frontend/src/app/components/tabular/citation-utils.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"use client";
|
||||
|
||||
const PAGE_CITATION_RE = /\[\[page:(\d+)\|\|(?:quote:)?((?:[^\[\]]|\[[^\]]*\])+)\]\]/gi;
|
||||
|
||||
export interface ParsedCitation {
|
||||
page: number;
|
||||
quote: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces [[page:n||quote:...]] markers with `§idx§` placeholders.
|
||||
* Returns the processed string and an ordered array of extracted citation data.
|
||||
*/
|
||||
export function preprocessCitations(text: string): {
|
||||
processed: string;
|
||||
citations: ParsedCitation[];
|
||||
} {
|
||||
const citations: ParsedCitation[] = [];
|
||||
PAGE_CITATION_RE.lastIndex = 0;
|
||||
const processed = text.replace(PAGE_CITATION_RE, (_, page, quote) => {
|
||||
const idx = citations.length;
|
||||
citations.push({ page: parseInt(page, 10), quote: quote.trim() });
|
||||
return `§${idx}§`;
|
||||
});
|
||||
return { processed, citations };
|
||||
}
|
||||
23
frontend/src/app/components/tabular/columnFormat.ts
Normal file
23
frontend/src/app/components/tabular/columnFormat.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { LucideIcon } from "lucide-react";
|
||||
import { AlignLeft, List, Hash, DollarSign, ToggleLeft, Calendar, Tag, Percent, Banknote } from "lucide-react";
|
||||
import type { ColumnFormat } from "../shared/types";
|
||||
|
||||
export const FORMAT_OPTIONS: Array<{ value: ColumnFormat; label: string; icon: LucideIcon }> = [
|
||||
{ value: "text", label: "Free Text", icon: AlignLeft },
|
||||
{ value: "bulleted_list", label: "Bulleted list", icon: List },
|
||||
{ value: "number", label: "Number", icon: Hash },
|
||||
{ value: "percentage", label: "Percentage", icon: Percent },
|
||||
{ value: "monetary_amount", label: "Monetary Amount", icon: Banknote },
|
||||
{ value: "currency", label: "Currency", icon: DollarSign },
|
||||
{ value: "yes_no", label: "Yes / No", icon: ToggleLeft },
|
||||
{ value: "date", label: "Date", icon: Calendar },
|
||||
{ value: "tag", label: "Tags", icon: Tag },
|
||||
];
|
||||
|
||||
export function formatLabel(format: ColumnFormat): string {
|
||||
return FORMAT_OPTIONS.find((o) => o.value === format)?.label ?? "Text";
|
||||
}
|
||||
|
||||
export function formatIcon(format: ColumnFormat): LucideIcon {
|
||||
return FORMAT_OPTIONS.find((o) => o.value === format)?.icon ?? AlignLeft;
|
||||
}
|
||||
104
frontend/src/app/components/tabular/columnPresets.ts
Normal file
104
frontend/src/app/components/tabular/columnPresets.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import type { ColumnFormat } from "../shared/types";
|
||||
|
||||
export interface ColumnPreset {
|
||||
name: string;
|
||||
matches: RegExp;
|
||||
prompt: string;
|
||||
format: ColumnFormat;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export const PROMPT_PRESETS: ColumnPreset[] = [
|
||||
{
|
||||
name: "Parties",
|
||||
matches: /\bpart(y|ies)\b/i,
|
||||
format: "bulleted_list",
|
||||
prompt: 'List all parties to this agreement. For each party, state their full legal name, entity type, and defined role, e.g.:\n• ABC Corp, a Delaware corporation ("Company")\n• John Smith ("Shareholder")\nOne party per bullet. No additional commentary.',
|
||||
},
|
||||
{
|
||||
name: "Governing Law",
|
||||
matches: /\bgoverning law\b|\bjurisdiction\b/i,
|
||||
format: "text",
|
||||
prompt: 'State only the governing law of this agreement using the short-form jurisdiction name, e.g. "New York Law", "English Law", "Indian Law", "PRC Law". No other text.',
|
||||
},
|
||||
{
|
||||
name: "Effective Date",
|
||||
matches: /\beffective date\b/i,
|
||||
format: "date",
|
||||
prompt: 'State only the effective date of this agreement in DD Mon YYYY format, e.g. "2 Jan 2026". If not explicitly stated, write "Not specified".',
|
||||
},
|
||||
{
|
||||
name: "Term",
|
||||
matches: /\bterm\b|\bduration\b/i,
|
||||
format: "text",
|
||||
prompt: 'State only the duration or term of this agreement in a concise form, e.g. "3 years", "24 months", "perpetual". No other text.',
|
||||
},
|
||||
{
|
||||
name: "Termination",
|
||||
matches: /\bterminat(e|ion|ing)\b/i,
|
||||
format: "text",
|
||||
prompt: "Extract the termination provisions. State who may terminate, the trigger events, required notice period, any cure period, and the key consequences of termination. Be concise.",
|
||||
},
|
||||
{
|
||||
name: "Change of Control",
|
||||
matches: /\bchange of control\b/i,
|
||||
format: "text",
|
||||
prompt: "Identify any change of control provisions. Summarize the trigger events, consequences, consent requirements, and any related termination or acceleration rights. Be concise.",
|
||||
},
|
||||
{
|
||||
name: "Confidentiality",
|
||||
matches: /\bconfidential(ity)?\b|\bnon-?disclosure\b/i,
|
||||
format: "text",
|
||||
prompt: "Summarize the confidentiality obligations: scope of confidential information, permitted disclosures, use restrictions, duration, and key carve-outs or exceptions.",
|
||||
},
|
||||
{
|
||||
name: "Assignment",
|
||||
matches: /\bassign(ment|ability)?\b/i,
|
||||
format: "yes_no",
|
||||
prompt: "Is assignment of this agreement permitted without the other party's consent?",
|
||||
},
|
||||
{
|
||||
name: "Payment & Fees",
|
||||
matches: /\bpayment\b|\bfees?\b/i,
|
||||
format: "text",
|
||||
prompt: 'State the key payment obligations concisely: amount, timing, and currency, e.g. "USD 10,000 payable within 30 days of invoice". Note any late payment consequences.',
|
||||
},
|
||||
{
|
||||
name: "Amendment",
|
||||
matches: /\bamendment\b|\bvariation\b/i,
|
||||
format: "text",
|
||||
prompt: "Summarize the amendment provisions: how amendments may be made, who must consent, and any formality requirements such as writing or signature.",
|
||||
},
|
||||
{
|
||||
name: "Indemnity",
|
||||
matches: /\bindemni(ty|ties|fication)\b/i,
|
||||
format: "text",
|
||||
prompt: "Summarize the indemnity provisions: who indemnifies whom, the scope of indemnified losses, any liability caps or exclusions, and key claims procedures.",
|
||||
},
|
||||
{
|
||||
name: "Warranties",
|
||||
matches: /\bwarrant(y|ies|ing)\b|\brepresentations?\b/i,
|
||||
format: "text",
|
||||
prompt: "Identify and describe key representations and warranties provided by any party, including the scope of such assurances and any specific time periods or conditions applicable to them. In particular highlight any non-standard warranties.",
|
||||
},
|
||||
{
|
||||
name: "Force Majeure",
|
||||
matches: /\bforce majeure\b/i,
|
||||
format: "yes_no",
|
||||
prompt: "Does this agreement contain a force majeure clause?",
|
||||
},
|
||||
];
|
||||
|
||||
export function getPresetConfig(
|
||||
title: string,
|
||||
): Pick<ColumnPreset, "prompt" | "format" | "tags"> | null {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) return null;
|
||||
const preset = PROMPT_PRESETS.find(({ matches }) => matches.test(trimmed));
|
||||
if (!preset) return null;
|
||||
return { prompt: preset.prompt, format: preset.format, tags: preset.tags };
|
||||
}
|
||||
|
||||
export function getPresetPrompt(title: string): string | null {
|
||||
return getPresetConfig(title)?.prompt ?? null;
|
||||
}
|
||||
81
frontend/src/app/components/tabular/exportToExcel.ts
Normal file
81
frontend/src/app/components/tabular/exportToExcel.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"use client";
|
||||
|
||||
import ExcelJS from "exceljs";
|
||||
import type { ColumnConfig, MikeDocument, TabularCell } from "../shared/types";
|
||||
import { preprocessCitations } from "./citation-utils";
|
||||
|
||||
function formatCellForExport(cell: TabularCell | undefined): string {
|
||||
if (!cell) return "";
|
||||
if (cell.status === "pending" || cell.status === "generating") return "";
|
||||
if (cell.status === "error") return "Error";
|
||||
const summary = cell.content?.summary;
|
||||
if (!summary) return "";
|
||||
const { processed } = preprocessCitations(summary);
|
||||
return processed
|
||||
.replace(/§\d+§/g, "")
|
||||
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return (
|
||||
name
|
||||
.replace(/[\\/:*?"<>|]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 80) || "Tabular Review"
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportTabularReviewToExcel(params: {
|
||||
reviewTitle: string;
|
||||
columns: ColumnConfig[];
|
||||
documents: MikeDocument[];
|
||||
cells: TabularCell[];
|
||||
}) {
|
||||
const { reviewTitle, columns, documents, cells } = params;
|
||||
|
||||
const sortedCols = [...columns].sort((a, b) => a.index - b.index);
|
||||
const cellMap = new Map<string, TabularCell>();
|
||||
for (const c of cells) cellMap.set(`${c.document_id}:${c.column_index}`, c);
|
||||
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet("Review");
|
||||
|
||||
ws.columns = [
|
||||
{ header: "Document", width: 40 },
|
||||
...sortedCols.map((c) => ({ header: c.name, width: 40 })),
|
||||
];
|
||||
|
||||
const headerRow = ws.getRow(1);
|
||||
headerRow.font = { bold: true };
|
||||
headerRow.alignment = { vertical: "middle" };
|
||||
headerRow.fill = {
|
||||
type: "pattern",
|
||||
pattern: "solid",
|
||||
fgColor: { argb: "FFF3F4F6" },
|
||||
};
|
||||
|
||||
for (const doc of documents) {
|
||||
const row: string[] = [doc.filename];
|
||||
for (const col of sortedCols) {
|
||||
row.push(formatCellForExport(cellMap.get(`${doc.id}:${col.index}`)));
|
||||
}
|
||||
const excelRow = ws.addRow(row);
|
||||
excelRow.alignment = { vertical: "top", wrapText: true };
|
||||
}
|
||||
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
const blob = new Blob([buf], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${sanitizeFilename(reviewTitle)}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
72
frontend/src/app/components/tabular/pillUtils.ts
Normal file
72
frontend/src/app/components/tabular/pillUtils.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import type { ColumnConfig } from "../shared/types";
|
||||
|
||||
export type PillSegment =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "pill"; content: string };
|
||||
|
||||
/** Sequential colors assigned to tags by their position in the tags array. */
|
||||
export const TAG_COLORS = [
|
||||
"bg-blue-100 text-blue-700",
|
||||
"bg-violet-100 text-violet-700",
|
||||
"bg-pink-100 text-pink-700",
|
||||
"bg-orange-100 text-orange-700",
|
||||
"bg-teal-100 text-teal-700",
|
||||
"bg-amber-100 text-amber-700",
|
||||
"bg-indigo-100 text-indigo-700",
|
||||
"bg-rose-100 text-rose-700",
|
||||
];
|
||||
|
||||
const CURRENCY_COLORS: Record<string, string> = {
|
||||
USD: "bg-green-100 text-green-700",
|
||||
EUR: "bg-blue-100 text-blue-700",
|
||||
GBP: "bg-purple-100 text-purple-700",
|
||||
JPY: "bg-red-100 text-red-700",
|
||||
CHF: "bg-orange-100 text-orange-700",
|
||||
AUD: "bg-cyan-100 text-cyan-700",
|
||||
CAD: "bg-teal-100 text-teal-700",
|
||||
SGD: "bg-pink-100 text-pink-700",
|
||||
HKD: "bg-rose-100 text-rose-700",
|
||||
NZD: "bg-lime-100 text-lime-700",
|
||||
CNY: "bg-amber-100 text-amber-700",
|
||||
};
|
||||
|
||||
export function getPillClass(content: string, column?: ColumnConfig): string {
|
||||
if (column?.format === "yes_no") {
|
||||
const lower = content.toLowerCase();
|
||||
if (lower === "yes") return "bg-green-100 text-green-700";
|
||||
if (lower === "no") return "bg-red-100 text-red-700";
|
||||
return "bg-gray-100 text-gray-700";
|
||||
}
|
||||
if (column?.format === "currency") {
|
||||
return (
|
||||
CURRENCY_COLORS[content.toUpperCase()] ??
|
||||
"bg-slate-100 text-slate-700"
|
||||
);
|
||||
}
|
||||
if (column?.format === "tag" && column.tags?.length) {
|
||||
const idx = column.tags.findIndex(
|
||||
(t) => t.toLowerCase() === content.toLowerCase(),
|
||||
);
|
||||
if (idx >= 0) return TAG_COLORS[idx % TAG_COLORS.length]!;
|
||||
}
|
||||
return "bg-gray-100 text-gray-700";
|
||||
}
|
||||
|
||||
/** Split text on [[...]] pill markers, preserving surrounding text. */
|
||||
export function parsePills(text: string): PillSegment[] {
|
||||
const segments: PillSegment[] = [];
|
||||
const regex = /\[\[([^\]]+)\]\]/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({ type: "text", content: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
segments.push({ type: "pill", content: match[1] });
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({ type: "text", content: text.slice(lastIndex) });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
60
frontend/src/app/components/tabular/prompt-generator.ts
Normal file
60
frontend/src/app/components/tabular/prompt-generator.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
const PROMPT_PRESETS: Array<{
|
||||
matches: RegExp;
|
||||
prompt: (title: string) => string;
|
||||
}> = [
|
||||
{
|
||||
matches: /\bpart(y|ies)\b/i,
|
||||
prompt: () =>
|
||||
'Identify all parties referenced in the document. List their full names and describe each party\'s role or capacity in the agreement. If a party is not clearly identified, state "Not addressed".',
|
||||
},
|
||||
{
|
||||
matches: /\bchange of control\b/i,
|
||||
prompt: () =>
|
||||
'Identify any change of control provisions in the document. Summarize the trigger, the consequences, any consent requirements, and any related termination, acceleration, or notice obligations. If not addressed, state "Not addressed".',
|
||||
},
|
||||
{
|
||||
matches: /\bterminat(e|ion|ing)\b/i,
|
||||
prompt: () =>
|
||||
'Extract the termination provisions in the document. Summarize who may terminate, the termination triggers, any notice requirements, cure periods, and the consequences of termination. If not addressed, state "Not addressed".',
|
||||
},
|
||||
{
|
||||
matches: /\bgoverning law\b|\bjurisdiction\b/i,
|
||||
prompt: () =>
|
||||
'Identify the governing law and jurisdiction provisions in the document. State the governing law, the forum for disputes, and any submission to jurisdiction or venue requirements. If not addressed, state "Not addressed".',
|
||||
},
|
||||
{
|
||||
matches: /\bconfidential(ity)?\b|\bnon-?disclosure\b/i,
|
||||
prompt: () =>
|
||||
'Extract the confidentiality provisions in the document. Summarize the scope of confidential information, permitted disclosures, use restrictions, duration, and any carve-outs or exceptions. If not addressed, state "Not addressed".',
|
||||
},
|
||||
{
|
||||
matches: /\bassign(ment|ability)?\b/i,
|
||||
prompt: () =>
|
||||
'Identify any assignment provisions in the document. Summarize whether assignment is permitted, restricted, or requires consent, and note any exceptions or deemed assignments. If not addressed, state "Not addressed".',
|
||||
},
|
||||
{
|
||||
matches: /\bpayment\b|\bfees?\b/i,
|
||||
prompt: () =>
|
||||
'Extract the payment and fee terms in the document. Summarize payment obligations, amounts, timing, currencies, fee types, and any consequences for late or missed payment. If not addressed, state "Not addressed".',
|
||||
},
|
||||
];
|
||||
|
||||
export function getPresetTabularPrompt(title: string): string | null {
|
||||
const trimmedTitle = title.trim();
|
||||
if (!trimmedTitle) return null;
|
||||
|
||||
const preset = PROMPT_PRESETS.find(({ matches }) => matches.test(trimmedTitle));
|
||||
return preset ? preset.prompt(trimmedTitle) : null;
|
||||
}
|
||||
|
||||
export function buildFallbackTabularPrompt(title: string): string {
|
||||
const trimmedTitle = title.trim();
|
||||
if (!trimmedTitle) return "";
|
||||
|
||||
return (
|
||||
`Review each document and extract the information relevant to "${trimmedTitle}". ` +
|
||||
`Provide a concise, document-specific summary for this column. ` +
|
||||
`Include the key facts, dates, thresholds, parties, and conditions where applicable. ` +
|
||||
`If the document does not contain relevant information, return "Not addressed".`
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue