diff --git a/packages/views/projects/components/project-detail.tsx b/packages/views/projects/components/project-detail.tsx index a830f6b63..29aa59b36 100644 --- a/packages/views/projects/components/project-detail.tsx +++ b/packages/views/projects/components/project-detail.tsx @@ -1,26 +1,47 @@ "use client"; -import { useMemo, useState } from "react"; -import { ArrowLeft, Check, Trash2 } from "lucide-react"; +import { useMemo, useState, useCallback, useRef } from "react"; +import { Check, ChevronRight, Link2, ListTodo, MoreHorizontal, 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 } from "@multica/core/types"; import { projectDetailOptions } from "@multica/core/projects/queries"; import { useUpdateProject, useDeleteProject } from "@multica/core/projects/mutations"; import { issueListOptions } 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 { useWorkspaceStore } from "@multica/core/workspace"; +import { useActorName } from "@multica/core/workspace/hooks"; import { PROJECT_STATUS_ORDER, PROJECT_STATUS_CONFIG } from "@multica/core/projects/config"; -import { STATUS_CONFIG } from "@multica/core/issues/config"; -import { StatusIcon } from "../../issues/components/status-icon"; +import { BOARD_STATUSES } from "@multica/core/issues/config"; +import { createIssueViewStore, useIssueViewStore as useGlobalIssueViewStore } from "@multica/core/issues/stores/view-store"; +import { ViewStoreProvider, useViewStore } from "@multica/core/issues/stores/view-store-context"; +import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store"; +import { filterIssues } from "../../issues/utils/filter"; +import { ActorAvatar } from "../../common/actor-avatar"; import { AppLink, useNavigation } from "../../navigation"; +import { TitleEditor, ContentEditor, type ContentEditorRef } from "../../editor"; +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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@multica/ui/components/ui/tabs"; -import { Textarea } from "@multica/ui/components/ui/textarea"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@multica/ui/components/ui/dropdown-menu"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@multica/ui/components/ui/popover"; +import { EmojiPicker } from "@multica/ui/components/common/emoji-picker"; import { AlertDialog, AlertDialogAction, @@ -31,44 +52,163 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@multica/ui/components/ui/alert-dialog"; -import type { Issue } from "@multica/core/types"; -function IssueRow({ issue }: { issue: Issue }) { +// --------------------------------------------------------------------------- +// Property pill — inline clickable pill for status/lead +// --------------------------------------------------------------------------- + +function PropertyPill({ + children, + className, + ...props +}: React.ButtonHTMLAttributes) { return ( - - - {issue.identifier} - {issue.title} - - {STATUS_CONFIG[issue.status].label} - - + {children} + ); } +// --------------------------------------------------------------------------- +// Project Issues Tab — reuses the existing issues list/board components +// --------------------------------------------------------------------------- + +const projectViewStore = createIssueViewStore("project_issues_view"); + +function ProjectIssuesTab({ projectIssues }: { projectIssues: Issue[] }) { + 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 }), + [projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters], + ); + + 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 router = useNavigation(); + const workspaceName = useWorkspaceStore((s) => s.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 descEditorRef = useRef(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [iconPickerOpen, setIconPickerOpen] = useState(false); + const [activeTab, setActiveTab] = useState<"overview" | "issues">("overview"); + + // 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("/projects"); + }, + }); + }, [project, deleteProject, router]); + if (isLoading) { return ( -
- +
+ + - +
); } @@ -81,111 +221,268 @@ export function ProjectDetail({ projectId }: { projectId: string }) { return (
-
- - {project.icon || "📁"} -

{project.title}

- - - - {statusCfg.label} - - } - /> - - {PROJECT_STATUS_ORDER.map((s) => ( - updateProject.mutate({ id: project.id, status: s })}> - {PROJECT_STATUS_CONFIG[s].label} - {s === project.status && } + {/* Header bar — breadcrumb */} +
+
+ + {workspaceName ?? "Projects"} + + + {project.title} + + + + + } + /> + + { + navigator.clipboard.writeText(window.location.href); + toast.success("Link copied"); + }}> + + Copy link - ))} - - - - - - - - Delete project - This will delete the project. Issues will not be deleted but will be unlinked. - - - Cancel - deleteProject.mutate(project.id, { onSuccess: () => router.push("/projects") })}> - Delete - - - - + + setDeleteDialogOpen(true)} + > + + Delete project + + + +
+
+ +
- - - Overview - Issues{projectIssues.length > 0 && ` (${projectIssues.length})`} - - - -
- -