diff --git a/apps/web/app/(dashboard)/issues/page.tsx b/apps/web/app/(dashboard)/issues/page.tsx index 3a6a9016..546ca001 100644 --- a/apps/web/app/(dashboard)/issues/page.tsx +++ b/apps/web/app/(dashboard)/issues/page.tsx @@ -1,471 +1,7 @@ "use client"; -import { useState, useCallback, useMemo } from "react"; -import { useIssueStore } from "@/features/issues"; -import { useModalStore } from "@/features/modals"; -import { toast } from "sonner"; -import Link from "next/link"; -import { - Columns3, - List, - Plus, -} from "lucide-react"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - DndContext, - DragOverlay, - PointerSensor, - useSensor, - useSensors, - useDroppable, - closestCorners, - type DragStartEvent, - type DragEndEvent, -} from "@dnd-kit/core"; -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import type { Issue, IssueStatus, IssuePriority } from "@multica/types"; -import { STATUS_CONFIG, PRIORITY_CONFIG, ALL_STATUSES, PRIORITY_ORDER, STATUS_ORDER } from "@/features/issues/config"; -import { Button } from "@/components/ui/button"; -import { - Select, - SelectTrigger, - SelectValue, - SelectContent, - SelectItem, - SelectGroup, -} from "@/components/ui/select"; -import { ActorAvatar } from "@/components/common/actor-avatar"; -import { StatusIcon, PriorityIcon } from "@/features/issues/components"; -import { api } from "@/shared/api"; -import { useActorName } from "@/features/workspace"; +import { IssuesPage } from "@/features/issues/components/issues-page"; -function formatDate(date: string): string { - return new Date(date).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }); -} - -const BOARD_STATUSES: IssueStatus[] = [ - "backlog", - "todo", - "in_progress", - "in_review", - "done", - "blocked", -]; - -// --------------------------------------------------------------------------- -// Board View — Card -// --------------------------------------------------------------------------- - -function BoardCardContent({ issue }: { issue: Issue }) { - const { getActorName, getActorInitials } = useActorName(); - return ( -
-
- - {issue.id.slice(0, 8)} -
-

{issue.title}

