- Decompose monolithic 472-line page.tsx into focused components: board-card, board-column, board-view, list-row, list-view, issues-header, issues-page - Add view-store with Zustand persist for viewMode and multi-select status/priority filters - Fix kanban DnD with pointerWithin + closestCenter collision detection - Add workspace breadcrumb header and Linear-style filter dropdowns using DropdownMenu with CheckboxItem for multi-select - Status filter hides kanban columns, priority filter hides cards - Drop target highlight with bg-accent Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
133 lines
4.5 KiB
TypeScript
133 lines
4.5 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useMemo } from "react";
|
|
import { toast } from "sonner";
|
|
import { ChevronRight } from "lucide-react";
|
|
import type { IssueStatus } from "@multica/types";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { useIssueStore } from "@/features/issues/store";
|
|
import { useIssueViewStore } from "@/features/issues/stores/view-store";
|
|
import { useWorkspaceStore } from "@/features/workspace";
|
|
import { WorkspaceAvatar } from "@/features/workspace";
|
|
import { api } from "@/shared/api";
|
|
import { IssuesHeader } from "./issues-header";
|
|
import { BoardView } from "./board-view";
|
|
import { ListView } from "./list-view";
|
|
|
|
const BOARD_STATUSES: IssueStatus[] = [
|
|
"backlog",
|
|
"todo",
|
|
"in_progress",
|
|
"in_review",
|
|
"done",
|
|
"blocked",
|
|
];
|
|
|
|
export function IssuesPage() {
|
|
const allIssues = useIssueStore((s) => s.issues);
|
|
const loading = useIssueStore((s) => s.loading);
|
|
const workspace = useWorkspaceStore((s) => s.workspace);
|
|
const viewMode = useIssueViewStore((s) => s.viewMode);
|
|
const statusFilters = useIssueViewStore((s) => s.statusFilters);
|
|
const priorityFilters = useIssueViewStore((s) => s.priorityFilters);
|
|
const clearFilters = useIssueViewStore((s) => s.clearFilters);
|
|
|
|
const issues = useMemo(() => {
|
|
return allIssues.filter((issue) => {
|
|
if (statusFilters.length > 0 && !statusFilters.includes(issue.status))
|
|
return false;
|
|
if (
|
|
priorityFilters.length > 0 &&
|
|
!priorityFilters.includes(issue.priority)
|
|
)
|
|
return false;
|
|
return true;
|
|
});
|
|
}, [allIssues, statusFilters, priorityFilters]);
|
|
|
|
const visibleStatuses = useMemo(() => {
|
|
if (statusFilters.length > 0)
|
|
return BOARD_STATUSES.filter((s) => statusFilters.includes(s));
|
|
return BOARD_STATUSES;
|
|
}, [statusFilters]);
|
|
|
|
const handleMoveIssue = useCallback(
|
|
(issueId: string, newStatus: IssueStatus) => {
|
|
useIssueStore.getState().updateIssue(issueId, { status: newStatus });
|
|
|
|
api.updateIssue(issueId, { status: newStatus }).catch(() => {
|
|
toast.error("Failed to move issue");
|
|
api.listIssues({ limit: 200 }).then((res) => {
|
|
useIssueStore.getState().setIssues(res.issues);
|
|
});
|
|
});
|
|
},
|
|
[]
|
|
);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex flex-1 min-h-0 flex-col">
|
|
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2">
|
|
<Skeleton className="h-5 w-5 rounded" />
|
|
<Skeleton className="h-4 w-32" />
|
|
</div>
|
|
<div className="flex shrink-0 items-center justify-between border-b px-4 py-2">
|
|
<Skeleton className="h-5 w-24" />
|
|
<Skeleton className="h-8 w-24" />
|
|
</div>
|
|
<div className="flex flex-1 min-h-0 gap-3 overflow-x-auto p-4">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
|
|
<Skeleton className="h-4 w-20" />
|
|
<Skeleton className="h-24 w-full rounded-lg" />
|
|
<Skeleton className="h-24 w-full rounded-lg" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-1 min-h-0 flex-col">
|
|
{/* Header 1: Workspace breadcrumb */}
|
|
<div className="flex shrink-0 items-center gap-1.5 border-b px-4 py-2">
|
|
<WorkspaceAvatar name={workspace?.name ?? "W"} size="sm" />
|
|
<span className="text-sm text-muted-foreground">
|
|
{workspace?.name ?? "Workspace"}
|
|
</span>
|
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
|
<span className="text-sm font-medium">Issues</span>
|
|
</div>
|
|
|
|
{/* Header 2: View toggle + filters */}
|
|
<IssuesHeader />
|
|
|
|
{/* Content: scrollable */}
|
|
<div className="flex flex-col flex-1 min-h-0">
|
|
{issues.length === 0 ? (
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
|
|
<p>No matching issues</p>
|
|
{(statusFilters.length > 0 || priorityFilters.length > 0) && (
|
|
<button
|
|
className="text-xs text-primary hover:underline"
|
|
onClick={clearFilters}
|
|
>
|
|
Clear filters
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : viewMode === "board" ? (
|
|
<BoardView
|
|
issues={issues}
|
|
visibleStatuses={visibleStatuses}
|
|
onMoveIssue={handleMoveIssue}
|
|
/>
|
|
) : (
|
|
<ListView issues={issues} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|