"use client"; import * as React from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs"; import { Check, CheckSquare, ChevronDown, ExternalLink, FileText, Folder, Link2, Plus, X, } from "lucide-react"; import { isTRPCClientError } from "@trpc/client"; import { cn } from "@/lib/utils"; import { api } from "@/lib/trpc"; import { usePanelStore, type PanelDetailTab, } from "@/lib/stores/panel-store"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { AssigneePicker } from "@/components/panels/assignee-picker"; import { PropertyEditor, type PropertyFieldType, } from "@/components/panels/property-editor"; import { Avatar, AvatarFallback, AvatarImage, } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; type StatusValue = "open" | "in_progress" | "done" | "closed"; const STATUS_OPTIONS: { value: StatusValue; label: string; dot: string; }[] = [ { value: "open", label: "Open", dot: "bg-slate-400" }, { value: "in_progress", label: "In progress", dot: "bg-amber-500" }, { value: "done", label: "Done", dot: "bg-emerald-500" }, { value: "closed", label: "Closed", dot: "bg-zinc-400" }, ]; export interface ObjectDetailData { id: string; title: string; type: string; status: string; description: string | null; assignees: { user: { id?: string; name: string; avatarUrl?: string | null }; }[]; propertyValues: { id?: string; propertyDefinition: { id: string; name: string; fieldType: string; config?: Record | null; }; value: unknown; }[]; children?: { id: string; title: string; type: string }[]; relations?: { id: string; title: string; type: string }[]; } function normalizeFieldType(raw: string): PropertyFieldType { const t = raw.toLowerCase().replace(/[\s-]+/g, "_") as PropertyFieldType; const allowed: PropertyFieldType[] = [ "text", "richtext", "number", "date", "select", "multiselect", "person", "relation", "url", "file", "checkbox", ]; return allowed.includes(t) ? t : "text"; } function typeLabel(type: string) { const t = type.toLowerCase(); if (t === "task") return "Task"; if (t === "document") return "Document"; return type.charAt(0).toUpperCase() + type.slice(1); } function TypeIcon({ type }: { type: string }) { const t = type.toLowerCase(); if (t === "document") return ; if (t === "task") return ; return ; } function initials(name: string) { return name .split(/\s+/) .map((p) => p[0]) .join("") .slice(0, 2) .toUpperCase(); } function useObjectDetailQuery(objectId: string | null) { return api.objects.getById.useQuery( { id: objectId as string }, { enabled: Boolean(objectId) }, ); } function DetailSkeleton() { return (
); } export function ObjectDetail() { const objectId = usePanelStore((s) => s.objectId); const activeTab = usePanelStore((s) => s.activeTab); const setActiveTab = usePanelStore((s) => s.setActiveTab); const closePanel = usePanelStore((s) => s.close); const openPanel = usePanelStore((s) => s.open); const utils = api.useUtils(); const workspace = useWorkspaceStore((s) => s.currentWorkspace); const workspaceId = workspace?.id; const { data: workspaceMembersList } = api.workspaces.listMembers.useQuery( { workspaceId: workspaceId! }, { enabled: Boolean(workspaceId) }, ); const objectDetailQuery = useObjectDetailQuery(objectId); const data = objectDetailQuery.data as ObjectDetailData | undefined; const { isPending, isError, error } = objectDetailQuery; const [titleDraft, setTitleDraft] = React.useState(""); const [editingTitle, setEditingTitle] = React.useState(false); const [descriptionDraft, setDescriptionDraft] = React.useState(""); const [assigneeOpen, setAssigneeOpen] = React.useState(false); React.useEffect(() => { if (data?.title != null) setTitleDraft(data.title); }, [data?.title]); React.useEffect(() => { if (data?.description != null) setDescriptionDraft(data.description ?? ""); }, [data?.description]); const mergeObjectCache = React.useCallback( (id: string, patch: Partial) => { utils.objects.getById.setData({ id }, (old) => { if (!old) return old; return { ...(old as ObjectDetailData), ...patch, } as typeof old; }); }, [utils], ); const updateObjectMutation = api.objects.update.useMutation({ onMutate: async (variables) => { mergeObjectCache(variables.id, variables as Partial); }, onSuccess: async (_data, variables) => { await utils.objects.getById.invalidate({ id: variables.id }); }, }); const assignMutation = api.objects.assign.useMutation({ onSuccess: async (_data, variables) => { await utils.objects.getById.invalidate({ id: variables.objectId }); }, }); const setPropertyValueMutation = api.properties.setValue.useMutation({ onSuccess: async (_data, variables) => { await utils.objects.getById.invalidate({ id: variables.objectId }); }, }); const handlePropertyChange = ( index: number, row: ObjectDetailData["propertyValues"][number], next: unknown, ) => { if (!data) return; const nextRows = [...data.propertyValues]; nextRows[index] = { ...row, value: next }; mergeObjectCache(data.id, { propertyValues: nextRows }); setPropertyValueMutation.mutate({ objectId: data.id, propertyDefId: row.propertyDefinition.id, value: next, }); }; const assignedIds = React.useMemo(() => { if (!data) return []; return data.assignees .map((a) => a.user.id) .filter((id): id is string => Boolean(id)); }, [data]); const toggleAssignee = (userId: string) => { if (!data) return; const has = assignedIds.includes(userId); const member = workspaceMembersList?.find((u) => u.id === userId); const displayName = member ? (member.name ?? member.email) : "User"; let nextAssignees: ObjectDetailData["assignees"]; if (has) { nextAssignees = data.assignees.filter((a) => a.user.id !== userId); } else { nextAssignees = [ ...data.assignees, { user: { id: userId, name: displayName, avatarUrl: member?.avatarUrl ?? null, }, }, ]; } mergeObjectCache(data.id, { assignees: nextAssignees }); assignMutation.mutate({ objectId: data.id, userId, action: has ? "remove" : "add", }); }; const commitTitle = () => { if (!data || titleDraft.trim() === data.title) { setEditingTitle(false); return; } updateObjectMutation.mutate({ id: data.id, title: titleDraft.trim() }); setEditingTitle(false); }; const commitDescription = () => { if (!data) return; if (descriptionDraft === (data.description ?? "")) return; updateObjectMutation.mutate({ id: data.id, description: descriptionDraft }); }; const setStatus = (status: StatusValue) => { if (!data) return; updateObjectMutation.mutate({ id: data.id, status }); }; const onTabChange = (v: string) => { setActiveTab(v as PanelDetailTab); }; if (!objectId) { return (

No object selected.

); } if (isPending) { return ; } if (isError || !data) { const notFound = isTRPCClientError(error) && error.data?.code === "NOT_FOUND"; return (
{isError ? notFound ? "Not found" : error instanceof Error ? error.message : "Failed to load object." : "Not found"}
); } const status = STATUS_OPTIONS.find((s) => s.value === data.status)?.value ?? (data.status as StatusValue); const statusMeta = STATUS_OPTIONS.find((s) => s.value === status) ?? STATUS_OPTIONS[0]; return (
{typeLabel(data.type)}
{editingTitle ? ( setTitleDraft(e.target.value)} onBlur={commitTitle} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); commitTitle(); } if (e.key === "Escape") { setTitleDraft(data.title); setEditingTitle(false); } }} className="h-8 text-sm font-semibold" /> ) : ( )}
{STATUS_OPTIONS.map((opt) => ( setStatus(opt.value)} > {opt.label} {opt.value === status ? ( ) : null} ))}
{( [ ["details", "Details"], ["activity", "Activity"], ["comments", "Comments"], ] as const ).map(([value, label]) => ( {label} ))}

Assignees

{data.assignees.map((a, i) => ( {initials(a.user.name)} ))}

Properties

{data.propertyValues.length === 0 ? (

No custom properties yet.

) : ( data.propertyValues.map((row, index) => (
handlePropertyChange(index, row, v)} />
)) )}

Description