mirror of
https://github.com/willchen96/mike.git
synced 2026-06-28 21:49:37 +02:00
Modal, header, mobile display and workflow UI updates
This commit is contained in:
parent
8a2dc05181
commit
3132e04ac0
34 changed files with 1635 additions and 1076 deletions
|
|
@ -11,6 +11,7 @@ import { getUserApiKeys as getStoredUserApiKeys } from "./userApiKeys";
|
||||||
export type UserModelSettings = {
|
export type UserModelSettings = {
|
||||||
title_model: string;
|
title_model: string;
|
||||||
tabular_model: string;
|
tabular_model: string;
|
||||||
|
legal_research_us: boolean;
|
||||||
api_keys: UserApiKeys;
|
api_keys: UserApiKeys;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -32,7 +33,7 @@ export async function getUserModelSettings(
|
||||||
const client = db ?? createServerSupabase();
|
const client = db ?? createServerSupabase();
|
||||||
const { data } = await client
|
const { data } = await client
|
||||||
.from("user_profiles")
|
.from("user_profiles")
|
||||||
.select("title_model, tabular_model")
|
.select("title_model, tabular_model, legal_research_us")
|
||||||
.eq("user_id", userId)
|
.eq("user_id", userId)
|
||||||
.single();
|
.single();
|
||||||
const api_keys = await getStoredUserApiKeys(userId, client);
|
const api_keys = await getStoredUserApiKeys(userId, client);
|
||||||
|
|
@ -40,6 +41,9 @@ export async function getUserModelSettings(
|
||||||
return {
|
return {
|
||||||
title_model: resolveModel(data?.title_model, resolveTitleModel(api_keys)),
|
title_model: resolveModel(data?.title_model, resolveTitleModel(api_keys)),
|
||||||
tabular_model: resolveModel(data?.tabular_model, DEFAULT_TABULAR_MODEL),
|
tabular_model: resolveModel(data?.tabular_model, DEFAULT_TABULAR_MODEL),
|
||||||
|
legal_research_us:
|
||||||
|
(data as { legal_research_us?: boolean | null } | null)
|
||||||
|
?.legal_research_us !== false,
|
||||||
api_keys,
|
api_keys,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -51,31 +55,3 @@ export async function getUserApiKeys(
|
||||||
const client = db ?? createServerSupabase();
|
const client = db ?? createServerSupabase();
|
||||||
return getStoredUserApiKeys(userId, client);
|
return getStoredUserApiKeys(userId, client);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the user has US legal research (CourtListener) tools enabled in
|
|
||||||
* chat. Controlled by the Features > Legal Research > Jurisdiction > US
|
|
||||||
* toggle in account settings. Defaults to enabled — both when the user has
|
|
||||||
* no profile row yet and when the column is missing (migration not applied),
|
|
||||||
* so existing behaviour is preserved on partially-migrated deployments.
|
|
||||||
*/
|
|
||||||
export async function getLegalResearchUsEnabled(
|
|
||||||
userId: string,
|
|
||||||
db?: ReturnType<typeof createServerSupabase>,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const client = db ?? createServerSupabase();
|
|
||||||
try {
|
|
||||||
const { data, error } = await client
|
|
||||||
.from("user_profiles")
|
|
||||||
.select("legal_research_us")
|
|
||||||
.eq("user_id", userId)
|
|
||||||
.maybeSingle();
|
|
||||||
if (error || !data) return true;
|
|
||||||
return (
|
|
||||||
(data as { legal_research_us?: boolean | null })
|
|
||||||
.legal_research_us !== false
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ import {
|
||||||
} from "../lib/chatTools";
|
} from "../lib/chatTools";
|
||||||
import { completeText } from "../lib/llm";
|
import { completeText } from "../lib/llm";
|
||||||
import {
|
import {
|
||||||
getLegalResearchUsEnabled,
|
|
||||||
getUserApiKeys,
|
|
||||||
getUserModelSettings,
|
getUserModelSettings,
|
||||||
} from "../lib/userSettings";
|
} from "../lib/userSettings";
|
||||||
import { checkProjectAccess } from "../lib/access";
|
import { checkProjectAccess } from "../lib/access";
|
||||||
|
|
@ -556,7 +554,10 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
db,
|
db,
|
||||||
docIndex,
|
docIndex,
|
||||||
);
|
);
|
||||||
const legalResearchUs = await getLegalResearchUsEnabled(userId, db);
|
const {
|
||||||
|
api_keys: apiKeys,
|
||||||
|
legal_research_us: legalResearchUs,
|
||||||
|
} = await getUserModelSettings(userId, db);
|
||||||
const apiMessages = buildMessages(
|
const apiMessages = buildMessages(
|
||||||
enrichedMessages,
|
enrichedMessages,
|
||||||
docAvailability,
|
docAvailability,
|
||||||
|
|
@ -586,8 +587,6 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
if (!streamFinished) streamAbort.abort();
|
if (!streamFinished) streamAbort.abort();
|
||||||
});
|
});
|
||||||
|
|
||||||
const apiKeys = await getUserApiKeys(userId, db);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
|
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,7 @@ import {
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
} from "../lib/chatTools";
|
} from "../lib/chatTools";
|
||||||
import {
|
import {
|
||||||
getLegalResearchUsEnabled,
|
getUserModelSettings,
|
||||||
getUserApiKeys,
|
|
||||||
} from "../lib/userSettings";
|
} from "../lib/userSettings";
|
||||||
import { checkProjectAccess } from "../lib/access";
|
import { checkProjectAccess } from "../lib/access";
|
||||||
import { safeErrorLog, safeErrorMessage } from "../lib/safeError";
|
import { safeErrorLog, safeErrorMessage } from "../lib/safeError";
|
||||||
|
|
@ -144,7 +143,10 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
systemPromptExtra += `\n\nUSER-ATTACHED DOCUMENTS FOR THIS TURN:\nThe user has attached the following document(s) directly to their latest message. Treat these as the primary focus of the request unless their message clearly says otherwise.\n${lines.join("\n")}`;
|
systemPromptExtra += `\n\nUSER-ATTACHED DOCUMENTS FOR THIS TURN:\nThe user has attached the following document(s) directly to their latest message. Treat these as the primary focus of the request unless their message clearly says otherwise.\n${lines.join("\n")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const legalResearchUs = await getLegalResearchUsEnabled(userId, db);
|
const {
|
||||||
|
api_keys: apiKeys,
|
||||||
|
legal_research_us: legalResearchUs,
|
||||||
|
} = await getUserModelSettings(userId, db);
|
||||||
const apiMessages = buildMessages(
|
const apiMessages = buildMessages(
|
||||||
messagesForLLM,
|
messagesForLLM,
|
||||||
docAvailability,
|
docAvailability,
|
||||||
|
|
@ -168,8 +170,6 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
if (!streamFinished) streamAbort.abort();
|
if (!streamFinished) streamAbort.abort();
|
||||||
});
|
});
|
||||||
|
|
||||||
const apiKeys = await getUserApiKeys(userId, db);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
|
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,20 +70,6 @@ async function attachDocumentOwnerLabels(
|
||||||
.filter((id, index, arr) => arr.indexOf(id) === index);
|
.filter((id, index, arr) => arr.indexOf(id) === index);
|
||||||
if (ownerIds.length === 0) return;
|
if (ownerIds.length === 0) return;
|
||||||
|
|
||||||
const emailByUserId = new Map<string, string>();
|
|
||||||
const userResults = await Promise.allSettled(
|
|
||||||
ownerIds.map(async (id) => {
|
|
||||||
const { data, error } = await db.auth.admin.getUserById(id);
|
|
||||||
if (error) throw error;
|
|
||||||
return { id, email: data.user?.email ?? null };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
for (const result of userResults) {
|
|
||||||
if (result.status === "fulfilled" && result.value.email) {
|
|
||||||
emailByUserId.set(result.value.id, result.value.email);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayNameByUserId = new Map<string, string>();
|
const displayNameByUserId = new Map<string, string>();
|
||||||
const { data: profiles, error: profilesError } = await db
|
const { data: profiles, error: profilesError } = await db
|
||||||
.from("user_profiles")
|
.from("user_profiles")
|
||||||
|
|
@ -108,7 +94,7 @@ async function attachDocumentOwnerLabels(
|
||||||
owner_display_name?: string | null;
|
owner_display_name?: string | null;
|
||||||
})[]) {
|
})[]) {
|
||||||
if (!doc.user_id) continue;
|
if (!doc.user_id) continue;
|
||||||
doc.owner_email = emailByUserId.get(doc.user_id) ?? null;
|
doc.owner_email = null;
|
||||||
doc.owner_display_name = displayNameByUserId.get(doc.user_id) ?? null;
|
doc.owner_display_name = displayNameByUserId.get(doc.user_id) ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useCallback, useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { PanelLeft } from "lucide-react";
|
import { PanelLeft } from "lucide-react";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { ChatHistoryProvider } from "@/app/contexts/ChatHistoryContext";
|
import { ChatHistoryProvider } from "@/app/contexts/ChatHistoryContext";
|
||||||
import { SidebarContext } from "@/app/contexts/SidebarContext";
|
import { SidebarContext } from "@/app/contexts/SidebarContext";
|
||||||
|
import { PageChromeContext } from "@/app/contexts/PageChromeContext";
|
||||||
import { AppSidebar } from "@/app/components/shared/AppSidebar";
|
import { AppSidebar } from "@/app/components/shared/AppSidebar";
|
||||||
|
|
||||||
export default function MikeLayout({
|
export default function MikeLayout({
|
||||||
|
|
@ -15,6 +16,8 @@ export default function MikeLayout({
|
||||||
}) {
|
}) {
|
||||||
const { isAuthenticated, authLoading } = useAuth();
|
const { isAuthenticated, authLoading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [mobileActionsContainer, setMobileActionsContainer] =
|
||||||
|
useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const [isSidebarOpenDesktop, setIsSidebarOpenDesktop] = useState(() => {
|
const [isSidebarOpenDesktop, setIsSidebarOpenDesktop] = useState(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
|
|
@ -58,6 +61,13 @@ export default function MikeLayout({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMobileActionsContainerRef = useCallback(
|
||||||
|
(node: HTMLDivElement | null) => {
|
||||||
|
setMobileActionsContainer(node);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && !isAuthenticated) {
|
if (!authLoading && !isAuthenticated) {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
|
|
@ -76,46 +86,52 @@ export default function MikeLayout({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChatHistoryProvider>
|
<ChatHistoryProvider>
|
||||||
<SidebarContext.Provider
|
<PageChromeContext.Provider value={{ mobileActionsContainer }}>
|
||||||
value={{
|
<SidebarContext.Provider
|
||||||
setSidebarOpen: (open) => {
|
value={{
|
||||||
const isSmall =
|
setSidebarOpen: (open) => {
|
||||||
typeof window !== "undefined" &&
|
const isSmall =
|
||||||
window.innerWidth < 768;
|
typeof window !== "undefined" &&
|
||||||
if (isSmall) {
|
window.innerWidth < 768;
|
||||||
if (!open) setIsSidebarOpen(false);
|
if (isSmall) {
|
||||||
return;
|
if (!open) setIsSidebarOpen(false);
|
||||||
}
|
return;
|
||||||
setIsSidebarOpen(open);
|
}
|
||||||
setIsSidebarOpenDesktop(open);
|
setIsSidebarOpen(open);
|
||||||
},
|
setIsSidebarOpenDesktop(open);
|
||||||
}}
|
},
|
||||||
>
|
}}
|
||||||
<div className="h-dvh flex flex-col bg-gray-50/80">
|
>
|
||||||
<div className="flex-1 flex min-w-0 overflow-visible">
|
<div className="h-dvh flex flex-col bg-gray-50/80">
|
||||||
<AppSidebar
|
<div className="flex-1 flex min-w-0 overflow-visible">
|
||||||
isOpen={isSidebarOpen}
|
<AppSidebar
|
||||||
onToggle={handleSidebarToggle}
|
isOpen={isSidebarOpen}
|
||||||
/>
|
onToggle={handleSidebarToggle}
|
||||||
<div className="flex-1 flex flex-col h-dvh md:overflow-hidden relative w-full">
|
/>
|
||||||
{/* Mobile header */}
|
<div className="flex-1 flex flex-col h-dvh md:overflow-hidden relative w-full">
|
||||||
<div className="flex md:hidden items-center gap-3 px-4 pt-3 pb-1 shrink-0">
|
{/* Mobile header */}
|
||||||
<button
|
<div className="relative z-20 flex md:hidden items-center gap-3 overflow-visible px-4 pt-3 pb-2 shrink-0">
|
||||||
onClick={handleSidebarToggle}
|
<button
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/70 text-gray-700 shadow-[0_8px_24px_rgba(15,23,42,0.12)] ring-1 ring-white/70 backdrop-blur-md transition-all hover:bg-white/90 active:scale-95"
|
onClick={handleSidebarToggle}
|
||||||
title="Open sidebar"
|
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/70 text-gray-700 shadow-[0_8px_24px_rgba(15,23,42,0.12)] ring-1 ring-white/70 backdrop-blur-md transition-all hover:bg-white/90 active:scale-95"
|
||||||
aria-label="Open sidebar"
|
title="Open sidebar"
|
||||||
>
|
aria-label="Open sidebar"
|
||||||
<PanelLeft className="h-4 w-4" />
|
>
|
||||||
</button>
|
<PanelLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
ref={handleMobileActionsContainerRef}
|
||||||
|
className="ml-auto flex min-w-0 flex-1 items-center justify-end"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<main className="flex-1 overflow-y-auto md:overflow-hidden w-full h-full">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<main className="flex-1 overflow-y-auto md:overflow-hidden w-full h-full">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SidebarContext.Provider>
|
||||||
</SidebarContext.Provider>
|
</PageChromeContext.Provider>
|
||||||
</ChatHistoryProvider>
|
</ChatHistoryProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -782,18 +782,15 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
||||||
project
|
project
|
||||||
? {
|
? {
|
||||||
label: project.name,
|
label: project.name,
|
||||||
suffix: project.cm_number ? (
|
onClick: () =>
|
||||||
<span className="ml-1 text-gray-400">
|
router.push(`/projects/${projectId}?tab=assistant`),
|
||||||
(#{project.cm_number})
|
|
||||||
</span>
|
|
||||||
) : null,
|
|
||||||
onClick: () => router.push(`/projects/${projectId}`),
|
|
||||||
title: "Back to project",
|
title: "Back to project",
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
loading: true,
|
loading: true,
|
||||||
skeletonClassName: "w-32",
|
skeletonClassName: "w-32",
|
||||||
onClick: () => router.push(`/projects/${projectId}`),
|
onClick: () =>
|
||||||
|
router.push(`/projects/${projectId}?tab=assistant`),
|
||||||
title: "Back to project",
|
title: "Back to project",
|
||||||
},
|
},
|
||||||
chatLoaded
|
chatLoaded
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export default function TabularReviewsPage() {
|
||||||
const actionsRef = useRef<HTMLDivElement>(null);
|
const actionsRef = useRef<HTMLDivElement>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const stickyCellBg = "bg-[#fcfcfd]";
|
const stickyCellBg = "bg-[#fafbfc]";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
|
|
|
||||||
13
frontend/src/app/(pages)/workflows/assistant/[id]/page.tsx
Normal file
13
frontend/src/app/(pages)/workflows/assistant/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { use } from "react";
|
||||||
|
import { WorkflowDetailPage } from "@/app/components/workflows/WorkflowDetailPage";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssistantWorkflowPage({ params }: Props) {
|
||||||
|
const { id } = use(params);
|
||||||
|
return <WorkflowDetailPage id={id} workflowType="assistant" />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { use } from "react";
|
||||||
|
import { WorkflowDetailPage } from "@/app/components/workflows/WorkflowDetailPage";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TabularReviewWorkflowPage({ params }: Props) {
|
||||||
|
const { id } = use(params);
|
||||||
|
return <WorkflowDetailPage id={id} workflowType="tabular" />;
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { ChevronLeft, Search, X } from "lucide-react";
|
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import type { Workflow } from "../shared/types";
|
import type { Workflow } from "../shared/types";
|
||||||
import { listWorkflows } from "@/app/lib/mikeApi";
|
import { WorkflowPickerModal } from "../workflows/WorkflowPickerModal";
|
||||||
import { BUILT_IN_WORKFLOWS } from "../workflows/builtinWorkflows";
|
|
||||||
import { Modal } from "../shared/Modal";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSelect: (workflow: Workflow) => void;
|
onSelect: (workflow: Workflow) => Promise<void> | void;
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
projectCmNumber?: string | null;
|
projectCmNumber?: string | null;
|
||||||
initialWorkflowId?: string;
|
initialWorkflowId?: string;
|
||||||
|
|
@ -26,70 +20,6 @@ export function AssistantWorkflowModal({
|
||||||
projectCmNumber,
|
projectCmNumber,
|
||||||
initialWorkflowId,
|
initialWorkflowId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [selected, setSelected] = useState<Workflow | null>(null);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
let cancelled = false;
|
|
||||||
const builtins = BUILT_IN_WORKFLOWS.filter(
|
|
||||||
(w) => w.type === "assistant",
|
|
||||||
);
|
|
||||||
const frame = requestAnimationFrame(() => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setWorkflows(builtins);
|
|
||||||
setLoading(true);
|
|
||||||
if (initialWorkflowId) {
|
|
||||||
const match = builtins.find((w) => w.id === initialWorkflowId);
|
|
||||||
if (match) setSelected(match);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
listWorkflows("assistant")
|
|
||||||
.then((custom) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const all = [...builtins, ...custom];
|
|
||||||
setWorkflows(all);
|
|
||||||
if (initialWorkflowId) {
|
|
||||||
const match = all.find((w) => w.id === initialWorkflowId);
|
|
||||||
if (match) setSelected(match);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (cancelled) return;
|
|
||||||
if (initialWorkflowId) {
|
|
||||||
const match = builtins.find((w) => w.id === initialWorkflowId);
|
|
||||||
if (match) setSelected(match);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
cancelAnimationFrame(frame);
|
|
||||||
};
|
|
||||||
}, [open, initialWorkflowId]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
const filteredWorkflows = search
|
|
||||||
? workflows.filter((w) => w.title.toLowerCase().includes(search.toLowerCase()))
|
|
||||||
: workflows;
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
setSelected(null);
|
|
||||||
setSearch("");
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUse() {
|
|
||||||
if (!selected) return;
|
|
||||||
onSelect(selected);
|
|
||||||
handleClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
const breadcrumbs = projectName
|
const breadcrumbs = projectName
|
||||||
? [
|
? [
|
||||||
"Projects",
|
"Projects",
|
||||||
|
|
@ -100,162 +30,14 @@ export function AssistantWorkflowModal({
|
||||||
: ["Assistant", "Add workflow"];
|
: ["Assistant", "Add workflow"];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<WorkflowPickerModal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={handleClose}
|
onClose={onClose}
|
||||||
size={selected ? "xl" : "lg"}
|
onSelect={onSelect}
|
||||||
|
workflowType="assistant"
|
||||||
breadcrumbs={breadcrumbs}
|
breadcrumbs={breadcrumbs}
|
||||||
primaryAction={{
|
primaryLabel="Use"
|
||||||
label: "Use",
|
initialWorkflowId={initialWorkflowId}
|
||||||
type: "button",
|
/>
|
||||||
onClick: handleUse,
|
|
||||||
disabled: !selected,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex flex-row flex-1 min-h-0 overflow-hidden gap-3">
|
|
||||||
{/* Left panel — workflow list */}
|
|
||||||
<div
|
|
||||||
className={`flex flex-col overflow-hidden ${selected ? "w-80 shrink-0" : "flex-1"}`}
|
|
||||||
>
|
|
||||||
{/* Search */}
|
|
||||||
<div className="px-2 pt-3 pb-2 shrink-0">
|
|
||||||
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
|
|
||||||
<Search className="h-3 w-3 text-gray-400 shrink-0" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search workflows…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className="flex-1 bg-transparent text-xs text-gray-700 placeholder:text-gray-400 outline-none"
|
|
||||||
/>
|
|
||||||
{search && (
|
|
||||||
<button onClick={() => setSearch("")} className="text-gray-400 hover:text-gray-600">
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="space-y-1 px-2 pb-2 pt-1">
|
|
||||||
{[60, 45, 75, 50, 65, 40, 55].map((w, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="flex items-center justify-between gap-3 rounded-lg px-3 py-2.5"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="h-3 rounded bg-gray-100 animate-pulse"
|
|
||||||
style={{ width: `${w}%` }}
|
|
||||||
/>
|
|
||||||
<div className="h-3 w-10 rounded bg-gray-100 animate-pulse shrink-0" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : filteredWorkflows.length === 0 ? (
|
|
||||||
<p className="py-8 text-sm text-center text-gray-400">
|
|
||||||
{search ? "No matches found" : "No assistant workflows found"}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-y-auto">
|
|
||||||
{filteredWorkflows.map((wf) => (
|
|
||||||
<button
|
|
||||||
key={wf.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setSelected((prev) =>
|
|
||||||
prev?.id === wf.id ? null : wf,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={`w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-xs text-left transition-colors ${
|
|
||||||
selected?.id === wf.id
|
|
||||||
? "bg-gray-100"
|
|
||||||
: "hover:bg-gray-50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="flex-1 truncate text-gray-800">
|
|
||||||
{wf.title}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-xs text-gray-400">
|
|
||||||
{wf.is_system ? "Built-in" : "Custom"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right panel — prompt preview */}
|
|
||||||
{selected && (
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between py-3 shrink-0">
|
|
||||||
<p className="text-xs font-medium text-gray-700">
|
|
||||||
Workflow Prompt
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelected(null)}
|
|
||||||
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-3 text-sm border border-gray-200 rounded-md text-gray-600 leading-relaxed font-serif bg-gray-50">
|
|
||||||
<ReactMarkdown
|
|
||||||
remarkPlugins={[remarkGfm]}
|
|
||||||
components={{
|
|
||||||
h1: ({ children }) => (
|
|
||||||
<h1 className="text-base font-semibold text-gray-900 mt-4 mb-1 first:mt-0">
|
|
||||||
{children}
|
|
||||||
</h1>
|
|
||||||
),
|
|
||||||
h2: ({ children }) => (
|
|
||||||
<h2 className="text-sm font-semibold text-gray-900 mt-3 mb-1 first:mt-0">
|
|
||||||
{children}
|
|
||||||
</h2>
|
|
||||||
),
|
|
||||||
h3: ({ children }) => (
|
|
||||||
<h3 className="text-xs font-semibold text-gray-900 mt-2 mb-0.5 first:mt-0">
|
|
||||||
{children}
|
|
||||||
</h3>
|
|
||||||
),
|
|
||||||
p: ({ children }) => (
|
|
||||||
<p className="mb-2 last:mb-0">
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
),
|
|
||||||
ul: ({ children }) => (
|
|
||||||
<ul className="list-disc pl-4 mb-2 space-y-0.5">
|
|
||||||
{children}
|
|
||||||
</ul>
|
|
||||||
),
|
|
||||||
ol: ({ children }) => (
|
|
||||||
<ol className="list-decimal pl-4 mb-2 space-y-0.5">
|
|
||||||
{children}
|
|
||||||
</ol>
|
|
||||||
),
|
|
||||||
li: ({ children }) => (
|
|
||||||
<li>{children}</li>
|
|
||||||
),
|
|
||||||
strong: ({ children }) => (
|
|
||||||
<strong className="font-semibold text-gray-800">
|
|
||||||
{children}
|
|
||||||
</strong>
|
|
||||||
),
|
|
||||||
em: ({ children }) => (
|
|
||||||
<em className="italic">
|
|
||||||
{children}
|
|
||||||
</em>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selected.prompt_md ??
|
|
||||||
"_No prompt defined._"}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</Modal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export function ProjectAssistantTab({
|
||||||
setRenamingChatId: Dispatch<SetStateAction<string | null>>;
|
setRenamingChatId: Dispatch<SetStateAction<string | null>>;
|
||||||
setRenameChatValue: Dispatch<SetStateAction<string>>;
|
setRenameChatValue: Dispatch<SetStateAction<string>>;
|
||||||
}) {
|
}) {
|
||||||
const stickyCellBg = "bg-[#fcfcfd]";
|
const stickyCellBg = "bg-[#fafbfc]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
216
frontend/src/app/components/projects/ProjectDetailsModal.tsx
Normal file
216
frontend/src/app/components/projects/ProjectDetailsModal.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Loader2, Users } from "lucide-react";
|
||||||
|
import { Modal } from "@/app/components/shared/Modal";
|
||||||
|
import type { Project } from "@/app/components/shared/types";
|
||||||
|
import type { ProjectPeople } from "@/app/lib/mikeApi";
|
||||||
|
|
||||||
|
interface ProjectDetailsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
project: Project | null;
|
||||||
|
canEdit: boolean;
|
||||||
|
currentUserDisplayName?: string | null;
|
||||||
|
currentUserEmail?: string | null;
|
||||||
|
fetchPeople: (projectId: string) => Promise<ProjectPeople>;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (values: { name: string; cmNumber: string }) => Promise<void>;
|
||||||
|
onShareProject: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectDetailsModal({
|
||||||
|
open,
|
||||||
|
project,
|
||||||
|
canEdit,
|
||||||
|
currentUserDisplayName,
|
||||||
|
currentUserEmail,
|
||||||
|
fetchPeople,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
onShareProject,
|
||||||
|
}: ProjectDetailsModalProps) {
|
||||||
|
const [nameDraft, setNameDraft] = useState("");
|
||||||
|
const [cmDraft, setCmDraft] = useState("");
|
||||||
|
const [people, setPeople] = useState<ProjectPeople | null>(null);
|
||||||
|
const [peopleLoading, setPeopleLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !project) return;
|
||||||
|
setNameDraft(project.name);
|
||||||
|
setCmDraft(project.cm_number ?? "");
|
||||||
|
setSaved(false);
|
||||||
|
setError(null);
|
||||||
|
}, [open, project]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !project) return;
|
||||||
|
const isPrivateOwnedProject =
|
||||||
|
project.is_owner !== false &&
|
||||||
|
(!Array.isArray(project.shared_with) ||
|
||||||
|
project.shared_with.length === 0);
|
||||||
|
if (isPrivateOwnedProject) {
|
||||||
|
setPeople(null);
|
||||||
|
setPeopleLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setPeopleLoading(true);
|
||||||
|
fetchPeople(project.id)
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setPeople(data);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setPeople(null);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setPeopleLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, project, fetchPeople]);
|
||||||
|
|
||||||
|
const trimmedName = nameDraft.trim();
|
||||||
|
const trimmedCm = cmDraft.trim();
|
||||||
|
const hasChanges = useMemo(() => {
|
||||||
|
if (!project) return false;
|
||||||
|
return (
|
||||||
|
trimmedName !== project.name ||
|
||||||
|
trimmedCm !== (project.cm_number ?? "")
|
||||||
|
);
|
||||||
|
}, [project, trimmedCm, trimmedName]);
|
||||||
|
|
||||||
|
if (!project) return null;
|
||||||
|
|
||||||
|
const accessLabel =
|
||||||
|
Array.isArray(project.shared_with) && project.shared_with.length > 0
|
||||||
|
? "Shared"
|
||||||
|
: "Private";
|
||||||
|
const isPrivateOwnedProject =
|
||||||
|
project.is_owner !== false && accessLabel === "Private";
|
||||||
|
const ownerLabel =
|
||||||
|
people?.owner.display_name?.trim() ||
|
||||||
|
people?.owner.email?.trim() ||
|
||||||
|
(isPrivateOwnedProject ? currentUserDisplayName?.trim() : "") ||
|
||||||
|
(isPrivateOwnedProject ? currentUserEmail?.trim() : "") ||
|
||||||
|
"Unknown";
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!canEdit || saving || !hasChanges || !trimmedName) return;
|
||||||
|
setSaving(true);
|
||||||
|
setSaved(false);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await onSave({ name: trimmedName, cmNumber: trimmedCm });
|
||||||
|
setSaved(true);
|
||||||
|
} catch {
|
||||||
|
setError("Could not update project details.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
breadcrumbs={["Projects", project.name, "Details"]}
|
||||||
|
secondaryAction={{
|
||||||
|
label: "Share Project",
|
||||||
|
icon: <Users className="h-4 w-4" />,
|
||||||
|
onClick: onShareProject,
|
||||||
|
}}
|
||||||
|
footerStatus={
|
||||||
|
error ? (
|
||||||
|
<span className="text-sm text-red-600">{error}</span>
|
||||||
|
) : saved ? (
|
||||||
|
<span className="text-sm text-gray-400">Updated</span>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
primaryAction={
|
||||||
|
canEdit
|
||||||
|
? {
|
||||||
|
label: saving ? "Updating..." : "Update",
|
||||||
|
onClick: () => void handleSave(),
|
||||||
|
disabled: saving || !hasChanges || !trimmedName,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
cancelAction={canEdit ? undefined : false}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-5 py-1">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label
|
||||||
|
htmlFor="project-details-name"
|
||||||
|
className="text-xs font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Project Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="project-details-name"
|
||||||
|
value={nameDraft}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNameDraft(e.target.value);
|
||||||
|
setSaved(false);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
disabled={!canEdit || saving}
|
||||||
|
className="h-9 w-full rounded-md border border-gray-200 bg-gray-50 px-3 text-sm text-gray-900 outline-none transition-colors focus:border-gray-300 disabled:cursor-not-allowed disabled:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label
|
||||||
|
htmlFor="project-details-cm"
|
||||||
|
className="text-xs font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
CM
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="project-details-cm"
|
||||||
|
value={cmDraft}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCmDraft(e.target.value);
|
||||||
|
setSaved(false);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
disabled={!canEdit || saving}
|
||||||
|
placeholder="No CM"
|
||||||
|
className="h-9 w-full rounded-md border border-gray-200 bg-gray-50 px-3 text-sm text-gray-900 outline-none transition-colors focus:border-gray-300 disabled:cursor-not-allowed disabled:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-gray-100 text-sm">
|
||||||
|
<DetailRow label="Ownership" value={accessLabel} />
|
||||||
|
<DetailRow
|
||||||
|
label="Owner"
|
||||||
|
value={
|
||||||
|
peopleLoading && !isPrivateOwnedProject ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-gray-400">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Loading
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
ownerLabel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value }: { label: string; value: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4 py-3">
|
||||||
|
<span className="text-gray-500">{label}</span>
|
||||||
|
<span className="min-w-0 truncate text-right text-gray-900">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -63,6 +63,7 @@ import {
|
||||||
import { PeopleModal } from "@/app/components/shared/PeopleModal";
|
import { PeopleModal } from "@/app/components/shared/PeopleModal";
|
||||||
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||||
import { AddNewTRModal } from "@/app/components/tabular/AddNewTRModal";
|
import { AddNewTRModal } from "@/app/components/tabular/AddNewTRModal";
|
||||||
import { WarningPopup } from "@/app/components/shared/WarningPopup";
|
import { WarningPopup } from "@/app/components/shared/WarningPopup";
|
||||||
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
|
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
|
||||||
|
|
@ -83,6 +84,7 @@ import {
|
||||||
type ProjectTab,
|
type ProjectTab,
|
||||||
} from "./ProjectPageParts";
|
} from "./ProjectPageParts";
|
||||||
import { DocumentSidePanel } from "./DocumentSidePanel";
|
import { DocumentSidePanel } from "./DocumentSidePanel";
|
||||||
|
import { ProjectDetailsModal } from "./ProjectDetailsModal";
|
||||||
import { ProjectAssistantTab } from "./ProjectAssistantTab";
|
import { ProjectAssistantTab } from "./ProjectAssistantTab";
|
||||||
import { ProjectReviewsTab } from "./ProjectReviewsTab";
|
import { ProjectReviewsTab } from "./ProjectReviewsTab";
|
||||||
|
|
||||||
|
|
@ -274,9 +276,11 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
||||||
: initialTab;
|
: initialTab;
|
||||||
const [addDocsOpen, setAddDocsOpen] = useState(false);
|
const [addDocsOpen, setAddDocsOpen] = useState(false);
|
||||||
const [peopleModalOpen, setPeopleModalOpen] = useState(false);
|
const [peopleModalOpen, setPeopleModalOpen] = useState(false);
|
||||||
|
const [projectDetailsOpen, setProjectDetailsOpen] = useState(false);
|
||||||
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
|
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const stickyCellBg = "bg-[#fcfcfd]";
|
const { profile } = useUserProfile();
|
||||||
|
const stickyCellBg = "bg-[#fafbfc]";
|
||||||
const [viewingDoc, setViewingDoc] = useState<Document | null>(null);
|
const [viewingDoc, setViewingDoc] = useState<Document | null>(null);
|
||||||
const [viewingDocVersion, setViewingDocVersion] = useState<{
|
const [viewingDocVersion, setViewingDocVersion] = useState<{
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -1133,33 +1137,30 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTitleCommit(newName: string) {
|
async function handleProjectDetailsSave(values: {
|
||||||
if (!newName || newName === project?.name) return;
|
name: string;
|
||||||
// Server-side this would 404 silently for non-owners; surface a
|
cmNumber: string;
|
||||||
// clear permission warning instead.
|
}) {
|
||||||
if (project && project.is_owner === false) {
|
if (project && project.is_owner === false) {
|
||||||
setOwnerOnlyAction("rename this project");
|
setOwnerOnlyAction("edit project details");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setProject((prev) => (prev ? { ...prev, name: newName } : prev));
|
const name = values.name.trim();
|
||||||
await updateProject(projectId, { name: newName });
|
const cmNumber = values.cmNumber.trim();
|
||||||
}
|
if (!name) return;
|
||||||
|
|
||||||
async function handleCmNumberCommit(newCmNumber: string) {
|
|
||||||
if (project && project.is_owner === false) {
|
|
||||||
setOwnerOnlyAction("rename this project's CM number");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const trimmed = newCmNumber.trim();
|
|
||||||
if (trimmed === (project?.cm_number ?? "")) return;
|
|
||||||
setProject((prev) =>
|
|
||||||
prev ? { ...prev, cm_number: trimmed || null } : prev,
|
|
||||||
);
|
|
||||||
const updated = await updateProject(projectId, {
|
const updated = await updateProject(projectId, {
|
||||||
cm_number: trimmed,
|
name,
|
||||||
|
cm_number: cmNumber,
|
||||||
});
|
});
|
||||||
setProject((prev) =>
|
setProject((prev) =>
|
||||||
prev ? { ...prev, cm_number: updated.cm_number } : prev,
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
name: updated.name,
|
||||||
|
cm_number: updated.cm_number,
|
||||||
|
updated_at: updated.updated_at,
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2519,9 +2520,8 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
||||||
docsCount={docs.length}
|
docsCount={docs.length}
|
||||||
isOwner={project?.is_owner !== false}
|
isOwner={project?.is_owner !== false}
|
||||||
onBackToProjects={() => router.push("/projects")}
|
onBackToProjects={() => router.push("/projects")}
|
||||||
onRenameProject={handleTitleCommit}
|
|
||||||
onRenameCmNumber={handleCmNumberCommit}
|
|
||||||
onOwnerOnly={setOwnerOnlyAction}
|
onOwnerOnly={setOwnerOnlyAction}
|
||||||
|
onOpenDetails={() => setProjectDetailsOpen(true)}
|
||||||
onDeleteProject={requestProjectDelete}
|
onDeleteProject={requestProjectDelete}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
onOpenPeople={() => setPeopleModalOpen(true)}
|
onOpenPeople={() => setPeopleModalOpen(true)}
|
||||||
|
|
@ -3426,6 +3426,21 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
||||||
onClose={() => setOwnerOnlyAction(null)}
|
onClose={() => setOwnerOnlyAction(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProjectDetailsModal
|
||||||
|
open={projectDetailsOpen}
|
||||||
|
project={project}
|
||||||
|
canEdit={project?.is_owner !== false}
|
||||||
|
currentUserDisplayName={profile?.displayName ?? null}
|
||||||
|
currentUserEmail={user?.email ?? null}
|
||||||
|
fetchPeople={getProjectPeople}
|
||||||
|
onClose={() => setProjectDetailsOpen(false)}
|
||||||
|
onSave={handleProjectDetailsSave}
|
||||||
|
onShareProject={() => {
|
||||||
|
setProjectDetailsOpen(false);
|
||||||
|
setPeopleModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmPopup
|
<ConfirmPopup
|
||||||
open={deleteProjectConfirmOpen}
|
open={deleteProjectConfirmOpen}
|
||||||
title="Delete project?"
|
title="Delete project?"
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { type CSSProperties, type KeyboardEvent, useState } from "react";
|
import { type CSSProperties, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
CornerDownRight,
|
CornerDownRight,
|
||||||
File,
|
File,
|
||||||
FileText,
|
FileText,
|
||||||
Hash,
|
Info,
|
||||||
Loader2,
|
Loader2,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
|
@ -108,8 +108,10 @@ export function DocVersionHistory({
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [editingValue, setEditingValue] = useState("");
|
const [editingValue, setEditingValue] = useState("");
|
||||||
|
const committingVersionId = useRef<string | null>(null);
|
||||||
|
|
||||||
const commit = async (versionId: string) => {
|
const commit = async (versionId: string) => {
|
||||||
|
if (committingVersionId.current === versionId) return;
|
||||||
const trimmed = editingValue.trim();
|
const trimmed = editingValue.trim();
|
||||||
const previousFilename = versions
|
const previousFilename = versions
|
||||||
.find((version) => version.id === versionId)
|
.find((version) => version.id === versionId)
|
||||||
|
|
@ -123,6 +125,7 @@ export function DocVersionHistory({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
committingVersionId.current = versionId;
|
||||||
setEditingVersionId(null);
|
setEditingVersionId(null);
|
||||||
const next = trimmed.length > 0 ? trimmed : null;
|
const next = trimmed.length > 0 ? trimmed : null;
|
||||||
await onRenameVersion?.(versionId, next);
|
await onRenameVersion?.(versionId, next);
|
||||||
|
|
@ -260,6 +263,7 @@ export function DocVersionHistory({
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void commit(v.id);
|
void commit(v.id);
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
|
committingVersionId.current = null;
|
||||||
setEditingVersionId(null);
|
setEditingVersionId(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -322,6 +326,7 @@ export function DocVersionHistory({
|
||||||
onRename={
|
onRename={
|
||||||
onRenameVersion
|
onRenameVersion
|
||||||
? () => {
|
? () => {
|
||||||
|
committingVersionId.current = null;
|
||||||
setEditingVersionId(v.id);
|
setEditingVersionId(v.id);
|
||||||
setEditingValue(
|
setEditingValue(
|
||||||
v.filename ?? "",
|
v.filename ?? "",
|
||||||
|
|
@ -355,9 +360,8 @@ export function ProjectPageHeader({
|
||||||
docsCount,
|
docsCount,
|
||||||
isOwner,
|
isOwner,
|
||||||
onBackToProjects,
|
onBackToProjects,
|
||||||
onRenameProject,
|
|
||||||
onRenameCmNumber,
|
|
||||||
onOwnerOnly,
|
onOwnerOnly,
|
||||||
|
onOpenDetails,
|
||||||
onDeleteProject,
|
onDeleteProject,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onOpenPeople,
|
onOpenPeople,
|
||||||
|
|
@ -371,69 +375,26 @@ export function ProjectPageHeader({
|
||||||
docsCount: number;
|
docsCount: number;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
onBackToProjects: () => void;
|
onBackToProjects: () => void;
|
||||||
onRenameProject: (name: string) => void;
|
|
||||||
onRenameCmNumber: (cmNumber: string) => void;
|
|
||||||
onOwnerOnly: (action: string) => void;
|
onOwnerOnly: (action: string) => void;
|
||||||
|
onOpenDetails: () => void;
|
||||||
onDeleteProject: () => void;
|
onDeleteProject: () => void;
|
||||||
onSearchChange: (search: string) => void;
|
onSearchChange: (search: string) => void;
|
||||||
onOpenPeople: () => void;
|
onOpenPeople: () => void;
|
||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
onNewReview: () => void;
|
onNewReview: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [editingField, setEditingField] = useState<"name" | "cm" | null>(
|
const requestRename = () => {
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [draft, setDraft] = useState("");
|
|
||||||
|
|
||||||
const startEdit = (field: "name" | "cm") => {
|
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
if (!isOwner) {
|
if (!isOwner) {
|
||||||
onOwnerOnly(
|
onOwnerOnly("rename this project");
|
||||||
field === "name"
|
|
||||||
? "rename this project"
|
|
||||||
: "rename this project's CM number",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDraft(field === "name" ? project.name : project.cm_number ?? "");
|
onOpenDetails();
|
||||||
setEditingField(field);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const commitEdit = () => {
|
const titleLabel = !project ? undefined : (
|
||||||
if (!editingField) return;
|
|
||||||
const value = draft.trim();
|
|
||||||
if (editingField === "name") onRenameProject(value);
|
|
||||||
else onRenameCmNumber(value);
|
|
||||||
setEditingField(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
commitEdit();
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
setEditingField(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const editInputClassName =
|
|
||||||
"min-w-0 cursor-text border-0 border-b border-gray-200 bg-transparent font-serif text-2xl font-medium outline-none transition-colors focus:border-gray-300";
|
|
||||||
|
|
||||||
const titleLabel = !project ? undefined : editingField === "name" ? (
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
value={draft}
|
|
||||||
size={Math.max(draft.length + 1, 3)}
|
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
|
||||||
onKeyDown={handleEditKeyDown}
|
|
||||||
onBlur={commitEdit}
|
|
||||||
className={`${editInputClassName} text-gray-900`}
|
|
||||||
aria-label="Rename project"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span
|
<span
|
||||||
onClick={() => startEdit("name")}
|
onClick={requestRename}
|
||||||
className="inline-block cursor-text"
|
className="inline-block cursor-text"
|
||||||
title="Rename"
|
title="Rename"
|
||||||
>
|
>
|
||||||
|
|
@ -441,31 +402,6 @@ export function ProjectPageHeader({
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
const cmSuffix = !project ? null : editingField === "cm" ? (
|
|
||||||
<span className="ml-1 inline-flex items-center text-gray-400">
|
|
||||||
(#
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
value={draft}
|
|
||||||
size={Math.max(draft.length + 1, 3)}
|
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
|
||||||
onKeyDown={handleEditKeyDown}
|
|
||||||
onBlur={commitEdit}
|
|
||||||
className={`${editInputClassName} text-gray-400`}
|
|
||||||
aria-label="Rename CM number"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
</span>
|
|
||||||
) : project.cm_number ? (
|
|
||||||
<span
|
|
||||||
onClick={() => startEdit("cm")}
|
|
||||||
className="ml-1 inline-block cursor-text text-gray-400"
|
|
||||||
title="Rename CM"
|
|
||||||
>
|
|
||||||
(#{project.cm_number})
|
|
||||||
</span>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageHeader
|
<PageHeader
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
|
|
@ -478,7 +414,6 @@ export function ProjectPageHeader({
|
||||||
...(project
|
...(project
|
||||||
? {
|
? {
|
||||||
label: titleLabel,
|
label: titleLabel,
|
||||||
suffix: cmSuffix,
|
|
||||||
cursor: "text",
|
cursor: "text",
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
|
|
@ -511,12 +446,12 @@ export function ProjectPageHeader({
|
||||||
{
|
{
|
||||||
label: "Rename",
|
label: "Rename",
|
||||||
icon: Pencil,
|
icon: Pencil,
|
||||||
onSelect: () => startEdit("name"),
|
onSelect: requestRename,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Rename CM",
|
label: "Project Details",
|
||||||
icon: Hash,
|
icon: Info,
|
||||||
onSelect: () => startEdit("cm"),
|
onSelect: onOpenDetails,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Delete",
|
label: "Delete",
|
||||||
|
|
@ -535,6 +470,7 @@ export function ProjectPageHeader({
|
||||||
{
|
{
|
||||||
onClick: onNewChat,
|
onClick: onNewChat,
|
||||||
disabled: creatingChat,
|
disabled: creatingChat,
|
||||||
|
compact: true,
|
||||||
icon: creatingChat ? (
|
icon: creatingChat ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -549,6 +485,7 @@ export function ProjectPageHeader({
|
||||||
{
|
{
|
||||||
onClick: onNewReview,
|
onClick: onNewReview,
|
||||||
disabled: docsCount === 0 || creatingReview,
|
disabled: docsCount === 0 || creatingReview,
|
||||||
|
compact: true,
|
||||||
icon: creatingReview ? (
|
icon: creatingReview ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export function ProjectReviewsTab({
|
||||||
setRenamingReviewId: Dispatch<SetStateAction<string | null>>;
|
setRenamingReviewId: Dispatch<SetStateAction<string | null>>;
|
||||||
setRenameReviewValue: Dispatch<SetStateAction<string>>;
|
setRenameReviewValue: Dispatch<SetStateAction<string>>;
|
||||||
}) {
|
}) {
|
||||||
const stickyCellBg = "bg-[#fcfcfd]";
|
const stickyCellBg = "bg-[#fafbfc]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export function ProjectsOverview() {
|
||||||
const actionsRef = useRef<HTMLDivElement>(null);
|
const actionsRef = useRef<HTMLDivElement>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, isAuthenticated, authLoading } = useAuth();
|
const { user, isAuthenticated, authLoading } = useAuth();
|
||||||
const stickyCellBg = "bg-[#fcfcfd]";
|
const stickyCellBg = "bg-[#fafbfc]";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,13 @@ import {
|
||||||
type ButtonHTMLAttributes,
|
type ButtonHTMLAttributes,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { ChevronLeft, Loader2, Plus, Search, Trash2 } from "lucide-react";
|
import { ChevronLeft, Loader2, Plus, Search, Trash2 } from "lucide-react";
|
||||||
|
import { usePageChrome } from "@/app/contexts/PageChromeContext";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface PageHeaderBreadcrumb {
|
export interface PageHeaderBreadcrumb {
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
suffix?: ReactNode;
|
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
cursor?: "text";
|
cursor?: "text";
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
|
@ -31,6 +32,7 @@ type PageHeaderButtonAction = {
|
||||||
title?: string;
|
title?: string;
|
||||||
variant?: "default" | "danger";
|
variant?: "default" | "danger";
|
||||||
iconOnly?: boolean;
|
iconOnly?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
tooltip?: ReactNode;
|
tooltip?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -108,6 +110,7 @@ export function PageHeader({
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
loading = false,
|
loading = false,
|
||||||
}: PageHeaderProps) {
|
}: PageHeaderProps) {
|
||||||
|
const { mobileActionsContainer } = usePageChrome();
|
||||||
const headerContent = breadcrumbs?.length ? (
|
const headerContent = breadcrumbs?.length ? (
|
||||||
<PageHeaderBreadcrumbs items={breadcrumbs} />
|
<PageHeaderBreadcrumbs items={breadcrumbs} />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -124,6 +127,7 @@ export function PageHeader({
|
||||||
? [{ actions: actionItems, gap: actionGap }]
|
? [{ actions: actionItems, gap: actionGap }]
|
||||||
: [])
|
: [])
|
||||||
);
|
);
|
||||||
|
const hasActions = groupedActionItems.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -131,39 +135,70 @@ export function PageHeader({
|
||||||
"flex justify-between",
|
"flex justify-between",
|
||||||
align === "start" ? "items-start" : "items-center",
|
align === "start" ? "items-start" : "items-center",
|
||||||
"px-4 md:px-10",
|
"px-4 md:px-10",
|
||||||
"pb-4 pt-5.5",
|
"min-h-[76px] pb-4 pt-5.5",
|
||||||
shrink && "shrink-0",
|
shrink && "shrink-0",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{headerContent}
|
{headerContent}
|
||||||
{groupedActionItems.length > 0 && (
|
{hasActions && (
|
||||||
<div className="ml-4 flex shrink-0 items-center gap-3">
|
<div className="ml-4 hidden shrink-0 items-center gap-3 md:flex">
|
||||||
{groupedActionItems.map((group, groupIndex) => (
|
<PageHeaderActionGroups
|
||||||
<div
|
groupedActionItems={groupedActionItems}
|
||||||
key={groupIndex}
|
actionsDisabled={actionsDisabled}
|
||||||
className={cn(
|
/>
|
||||||
"flex shrink-0 items-center",
|
|
||||||
actionGapClassName[group.gap],
|
|
||||||
"rounded-full border border-white/70 bg-white px-1 py-1 shadow-[0_-1px_3px_rgba(15,23,42,0.03),0_2px_7px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.82),inset_0_-3px_7px_rgba(255,255,255,0.13)] backdrop-blur-2xl",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{group.actions.map((action, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
<PageHeaderActionRenderer
|
|
||||||
action={action}
|
|
||||||
disabled={actionsDisabled}
|
|
||||||
/>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{hasActions &&
|
||||||
|
mobileActionsContainer &&
|
||||||
|
createPortal(
|
||||||
|
<div className="flex min-w-0 items-center justify-end gap-3 overflow-visible py-2 -my-2">
|
||||||
|
<PageHeaderActionGroups
|
||||||
|
groupedActionItems={groupedActionItems}
|
||||||
|
actionsDisabled={actionsDisabled}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
mobileActionsContainer,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PageHeaderActionGroups({
|
||||||
|
groupedActionItems,
|
||||||
|
actionsDisabled,
|
||||||
|
}: {
|
||||||
|
groupedActionItems: {
|
||||||
|
actions: PageHeaderAction[];
|
||||||
|
gap: PageHeaderActionGap;
|
||||||
|
}[];
|
||||||
|
actionsDisabled: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{groupedActionItems.map((group, groupIndex) => (
|
||||||
|
<div
|
||||||
|
key={groupIndex}
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 items-center",
|
||||||
|
actionGapClassName[group.gap],
|
||||||
|
"rounded-full border border-white/70 bg-white px-1 py-1 shadow-[0_8px_24px_rgba(15,23,42,0.06)] backdrop-blur-2xl",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{group.actions.map((action, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<PageHeaderActionRenderer
|
||||||
|
action={action}
|
||||||
|
disabled={actionsDisabled}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeActionGroup(
|
function normalizeActionGroup(
|
||||||
group: PageHeaderActionGroup,
|
group: PageHeaderActionGroup,
|
||||||
fallbackGap: PageHeaderActionGap,
|
fallbackGap: PageHeaderActionGap,
|
||||||
|
|
@ -264,6 +299,7 @@ function PageHeaderButtonActionControl({
|
||||||
aria-label={action.title}
|
aria-label={action.title}
|
||||||
variant={action.variant}
|
variant={action.variant}
|
||||||
iconOnly={iconOnly}
|
iconOnly={iconOnly}
|
||||||
|
compact={action.compact}
|
||||||
>
|
>
|
||||||
{action.icon}
|
{action.icon}
|
||||||
{action.label}
|
{action.label}
|
||||||
|
|
@ -394,11 +430,13 @@ type PageHeaderActionButtonProps = Omit<
|
||||||
> & {
|
> & {
|
||||||
variant?: "default" | "danger";
|
variant?: "default" | "danger";
|
||||||
iconOnly?: boolean;
|
iconOnly?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PageHeaderActionControlClassNameOptions = {
|
type PageHeaderActionControlClassNameOptions = {
|
||||||
variant?: "default" | "danger";
|
variant?: "default" | "danger";
|
||||||
iconOnly?: boolean;
|
iconOnly?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
@ -406,12 +444,13 @@ type PageHeaderActionControlClassNameOptions = {
|
||||||
function pageHeaderActionControlClassName({
|
function pageHeaderActionControlClassName({
|
||||||
variant = "default",
|
variant = "default",
|
||||||
iconOnly = false,
|
iconOnly = false,
|
||||||
|
compact = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className,
|
className,
|
||||||
}: PageHeaderActionControlClassNameOptions = {}) {
|
}: PageHeaderActionControlClassNameOptions = {}) {
|
||||||
return cn(
|
return cn(
|
||||||
"flex h-7 items-center justify-center rounded-full text-sm transition-colors hover:bg-gray-100 active:bg-gray-100 disabled:cursor-default disabled:text-gray-300 disabled:hover:bg-transparent disabled:hover:text-gray-300",
|
"flex h-7 items-center justify-center rounded-full text-sm transition-colors hover:bg-gray-100 active:bg-gray-100 disabled:cursor-default disabled:text-gray-300 disabled:hover:bg-transparent disabled:hover:text-gray-300",
|
||||||
iconOnly ? "w-7" : "gap-1.5 px-3",
|
iconOnly ? "w-7" : compact ? "gap-1.5 px-2" : "gap-1.5 px-3",
|
||||||
disabled ? "cursor-default" : "cursor-pointer",
|
disabled ? "cursor-default" : "cursor-pointer",
|
||||||
"hover:bg-gray-100 active:bg-gray-100",
|
"hover:bg-gray-100 active:bg-gray-100",
|
||||||
variant === "danger"
|
variant === "danger"
|
||||||
|
|
@ -425,6 +464,7 @@ function PageHeaderActionButton({
|
||||||
children,
|
children,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
iconOnly = false,
|
iconOnly = false,
|
||||||
|
compact = false,
|
||||||
disabled,
|
disabled,
|
||||||
...props
|
...props
|
||||||
}: PageHeaderActionButtonProps) {
|
}: PageHeaderActionButtonProps) {
|
||||||
|
|
@ -434,6 +474,7 @@ function PageHeaderActionButton({
|
||||||
className={pageHeaderActionControlClassName({
|
className={pageHeaderActionControlClassName({
|
||||||
variant,
|
variant,
|
||||||
iconOnly,
|
iconOnly,
|
||||||
|
compact,
|
||||||
disabled,
|
disabled,
|
||||||
})}
|
})}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -444,7 +485,6 @@ function PageHeaderActionButton({
|
||||||
}
|
}
|
||||||
|
|
||||||
function PageHeaderBreadcrumbs({ items }: { items: PageHeaderBreadcrumb[] }) {
|
function PageHeaderBreadcrumbs({ items }: { items: PageHeaderBreadcrumb[] }) {
|
||||||
const current = items[items.length - 1];
|
|
||||||
const parent = [...items]
|
const parent = [...items]
|
||||||
.slice(0, -1)
|
.slice(0, -1)
|
||||||
.reverse()
|
.reverse()
|
||||||
|
|
@ -462,21 +502,15 @@ function PageHeaderBreadcrumbs({ items }: { items: PageHeaderBreadcrumb[] }) {
|
||||||
<ChevronLeft className="h-5 w-5" />
|
<ChevronLeft className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="hidden min-w-0 items-center gap-1.5 sm:flex">
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<BreadcrumbItem
|
<BreadcrumbItem
|
||||||
key={index}
|
key={index}
|
||||||
item={item}
|
item={item}
|
||||||
current={index === items.length - 1}
|
current={index === items.length - 1}
|
||||||
showSuffix
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 sm:hidden">
|
|
||||||
{current ? (
|
|
||||||
<BreadcrumbItem item={current} current showSuffix={false} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -484,11 +518,9 @@ function PageHeaderBreadcrumbs({ items }: { items: PageHeaderBreadcrumb[] }) {
|
||||||
function BreadcrumbItem({
|
function BreadcrumbItem({
|
||||||
item,
|
item,
|
||||||
current,
|
current,
|
||||||
showSuffix,
|
|
||||||
}: {
|
}: {
|
||||||
item: PageHeaderBreadcrumb;
|
item: PageHeaderBreadcrumb;
|
||||||
current: boolean;
|
current: boolean;
|
||||||
showSuffix: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const content = item.loading ? (
|
const content = item.loading ? (
|
||||||
<div
|
<div
|
||||||
|
|
@ -507,7 +539,6 @@ function BreadcrumbItem({
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
{showSuffix && item.suffix}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -520,9 +551,13 @@ function BreadcrumbItem({
|
||||||
? "text-gray-500 hover:text-gray-700"
|
? "text-gray-500 hover:text-gray-700"
|
||||||
: "text-gray-500",
|
: "text-gray-500",
|
||||||
);
|
);
|
||||||
|
const wrapperClassName = cn(
|
||||||
|
"min-w-0 items-center gap-1.5",
|
||||||
|
current ? "flex" : "hidden sm:flex",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<span className={wrapperClassName}>
|
||||||
{current ? (
|
{current ? (
|
||||||
<span className={className}>{content}</span>
|
<span className={className}>{content}</span>
|
||||||
) : item.onClick ? (
|
) : item.onClick ? (
|
||||||
|
|
@ -533,6 +568,6 @@ function BreadcrumbItem({
|
||||||
<span className={className}>{content}</span>
|
<span className={className}>{content}</span>
|
||||||
)}
|
)}
|
||||||
{!current && <span className="shrink-0 text-gray-300">›</span>}
|
{!current && <span className="shrink-0 text-gray-300">›</span>}
|
||||||
</>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -207,9 +207,10 @@ export function PeopleModal({
|
||||||
} with access.`
|
} with access.`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-6">
|
||||||
{/* Add-member row */}
|
{/* Add-member row */}
|
||||||
{onSharedWithChange && (
|
{onSharedWithChange && (
|
||||||
<div className="pt-1 pb-2">
|
<section className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex flex-1 items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
<div className="flex flex-1 items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
||||||
<UserPlus className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
<UserPlus className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||||
|
|
@ -269,90 +270,92 @@ export function PeopleModal({
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Section heading */}
|
<section className="min-h-0 flex-1">
|
||||||
<div className="pt-3 pb-1 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<h3 className="text-xs font-medium text-gray-500">
|
<h3 className="text-xs font-medium text-gray-500">
|
||||||
People with Access
|
People with Access
|
||||||
</h3>
|
</h3>
|
||||||
{peopleLoading && (
|
{peopleLoading && (
|
||||||
<Loader2 className="h-3 w-3 animate-spin text-gray-400" />
|
<Loader2 className="h-3 w-3 animate-spin text-gray-400" />
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Member list */}
|
|
||||||
{roster.length === 0 ? (
|
|
||||||
<div className="flex h-full items-center justify-center text-sm text-gray-400">
|
|
||||||
No one has access yet.
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-gray-100 [&>li:nth-child(2)]:border-t-0">
|
{/* Member list */}
|
||||||
{roster.map((entry) => {
|
{roster.length === 0 ? (
|
||||||
const isYou =
|
<div className="flex h-full items-center justify-center text-sm text-gray-400">
|
||||||
!!currentUserEmail &&
|
No one has access yet.
|
||||||
entry.email.toLowerCase() ===
|
</div>
|
||||||
currentUserEmail.toLowerCase();
|
) : (
|
||||||
const isRemoving =
|
<ul className="divide-y divide-gray-100 [&>li:nth-child(2)]:border-t-0">
|
||||||
busy === "remove" &&
|
{roster.map((entry) => {
|
||||||
removingEmail === entry.email;
|
const isYou =
|
||||||
const primary =
|
!!currentUserEmail &&
|
||||||
entry.display_name?.trim() || entry.email;
|
entry.email.toLowerCase() ===
|
||||||
const showSecondary =
|
currentUserEmail.toLowerCase();
|
||||||
!!entry.display_name?.trim() &&
|
const isRemoving =
|
||||||
primary !== entry.email;
|
busy === "remove" &&
|
||||||
return (
|
removingEmail === entry.email;
|
||||||
<li
|
const primary =
|
||||||
key={`${entry.role}-${entry.email}`}
|
entry.display_name?.trim() || entry.email;
|
||||||
className="flex items-center gap-3 py-3"
|
const showSecondary =
|
||||||
>
|
!!entry.display_name?.trim() &&
|
||||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-900 text-white">
|
primary !== entry.email;
|
||||||
<User className="h-3 w-3" />
|
return (
|
||||||
</div>
|
<li
|
||||||
<div className="min-w-0 flex-1">
|
key={`${entry.role}-${entry.email}`}
|
||||||
<p className="truncate text-sm text-gray-800">
|
className="flex items-center gap-3 py-3"
|
||||||
{primary}
|
>
|
||||||
{isYou && (
|
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-900 text-white">
|
||||||
<span className="ml-1.5 text-xs text-gray-400">
|
<User className="h-3 w-3" />
|
||||||
(You)
|
</div>
|
||||||
</span>
|
<div className="min-w-0 flex-1">
|
||||||
)}
|
<p className="truncate text-sm text-gray-800">
|
||||||
{entry.role === "owner" && (
|
{primary}
|
||||||
<span className="ml-1.5 text-[10px] text-gray-400">
|
{isYou && (
|
||||||
Owner
|
<span className="ml-1.5 text-xs text-gray-400">
|
||||||
</span>
|
(You)
|
||||||
)}
|
</span>
|
||||||
</p>
|
|
||||||
{showSecondary && (
|
|
||||||
<p className="truncate text-xs text-gray-400">
|
|
||||||
{entry.email}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{entry.role === "member" &&
|
|
||||||
onSharedWithChange && (
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
void handleRemove(
|
|
||||||
entry.email,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={busy !== null}
|
|
||||||
title="Remove access"
|
|
||||||
className="self-center inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-gray-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isRemoving && (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
)}
|
)}
|
||||||
Remove
|
{entry.role === "owner" && (
|
||||||
</button>
|
<span className="ml-1.5 text-[10px] text-gray-400">
|
||||||
)}
|
Owner
|
||||||
</li>
|
</span>
|
||||||
);
|
)}
|
||||||
})}
|
</p>
|
||||||
</ul>
|
{showSecondary && (
|
||||||
)}
|
<p className="truncate text-xs text-gray-400">
|
||||||
|
{entry.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{entry.role === "member" &&
|
||||||
|
onSharedWithChange && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
void handleRemove(
|
||||||
|
entry.email,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={busy !== null}
|
||||||
|
title="Remove access"
|
||||||
|
className="self-center inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-gray-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isRemoving && (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
)}
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export function RenameableTitle({ value, onCommit, suffix }: Props) {
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const caretPos = useRef<number | null>(null);
|
const caretPos = useRef<number | null>(null);
|
||||||
const escaped = useRef(false);
|
const escaped = useRef(false);
|
||||||
|
const committed = useRef(false);
|
||||||
|
|
||||||
function startEditing(e: React.MouseEvent) {
|
function startEditing(e: React.MouseEvent) {
|
||||||
const doc = document as CaretDocument;
|
const doc = document as CaretDocument;
|
||||||
|
|
@ -32,15 +33,18 @@ export function RenameableTitle({ value, onCommit, suffix }: Props) {
|
||||||
? range.startOffset
|
? range.startOffset
|
||||||
: null;
|
: null;
|
||||||
escaped.current = false;
|
escaped.current = false;
|
||||||
|
committed.current = false;
|
||||||
setDraft(value);
|
setDraft(value);
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function commit() {
|
function commit() {
|
||||||
|
if (committed.current) return;
|
||||||
if (escaped.current) {
|
if (escaped.current) {
|
||||||
escaped.current = false;
|
escaped.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
committed.current = true;
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
onCommit(draft.trim());
|
onCommit(draft.trim());
|
||||||
}
|
}
|
||||||
|
|
@ -58,9 +62,13 @@ export function RenameableTitle({ value, onCommit, suffix }: Props) {
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") commit();
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
commit();
|
||||||
|
}
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
escaped.current = true;
|
escaped.current = true;
|
||||||
|
committed.current = true;
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -141,9 +141,12 @@ export function RowActionMenuItems({
|
||||||
)}
|
)}
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { onClose(); onDelete(); }}
|
onClick={() => {
|
||||||
disabled={deleting}
|
if (deleteDisabled || deleting) return;
|
||||||
aria-disabled={deleteDisabled}
|
onClose();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
disabled={deleting || deleteDisabled}
|
||||||
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-red-500 transition-colors disabled:opacity-40 ${
|
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-red-500 transition-colors disabled:opacity-40 ${
|
||||||
deleteDisabled
|
deleteDisabled
|
||||||
? "cursor-not-allowed opacity-40 hover:bg-transparent"
|
? "cursor-not-allowed opacity-40 hover:bg-transparent"
|
||||||
|
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { Check, Loader2, Search } from "lucide-react";
|
|
||||||
import { listWorkflows } from "@/app/lib/mikeApi";
|
|
||||||
import { Modal } from "@/app/components/shared/Modal";
|
|
||||||
import type { Workflow } from "@/app/components/shared/types";
|
|
||||||
import { BUILT_IN_WORKFLOWS } from "../workflows/builtinWorkflows";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
open: boolean;
|
|
||||||
applying?: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onApply: (workflow: Workflow) => Promise<void> | void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ApplyWorkflowPresetModal({
|
|
||||||
open,
|
|
||||||
applying = false,
|
|
||||||
onClose,
|
|
||||||
onApply,
|
|
||||||
}: Props) {
|
|
||||||
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
|
||||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const builtinTabular = BUILT_IN_WORKFLOWS.filter(
|
|
||||||
(workflow) => workflow.type === "tabular",
|
|
||||||
);
|
|
||||||
setLoading(true);
|
|
||||||
setSearch("");
|
|
||||||
setSelectedWorkflowId(null);
|
|
||||||
listWorkflows("tabular")
|
|
||||||
.then((custom) => setWorkflows([...builtinTabular, ...custom]))
|
|
||||||
.catch(() => setWorkflows(builtinTabular))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const filteredWorkflows = useMemo(() => {
|
|
||||||
const q = search.trim().toLowerCase();
|
|
||||||
if (!q) return workflows;
|
|
||||||
return workflows.filter((workflow) =>
|
|
||||||
[workflow.title, workflow.practice ?? ""]
|
|
||||||
.join(" ")
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(q),
|
|
||||||
);
|
|
||||||
}, [search, workflows]);
|
|
||||||
|
|
||||||
const selectedWorkflow =
|
|
||||||
workflows.find((workflow) => workflow.id === selectedWorkflowId) ?? null;
|
|
||||||
const canApply =
|
|
||||||
!!selectedWorkflow &&
|
|
||||||
!applying &&
|
|
||||||
!loading &&
|
|
||||||
!!selectedWorkflow.columns_config?.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
title="Apply preset workflow"
|
|
||||||
size="md"
|
|
||||||
primaryAction={{
|
|
||||||
label: applying ? "Applying..." : "Apply",
|
|
||||||
onClick: () => {
|
|
||||||
if (selectedWorkflow) void onApply(selectedWorkflow);
|
|
||||||
},
|
|
||||||
disabled: !canApply,
|
|
||||||
icon: applying ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : undefined,
|
|
||||||
}}
|
|
||||||
cancelAction={{ label: "Cancel", onClick: onClose }}
|
|
||||||
>
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Choose a tabular review workflow. Applying it will replace
|
|
||||||
the current review columns with the workflow preset.
|
|
||||||
</p>
|
|
||||||
<div className="flex h-9 items-center gap-2 rounded-xl bg-gray-100 px-3">
|
|
||||||
<Search className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
|
||||||
<input
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
|
||||||
placeholder="Search workflows..."
|
|
||||||
className="min-w-0 flex-1 bg-transparent text-sm text-gray-800 outline-none placeholder:text-gray-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-xl bg-gray-50 p-1.5">
|
|
||||||
{loading ? (
|
|
||||||
<div className="space-y-2 p-1">
|
|
||||||
{[1, 2, 3, 4].map((index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="h-14 animate-pulse rounded-xl bg-white"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : filteredWorkflows.length === 0 ? (
|
|
||||||
<div className="flex h-32 items-center justify-center text-sm text-gray-400">
|
|
||||||
No workflows found
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredWorkflows.map((workflow) => {
|
|
||||||
const selected = workflow.id === selectedWorkflowId;
|
|
||||||
const columnCount =
|
|
||||||
workflow.columns_config?.length ?? 0;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={workflow.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setSelectedWorkflowId(workflow.id)
|
|
||||||
}
|
|
||||||
disabled={columnCount === 0}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2.5 text-left transition-colors",
|
|
||||||
selected
|
|
||||||
? "bg-white text-gray-950 shadow-[0_1px_4px_rgba(15,23,42,0.06)]"
|
|
||||||
: "text-gray-700 hover:bg-white/75",
|
|
||||||
columnCount === 0 &&
|
|
||||||
"cursor-not-allowed opacity-45",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block truncate text-sm font-medium">
|
|
||||||
{workflow.title}
|
|
||||||
</span>
|
|
||||||
<span className="mt-0.5 block truncate text-xs text-gray-400">
|
|
||||||
{workflow.practice ?? "Tabular"} ·{" "}
|
|
||||||
{columnCount}{" "}
|
|
||||||
{columnCount === 1
|
|
||||||
? "column"
|
|
||||||
: "columns"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{selected && (
|
|
||||||
<Check className="h-4 w-4 shrink-0 text-green-600" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -67,7 +67,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const stickyCellBg = "bg-[#fcfcfd]";
|
const stickyCellBg = "bg-[#fafbfc]";
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
|
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
|
||||||
const totalContentWidth =
|
const totalContentWidth =
|
||||||
|
|
|
||||||
36
frontend/src/app/components/tabular/TRWorkflowModal.tsx
Normal file
36
frontend/src/app/components/tabular/TRWorkflowModal.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { Workflow } from "../shared/types";
|
||||||
|
import { WorkflowPickerModal } from "../workflows/WorkflowPickerModal";
|
||||||
|
|
||||||
|
interface TRWorkflowModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onApply: (workflow: Workflow) => Promise<void> | void;
|
||||||
|
breadcrumbs: ReactNode[];
|
||||||
|
applying?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TRWorkflowModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onApply,
|
||||||
|
breadcrumbs,
|
||||||
|
applying = false,
|
||||||
|
}: TRWorkflowModalProps) {
|
||||||
|
return (
|
||||||
|
<WorkflowPickerModal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
onSelect={onApply}
|
||||||
|
workflowType="tabular"
|
||||||
|
breadcrumbs={breadcrumbs}
|
||||||
|
primaryLabel="Apply"
|
||||||
|
selectingLabel="Applying..."
|
||||||
|
selecting={applying}
|
||||||
|
closeOnSelect={false}
|
||||||
|
disabledWorkflow={(workflow) => !workflow.columns_config?.length}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -37,7 +37,7 @@ import type {
|
||||||
Workflow,
|
Workflow,
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
import { AddColumnModal } from "./AddColumnModal";
|
import { AddColumnModal } from "./AddColumnModal";
|
||||||
import { ApplyWorkflowPresetModal } from "./ApplyWorkflowPresetModal";
|
import { TRWorkflowModal } from "./TRWorkflowModal";
|
||||||
import { AddDocumentsModal } from "../shared/AddDocumentsModal";
|
import { AddDocumentsModal } from "../shared/AddDocumentsModal";
|
||||||
import { AddProjectDocsModal } from "../shared/AddProjectDocsModal";
|
import { AddProjectDocsModal } from "../shared/AddProjectDocsModal";
|
||||||
import { PeopleModal } from "../shared/PeopleModal";
|
import { PeopleModal } from "../shared/PeopleModal";
|
||||||
|
|
@ -79,9 +79,8 @@ export function TRView({ reviewId, projectId }: Props) {
|
||||||
const [addColOpen, setAddColOpen] = useState(false);
|
const [addColOpen, setAddColOpen] = useState(false);
|
||||||
const [addDocsOpen, setAddDocsOpen] = useState(false);
|
const [addDocsOpen, setAddDocsOpen] = useState(false);
|
||||||
const [peopleModalOpen, setPeopleModalOpen] = useState(false);
|
const [peopleModalOpen, setPeopleModalOpen] = useState(false);
|
||||||
const [workflowPresetModalOpen, setWorkflowPresetModalOpen] =
|
const [workflowModalOpen, setWorkflowModalOpen] = useState(false);
|
||||||
useState(false);
|
const [applyingWorkflow, setApplyingWorkflow] = useState(false);
|
||||||
const [applyingWorkflowPreset, setApplyingWorkflowPreset] = useState(false);
|
|
||||||
const [deleteReviewConfirmOpen, setDeleteReviewConfirmOpen] =
|
const [deleteReviewConfirmOpen, setDeleteReviewConfirmOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [deleteReviewStatus, setDeleteReviewStatus] = useState<
|
const [deleteReviewStatus, setDeleteReviewStatus] = useState<
|
||||||
|
|
@ -591,15 +590,15 @@ export function TRView({ reviewId, projectId }: Props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestWorkflowPreset() {
|
function requestWorkflow() {
|
||||||
if (review?.is_owner === false) {
|
if (review?.is_owner === false) {
|
||||||
setOwnerOnlyAction("apply a preset workflow");
|
setOwnerOnlyAction("apply a workflow");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setWorkflowPresetModalOpen(true);
|
setWorkflowModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleApplyWorkflowPreset(workflow: Workflow) {
|
async function handleApplyWorkflow(workflow: Workflow) {
|
||||||
if (!workflow.columns_config?.length) return;
|
if (!workflow.columns_config?.length) return;
|
||||||
const nextColumns = workflow.columns_config.map((column, index) => ({
|
const nextColumns = workflow.columns_config.map((column, index) => ({
|
||||||
...column,
|
...column,
|
||||||
|
|
@ -607,7 +606,7 @@ export function TRView({ reviewId, projectId }: Props) {
|
||||||
}));
|
}));
|
||||||
const previousColumns = columns;
|
const previousColumns = columns;
|
||||||
const previousCells = cells;
|
const previousCells = cells;
|
||||||
setApplyingWorkflowPreset(true);
|
setApplyingWorkflow(true);
|
||||||
setColumns(nextColumns);
|
setColumns(nextColumns);
|
||||||
setCells([]);
|
setCells([]);
|
||||||
try {
|
try {
|
||||||
|
|
@ -622,13 +621,13 @@ export function TRView({ reviewId, projectId }: Props) {
|
||||||
console.error("Failed to clear old tabular cells", err);
|
console.error("Failed to clear old tabular cells", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setWorkflowPresetModalOpen(false);
|
setWorkflowModalOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setColumns(previousColumns);
|
setColumns(previousColumns);
|
||||||
setCells(previousCells);
|
setCells(previousCells);
|
||||||
console.error("Failed to apply workflow preset", err);
|
console.error("Failed to apply workflow", err);
|
||||||
} finally {
|
} finally {
|
||||||
setApplyingWorkflowPreset(false);
|
setApplyingWorkflow(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -657,18 +656,17 @@ export function TRView({ reviewId, projectId }: Props) {
|
||||||
loading: true,
|
loading: true,
|
||||||
skeletonClassName: "w-32",
|
skeletonClassName: "w-32",
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
router.push(`/projects/${projectId}`),
|
router.push(
|
||||||
|
`/projects/${projectId}?tab=reviews`,
|
||||||
|
),
|
||||||
title: "Back to project",
|
title: "Back to project",
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
label: project?.name ?? "",
|
label: project?.name ?? "",
|
||||||
suffix: project?.cm_number ? (
|
|
||||||
<span className="ml-1 text-gray-400">
|
|
||||||
(#{project.cm_number})
|
|
||||||
</span>
|
|
||||||
) : null,
|
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
router.push(`/projects/${projectId}`),
|
router.push(
|
||||||
|
`/projects/${projectId}?tab=reviews`,
|
||||||
|
),
|
||||||
title: "Back to project",
|
title: "Back to project",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
@ -716,10 +714,9 @@ export function TRView({ reviewId, projectId }: Props) {
|
||||||
onSelect: requestReviewRename,
|
onSelect: requestReviewRename,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Apply preset workflow",
|
label: "Apply workflow",
|
||||||
icon: WandSparkles,
|
icon: WandSparkles,
|
||||||
onSelect:
|
onSelect: requestWorkflow,
|
||||||
requestWorkflowPreset,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Delete",
|
label: "Delete",
|
||||||
|
|
@ -1057,14 +1054,28 @@ export function TRView({ reviewId, projectId }: Props) {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ApplyWorkflowPresetModal
|
<TRWorkflowModal
|
||||||
open={workflowPresetModalOpen}
|
open={workflowModalOpen}
|
||||||
applying={applyingWorkflowPreset}
|
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
if (applyingWorkflowPreset) return;
|
if (applyingWorkflow) return;
|
||||||
setWorkflowPresetModalOpen(false);
|
setWorkflowModalOpen(false);
|
||||||
}}
|
}}
|
||||||
onApply={handleApplyWorkflowPreset}
|
onApply={handleApplyWorkflow}
|
||||||
|
breadcrumbs={[
|
||||||
|
...(project
|
||||||
|
? [
|
||||||
|
"Projects",
|
||||||
|
project.name +
|
||||||
|
(project.cm_number
|
||||||
|
? ` (#${project.cm_number})`
|
||||||
|
: ""),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
"Tabular Reviews",
|
||||||
|
review?.title || "Untitled Review",
|
||||||
|
"Add workflow",
|
||||||
|
]}
|
||||||
|
applying={applyingWorkflow}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmPopup
|
<ConfirmPopup
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,17 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import { Folder, Search, X } from "lucide-react";
|
||||||
ChevronDown,
|
import type { Document, Workflow } from "../shared/types";
|
||||||
Folder,
|
|
||||||
MessageSquare,
|
|
||||||
Search,
|
|
||||||
Table2,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import type {
|
|
||||||
Document,
|
|
||||||
Workflow,
|
|
||||||
} from "../shared/types";
|
|
||||||
import { createTabularReview } from "@/app/lib/mikeApi";
|
import { createTabularReview } from "@/app/lib/mikeApi";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { formatIcon, formatLabel } from "../tabular/columnFormat";
|
|
||||||
import { useDirectoryData } from "../shared/useDirectoryData";
|
import { useDirectoryData } from "../shared/useDirectoryData";
|
||||||
import { FileDirectory } from "../shared/FileDirectory";
|
import { FileDirectory } from "../shared/FileDirectory";
|
||||||
import type { Project } from "../shared/types";
|
import type { Project } from "../shared/types";
|
||||||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||||
import { Modal } from "../shared/Modal";
|
import { Modal } from "../shared/Modal";
|
||||||
|
import { WorkflowPickerContent } from "./WorkflowPickerContent";
|
||||||
|
import { workflowDetailPath } from "./workflowRoutes";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
workflows: Workflow[];
|
workflows: Workflow[];
|
||||||
|
|
@ -122,165 +111,6 @@ function SimpleProjectPicker({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared markdown renderer
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function MarkdownBody({ content }: { content: string }) {
|
|
||||||
return (
|
|
||||||
<ReactMarkdown
|
|
||||||
remarkPlugins={[remarkGfm]}
|
|
||||||
components={{
|
|
||||||
h1: ({ children }) => (
|
|
||||||
<h1 className="text-base font-semibold text-gray-900 mt-4 mb-1 first:mt-0">
|
|
||||||
{children}
|
|
||||||
</h1>
|
|
||||||
),
|
|
||||||
h2: ({ children }) => (
|
|
||||||
<h2 className="text-sm font-semibold text-gray-900 mt-3 mb-1 first:mt-0">
|
|
||||||
{children}
|
|
||||||
</h2>
|
|
||||||
),
|
|
||||||
h3: ({ children }) => (
|
|
||||||
<h3 className="text-xs font-semibold text-gray-900 mt-2 mb-0.5 first:mt-0">
|
|
||||||
{children}
|
|
||||||
</h3>
|
|
||||||
),
|
|
||||||
p: ({ children }) => (
|
|
||||||
<p className="mb-2 last:mb-0">{children}</p>
|
|
||||||
),
|
|
||||||
ul: ({ children }) => (
|
|
||||||
<ul className="list-disc pl-4 mb-2 space-y-0.5">
|
|
||||||
{children}
|
|
||||||
</ul>
|
|
||||||
),
|
|
||||||
ol: ({ children }) => (
|
|
||||||
<ol className="list-decimal pl-4 mb-2 space-y-0.5">
|
|
||||||
{children}
|
|
||||||
</ol>
|
|
||||||
),
|
|
||||||
li: ({ children }) => <li>{children}</li>,
|
|
||||||
strong: ({ children }) => (
|
|
||||||
<strong className="font-semibold text-gray-800">
|
|
||||||
{children}
|
|
||||||
</strong>
|
|
||||||
),
|
|
||||||
em: ({ children }) => <em className="italic">{children}</em>,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</ReactMarkdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Right panel for assistant workflows (select screen)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function AssistantPanel({ workflow }: { workflow: Workflow }) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
|
||||||
<div className="py-3 shrink-0">
|
|
||||||
<p className="text-xs font-medium text-gray-700">
|
|
||||||
Workflow Prompt
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-3 text-sm border border-gray-200 rounded-md text-gray-600 leading-relaxed font-serif bg-gray-50">
|
|
||||||
<MarkdownBody
|
|
||||||
content={workflow.prompt_md ?? "_No prompt defined._"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Right panel for tabular workflows — accordion column list (select screen)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function TabularPanel({ workflow }: { workflow: Workflow }) {
|
|
||||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
|
||||||
const columns = (workflow.columns_config ?? []).sort(
|
|
||||||
(a, b) => a.index - b.index,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
|
||||||
<div className="py-3 shrink-0">
|
|
||||||
<p className="text-xs font-medium text-gray-700">Columns</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto border border-gray-200 rounded-md bg-gray-50">
|
|
||||||
{columns.length === 0 ? (
|
|
||||||
<p className="px-4 py-6 text-xs text-center text-gray-400">
|
|
||||||
No columns defined
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
columns.map((col) => {
|
|
||||||
const isExpanded = expandedIndex === col.index;
|
|
||||||
const FormatIcon = formatIcon(col.format ?? "text");
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={col.index}
|
|
||||||
className="border-b border-gray-200"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setExpandedIndex(
|
|
||||||
isExpanded ? null : col.index,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="w-full flex items-center gap-2.5 px-3 py-2.5 text-xs text-left hover:bg-white transition-colors"
|
|
||||||
>
|
|
||||||
<FormatIcon className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
|
||||||
<span className="flex-1 truncate text-gray-800">
|
|
||||||
{col.name}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-gray-400">
|
|
||||||
{formatLabel(col.format ?? "text")}
|
|
||||||
</span>
|
|
||||||
<ChevronDown
|
|
||||||
className={`h-3 w-3 shrink-0 text-gray-300 transition-transform duration-150 ${isExpanded ? "rotate-180" : ""}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="px-4 py-3 bg-white border-t border-gray-200 text-sm text-gray-600 leading-relaxed font-serif space-y-3">
|
|
||||||
{col.tags && col.tags.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-gray-400 mb-1.5 font-sans">
|
|
||||||
Tags
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{col.tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600 font-sans"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-gray-400 mb-1 font-sans">
|
|
||||||
Prompt
|
|
||||||
</p>
|
|
||||||
<MarkdownBody
|
|
||||||
content={
|
|
||||||
col.prompt ||
|
|
||||||
"_No prompt defined._"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// DisplayWorkflowModal
|
// DisplayWorkflowModal
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -288,7 +118,6 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
||||||
const [screen, setScreen] = useState<"select" | "configure">("select");
|
const [screen, setScreen] = useState<"select" | "configure">("select");
|
||||||
const [selected, setSelected] = useState<Workflow | null>(workflow);
|
const [selected, setSelected] = useState<Workflow | null>(workflow);
|
||||||
const [listSearch, setListSearch] = useState("");
|
const [listSearch, setListSearch] = useState("");
|
||||||
const selectedRowRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
// Configure screen state
|
// Configure screen state
|
||||||
const [inProject, setInProject] = useState(false);
|
const [inProject, setInProject] = useState(false);
|
||||||
|
|
@ -320,12 +149,6 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
||||||
}
|
}
|
||||||
}, [workflow?.id]);
|
}, [workflow?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selected && selectedRowRef.current) {
|
|
||||||
selectedRowRef.current.scrollIntoView({ block: "nearest" });
|
|
||||||
}
|
|
||||||
}, [selected?.id]);
|
|
||||||
|
|
||||||
// Reset configure state on back
|
// Reset configure state on back
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (screen === "select") {
|
if (screen === "select") {
|
||||||
|
|
@ -467,7 +290,7 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
||||||
];
|
];
|
||||||
|
|
||||||
const selectPageAction = () => {
|
const selectPageAction = () => {
|
||||||
router.push(`/workflows/${wf.id}`);
|
router.push(workflowDetailPath(wf));
|
||||||
handleClose();
|
handleClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -525,59 +348,19 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
||||||
|
|
||||||
{/* ── SELECT SCREEN ── */}
|
{/* ── SELECT SCREEN ── */}
|
||||||
{screen === "select" && (
|
{screen === "select" && (
|
||||||
<div className="flex flex-row flex-1 min-h-0 overflow-hidden gap-3">
|
<WorkflowPickerContent
|
||||||
{/* Left: workflow list */}
|
workflows={workflows}
|
||||||
<div className="w-80 shrink-0 flex flex-col overflow-hidden">
|
selected={wf}
|
||||||
{/* Search */}
|
onSelect={(next) => {
|
||||||
<div className="px-2 py-3 shrink-0">
|
if (next) setSelected(next);
|
||||||
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
|
}}
|
||||||
<Search className="h-3 w-3 text-gray-400 shrink-0" />
|
search={listSearch}
|
||||||
<input
|
onSearchChange={setListSearch}
|
||||||
type="text"
|
workflowType="all"
|
||||||
placeholder="Search…"
|
previewMode="auto"
|
||||||
value={listSearch}
|
showTypeIcon
|
||||||
onChange={(e) => setListSearch(e.target.value)}
|
allowClearPreview={false}
|
||||||
className="flex-1 bg-transparent text-xs text-gray-700 placeholder:text-gray-400 outline-none"
|
/>
|
||||||
/>
|
|
||||||
{listSearch && (
|
|
||||||
<button onClick={() => setListSearch("")} className="text-gray-400 hover:text-gray-600">
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* List */}
|
|
||||||
<div className="overflow-y-auto flex-1">
|
|
||||||
{workflows
|
|
||||||
.filter((wfItem) => !listSearch || wfItem.title.toLowerCase().includes(listSearch.toLowerCase()))
|
|
||||||
.map((wfItem) => {
|
|
||||||
const isSelected = selected?.id === wfItem.id;
|
|
||||||
const Icon = wfItem.type === "tabular" ? Table2 : MessageSquare;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={wfItem.id}
|
|
||||||
ref={isSelected ? selectedRowRef : null}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelected(wfItem)}
|
|
||||||
className={`w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-xs text-left transition-colors ${isSelected ? "bg-gray-100 text-gray-900" : "hover:bg-gray-50"}`}
|
|
||||||
>
|
|
||||||
<span className={`flex-1 truncate ${isSelected ? "text-gray-900 font-medium" : "text-gray-700"}`}>
|
|
||||||
{wfItem.title}
|
|
||||||
</span>
|
|
||||||
<Icon className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: workflow detail */}
|
|
||||||
{wf.type === "assistant" ? (
|
|
||||||
<AssistantPanel key={wf.id} workflow={wf} />
|
|
||||||
) : (
|
|
||||||
<TabularPanel key={wf.id} workflow={wf} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── ASSISTANT CONFIGURE SCREEN ── */}
|
{/* ── ASSISTANT CONFIGURE SCREEN ── */}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,8 @@ export function ShareWorkflowModal({
|
||||||
disabled: saving || pendingEmails.length === 0,
|
disabled: saving || pendingEmails.length === 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-6">
|
||||||
|
<section className="space-y-3">
|
||||||
<EmailPillInput
|
<EmailPillInput
|
||||||
emails={pendingEmails}
|
emails={pendingEmails}
|
||||||
onChange={setPendingEmails}
|
onChange={setPendingEmails}
|
||||||
|
|
@ -101,9 +103,10 @@ export function ShareWorkflowModal({
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Permission toggle */}
|
{/* Permission toggle */}
|
||||||
<div className="flex flex-col gap-2">
|
<section className="flex flex-col gap-3">
|
||||||
<span className="text-xs font-medium text-gray-700">Allow editing by share recipients</span>
|
<span className="text-xs font-medium text-gray-700">Allow editing by share recipients</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -112,10 +115,10 @@ export function ShareWorkflowModal({
|
||||||
>
|
>
|
||||||
<span className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${allowEdit ? "translate-x-4" : "translate-x-0"}`} />
|
<span className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${allowEdit ? "translate-x-4" : "translate-x-0"}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* Existing access */}
|
{/* Existing access */}
|
||||||
<div>
|
<section className="min-h-0 flex-1">
|
||||||
<p className="text-xs font-medium text-gray-700 mb-2">People with access</p>
|
<p className="text-xs font-medium text-gray-700 mb-2">People with access</p>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -146,7 +149,8 @@ export function ShareWorkflowModal({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</section>
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,32 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { use, useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { ChevronDown, Plus, Users, X } from "lucide-react";
|
import {
|
||||||
import { getWorkflow, updateWorkflow } from "@/app/lib/mikeApi";
|
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 { ShareWorkflowModal } from "@/app/components/workflows/ShareWorkflowModal";
|
||||||
import { WFEditColumnModal } from "@/app/components/workflows/WFEditColumnModal";
|
import { WFEditColumnModal } from "@/app/components/workflows/WFEditColumnModal";
|
||||||
import { WFColumnViewModal } from "@/app/components/workflows/WFColumnViewModal";
|
import { WFColumnViewModal } from "@/app/components/workflows/WFColumnViewModal";
|
||||||
import { AddColumnModal } from "@/app/components/tabular/AddColumnModal";
|
import { AddColumnModal } from "@/app/components/tabular/AddColumnModal";
|
||||||
import type { ColumnConfig, Workflow } from "@/app/components/shared/types";
|
import type { ColumnConfig, Workflow } from "@/app/components/shared/types";
|
||||||
import {
|
import { BUILT_IN_WORKFLOWS } from "@/app/components/workflows/builtinWorkflows";
|
||||||
BUILT_IN_IDS,
|
|
||||||
BUILT_IN_WORKFLOWS,
|
|
||||||
} from "@/app/components/workflows/builtinWorkflows";
|
|
||||||
import { formatIcon, formatLabel } from "@/app/components/tabular/columnFormat";
|
import { formatIcon, formatLabel } from "@/app/components/tabular/columnFormat";
|
||||||
import { RenameableTitle } from "@/app/components/shared/RenameableTitle";
|
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
|
||||||
|
import { HeaderActionsMenu } from "@/app/components/shared/HeaderActionsMenu";
|
||||||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
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
|
// dynamic import keeps Tiptap (browser-only) out of the SSR bundle
|
||||||
const WorkflowPromptEditor = dynamic(
|
const WorkflowPromptEditor = dynamic(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -27,26 +37,32 @@ const WorkflowPromptEditor = dynamic(
|
||||||
);
|
);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ id: string }>;
|
id: string;
|
||||||
|
workflowType: Workflow["type"];
|
||||||
}
|
}
|
||||||
|
|
||||||
type SaveStatus = "idle" | "saving" | "saved";
|
type SaveStatus = "idle" | "saving" | "saved";
|
||||||
|
type DeleteStatus = "idle" | "loading" | "complete";
|
||||||
|
|
||||||
const NAME_COL_W = "w-[332px] shrink-0";
|
const NAME_COL_W = "w-[332px] shrink-0";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Page
|
// Page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export default function WorkflowDetailPage({ params }: Props) {
|
export function WorkflowDetailPage({ id, workflowType }: Props) {
|
||||||
const { id } = use(params);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const stickyCellBg = "bg-[#fcfcfd]";
|
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 [workflow, setWorkflow] = useState<Workflow | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [notFound, setNotFound] = useState(false);
|
const [notFound, setNotFound] = useState(false);
|
||||||
|
|
||||||
const isBuiltin = BUILT_IN_IDS.has(id);
|
|
||||||
const readOnly =
|
const readOnly =
|
||||||
isBuiltin ||
|
isBuiltin ||
|
||||||
(workflow?.is_system ?? false) ||
|
(workflow?.is_system ?? false) ||
|
||||||
|
|
@ -71,6 +87,9 @@ export default function WorkflowDetailPage({ params }: Props) {
|
||||||
|
|
||||||
// Share popover
|
// Share popover
|
||||||
const [shareOpen, setShareOpen] = useState(false);
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [deleteStatus, setDeleteStatus] = useState<DeleteStatus>("idle");
|
||||||
|
|
||||||
// Column actions dropdown
|
// Column actions dropdown
|
||||||
const [colActionsOpen, setColActionsOpen] = useState(false);
|
const [colActionsOpen, setColActionsOpen] = useState(false);
|
||||||
|
|
@ -91,7 +110,7 @@ export default function WorkflowDetailPage({ params }: Props) {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isBuiltin) {
|
if (isBuiltin) {
|
||||||
const wf = BUILT_IN_WORKFLOWS.find((w) => w.id === id) ?? null;
|
const wf = builtinWorkflow;
|
||||||
if (!wf) {
|
if (!wf) {
|
||||||
setNotFound(true);
|
setNotFound(true);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -105,6 +124,10 @@ export default function WorkflowDetailPage({ params }: Props) {
|
||||||
|
|
||||||
getWorkflow(id)
|
getWorkflow(id)
|
||||||
.then((wf) => {
|
.then((wf) => {
|
||||||
|
if (wf.type !== workflowType) {
|
||||||
|
setNotFound(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setWorkflow(wf);
|
setWorkflow(wf);
|
||||||
setPromptMd(wf.prompt_md ?? "");
|
setPromptMd(wf.prompt_md ?? "");
|
||||||
setColumns(
|
setColumns(
|
||||||
|
|
@ -115,7 +138,7 @@ export default function WorkflowDetailPage({ params }: Props) {
|
||||||
})
|
})
|
||||||
.catch(() => setNotFound(true))
|
.catch(() => setNotFound(true))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id, isBuiltin]);
|
}, [id, isBuiltin, builtinWorkflow, workflowType]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Debounced auto-save for prompt
|
// Debounced auto-save for prompt
|
||||||
|
|
@ -138,10 +161,27 @@ export default function WorkflowDetailPage({ params }: Props) {
|
||||||
[id, readOnly],
|
[id, readOnly],
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleTitleCommit(newTitle: string) {
|
async function handleWorkflowDetailsSave(values: { title: string }) {
|
||||||
if (!newTitle || newTitle === workflow?.title) return;
|
if (!workflow || readOnly || !values.title) return;
|
||||||
const updated = await updateWorkflow(id, { title: newTitle });
|
if (values.title === workflow.title) return;
|
||||||
setWorkflow(updated);
|
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) {
|
function handlePromptChange(val: string | undefined) {
|
||||||
|
|
@ -190,53 +230,24 @@ export default function WorkflowDetailPage({ params }: Props) {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex h-full flex-col">
|
||||||
{/* Header skeleton */}
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
shrink
|
shrink
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
{ label: "Workflows" },
|
{
|
||||||
|
label: "Workflows",
|
||||||
|
onClick: () => router.push("/workflows"),
|
||||||
|
title: "Back to Workflows",
|
||||||
|
},
|
||||||
{ loading: true, skeletonClassName: "w-40" },
|
{ loading: true, skeletonClassName: "w-40" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
{/* Toolbar skeleton */}
|
{workflowType === "tabular" ? (
|
||||||
<div className="flex items-center px-8 h-10 border-b border-gray-200 shrink-0">
|
<TabularWorkflowEditorSkeleton />
|
||||||
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
|
) : (
|
||||||
</div>
|
<AssistantWorkflowEditorSkeleton />
|
||||||
|
)}
|
||||||
{/* Table header skeleton */}
|
|
||||||
<div className="flex items-center h-8 pr-8 border-b border-gray-200 shrink-0">
|
|
||||||
<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 rounded bg-gray-100 animate-pulse" />
|
|
||||||
<div className="h-2.5 w-20 rounded bg-gray-100 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="w-36 shrink-0">
|
|
||||||
<div className="h-2.5 w-14 rounded bg-gray-100 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="h-2.5 w-12 rounded bg-gray-100 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="w-8 shrink-0" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row skeletons */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
{[1, 2, 3, 4, 5].map((i) => (
|
|
||||||
<div key={i} className="flex items-center h-10 pr-8 border-b border-gray-50">
|
|
||||||
<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 rounded bg-gray-100 animate-pulse" />
|
|
||||||
<div className="h-3 rounded bg-gray-100 animate-pulse" style={{ width: `${40 + (i * 13) % 35}%` }} />
|
|
||||||
</div>
|
|
||||||
<div className="w-36 shrink-0">
|
|
||||||
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 pr-4">
|
|
||||||
<div className="h-3 rounded bg-gray-100 animate-pulse" style={{ width: `${50 + (i * 17) % 35}%` }} />
|
|
||||||
</div>
|
|
||||||
<div className="w-8 shrink-0" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -263,31 +274,29 @@ export default function WorkflowDetailPage({ params }: Props) {
|
||||||
title: "Back to Workflows",
|
title: "Back to Workflows",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: readOnly ? (
|
label: (
|
||||||
<span className="text-gray-900 truncate max-w-xs">
|
<span className="text-gray-900 truncate max-w-xs">
|
||||||
{workflow.title}
|
{workflow.title}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
<RenameableTitle
|
|
||||||
value={workflow.title}
|
|
||||||
onCommit={handleTitleCommit}
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
saveStatus !== "idle"
|
||||||
type: "custom",
|
? {
|
||||||
render: (
|
type: "custom",
|
||||||
<span className="text-xs text-gray-400">
|
render: (
|
||||||
{saveStatus === "saving"
|
<span className="inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-sm text-gray-500">
|
||||||
? "Saving…"
|
{saveStatus === "saved" ? (
|
||||||
: saveStatus === "saved"
|
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||||
? "Saved"
|
) : null}
|
||||||
: ""}
|
{saveStatus === "saving"
|
||||||
</span>
|
? "Saving…"
|
||||||
),
|
: "Saved"}
|
||||||
},
|
</span>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
canShare
|
canShare
|
||||||
? {
|
? {
|
||||||
onClick: () => setShareOpen(true),
|
onClick: () => setShareOpen(true),
|
||||||
|
|
@ -296,8 +305,57 @@ export default function WorkflowDetailPage({ params }: Props) {
|
||||||
icon: <Users className="h-4 w-4" />,
|
icon: <Users className="h-4 w-4" />,
|
||||||
}
|
}
|
||||||
: null,
|
: 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 && (
|
{shareOpen && (
|
||||||
<ShareWorkflowModal
|
<ShareWorkflowModal
|
||||||
workflowId={id}
|
workflowId={id}
|
||||||
|
|
@ -305,19 +363,25 @@ export default function WorkflowDetailPage({ params }: Props) {
|
||||||
onClose={() => setShareOpen(false)}
|
onClose={() => setShareOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmPopup
|
||||||
{/* Read-only badge for built-in workflows */}
|
open={deleteOpen}
|
||||||
{readOnly && (
|
title="Delete workflow?"
|
||||||
<div className="flex items-center h-10 px-8 border-b border-gray-200">
|
message="This workflow will be permanently deleted."
|
||||||
<span className="text-xs text-gray-400">Read-only</span>
|
confirmLabel="Delete"
|
||||||
</div>
|
confirmStatus={deleteStatus}
|
||||||
)}
|
onConfirm={() => void handleDeleteWorkflow()}
|
||||||
|
onCancel={() => {
|
||||||
|
if (deleteStatus === "loading") return;
|
||||||
|
setDeleteOpen(false);
|
||||||
|
setDeleteStatus("idle");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="flex-1 min-h-0 flex flex-col">
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
{workflow.type === "assistant" ? (
|
{workflow.type === "assistant" ? (
|
||||||
/* ── Assistant: WYSIWYG editor ── */
|
/* ── Assistant: WYSIWYG editor ── */
|
||||||
<div className="flex-1 min-h-0 p-6">
|
<div className="flex-1 min-h-0 px-4 pb-2 pt-0 md:px-10 md:pb-3">
|
||||||
<WorkflowPromptEditor
|
<WorkflowPromptEditor
|
||||||
value={promptMd}
|
value={promptMd}
|
||||||
onChange={readOnly ? undefined : handlePromptChange}
|
onChange={readOnly ? undefined : handlePromptChange}
|
||||||
|
|
@ -329,7 +393,7 @@ export default function WorkflowDetailPage({ params }: Props) {
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<div className="flex items-center justify-between px-8 h-10 border-b border-gray-200 shrink-0">
|
<div className="flex items-center justify-between px-4 md:px-10 h-10 border-b border-gray-200 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setAddColumnOpen(true)}
|
onClick={() => setAddColumnOpen(true)}
|
||||||
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
||||||
|
|
@ -368,11 +432,18 @@ export default function WorkflowDetailPage({ params }: Props) {
|
||||||
)}
|
)}
|
||||||
</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="flex-1 min-h-0 overflow-auto">
|
||||||
<div className="min-w-max flex min-h-full flex-col">
|
<div className="min-w-max flex min-h-full flex-col">
|
||||||
{/* Table header */}
|
{/* Table header */}
|
||||||
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium shrink-0 select-none">
|
<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`}>
|
<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 && (
|
{columns.length > 0 && (
|
||||||
<input
|
<input
|
||||||
|
|
@ -418,7 +489,7 @@ export default function WorkflowDetailPage({ params }: Props) {
|
||||||
<div
|
<div
|
||||||
key={col.index}
|
key={col.index}
|
||||||
onClick={() => readOnly ? setViewingColumn(col) : setEditingColumn(col)}
|
onClick={() => readOnly ? setViewingColumn(col) : setEditingColumn(col)}
|
||||||
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors"
|
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={`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">
|
<div className="flex min-w-0 items-center gap-4">
|
||||||
|
|
@ -505,3 +576,84 @@ export default function WorkflowDetailPage({ params }: Props) {
|
||||||
</div>
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
frontend/src/app/components/workflows/WorkflowDetailsModal.tsx
Normal file
190
frontend/src/app/components/workflows/WorkflowDetailsModal.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Users } from "lucide-react";
|
||||||
|
import { Modal } from "@/app/components/shared/Modal";
|
||||||
|
import type { Workflow } from "@/app/components/shared/types";
|
||||||
|
import { listWorkflowShares } from "@/app/lib/mikeApi";
|
||||||
|
|
||||||
|
interface WorkflowDetailsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
workflow: Workflow | null;
|
||||||
|
canEdit: boolean;
|
||||||
|
canShare: boolean;
|
||||||
|
currentUserDisplayName?: string | null;
|
||||||
|
currentUserEmail?: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (values: { title: string }) => Promise<void>;
|
||||||
|
onShareWorkflow: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowDetailsModal({
|
||||||
|
open,
|
||||||
|
workflow,
|
||||||
|
canEdit,
|
||||||
|
canShare,
|
||||||
|
currentUserDisplayName,
|
||||||
|
currentUserEmail,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
onShareWorkflow,
|
||||||
|
}: WorkflowDetailsModalProps) {
|
||||||
|
const [titleDraft, setTitleDraft] = useState("");
|
||||||
|
const [shareCount, setShareCount] = useState<number | null>(null);
|
||||||
|
const [sharesLoading, setSharesLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !workflow) return;
|
||||||
|
setTitleDraft(workflow.title);
|
||||||
|
setShareCount(null);
|
||||||
|
setSaved(false);
|
||||||
|
setError(null);
|
||||||
|
}, [open, workflow]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !workflow || !canShare) {
|
||||||
|
setSharesLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setSharesLoading(true);
|
||||||
|
listWorkflowShares(workflow.id)
|
||||||
|
.then((shares) => {
|
||||||
|
if (!cancelled) setShareCount(shares.length);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setShareCount(null);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setSharesLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [canShare, open, workflow]);
|
||||||
|
|
||||||
|
const trimmedTitle = titleDraft.trim();
|
||||||
|
const hasChanges = useMemo(() => {
|
||||||
|
if (!workflow) return false;
|
||||||
|
return trimmedTitle !== workflow.title;
|
||||||
|
}, [trimmedTitle, workflow]);
|
||||||
|
|
||||||
|
if (!workflow) return null;
|
||||||
|
|
||||||
|
const typeLabel = workflow.type === "tabular" ? "Tabular" : "Assistant";
|
||||||
|
const ownershipLabel = workflow.is_system
|
||||||
|
? "Built-in"
|
||||||
|
: workflow.is_owner === false
|
||||||
|
? "Shared with you"
|
||||||
|
: shareCount && shareCount > 0
|
||||||
|
? "Shared"
|
||||||
|
: "Private";
|
||||||
|
const ownerLabel =
|
||||||
|
workflow.is_owner === false
|
||||||
|
? workflow.shared_by_name?.trim() || "Unknown"
|
||||||
|
: currentUserDisplayName?.trim() ||
|
||||||
|
currentUserEmail?.trim() ||
|
||||||
|
"You";
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!canEdit || saving || !hasChanges || !trimmedTitle) return;
|
||||||
|
setSaving(true);
|
||||||
|
setSaved(false);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await onSave({ title: trimmedTitle });
|
||||||
|
setSaved(true);
|
||||||
|
} catch {
|
||||||
|
setError("Could not update workflow details.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
breadcrumbs={["Workflows", workflow.title, "Details"]}
|
||||||
|
secondaryAction={
|
||||||
|
canShare
|
||||||
|
? {
|
||||||
|
label: "Share Workflow",
|
||||||
|
icon: <Users className="h-4 w-4" />,
|
||||||
|
onClick: onShareWorkflow,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
footerStatus={
|
||||||
|
error ? (
|
||||||
|
<span className="text-sm text-red-600">{error}</span>
|
||||||
|
) : saved ? (
|
||||||
|
<span className="text-sm text-gray-400">Updated</span>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
primaryAction={
|
||||||
|
canEdit
|
||||||
|
? {
|
||||||
|
label: saving ? "Updating..." : "Update",
|
||||||
|
onClick: () => void handleSave(),
|
||||||
|
disabled: saving || !hasChanges || !trimmedTitle,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
cancelAction={canEdit ? undefined : false}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-5 py-1">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label
|
||||||
|
htmlFor="workflow-details-title"
|
||||||
|
className="text-xs font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Workflow Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="workflow-details-title"
|
||||||
|
value={titleDraft}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTitleDraft(e.target.value);
|
||||||
|
setSaved(false);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
disabled={!canEdit || saving}
|
||||||
|
className="h-9 w-full rounded-md border border-gray-200 bg-gray-50 px-3 text-sm text-gray-900 outline-none transition-colors focus:border-gray-300 disabled:cursor-not-allowed disabled:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-gray-100 text-sm">
|
||||||
|
<DetailRow label="Type" value={typeLabel} />
|
||||||
|
<DetailRow
|
||||||
|
label="Ownership"
|
||||||
|
value={
|
||||||
|
sharesLoading ? (
|
||||||
|
<span className="inline-block h-4 w-14 rounded bg-gray-100 animate-pulse" />
|
||||||
|
) : (
|
||||||
|
ownershipLabel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailRow label="Owner" value={ownerLabel} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value }: { label: string; value: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4 py-3">
|
||||||
|
<span className="text-gray-500">{label}</span>
|
||||||
|
<span className="min-w-0 truncate text-right text-gray-900">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ import { RowActions } from "../shared/RowActions";
|
||||||
import { MikeIcon } from "@/components/chat/mike-icon";
|
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
import { PageHeader } from "@/app/components/shared/PageHeader";
|
||||||
|
import { workflowDetailPath } from "./workflowRoutes";
|
||||||
|
|
||||||
type Tab = "all" | "builtin" | "custom" | "hidden";
|
type Tab = "all" | "builtin" | "custom" | "hidden";
|
||||||
|
|
||||||
|
|
@ -41,7 +42,7 @@ const TABS: { id: Tab; label: string }[] = [
|
||||||
export function WorkflowList() {
|
export function WorkflowList() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const stickyCellBg = "bg-[#fcfcfd]";
|
const stickyCellBg = "bg-[#fafbfc]";
|
||||||
const [custom, setCustom] = useState<Workflow[]>([]);
|
const [custom, setCustom] = useState<Workflow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selected, setSelected] = useState<Workflow | null>(null);
|
const [selected, setSelected] = useState<Workflow | null>(null);
|
||||||
|
|
@ -606,7 +607,7 @@ export function WorkflowList() {
|
||||||
onCreated={(wf) => {
|
onCreated={(wf) => {
|
||||||
setCustom((prev) => [wf, ...prev]);
|
setCustom((prev) => [wf, ...prev]);
|
||||||
setNewModalOpen(false);
|
setNewModalOpen(false);
|
||||||
router.push(`/workflows/${wf.id}`);
|
router.push(workflowDetailPath(wf));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
362
frontend/src/app/components/workflows/WorkflowPickerContent.tsx
Normal file
362
frontend/src/app/components/workflows/WorkflowPickerContent.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
MessageSquare,
|
||||||
|
Search,
|
||||||
|
Table2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import type { ColumnConfig, Workflow } from "../shared/types";
|
||||||
|
import { formatIcon, formatLabel } from "../tabular/columnFormat";
|
||||||
|
import { TAG_COLORS } from "../tabular/pillUtils";
|
||||||
|
|
||||||
|
type WorkflowPreviewMode = "auto" | "prompt" | "columns";
|
||||||
|
|
||||||
|
interface WorkflowPickerContentProps {
|
||||||
|
workflows: Workflow[];
|
||||||
|
selected: Workflow | null;
|
||||||
|
onSelect: (workflow: Workflow | null) => void;
|
||||||
|
search: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
workflowType?: Workflow["type"] | "all";
|
||||||
|
emptyMessage?: string;
|
||||||
|
previewMode?: WorkflowPreviewMode;
|
||||||
|
disabledWorkflow?: (workflow: Workflow) => boolean;
|
||||||
|
showTypeIcon?: boolean;
|
||||||
|
allowClearPreview?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowPickerContent({
|
||||||
|
workflows,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
loading = false,
|
||||||
|
workflowType = "all",
|
||||||
|
emptyMessage,
|
||||||
|
previewMode = "auto",
|
||||||
|
disabledWorkflow,
|
||||||
|
showTypeIcon = false,
|
||||||
|
allowClearPreview = true,
|
||||||
|
}: WorkflowPickerContentProps) {
|
||||||
|
const selectedRowRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedRowRef.current) {
|
||||||
|
selectedRowRef.current.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
}, [selected?.id]);
|
||||||
|
|
||||||
|
const normalizedSearch = search.trim().toLowerCase();
|
||||||
|
const filteredWorkflows = normalizedSearch
|
||||||
|
? workflows.filter((workflow) =>
|
||||||
|
[
|
||||||
|
workflow.title,
|
||||||
|
workflow.practice ?? "",
|
||||||
|
workflow.is_system ? "Built-in" : "Custom",
|
||||||
|
]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(normalizedSearch),
|
||||||
|
)
|
||||||
|
: workflows;
|
||||||
|
const resolvedEmptyMessage =
|
||||||
|
emptyMessage ??
|
||||||
|
(search
|
||||||
|
? "No matches found"
|
||||||
|
: workflowType === "all"
|
||||||
|
? "No workflows found"
|
||||||
|
: `No ${workflowType} workflows found`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-row gap-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`flex flex-col overflow-hidden ${selected ? "w-80 shrink-0" : "flex-1"}`}
|
||||||
|
>
|
||||||
|
<div className="shrink-0 px-2 pb-2 pt-3">
|
||||||
|
<div className="flex h-9 items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3">
|
||||||
|
<Search className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search workflows..."
|
||||||
|
value={search}
|
||||||
|
onChange={(event) =>
|
||||||
|
onSearchChange(event.target.value)
|
||||||
|
}
|
||||||
|
className="flex-1 bg-transparent text-sm text-gray-700 outline-none placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSearchChange("")}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{[60, 45, 75, 50, 65, 40, 55].map((width, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-lg px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-3 animate-pulse rounded bg-gray-100"
|
||||||
|
style={{ width: `${width}%` }}
|
||||||
|
/>
|
||||||
|
<div className="h-3 w-10 shrink-0 animate-pulse rounded bg-gray-100" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filteredWorkflows.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-gray-400">
|
||||||
|
{resolvedEmptyMessage}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 overflow-y-auto">
|
||||||
|
{filteredWorkflows.map((workflow) => {
|
||||||
|
const disabled = disabledWorkflow?.(workflow) ?? false;
|
||||||
|
const isSelected = selected?.id === workflow.id;
|
||||||
|
const TypeIcon =
|
||||||
|
workflow.type === "tabular"
|
||||||
|
? Table2
|
||||||
|
: MessageSquare;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={workflow.id}
|
||||||
|
ref={isSelected ? selectedRowRef : null}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() =>
|
||||||
|
onSelect(isSelected ? null : workflow)
|
||||||
|
}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-xs transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? "bg-gray-100 text-gray-900"
|
||||||
|
: "hover:bg-gray-50"
|
||||||
|
} ${disabled ? "cursor-not-allowed opacity-45" : ""}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex-1 truncate ${
|
||||||
|
isSelected
|
||||||
|
? "font-medium text-gray-900"
|
||||||
|
: "text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{workflow.title}
|
||||||
|
</span>
|
||||||
|
{showTypeIcon ? (
|
||||||
|
<TypeIcon className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<span className="shrink-0 text-xs text-gray-400">
|
||||||
|
{workflow.is_system
|
||||||
|
? "Built-in"
|
||||||
|
: "Custom"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<WorkflowPreview
|
||||||
|
workflow={selected}
|
||||||
|
mode={previewMode}
|
||||||
|
onClear={() => onSelect(null)}
|
||||||
|
allowClear={allowClearPreview}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkflowPreview({
|
||||||
|
workflow,
|
||||||
|
mode,
|
||||||
|
onClear,
|
||||||
|
allowClear,
|
||||||
|
}: {
|
||||||
|
workflow: Workflow;
|
||||||
|
mode: WorkflowPreviewMode;
|
||||||
|
onClear: () => void;
|
||||||
|
allowClear: boolean;
|
||||||
|
}) {
|
||||||
|
const resolvedMode =
|
||||||
|
mode === "auto"
|
||||||
|
? workflow.type === "tabular"
|
||||||
|
? "columns"
|
||||||
|
: "prompt"
|
||||||
|
: mode;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex h-14 shrink-0 items-center justify-between pb-2 pt-3">
|
||||||
|
<p className="text-sm font-medium text-gray-700">
|
||||||
|
Workflow Details
|
||||||
|
</p>
|
||||||
|
{allowClear ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className="rounded-md p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{resolvedMode === "columns" ? (
|
||||||
|
<WorkflowColumnPreview columns={workflow.columns_config ?? []} />
|
||||||
|
) : (
|
||||||
|
<WorkflowPromptPreview
|
||||||
|
content={workflow.prompt_md ?? "_No prompt defined._"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkflowPromptPreview({ content }: { content: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto rounded-md border border-gray-200 bg-gray-50 px-4 py-3 font-serif text-sm leading-relaxed text-gray-600">
|
||||||
|
<WorkflowPromptMarkdown content={content} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkflowPromptMarkdown({ content }: { content: string }) {
|
||||||
|
return (
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
h1: ({ children }) => (
|
||||||
|
<h1 className="mb-1 mt-4 text-base font-semibold text-gray-900 first:mt-0">
|
||||||
|
{children}
|
||||||
|
</h1>
|
||||||
|
),
|
||||||
|
h2: ({ children }) => (
|
||||||
|
<h2 className="mb-1 mt-3 text-sm font-semibold text-gray-900 first:mt-0">
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
),
|
||||||
|
h3: ({ children }) => (
|
||||||
|
<h3 className="mb-0.5 mt-2 text-xs font-semibold text-gray-900 first:mt-0">
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
),
|
||||||
|
p: ({ children }) => (
|
||||||
|
<p className="mb-2 last:mb-0">{children}</p>
|
||||||
|
),
|
||||||
|
ul: ({ children }) => (
|
||||||
|
<ul className="mb-2 list-disc space-y-0.5 pl-4">
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
),
|
||||||
|
ol: ({ children }) => (
|
||||||
|
<ol className="mb-2 list-decimal space-y-0.5 pl-4">
|
||||||
|
{children}
|
||||||
|
</ol>
|
||||||
|
),
|
||||||
|
li: ({ children }) => <li>{children}</li>,
|
||||||
|
strong: ({ children }) => (
|
||||||
|
<strong className="font-semibold text-gray-800">
|
||||||
|
{children}
|
||||||
|
</strong>
|
||||||
|
),
|
||||||
|
em: ({ children }) => <em className="italic">{children}</em>,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkflowColumnPreview({ columns }: { columns: ColumnConfig[] }) {
|
||||||
|
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||||
|
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto rounded-md border border-gray-200 bg-gray-50">
|
||||||
|
{sortedColumns.length === 0 ? (
|
||||||
|
<p className="px-4 py-6 text-center text-xs text-gray-400">
|
||||||
|
No columns defined
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
sortedColumns.map((column) => {
|
||||||
|
const isExpanded = expandedIndex === column.index;
|
||||||
|
const FormatIcon = formatIcon(column.format ?? "text");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={column.index}
|
||||||
|
className="border-b border-gray-200 last:border-b-0"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedIndex(
|
||||||
|
isExpanded ? null : column.index,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-xs transition-colors hover:bg-white"
|
||||||
|
>
|
||||||
|
<FormatIcon className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||||
|
<span className="flex-1 truncate text-gray-800">
|
||||||
|
{column.name}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-gray-400">
|
||||||
|
{formatLabel(column.format ?? "text")}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-3 w-3 shrink-0 text-gray-300 transition-transform duration-150 ${isExpanded ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{isExpanded ? (
|
||||||
|
<div className="space-y-3 border-t border-gray-200 bg-white px-4 py-3 font-serif text-sm leading-relaxed text-gray-600">
|
||||||
|
{column.tags && column.tags.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 font-sans text-[11px] font-medium text-gray-600">
|
||||||
|
Tags
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{column.tags.map((tag, tagIdx) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className={`inline-block rounded-full px-1.5 py-0.5 font-sans text-[10px] ${TAG_COLORS[tagIdx % TAG_COLORS.length]}`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 font-sans text-[11px] font-medium text-gray-600">
|
||||||
|
Prompt
|
||||||
|
</p>
|
||||||
|
<WorkflowPromptMarkdown
|
||||||
|
content={
|
||||||
|
column.prompt ||
|
||||||
|
"_No prompt defined._"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
frontend/src/app/components/workflows/WorkflowPickerModal.tsx
Normal file
137
frontend/src/app/components/workflows/WorkflowPickerModal.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { listWorkflows } from "@/app/lib/mikeApi";
|
||||||
|
import { Modal } from "../shared/Modal";
|
||||||
|
import type { Workflow } from "../shared/types";
|
||||||
|
import { BUILT_IN_WORKFLOWS } from "./builtinWorkflows";
|
||||||
|
import { WorkflowPickerContent } from "./WorkflowPickerContent";
|
||||||
|
|
||||||
|
interface WorkflowPickerModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (workflow: Workflow) => Promise<void> | void;
|
||||||
|
workflowType: Workflow["type"];
|
||||||
|
breadcrumbs: ReactNode[];
|
||||||
|
primaryLabel?: string;
|
||||||
|
selectingLabel?: string;
|
||||||
|
selecting?: boolean;
|
||||||
|
closeOnSelect?: boolean;
|
||||||
|
initialWorkflowId?: string;
|
||||||
|
disabledWorkflow?: (workflow: Workflow) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowPickerModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
workflowType,
|
||||||
|
breadcrumbs,
|
||||||
|
primaryLabel = "Use",
|
||||||
|
selectingLabel,
|
||||||
|
selecting = false,
|
||||||
|
closeOnSelect = true,
|
||||||
|
initialWorkflowId,
|
||||||
|
disabledWorkflow,
|
||||||
|
}: WorkflowPickerModalProps) {
|
||||||
|
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<Workflow | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const builtins = BUILT_IN_WORKFLOWS.filter(
|
||||||
|
(workflow) => workflow.type === workflowType,
|
||||||
|
);
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setWorkflows(builtins);
|
||||||
|
setLoading(true);
|
||||||
|
setSelected(
|
||||||
|
initialWorkflowId
|
||||||
|
? builtins.find((workflow) => workflow.id === initialWorkflowId) ??
|
||||||
|
null
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
setSearch("");
|
||||||
|
});
|
||||||
|
|
||||||
|
listWorkflows(workflowType)
|
||||||
|
.then((custom) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const all = [...builtins, ...custom];
|
||||||
|
setWorkflows(all);
|
||||||
|
if (initialWorkflowId) {
|
||||||
|
setSelected(
|
||||||
|
all.find((workflow) => workflow.id === initialWorkflowId) ??
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (initialWorkflowId) {
|
||||||
|
setSelected(
|
||||||
|
builtins.find(
|
||||||
|
(workflow) => workflow.id === initialWorkflowId,
|
||||||
|
) ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
}, [initialWorkflowId, open, workflowType]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const selectionDisabled =
|
||||||
|
!selected || selecting || (selected && disabledWorkflow?.(selected));
|
||||||
|
const resolvedPrimaryLabel =
|
||||||
|
selecting && selectingLabel ? selectingLabel : primaryLabel;
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
setSelected(null);
|
||||||
|
setSearch("");
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSelect() {
|
||||||
|
if (!selected || selectionDisabled) return;
|
||||||
|
await onSelect(selected);
|
||||||
|
if (closeOnSelect) handleClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
size={selected ? "xl" : "lg"}
|
||||||
|
breadcrumbs={breadcrumbs}
|
||||||
|
primaryAction={{
|
||||||
|
label: resolvedPrimaryLabel,
|
||||||
|
onClick: () => void handleSelect(),
|
||||||
|
disabled: selectionDisabled,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WorkflowPickerContent
|
||||||
|
workflows={workflows}
|
||||||
|
selected={selected}
|
||||||
|
onSelect={setSelected}
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
loading={loading}
|
||||||
|
workflowType={workflowType}
|
||||||
|
previewMode={workflowType === "tabular" ? "columns" : "prompt"}
|
||||||
|
disabledWorkflow={disabledWorkflow}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -99,7 +99,13 @@ export function WorkflowPromptEditor({
|
||||||
}, [value, editor]);
|
}, [value, editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full border border-gray-200 rounded-md overflow-hidden bg-white">
|
<div
|
||||||
|
className={`flex h-full flex-col overflow-hidden bg-white ${
|
||||||
|
readOnly
|
||||||
|
? "rounded-md border border-gray-200"
|
||||||
|
: "rounded-md border border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{!readOnly && editor && (
|
{!readOnly && editor && (
|
||||||
<div className="flex items-center gap-0.5 px-2 py-1.5 border-b border-gray-100 bg-gray-50 shrink-0">
|
<div className="flex items-center gap-0.5 px-2 py-1.5 border-b border-gray-100 bg-gray-50 shrink-0">
|
||||||
<ToolbarBtn
|
<ToolbarBtn
|
||||||
|
|
@ -181,7 +187,18 @@ export function WorkflowPromptEditor({
|
||||||
</ToolbarBtn>
|
</ToolbarBtn>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 overflow-y-auto">
|
{readOnly && (
|
||||||
|
<div className="flex h-9 shrink-0 items-center bg-gray-50 px-5">
|
||||||
|
<span className="text-xs font-medium text-gray-500">
|
||||||
|
Read-only
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`flex-1 overflow-y-auto ${
|
||||||
|
readOnly ? "border-t border-gray-100" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
7
frontend/src/app/components/workflows/workflowRoutes.ts
Normal file
7
frontend/src/app/components/workflows/workflowRoutes.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { Workflow } from "../shared/types";
|
||||||
|
|
||||||
|
export function workflowDetailPath(workflow: Pick<Workflow, "id" | "type">) {
|
||||||
|
return workflow.type === "assistant"
|
||||||
|
? `/workflows/assistant/${workflow.id}`
|
||||||
|
: `/workflows/tabular-review/${workflow.id}`;
|
||||||
|
}
|
||||||
15
frontend/src/app/contexts/PageChromeContext.tsx
Normal file
15
frontend/src/app/contexts/PageChromeContext.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
interface PageChromeContextValue {
|
||||||
|
mobileActionsContainer: HTMLElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageChromeContext = createContext<PageChromeContextValue>({
|
||||||
|
mobileActionsContainer: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function usePageChrome() {
|
||||||
|
return useContext(PageChromeContext);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue