"use client"; import { useMemo, useState, useCallback, useRef, useEffect } from "react"; import { useDefaultLayout, usePanelRef } from "react-resizable-panels"; import { Check, ChevronRight, Link2, ListTodo, MoreHorizontal, PanelRight, Pin, PinOff, Trash2, UserMinus } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { cn } from "@multica/ui/lib/utils"; import { toast } from "sonner"; import type { Issue, IssueStatus, ProjectStatus, ProjectPriority } from "@multica/core/types"; import { useAuthStore } from "@multica/core/auth"; import { projectDetailOptions } from "@multica/core/projects/queries"; import { useUpdateProject, useDeleteProject } from "@multica/core/projects/mutations"; import { pinListOptions } from "@multica/core/pins"; import { useCreatePin, useDeletePin } from "@multica/core/pins"; import { issueListOptions, childIssueProgressOptions } from "@multica/core/issues/queries"; import { useUpdateIssue } from "@multica/core/issues/mutations"; import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries"; import { useWorkspaceId } from "@multica/core/hooks"; import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths"; import { useActorName } from "@multica/core/workspace/hooks"; import { PROJECT_STATUS_ORDER, PROJECT_STATUS_CONFIG, PROJECT_PRIORITY_ORDER, PROJECT_PRIORITY_CONFIG } from "@multica/core/projects/config"; import { BOARD_STATUSES } from "@multica/core/issues/config"; import { createIssueViewStore } from "@multica/core/issues/stores/view-store"; import { ViewStoreProvider, useViewStore } from "@multica/core/issues/stores/view-store-context"; import { filterIssues } from "../../issues/utils/filter"; import { getProjectIssueMetrics } from "./project-issue-metrics"; import { ActorAvatar } from "../../common/actor-avatar"; import { AppLink, useNavigation } from "../../navigation"; import { TitleEditor, ContentEditor, type ContentEditorRef } from "../../editor"; import { PriorityIcon } from "../../issues/components/priority-icon"; import { IssuesHeader } from "../../issues/components/issues-header"; import { BoardView } from "../../issues/components/board-view"; import { ListView } from "../../issues/components/list-view"; import { BatchActionToolbar } from "../../issues/components/batch-action-toolbar"; import { Skeleton } from "@multica/ui/components/ui/skeleton"; import { Button } from "@multica/ui/components/ui/button"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@multica/ui/components/ui/resizable"; import { Sheet, SheetContent } from "@multica/ui/components/ui/sheet"; import { useIsMobile } from "@multica/ui/hooks/use-mobile"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@multica/ui/components/ui/dropdown-menu"; import { Popover, PopoverTrigger, PopoverContent, } from "@multica/ui/components/ui/popover"; import { Tooltip, TooltipTrigger, TooltipContent, } from "@multica/ui/components/ui/tooltip"; import { EmojiPicker } from "@multica/ui/components/common/emoji-picker"; import { PageHeader } from "../../layout/page-header"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@multica/ui/components/ui/alert-dialog"; // --------------------------------------------------------------------------- // Property row — sidebar property display // --------------------------------------------------------------------------- function PropRow({ label, children, }: { label: string; children: React.ReactNode; }) { return (
{label}
{children}
); } // --------------------------------------------------------------------------- // Project Issues — reuses the existing issues list/board components // --------------------------------------------------------------------------- const projectViewStore = createIssueViewStore("project_issues_view"); function ProjectIssuesContent({ projectIssues }: { projectIssues: Issue[] }) { const wsId = useWorkspaceId(); const viewMode = useViewStore((s) => s.viewMode); const statusFilters = useViewStore((s) => s.statusFilters); const priorityFilters = useViewStore((s) => s.priorityFilters); const assigneeFilters = useViewStore((s) => s.assigneeFilters); const includeNoAssignee = useViewStore((s) => s.includeNoAssignee); const creatorFilters = useViewStore((s) => s.creatorFilters); const issues = useMemo( () => filterIssues(projectIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false }), [projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters], ); const doneColumnCount = useMemo( () => projectIssues.filter((issue) => issue.status === "done").length, [projectIssues], ); const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId)); const visibleStatuses = useMemo(() => { if (statusFilters.length > 0) return BOARD_STATUSES.filter((s) => statusFilters.includes(s)); return BOARD_STATUSES; }, [statusFilters]); const hiddenStatuses = useMemo( () => BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s)), [visibleStatuses], ); const updateIssueMutation = useUpdateIssue(); const handleMoveIssue = useCallback( (issueId: string, newStatus: IssueStatus, newPosition?: number) => { const viewState = projectViewStore.getState(); if (viewState.sortBy !== "position") { viewState.setSortBy("position"); viewState.setSortDirection("asc"); } const updates: Partial<{ status: IssueStatus; position: number }> = { status: newStatus }; if (newPosition !== undefined) updates.position = newPosition; updateIssueMutation.mutate( { id: issueId, ...updates }, { onError: () => toast.error("Failed to move issue") }, ); }, [updateIssueMutation], ); if (projectIssues.length === 0) { return (

No issues linked

Assign issues to this project from the issue detail page.

); } return (
{viewMode === "board" ? ( ) : ( )}
); } // --------------------------------------------------------------------------- // ProjectDetail // --------------------------------------------------------------------------- export function ProjectDetail({ projectId }: { projectId: string }) { const wsId = useWorkspaceId(); const wsPaths = useWorkspacePaths(); const router = useNavigation(); const userId = useAuthStore((s) => s.user?.id); const workspace = useCurrentWorkspace(); const workspaceName = workspace?.name; const { data: project, isLoading } = useQuery(projectDetailOptions(wsId, projectId)); const { data: allIssues = [] } = useQuery(issueListOptions(wsId)); const { data: members = [] } = useQuery(memberListOptions(wsId)); const { data: agents = [] } = useQuery(agentListOptions(wsId)); const { getActorName } = useActorName(); const updateProject = useUpdateProject(); const deleteProject = useDeleteProject(); const { data: pinnedItems = [] } = useQuery({ ...pinListOptions(wsId, userId ?? ""), enabled: !!userId, }); const isPinned = pinnedItems.some((p) => p.item_type === "project" && p.item_id === projectId); const createPin = useCreatePin(); const deletePinMut = useDeletePin(); const descEditorRef = useRef(null); const isMobile = useIsMobile(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [iconPickerOpen, setIconPickerOpen] = useState(false); const [propertiesOpen, setPropertiesOpen] = useState(true); const [progressOpen, setProgressOpen] = useState(true); const [descriptionOpen, setDescriptionOpen] = useState(true); // Sidebar panel const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: "multica_project_detail_layout", }); const sidebarRef = usePanelRef(); const [sidebarOpen, setSidebarOpen] = useState(true); useEffect(() => { if (isMobile) { setSidebarOpen(false); sidebarRef.current?.collapse(); } }, [isMobile]); // Lead popover const [leadOpen, setLeadOpen] = useState(false); const [leadFilter, setLeadFilter] = useState(""); const leadQuery = leadFilter.toLowerCase(); const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery)); const filteredAgents = agents.filter((a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery)); const projectIssues = useMemo( () => allIssues.filter((i) => i.project_id === projectId), [allIssues, projectId], ); const handleUpdateField = useCallback( (data: Parameters[0] extends { id: string } & infer R ? R : never) => { if (!project) return; updateProject.mutate({ id: project.id, ...data }); }, [project, updateProject], ); const handleDelete = useCallback(() => { if (!project) return; deleteProject.mutate(project.id, { onSuccess: () => { toast.success("Project deleted"); router.push(wsPaths.projects()); }, }); }, [project, deleteProject, router, wsPaths]); if (isLoading) { return (
); } if (!project) { return
Project not found
; } const issueMetrics = getProjectIssueMetrics(project, projectIssues); const statusCfg = PROJECT_STATUS_CONFIG[project.status]; const priorityCfg = PROJECT_PRIORITY_CONFIG[project.priority]; const sidebarContent = (
{/* Icon + Title */}
{project.icon || "📁"} } /> { handleUpdateField({ icon: emoji }); setIconPickerOpen(false); }} /> { const trimmed = value.trim(); if (trimmed && trimmed !== project.title) handleUpdateField({ title: trimmed }); }} />
{/* Properties */}
{propertiesOpen &&
{statusCfg.label} } /> {PROJECT_STATUS_ORDER.map((s) => ( handleUpdateField({ status: s as ProjectStatus })}> {PROJECT_STATUS_CONFIG[s].label} {s === project.status && } ))} {priorityCfg.label} } /> {PROJECT_PRIORITY_ORDER.map((p) => ( handleUpdateField({ priority: p as ProjectPriority })}> {PROJECT_PRIORITY_CONFIG[p].label} {p === project.priority && } ))} { setLeadOpen(v); if (!v) setLeadFilter(""); }}> {project.lead_type && project.lead_id ? ( <> {getActorName(project.lead_type, project.lead_id)} ) : ( No lead )} } />
setLeadFilter(e.target.value)} placeholder="Assign lead..." className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none" />
{filteredMembers.length > 0 && ( <>
Members
{filteredMembers.map((m) => ( ))} )} {filteredAgents.length > 0 && ( <>
Agents
{filteredAgents.map((a) => ( ))} )} {filteredMembers.length === 0 && filteredAgents.length === 0 && leadFilter && (
No results
)}
}
{/* Progress */} {issueMetrics.totalCount > 0 && (() => { const pct = Math.round((issueMetrics.completedCount / issueMetrics.totalCount) * 100); return (
{progressOpen &&
{issueMetrics.completedCount}/{issueMetrics.totalCount}
}
); })()} {/* Description */}
{descriptionOpen &&
handleUpdateField({ description: md || null })} debounceMs={1500} />
}
); return ( <>
{workspaceName ?? "Projects"} {project.title}
} /> { navigator.clipboard.writeText(window.location.href); toast.success("Link copied"); }}> Copy link setDeleteDialogOpen(true)} > Delete project { if (isMobile) { setSidebarOpen(!sidebarOpen); } else { const panel = sidebarRef.current; if (!panel) return; if (panel.isCollapsed()) panel.expand(); else panel.collapse(); } }} > } /> Toggle sidebar
{!isMobile && } {!isMobile && ( setSidebarOpen(size.inPixels > 0)} >
{sidebarContent}
)} {isMobile && ( {sidebarContent} )}
{/* Delete confirmation */} Delete project This will delete the project. Issues will not be deleted but will be unlinked. Cancel Delete ); }