diff --git a/app/(dashboard)/projects/[id]/kanban/page.tsx b/app/(dashboard)/projects/[id]/kanban/page.tsx index 22ae338..6fab0f7 100644 --- a/app/(dashboard)/projects/[id]/kanban/page.tsx +++ b/app/(dashboard)/projects/[id]/kanban/page.tsx @@ -8,14 +8,18 @@ import { DragStartEvent, closestCorners, } from '@dnd-kit/core'; -import { useState } from 'react'; -import { useIssues, useChangeIssueStatus } from '@/lib/hooks/use-issues'; +import { useState, useMemo, useEffect } from 'react'; +import { useEpics } from '@/lib/hooks/use-epics'; +import { useStories } from '@/lib/hooks/use-stories'; +import { useTasks } from '@/lib/hooks/use-tasks'; +import { useSignalRContext } from '@/lib/signalr/SignalRContext'; +import { useQueryClient } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; import { Plus, Loader2 } from 'lucide-react'; -import { Issue } from '@/lib/api/issues'; import { KanbanColumn } from '@/components/features/kanban/KanbanColumn'; import { IssueCard } from '@/components/features/kanban/IssueCard'; import { CreateIssueDialog } from '@/components/features/issues/CreateIssueDialog'; +import type { Epic, Story, Task } from '@/types/project'; const COLUMNS = [ { id: 'Backlog', title: 'Backlog', color: 'bg-gray-100' }, @@ -24,39 +28,249 @@ const COLUMNS = [ { id: 'Done', title: 'Done', color: 'bg-green-100' }, ]; +// Unified work item type for Kanban +type WorkItemType = 'Epic' | 'Story' | 'Task'; +interface KanbanWorkItem { + id: string; + title: string; + description: string; + status: string; + priority: string; + type: WorkItemType; + // Epic properties + projectId?: string; + // Story properties + epicId?: string; + // Task properties + storyId?: string; + // Metadata + estimatedHours?: number; + actualHours?: number; + ownerId?: string; + createdAt?: string; + updatedAt?: string; +} + export default function KanbanPage() { const params = useParams(); const projectId = params.id as string; const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); - const [activeIssue, setActiveIssue] = useState(null); + const [activeItem, setActiveItem] = useState(null); - const { data: issues, isLoading } = useIssues(projectId); - const changeStatusMutation = useChangeIssueStatus(projectId); + // Fetch Epic/Story/Task from ProjectManagement API + const { data: epics, isLoading: epicsLoading } = useEpics(projectId); + const { data: stories, isLoading: storiesLoading } = useStories(); + const { data: tasks, isLoading: tasksLoading } = useTasks(); - // Group issues by status - const issuesByStatus = { - Backlog: issues?.filter((i) => i.status === 'Backlog') || [], - Todo: issues?.filter((i) => i.status === 'Todo') || [], - InProgress: issues?.filter((i) => i.status === 'InProgress') || [], - Done: issues?.filter((i) => i.status === 'Done') || [], - }; + const isLoading = epicsLoading || storiesLoading || tasksLoading; + + // SignalR real-time updates + const queryClient = useQueryClient(); + const { service, isConnected } = useSignalRContext(); + + // Subscribe to SignalR events for real-time updates + useEffect(() => { + if (!isConnected || !service) { + console.log('[Kanban] SignalR not connected, skipping event subscription'); + return; + } + + const handlers = service.getEventHandlers(); + if (!handlers) { + console.log('[Kanban] No event handlers available'); + return; + } + + console.log('[Kanban] Subscribing to SignalR events...'); + + // Epic events (6 events) + const unsubEpicCreated = handlers.subscribe('epic:created', (event: any) => { + console.log('[Kanban] Epic created:', event); + queryClient.invalidateQueries({ queryKey: ['epics', projectId] }); + }); + + const unsubEpicUpdated = handlers.subscribe('epic:updated', (event: any) => { + console.log('[Kanban] Epic updated:', event); + queryClient.invalidateQueries({ queryKey: ['epics', projectId] }); + }); + + const unsubEpicDeleted = handlers.subscribe('epic:deleted', (event: any) => { + console.log('[Kanban] Epic deleted:', event); + queryClient.invalidateQueries({ queryKey: ['epics', projectId] }); + }); + + const unsubEpicStatusChanged = handlers.subscribe('epic:statusChanged', (event: any) => { + console.log('[Kanban] Epic status changed:', event); + // Optimistic update + queryClient.setQueryData(['epics', projectId], (old: any) => { + if (!old) return old; + return old.map((epic: any) => + epic.id === event.epicId ? { ...epic, status: event.newStatus } : epic + ); + }); + }); + + const unsubEpicAssigned = handlers.subscribe('epic:assigned', (event: any) => { + console.log('[Kanban] Epic assigned:', event); + queryClient.invalidateQueries({ queryKey: ['epics', projectId] }); + }); + + const unsubEpicUnassigned = handlers.subscribe('epic:unassigned', (event: any) => { + console.log('[Kanban] Epic unassigned:', event); + queryClient.invalidateQueries({ queryKey: ['epics', projectId] }); + }); + + // Story events (6 events) + const unsubStoryCreated = handlers.subscribe('story:created', (event: any) => { + console.log('[Kanban] Story created:', event); + queryClient.invalidateQueries({ queryKey: ['stories'] }); + }); + + const unsubStoryUpdated = handlers.subscribe('story:updated', (event: any) => { + console.log('[Kanban] Story updated:', event); + queryClient.invalidateQueries({ queryKey: ['stories'] }); + }); + + const unsubStoryDeleted = handlers.subscribe('story:deleted', (event: any) => { + console.log('[Kanban] Story deleted:', event); + queryClient.invalidateQueries({ queryKey: ['stories'] }); + }); + + const unsubStoryStatusChanged = handlers.subscribe('story:statusChanged', (event: any) => { + console.log('[Kanban] Story status changed:', event); + // Optimistic update + queryClient.setQueryData(['stories'], (old: any) => { + if (!old) return old; + return old.map((story: any) => + story.id === event.storyId ? { ...story, status: event.newStatus } : story + ); + }); + }); + + const unsubStoryAssigned = handlers.subscribe('story:assigned', (event: any) => { + console.log('[Kanban] Story assigned:', event); + queryClient.invalidateQueries({ queryKey: ['stories'] }); + }); + + const unsubStoryUnassigned = handlers.subscribe('story:unassigned', (event: any) => { + console.log('[Kanban] Story unassigned:', event); + queryClient.invalidateQueries({ queryKey: ['stories'] }); + }); + + // Task events (7 events) + const unsubTaskCreated = handlers.subscribe('task:created', (event: any) => { + console.log('[Kanban] Task created:', event); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + }); + + const unsubTaskUpdated = handlers.subscribe('task:updated', (event: any) => { + console.log('[Kanban] Task updated:', event); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + }); + + const unsubTaskDeleted = handlers.subscribe('task:deleted', (event: any) => { + console.log('[Kanban] Task deleted:', event); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + }); + + const unsubTaskStatusChanged = handlers.subscribe('task:statusChanged', (event: any) => { + console.log('[Kanban] Task status changed:', event); + // Optimistic update + queryClient.setQueryData(['tasks'], (old: any) => { + if (!old) return old; + return old.map((task: any) => + task.id === event.taskId ? { ...task, status: event.newStatus } : task + ); + }); + }); + + const unsubTaskAssigned = handlers.subscribe('task:assigned', (event: any) => { + console.log('[Kanban] Task assigned:', event); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + }); + + const unsubTaskUnassigned = handlers.subscribe('task:unassigned', (event: any) => { + console.log('[Kanban] Task unassigned:', event); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + }); + + const unsubTaskCompleted = handlers.subscribe('task:completed', (event: any) => { + console.log('[Kanban] Task completed:', event); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + }); + + console.log('[Kanban] Subscribed to 19 SignalR events'); + + // Cleanup all subscriptions + return () => { + console.log('[Kanban] Unsubscribing from SignalR events...'); + unsubEpicCreated(); + unsubEpicUpdated(); + unsubEpicDeleted(); + unsubEpicStatusChanged(); + unsubEpicAssigned(); + unsubEpicUnassigned(); + unsubStoryCreated(); + unsubStoryUpdated(); + unsubStoryDeleted(); + unsubStoryStatusChanged(); + unsubStoryAssigned(); + unsubStoryUnassigned(); + unsubTaskCreated(); + unsubTaskUpdated(); + unsubTaskDeleted(); + unsubTaskStatusChanged(); + unsubTaskAssigned(); + unsubTaskUnassigned(); + unsubTaskCompleted(); + }; + }, [isConnected, service, projectId, queryClient]); + + // Combine all work items into unified format + const allWorkItems = useMemo(() => { + const items: KanbanWorkItem[] = [ + ...(epics || []).map((e) => ({ + ...e, + type: 'Epic' as const, + })), + ...(stories || []).map((s) => ({ + ...s, + type: 'Story' as const, + })), + ...(tasks || []).map((t) => ({ + ...t, + type: 'Task' as const, + })), + ]; + return items; + }, [epics, stories, tasks]); + + // Group work items by status + const itemsByStatus = useMemo(() => ({ + Backlog: allWorkItems.filter((i) => i.status === 'Backlog'), + Todo: allWorkItems.filter((i) => i.status === 'Todo'), + InProgress: allWorkItems.filter((i) => i.status === 'InProgress'), + Done: allWorkItems.filter((i) => i.status === 'Done'), + }), [allWorkItems]); const handleDragStart = (event: DragStartEvent) => { - const issue = issues?.find((i) => i.id === event.active.id); - setActiveIssue(issue || null); + const item = allWorkItems.find((i) => i.id === event.active.id); + setActiveItem(item || null); }; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; - setActiveIssue(null); + setActiveItem(null); if (!over || active.id === over.id) return; const newStatus = over.id as string; - const issue = issues?.find((i) => i.id === active.id); + const item = allWorkItems.find((i) => i.id === active.id); - if (issue && issue.status !== newStatus) { - changeStatusMutation.mutate({ issueId: issue.id, status: newStatus }); + if (item && item.status !== newStatus) { + // TODO: Implement status change mutation for Epic/Story/Task + // For now, we'll skip the mutation as we need to implement these hooks + console.log(`TODO: Change ${item.type} ${item.id} status to ${newStatus}`); } }; @@ -94,13 +308,13 @@ export default function KanbanPage() { key={column.id} id={column.id} title={column.title} - issues={issuesByStatus[column.id as keyof typeof issuesByStatus]} + issues={itemsByStatus[column.id as keyof typeof itemsByStatus] as any} /> ))} - {activeIssue && } + {activeItem && } diff --git a/components/features/kanban/IssueCard.tsx b/components/features/kanban/IssueCard.tsx index 83bbb52..dee45bf 100644 --- a/components/features/kanban/IssueCard.tsx +++ b/components/features/kanban/IssueCard.tsx @@ -5,6 +5,7 @@ import { CSS } from '@dnd-kit/utilities'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Issue } from '@/lib/api/issues'; +import { FolderKanban, FileText, CheckSquare } from 'lucide-react'; interface IssueCardProps { issue: Issue; @@ -26,11 +27,72 @@ export function IssueCard({ issue }: IssueCardProps) { Critical: 'bg-red-100 text-red-700', }; - const typeIcons = { - Story: '📖', - Task: '✓', - Bug: '🐛', - Epic: '🚀', + // Type icon components (replacing emojis with lucide icons) + const getTypeIcon = () => { + switch (issue.type) { + case 'Epic': + return ; + case 'Story': + return ; + case 'Task': + return ; + case 'Bug': + return 🐛; + default: + return null; + } + }; + + // Parent breadcrumb (for Story and Task) + const renderParentBreadcrumb = () => { + const item = issue as any; + + // Story shows parent Epic + if (issue.type === 'Story' && item.epicId) { + return ( +
+ + Epic +
+ ); + } + + // Task shows parent Story + if (issue.type === 'Task' && item.storyId) { + return ( +
+ + Story +
+ ); + } + + return null; + }; + + // Child count badge (for Epic and Story) + const renderChildCount = () => { + const item = issue as any; + + // Epic shows number of stories + if (issue.type === 'Epic' && item.childCount > 0) { + return ( + + {item.childCount} stories + + ); + } + + // Story shows number of tasks + if (issue.type === 'Story' && item.childCount > 0) { + return ( + + {item.childCount} tasks + + ); + } + + return null; }; return ( @@ -42,15 +104,36 @@ export function IssueCard({ issue }: IssueCardProps) { className="cursor-grab active:cursor-grabbing hover:shadow-md transition-shadow" > -
- {typeIcons[issue.type]} -

{issue.title}

+ {/* Header: Type icon + Child count */} +
+
+ {getTypeIcon()} + {issue.type} +
+ {renderChildCount()}
-
+ + {/* Parent breadcrumb */} + {renderParentBreadcrumb()} + + {/* Title */} +

{issue.title}

+ + {/* Description (if available) */} + {(issue as any).description && ( +

{(issue as any).description}

+ )} + + {/* Footer: Priority + Hours */} +
{issue.priority} - {issue.type} + {(issue as any).estimatedHours && ( + + {(issue as any).estimatedHours}h + + )}