mike/frontend/src/app/components/workflows/WorkflowDetailPage.tsx

659 lines
31 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import dynamic from "next/dynamic";
import {
Check,
ChevronDown,
Info,
Pencil,
Plus,
Trash2,
Users,
X,
} from "lucide-react";
import { deleteWorkflow, getWorkflow, updateWorkflow } from "@/app/lib/mikeApi";
import { ShareWorkflowModal } from "@/app/components/workflows/ShareWorkflowModal";
import { WFEditColumnModal } from "@/app/components/workflows/WFEditColumnModal";
import { WFColumnViewModal } from "@/app/components/workflows/WFColumnViewModal";
import { AddColumnModal } from "@/app/components/tabular/AddColumnModal";
import type { ColumnConfig, Workflow } from "@/app/components/shared/types";
import { BUILT_IN_WORKFLOWS } from "@/app/components/workflows/builtinWorkflows";
import { formatIcon, formatLabel } from "@/app/components/tabular/columnFormat";
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
import { HeaderActionsMenu } from "@/app/components/shared/HeaderActionsMenu";
import { PageHeader } from "@/app/components/shared/PageHeader";
import { WorkflowDetailsModal } from "@/app/components/workflows/WorkflowDetailsModal";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
// dynamic import keeps Tiptap (browser-only) out of the SSR bundle
const WorkflowPromptEditor = dynamic(
() =>
import("@/app/components/workflows/WorkflowPromptEditor").then(
(m) => ({ default: m.WorkflowPromptEditor }),
),
{ ssr: false },
);
interface Props {
id: string;
workflowType: Workflow["type"];
}
type SaveStatus = "idle" | "saving" | "saved";
type DeleteStatus = "idle" | "loading" | "complete";
const NAME_COL_W = "w-[332px] shrink-0";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export function WorkflowDetailPage({ id, workflowType }: Props) {
const router = useRouter();
const { user } = useAuth();
const { profile } = useUserProfile();
const stickyCellBg = "bg-[#fafbfc]";
const builtinWorkflow =
BUILT_IN_WORKFLOWS.find((w) => w.id === id && w.type === workflowType) ??
null;
const isBuiltin = builtinWorkflow !== null;
const [workflow, setWorkflow] = useState<Workflow | null>(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
const readOnly =
isBuiltin ||
(workflow?.is_system ?? false) ||
workflow?.allow_edit === false;
const canShare = !readOnly && (workflow?.is_owner ?? true);
// Editor state
const [promptMd, setPromptMd] = useState("");
const [columns, setColumns] = useState<ColumnConfig[]>([]);
// Save status
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Column selection
const [selectedColIndices, setSelectedColIndices] = useState<number[]>([]);
// Column modal
const [addColumnOpen, setAddColumnOpen] = useState(false);
const [editingColumn, setEditingColumn] = useState<ColumnConfig | null>(null);
const [viewingColumn, setViewingColumn] = useState<ColumnConfig | null>(null);
// Share popover
const [shareOpen, setShareOpen] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteStatus, setDeleteStatus] = useState<DeleteStatus>("idle");
// Column actions dropdown
const [colActionsOpen, setColActionsOpen] = useState(false);
const colActionsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (colActionsRef.current && !colActionsRef.current.contains(e.target as Node)) {
setColActionsOpen(false);
}
}
if (colActionsOpen) document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [colActionsOpen]);
// ---------------------------------------------------------------------------
// Load workflow
// ---------------------------------------------------------------------------
useEffect(() => {
if (isBuiltin) {
const wf = builtinWorkflow;
if (!wf) {
setNotFound(true);
} else {
setWorkflow(wf);
setPromptMd(wf.prompt_md ?? "");
setColumns(wf.columns_config ?? []);
}
setLoading(false);
return;
}
getWorkflow(id)
.then((wf) => {
if (wf.type !== workflowType) {
setNotFound(true);
return;
}
setWorkflow(wf);
setPromptMd(wf.prompt_md ?? "");
setColumns(
(wf.columns_config ?? [])
.slice()
.sort((a, b) => a.index - b.index),
);
})
.catch(() => setNotFound(true))
.finally(() => setLoading(false));
}, [id, isBuiltin, builtinWorkflow, workflowType]);
// ---------------------------------------------------------------------------
// Debounced auto-save for prompt
// ---------------------------------------------------------------------------
const save = useCallback(
(newPromptMd: string) => {
if (readOnly) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
setSaveStatus("saving");
debounceRef.current = setTimeout(async () => {
try {
await updateWorkflow(id, { prompt_md: newPromptMd });
setSaveStatus("saved");
setTimeout(() => setSaveStatus("idle"), 2000);
} catch {
setSaveStatus("idle");
}
}, 800);
},
[id, readOnly],
);
async function handleWorkflowDetailsSave(values: { title: string }) {
if (!workflow || readOnly || !values.title) return;
if (values.title === workflow.title) return;
const updated = await updateWorkflow(id, { title: values.title });
setWorkflow({
...updated,
shared_by_name:
updated.shared_by_name ?? workflow.shared_by_name ?? null,
});
}
async function handleDeleteWorkflow() {
if (!workflow || readOnly || workflow.is_owner === false) return;
setDeleteStatus("loading");
try {
await deleteWorkflow(id);
setDeleteStatus("complete");
setTimeout(() => router.push("/workflows"), 600);
} catch {
setDeleteStatus("idle");
}
}
function handlePromptChange(val: string | undefined) {
const next = val ?? "";
setPromptMd(next);
save(next);
}
// ---------------------------------------------------------------------------
// Column save
// ---------------------------------------------------------------------------
async function saveColumns(next: ColumnConfig[]) {
if (readOnly) return;
setSaveStatus("saving");
try {
const updated = await updateWorkflow(id, { columns_config: next });
setWorkflow(updated);
setSaveStatus("saved");
setTimeout(() => setSaveStatus("idle"), 2000);
} catch {
setSaveStatus("idle");
}
}
function handleColumnsAdded(added: ColumnConfig[]) {
const next = [
...columns,
...added.map((c, i) => ({ ...c, index: columns.length + i })),
];
setColumns(next);
saveColumns(next);
setAddColumnOpen(false);
}
function handleColumnSaved(updated: ColumnConfig) {
const next = columns.map((c) =>
c.index === updated.index ? updated : c,
);
setColumns(next);
saveColumns(next);
setEditingColumn(null);
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
if (loading) {
return (
<div className="flex h-full flex-col">
<PageHeader
shrink
breadcrumbs={[
{
label: "Workflows",
onClick: () => router.push("/workflows"),
title: "Back to Workflows",
},
{ loading: true, skeletonClassName: "w-40" },
]}
/>
<div className="flex min-h-0 flex-1 flex-col">
{workflowType === "tabular" ? (
<TabularWorkflowEditorSkeleton />
) : (
<AssistantWorkflowEditorSkeleton />
)}
</div>
</div>
);
}
if (notFound || !workflow) {
return (
<div className="flex-1 flex items-center justify-center">
<p className="text-gray-400 font-serif">Workflow not found.</p>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Page header */}
<PageHeader
shrink
actionGap="md"
breadcrumbs={[
{
label: "Workflows",
onClick: () => router.push("/workflows"),
title: "Back to Workflows",
},
{
label: (
<span className="text-gray-900 truncate max-w-xs">
{workflow.title}
</span>
),
},
]}
actions={[
saveStatus !== "idle"
? {
type: "custom",
render: (
<span className="inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-sm text-gray-500">
{saveStatus === "saved" ? (
<Check className="h-3.5 w-3.5 text-green-600" />
) : null}
{saveStatus === "saving"
? "Saving…"
: "Saved"}
</span>
),
}
: null,
canShare
? {
onClick: () => setShareOpen(true),
title: "Open workflow people",
iconOnly: true,
icon: <Users className="h-4 w-4" />,
}
: null,
!readOnly
? {
type: "custom",
render: (
<HeaderActionsMenu
title="Workflow actions"
items={[
{
label: "Rename",
icon: Pencil,
onSelect: () =>
setDetailsOpen(true),
},
{
label: "Workflow Details",
icon: Info,
onSelect: () =>
setDetailsOpen(true),
},
{
label: "Delete",
icon: Trash2,
variant: "danger",
disabled:
workflow.is_owner === false,
onSelect: () => {
setDeleteStatus("idle");
setDeleteOpen(true);
},
},
]}
/>
),
}
: null,
]}
/>
<WorkflowDetailsModal
open={detailsOpen}
workflow={workflow}
canEdit={!readOnly}
canShare={canShare}
currentUserDisplayName={profile?.displayName}
currentUserEmail={user?.email}
onClose={() => setDetailsOpen(false)}
onSave={handleWorkflowDetailsSave}
onShareWorkflow={() => {
setDetailsOpen(false);
setShareOpen(true);
}}
/>
{shareOpen && (
<ShareWorkflowModal
workflowId={id}
workflowName={workflow.title}
onClose={() => setShareOpen(false)}
/>
)}
<ConfirmPopup
open={deleteOpen}
title="Delete workflow?"
message="This workflow will be permanently deleted."
confirmLabel="Delete"
confirmStatus={deleteStatus}
onConfirm={() => void handleDeleteWorkflow()}
onCancel={() => {
if (deleteStatus === "loading") return;
setDeleteOpen(false);
setDeleteStatus("idle");
}}
/>
{/* Body */}
<div className="flex-1 min-h-0 flex flex-col">
{workflow.type === "assistant" ? (
/* ── Assistant: WYSIWYG editor ── */
<div className="flex-1 min-h-0 px-4 pb-2 pt-0 md:px-10 md:pb-3">
<WorkflowPromptEditor
value={promptMd}
onChange={readOnly ? undefined : handlePromptChange}
readOnly={readOnly}
/>
</div>
) : (
/* ── Tabular: Column table ── */
<div className="flex flex-col flex-1 min-h-0">
{/* Toolbar */}
{!readOnly && (
<div className="flex items-center justify-between px-4 md:px-10 h-10 border-b border-gray-200 shrink-0">
<button
onClick={() => setAddColumnOpen(true)}
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Add Column
</button>
{selectedColIndices.length > 0 && (
<div ref={colActionsRef} className="relative">
<button
onClick={() => setColActionsOpen((v) => !v)}
className="flex items-center gap-1 text-xs font-medium text-gray-700 hover:text-gray-900 transition-colors"
>
Actions
<ChevronDown className="h-3.5 w-3.5" />
</button>
{colActionsOpen && (
<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={() => {
const next = columns
.filter((c) => !selectedColIndices.includes(c.index))
.map((c, i) => ({ ...c, index: i }));
setColumns(next);
saveColumns(next);
setSelectedColIndices([]);
setColActionsOpen(false);
}}
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>
)}
</div>
)}
{readOnly && (
<div className="flex h-10 shrink-0 items-center bg-gray-50 px-4 md:px-10">
<span className="text-xs font-medium text-gray-500">
Read-only
</span>
</div>
)}
<div className="flex-1 min-h-0 overflow-auto">
<div className="min-w-max flex min-h-full flex-col">
{/* Table header */}
<div className={`flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium shrink-0 select-none ${readOnly ? "border-t" : ""}`}>
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
{columns.length > 0 && (
<input
type="checkbox"
checked={columns.length > 0 && selectedColIndices.length === columns.length}
ref={(el) => { if (el) el.indeterminate = selectedColIndices.length > 0 && selectedColIndices.length < columns.length; }}
onChange={() => setSelectedColIndices(selectedColIndices.length === columns.length ? [] : columns.map((c) => c.index))}
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
/>
)}
<span>Column Title</span>
</div>
<div className="ml-auto w-36 shrink-0">Format</div>
<div className="flex-1 min-w-0">Prompt</div>
{!readOnly && <div className="w-8 shrink-0" />}
</div>
{/* Rows */}
<div className="flex-1">
{columns.length === 0 ? (
<div className="flex flex-col items-start py-24 w-full max-w-xs mx-auto">
<Plus className="h-8 w-8 text-gray-300 mb-4" />
<p className="text-2xl font-medium font-serif text-gray-900">
Columns
</p>
<p className="mt-1 text-xs text-gray-400 text-left">
Add columns to define what this tabular review workflow extracts from each document.
</p>
{!readOnly && (
<button
onClick={() => setAddColumnOpen(true)}
className="mt-4 inline-flex items-center gap-1 rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white hover:bg-gray-700 transition-colors shadow-md"
>
+ Add Column
</button>
)}
</div>
) : (
columns.map((col) => {
const FormatIcon = formatIcon(col.format ?? "text");
const isChecked = selectedColIndices.includes(col.index);
return (
<div
key={col.index}
onClick={() => readOnly ? setViewingColumn(col) : setEditingColumn(col)}
className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors"
>
<div className={`sticky left-0 z-[60] ${NAME_COL_W} py-2 pl-4 pr-2 ${isChecked ? "bg-gray-50" : stickyCellBg} transition-colors group-hover:bg-gray-100`}>
<div className="flex min-w-0 items-center gap-4">
<input
type="checkbox"
checked={isChecked}
onChange={() => setSelectedColIndices((prev) => prev.includes(col.index) ? prev.filter((i) => i !== col.index) : [...prev, col.index])}
onClick={(e) => e.stopPropagation()}
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
/>
<span className="min-w-0 flex-1 truncate text-sm text-gray-800">
{col.name}
</span>
</div>
</div>
<div className="ml-auto w-36 shrink-0">
<span className="inline-flex items-center gap-1.5 text-xs text-gray-600">
<FormatIcon className="h-3.5 w-3.5 text-gray-400" />
{formatLabel(col.format ?? "text")}
</span>
</div>
<div className="flex-1 min-w-0 pr-4">
<span className="text-xs text-gray-500 truncate block">
{col.prompt}
</span>
</div>
{!readOnly && (
<div className="w-8 shrink-0 flex justify-end">
<button
onClick={(e) => {
e.stopPropagation();
const next = columns
.filter((c) => c.index !== col.index)
.map((c, i) => ({ ...c, index: i }));
setColumns(next);
saveColumns(next);
}}
className="p-1 text-gray-300 hover:text-red-500 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
);
})
)}
</div>
</div>
</div>
</div>
)}
</div>
{/* Read-only column view modal */}
{viewingColumn && (
<WFColumnViewModal col={viewingColumn} onClose={() => setViewingColumn(null)} />
)}
{/* Add column modal */}
<AddColumnModal
open={addColumnOpen}
existingCount={columns.length}
onClose={() => setAddColumnOpen(false)}
onAdd={handleColumnsAdded}
/>
{/* Edit column modal */}
{editingColumn && (
<WFEditColumnModal
column={editingColumn}
onClose={() => setEditingColumn(null)}
onSave={handleColumnSaved}
onDelete={() => {
const next = columns
.filter((c) => c.index !== editingColumn.index)
.map((c, i) => ({ ...c, index: i }));
setColumns(next);
saveColumns(next);
setEditingColumn(null);
}}
/>
)}
</div>
);
}
function AssistantWorkflowEditorSkeleton() {
return (
<div className="min-h-0 flex-1 px-4 pb-2 pt-0 md:px-10 md:pb-3">
<div className="h-full rounded-md border border-gray-200 bg-gray-50 px-5 py-4">
<div className="space-y-3">
<div className="h-3 w-24 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-5/6 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-3/4 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-4/5 animate-pulse rounded bg-gray-100" />
</div>
<div className="mt-8 space-y-3">
<div className="h-3 w-28 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-11/12 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-2/3 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-10/12 animate-pulse rounded bg-gray-100" />
</div>
<div className="mt-8 space-y-3">
<div className="h-3 w-20 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-4/6 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-5/6 animate-pulse rounded bg-gray-100" />
</div>
</div>
</div>
);
}
function TabularWorkflowEditorSkeleton() {
return (
<>
<div className="flex h-10 shrink-0 items-center border-b border-gray-200 px-4 md:px-10">
<div className="h-3 w-20 animate-pulse rounded bg-gray-100" />
</div>
<div className="flex h-8 shrink-0 items-center border-b border-gray-200 pr-3 md:pr-10">
<div
className={`${NAME_COL_W} flex shrink-0 items-center gap-4 self-stretch pl-4 pr-2`}
>
<div className="h-2.5 w-2.5 animate-pulse rounded bg-gray-100" />
<div className="h-2.5 w-20 animate-pulse rounded bg-gray-100" />
</div>
<div className="w-36 shrink-0">
<div className="h-2.5 w-14 animate-pulse rounded bg-gray-100" />
</div>
<div className="flex-1">
<div className="h-2.5 w-12 animate-pulse rounded bg-gray-100" />
</div>
<div className="w-8 shrink-0" />
</div>
<div className="flex-1 overflow-hidden">
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="flex h-10 items-center border-b border-gray-50 pr-3 md:pr-10"
>
<div
className={`${NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}
>
<div className="h-2.5 w-2.5 shrink-0 animate-pulse rounded bg-gray-100" />
<div
className="h-3 animate-pulse rounded bg-gray-100"
style={{ width: `${40 + (i * 13) % 35}%` }}
/>
</div>
<div className="w-36 shrink-0">
<div className="h-3 w-16 animate-pulse rounded bg-gray-100" />
</div>
<div className="flex-1 pr-4">
<div
className="h-3 animate-pulse rounded bg-gray-100"
style={{ width: `${50 + (i * 17) % 35}%` }}
/>
</div>
<div className="w-8 shrink-0" />
</div>
))}
</div>
</>
);
}