-
-
- {issue.assignee_type && issue.assignee_id && ( - - )} -
- {issue.due_date && ( - - {formatDate(issue.due_date)} - - )} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Draggable card wrapper -// --------------------------------------------------------------------------- - -function DraggableBoardCard({ issue }: { issue: Issue }) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ - id: issue.id, - data: { status: issue.status }, - }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - - return ( -
- - - -
- ); -} - -// --------------------------------------------------------------------------- -// Droppable column -// --------------------------------------------------------------------------- - -function DroppableColumn({ - status, - issues, -}: { - status: IssueStatus; - issues: Issue[]; -}) { - const cfg = STATUS_CONFIG[status]; - const { setNodeRef, isOver } = useDroppable({ id: status }); - - return ( -
-
- - {cfg.label} - {issues.length} -
-
- {issues.map((issue) => ( - - ))} - {issues.length === 0 && ( -

No issues

- )} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Board View (with DnD) -// --------------------------------------------------------------------------- - -function BoardView({ - issues, - onMoveIssue, -}: { - issues: Issue[]; - onMoveIssue: (issueId: string, newStatus: IssueStatus) => void; -}) { - const [activeIssue, setActiveIssue] = useState(null); - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { distance: 5 }, - }) - ); - - const visibleStatuses = BOARD_STATUSES; - - const handleDragStart = useCallback( - (event: DragStartEvent) => { - const issue = issues.find((i) => i.id === event.active.id); - if (issue) setActiveIssue(issue); - }, - [issues] - ); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - setActiveIssue(null); - const { active, over } = event; - if (!over) return; - - const issueId = active.id as string; - let targetStatus: IssueStatus | undefined; - - if (visibleStatuses.includes(over.id as IssueStatus)) { - targetStatus = over.id as IssueStatus; - } else { - const targetIssue = issues.find((i) => i.id === over.id); - if (targetIssue) targetStatus = targetIssue.status; - } - - if (targetStatus) { - const currentIssue = issues.find((i) => i.id === issueId); - if (currentIssue && currentIssue.status !== targetStatus) { - onMoveIssue(issueId, targetStatus); - } - } - }, - [issues, onMoveIssue, visibleStatuses] - ); - - return ( - -
- {visibleStatuses.map((status) => ( - i.status === status)} - /> - ))} -
- - - {activeIssue ? ( -
- -
- ) : null} -
-
- ); -} - -// --------------------------------------------------------------------------- -// List View -// --------------------------------------------------------------------------- - -function ListRow({ issue }: { issue: Issue }) { - const { getActorName, getActorInitials } = useActorName(); - return ( - - - - {issue.id.slice(0, 8)} - - - {issue.title} - {issue.due_date && ( - - {formatDate(issue.due_date)} - - )} - {issue.assignee_type && issue.assignee_id && ( - - )} - - ); -} - -function ListView({ issues }: { issues: Issue[] }) { - const groupOrder = STATUS_ORDER.filter((s) => s !== "cancelled"); - - return ( -
- {groupOrder.map((status) => { - const cfg = STATUS_CONFIG[status]; - const filtered = issues.filter((i) => i.status === status); - if (filtered.length === 0) return null; - return ( -
-
- - {cfg.label} - {filtered.length} -
- {filtered.map((issue) => ( - - ))} -
- ); - })} -
- ); -} - -// --------------------------------------------------------------------------- -// Create Issue Dialog -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Page -// --------------------------------------------------------------------------- - -type ViewMode = "board" | "list"; - -export default function IssuesPage() { - const [view, setView] = useState("board"); - const [filterStatus, setFilterStatus] = useState(""); - const [filterPriority, setFilterPriority] = useState(""); - - // Read from global store (populated by workspace hydrate + useRealtimeSync) - const allIssues = useIssueStore((s) => s.issues); - const loading = useIssueStore((s) => s.loading); - - // Apply local filters - const issues = useMemo(() => { - return allIssues.filter((issue) => { - if (filterStatus && issue.status !== filterStatus) return false; - if (filterPriority && issue.priority !== filterPriority) return false; - return true; - }); - }, [allIssues, filterStatus, filterPriority]); - - const handleMoveIssue = useCallback( - (issueId: string, newStatus: IssueStatus) => { - // Optimistic update in store - useIssueStore.getState().updateIssue(issueId, { status: newStatus }); - - // Persist to API - api.updateIssue(issueId, { status: newStatus }).catch((err) => { - toast.error("Failed to move issue"); - // Revert on error by refetching - api.listIssues({ limit: 200 }).then((res) => { - useIssueStore.getState().setIssues(res.issues); - }); - }); - }, - [] - ); - - if (loading) { - return ( -
-
- - -
-
- {Array.from({ length: 5 }).map((_, i) => ( -
- - - -
- ))} -
-
- ); - } - - return ( -
- {/* Toolbar */} -
-
-

All Issues

-
- - -
-
- - -
-
- -
- -
- {issues.length === 0 && !loading ? ( -
-

No matching issues

- {(filterStatus || filterPriority) && ( - - )} -
- ) : view === "board" ? ( - - ) : ( - - )} -
-
- ); +export default function Page() { + return ; } diff --git a/apps/web/features/issues/components/board-card.tsx b/apps/web/features/issues/components/board-card.tsx new file mode 100644 index 00000000..bde5eb7c --- /dev/null +++ b/apps/web/features/issues/components/board-card.tsx @@ -0,0 +1,79 @@ +"use client"; + +import Link from "next/link"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import type { Issue } from "@multica/types"; +import { ActorAvatar } from "@/components/common/actor-avatar"; +import { PriorityIcon } from "./priority-icon"; + +function formatDate(date: string): string { + return new Date(date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +export function BoardCardContent({ issue }: { issue: Issue }) { + return ( +
+
+ + {issue.id.slice(0, 8)} +
+

{issue.title}

+
+
+ {issue.assignee_type && issue.assignee_id && ( + + )} +
+ {issue.due_date && ( + + {formatDate(issue.due_date)} + + )} +
+
+ ); +} + +export function DraggableBoardCard({ issue }: { issue: Issue }) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: issue.id, + data: { status: issue.status }, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ + + +
+ ); +} diff --git a/apps/web/features/issues/components/board-column.tsx b/apps/web/features/issues/components/board-column.tsx new file mode 100644 index 00000000..29bd1a87 --- /dev/null +++ b/apps/web/features/issues/components/board-column.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useDroppable } from "@dnd-kit/core"; +import type { Issue, IssueStatus } from "@multica/types"; +import { STATUS_CONFIG } from "@/features/issues/config"; +import { StatusIcon } from "./status-icon"; +import { DraggableBoardCard } from "./board-card"; + +export function BoardColumn({ + status, + issues, +}: { + status: IssueStatus; + issues: Issue[]; +}) { + const cfg = STATUS_CONFIG[status]; + const { setNodeRef, isOver } = useDroppable({ id: status }); + + return ( +
+
+ + {cfg.label} + {issues.length} +
+
+ {issues.map((issue) => ( + + ))} + {issues.length === 0 && ( +

+ No issues +

+ )} +
+
+ ); +} diff --git a/apps/web/features/issues/components/board-view.tsx b/apps/web/features/issues/components/board-view.tsx new file mode 100644 index 00000000..1410ed38 --- /dev/null +++ b/apps/web/features/issues/components/board-view.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { + DndContext, + DragOverlay, + PointerSensor, + useSensor, + useSensors, + pointerWithin, + closestCenter, + type CollisionDetection, + type DragStartEvent, + type DragEndEvent, +} from "@dnd-kit/core"; +import type { Issue, IssueStatus } from "@multica/types"; +import { BoardColumn } from "./board-column"; +import { BoardCardContent } from "./board-card"; + +const kanbanCollision: CollisionDetection = (args) => { + const pointer = pointerWithin(args); + if (pointer.length > 0) return pointer; + return closestCenter(args); +}; + +export function BoardView({ + issues, + visibleStatuses, + onMoveIssue, +}: { + issues: Issue[]; + visibleStatuses: IssueStatus[]; + onMoveIssue: (issueId: string, newStatus: IssueStatus) => void; +}) { + const [activeIssue, setActiveIssue] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }) + ); + + const handleDragStart = useCallback( + (event: DragStartEvent) => { + const issue = issues.find((i) => i.id === event.active.id); + if (issue) setActiveIssue(issue); + }, + [issues] + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + setActiveIssue(null); + const { active, over } = event; + if (!over) return; + + const issueId = active.id as string; + let targetStatus: IssueStatus | undefined; + + if (visibleStatuses.includes(over.id as IssueStatus)) { + targetStatus = over.id as IssueStatus; + } else { + const targetIssue = issues.find((i) => i.id === over.id); + if (targetIssue) targetStatus = targetIssue.status; + } + + if (targetStatus) { + const currentIssue = issues.find((i) => i.id === issueId); + if (currentIssue && currentIssue.status !== targetStatus) { + onMoveIssue(issueId, targetStatus); + } + } + }, + [issues, onMoveIssue, visibleStatuses] + ); + + return ( + +
+ {visibleStatuses.map((status) => ( + i.status === status)} + /> + ))} +
+ + + {activeIssue ? ( +
+ +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/features/issues/components/index.ts b/apps/web/features/issues/components/index.ts index ef5b8364..934aab28 100644 --- a/apps/web/features/issues/components/index.ts +++ b/apps/web/features/issues/components/index.ts @@ -2,3 +2,4 @@ export { StatusIcon } from "./status-icon"; export { PriorityIcon } from "./priority-icon"; export { StatusPicker, PriorityPicker, AssigneePicker } from "./pickers"; export { IssueDetail } from "./issue-detail"; +export { IssuesPage } from "./issues-page"; diff --git a/apps/web/features/issues/components/issues-header.tsx b/apps/web/features/issues/components/issues-header.tsx new file mode 100644 index 00000000..3e84f1a8 --- /dev/null +++ b/apps/web/features/issues/components/issues-header.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { ChevronDown, Columns3, List, Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuLabel, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { useModalStore } from "@/features/modals"; +import { + ALL_STATUSES, + STATUS_CONFIG, + PRIORITY_ORDER, + PRIORITY_CONFIG, +} from "@/features/issues/config"; +import { StatusIcon, PriorityIcon } from "@/features/issues/components"; +import { useIssueViewStore } from "@/features/issues/stores/view-store"; + +function formatFilterLabel( + prefix: string, + selected: string[], + configMap: Record +) { + if (selected.length === 0) return `${prefix}: All`; + if (selected.length === 1) { + const key = selected[0]; + if (key) return `${prefix}: ${configMap[key]?.label ?? key}`; + } + return `${prefix}: ${selected.length} selected`; +} + +export function IssuesHeader() { + const viewMode = useIssueViewStore((s) => s.viewMode); + const statusFilters = useIssueViewStore((s) => s.statusFilters); + const priorityFilters = useIssueViewStore((s) => s.priorityFilters); + const setViewMode = useIssueViewStore((s) => s.setViewMode); + const toggleStatusFilter = useIssueViewStore((s) => s.toggleStatusFilter); + const togglePriorityFilter = useIssueViewStore((s) => s.togglePriorityFilter); + + return ( +
+
+ {/* Status filter */} + + + {formatFilterLabel("Status", statusFilters, STATUS_CONFIG)} + + + } + /> + + + Status + + useIssueViewStore.setState({ statusFilters: [] }) + } + > + All + + + + + {ALL_STATUSES.map((s) => ( + toggleStatusFilter(s)} + > + + {STATUS_CONFIG[s].label} + + ))} + + + + + {/* Priority filter */} + + + {formatFilterLabel("Priority", priorityFilters, PRIORITY_CONFIG)} + + + } + /> + + + Priority + + useIssueViewStore.setState({ priorityFilters: [] }) + } + > + All + + + + + {PRIORITY_ORDER.map((p) => ( + togglePriorityFilter(p)} + > + + {PRIORITY_CONFIG[p].label} + + ))} + + + +
+ +
+ {/* View toggle */} + + + {viewMode === "board" ? : } + + } + /> + + + View + setViewMode("board")}> + + Board + + setViewMode("list")}> + + List + + + + + + {/* New issue */} + +
+
+ ); +} diff --git a/apps/web/features/issues/components/issues-page.tsx b/apps/web/features/issues/components/issues-page.tsx new file mode 100644 index 00000000..78dba128 --- /dev/null +++ b/apps/web/features/issues/components/issues-page.tsx @@ -0,0 +1,133 @@ +"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 ( +
+
+ + +
+
+ + +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+ ); + } + + return ( +
+ {/* Header 1: Workspace breadcrumb */} +
+ + + {workspace?.name ?? "Workspace"} + + + Issues +
+ + {/* Header 2: View toggle + filters */} + + + {/* Content: scrollable */} +
+ {issues.length === 0 ? ( +
+

No matching issues

+ {(statusFilters.length > 0 || priorityFilters.length > 0) && ( + + )} +
+ ) : viewMode === "board" ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/web/features/issues/components/list-row.tsx b/apps/web/features/issues/components/list-row.tsx new file mode 100644 index 00000000..0cbcfeef --- /dev/null +++ b/apps/web/features/issues/components/list-row.tsx @@ -0,0 +1,42 @@ +"use client"; + +import Link from "next/link"; +import type { Issue } from "@multica/types"; +import { ActorAvatar } from "@/components/common/actor-avatar"; +import { StatusIcon } from "./status-icon"; +import { PriorityIcon } from "./priority-icon"; + +function formatDate(date: string): string { + return new Date(date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +export function ListRow({ issue }: { issue: Issue }) { + return ( + + + + {issue.id.slice(0, 8)} + + + {issue.title} + {issue.due_date && ( + + {formatDate(issue.due_date)} + + )} + {issue.assignee_type && issue.assignee_id && ( + + )} + + ); +} diff --git a/apps/web/features/issues/components/list-view.tsx b/apps/web/features/issues/components/list-view.tsx new file mode 100644 index 00000000..e4a1842b --- /dev/null +++ b/apps/web/features/issues/components/list-view.tsx @@ -0,0 +1,34 @@ +"use client"; + +import type { Issue } from "@multica/types"; +import { STATUS_ORDER, STATUS_CONFIG } from "@/features/issues/config"; +import { StatusIcon } from "./status-icon"; +import { ListRow } from "./list-row"; + +export function ListView({ issues }: { issues: Issue[] }) { + const groupOrder = STATUS_ORDER.filter((s) => s !== "cancelled"); + + return ( +
+ {groupOrder.map((status) => { + const cfg = STATUS_CONFIG[status]; + const filtered = issues.filter((i) => i.status === status); + if (filtered.length === 0) return null; + return ( +
+
+ + {cfg.label} + + {filtered.length} + +
+ {filtered.map((issue) => ( + + ))} +
+ ); + })} +
+ ); +} diff --git a/apps/web/features/issues/index.ts b/apps/web/features/issues/index.ts index 7dbb612d..6de13853 100644 --- a/apps/web/features/issues/index.ts +++ b/apps/web/features/issues/index.ts @@ -1,3 +1,4 @@ export { useIssueStore } from "./store"; +export { useIssueViewStore } from "./stores/view-store"; export { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, AssigneePicker } from "./components"; export * from "./config"; diff --git a/apps/web/features/issues/stores/view-store.ts b/apps/web/features/issues/stores/view-store.ts new file mode 100644 index 00000000..67540b43 --- /dev/null +++ b/apps/web/features/issues/stores/view-store.ts @@ -0,0 +1,50 @@ +"use client"; + +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import type { IssueStatus, IssuePriority } from "@multica/types"; + +export type ViewMode = "board" | "list"; + +interface IssueViewState { + viewMode: ViewMode; + statusFilters: IssueStatus[]; + priorityFilters: IssuePriority[]; + setViewMode: (mode: ViewMode) => void; + toggleStatusFilter: (status: IssueStatus) => void; + togglePriorityFilter: (priority: IssuePriority) => void; + clearFilters: () => void; +} + +export const useIssueViewStore = create()( + persist( + (set) => ({ + viewMode: "board", + statusFilters: [], + priorityFilters: [], + + setViewMode: (mode) => set({ viewMode: mode }), + toggleStatusFilter: (status) => + set((state) => ({ + statusFilters: state.statusFilters.includes(status) + ? state.statusFilters.filter((s) => s !== status) + : [...state.statusFilters, status], + })), + togglePriorityFilter: (priority) => + set((state) => ({ + priorityFilters: state.priorityFilters.includes(priority) + ? state.priorityFilters.filter((p) => p !== priority) + : [...state.priorityFilters, priority], + })), + clearFilters: () => set({ statusFilters: [], priorityFilters: [] }), + }), + { + name: "multica_issues_view", + partialize: (state) => ({ + viewMode: state.viewMode, + statusFilters: state.statusFilters, + priorityFilters: state.priorityFilters, + }), + } + ) +);