"use client"; import * as React from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, CheckSquare, ChevronDown, ExternalLink, FileText, Folder, Link2, Plus, X, } from "lucide-react"; import { cn } from "@/lib/utils"; import { api } from "@/lib/trpc"; import { usePanelStore, type PanelDetailTab, } from "@/lib/stores/panel-store"; import { AssigneePicker, WORKSPACE_USERS } 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"; const OBJECT_DETAIL_KEY = "object-detail"; 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" }, ]; const MOCK_OBJECT: ObjectDetailData = { id: "demo", title: "Design Landing Page", type: "task", status: "in_progress", description: "Create wireframes and align with brand guidelines before handoff.", assignees: [ { user: { id: "u1", name: "Alice", avatarUrl: null } }, ], propertyValues: [ { id: "pv1", propertyDefinition: { name: "Priority", fieldType: "select", config: { options: [ { label: "High", value: "High" }, { label: "Medium", value: "Medium" }, { label: "Low", value: "Low" }, ], }, }, value: "High", }, { id: "pv2", propertyDefinition: { name: "Due Date", fieldType: "date", config: {}, }, value: "2025-04-01", }, ], children: [ { id: "child-1", title: "Review copy", type: "task" }, ], relations: [ { id: "rel-1", title: "Brand guidelines PDF", type: "document" }, { id: "rel-2", title: "Q2 marketing plan", type: "task" }, ], }; 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: { 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) { const utils = api.useUtils(); return useQuery({ queryKey: [OBJECT_DETAIL_KEY, objectId], queryFn: async (): Promise => { if (!objectId) return null; const fetcher = ( utils as unknown as { objects?: { getById?: { fetch: (args: { id: string }) => Promise } }; } ).objects?.getById?.fetch; if (typeof fetcher === "function") { return fetcher({ id: objectId }); } await new Promise((r) => setTimeout(r, 220)); return { ...MOCK_OBJECT, id: objectId }; }, 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 queryClient = useQueryClient(); const utils = api.useUtils(); const { data, isPending, isError, error } = useObjectDetailQuery(objectId); 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) => { queryClient.setQueryData( [OBJECT_DETAIL_KEY, id], (old) => (old ? { ...old, ...patch } : old), ); }, [queryClient], ); const updateObject = useMutation({ mutationFn: async (patch: Partial & { id: string }) => { const u = utils as unknown as { objects?: { update?: { mutate: (args: unknown) => Promise } }; }; if (typeof u.objects?.update?.mutate === "function") { return u.objects.update.mutate(patch); } }, onMutate: async (patch) => { mergeObjectCache(patch.id, patch); }, }); const setPropertyValue = useMutation({ mutationFn: async (args: { objectId: string; propertyValueId?: string; propertyDefinitionId?: string; value: unknown; }) => { const u = utils as unknown as { properties?: { setValue?: { mutate: (a: unknown) => Promise } }; }; if (typeof u.properties?.setValue?.mutate === "function") { return u.properties.setValue.mutate(args); } }, }); const handlePropertyChange = ( index: number, next: unknown, row: ObjectDetailData["propertyValues"][number], ) => { if (!data) return; const nextRows = [...data.propertyValues]; nextRows[index] = { ...row, value: next }; mergeObjectCache(data.id, { propertyValues: nextRows }); setPropertyValue.mutate({ objectId: data.id, propertyValueId: row.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 user = WORKSPACE_USERS.find((u) => u.id === userId); if (!user) return; const has = assignedIds.includes(userId); let nextAssignees: ObjectDetailData["assignees"]; if (has) { nextAssignees = data.assignees.filter((a) => a.user.id !== userId); } else { nextAssignees = [ ...data.assignees, { user: { id: user.id, name: user.name, avatarUrl: user.avatarUrl } }, ]; } mergeObjectCache(data.id, { assignees: nextAssignees }); const u = utils as unknown as { objects?: { assign?: { mutate: (a: unknown) => Promise } }; }; if (typeof u.objects?.assign?.mutate === "function") { u.objects.assign.mutate({ objectId: data.id, userId, assign: !has, }); } }; const commitTitle = () => { if (!data || titleDraft.trim() === data.title) { setEditingTitle(false); return; } updateObject.mutate({ id: data.id, title: titleDraft.trim() }); setEditingTitle(false); }; const commitDescription = () => { if (!data) return; if (descriptionDraft === (data.description ?? "")) return; updateObject.mutate({ id: data.id, description: descriptionDraft }); }; const setStatus = (status: StatusValue) => { if (!data) return; updateObject.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) { return (
{isError ? error instanceof Error ? error.message : "Failed to load object." : "Nothing to display."}
); } 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, v, row)} />
)) )}

Description