'use client'; import { useParams } from 'next/navigation'; import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, closestCorners, } from '@dnd-kit/core'; 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 { 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' }, { id: 'Todo', title: 'To Do', color: 'bg-blue-100' }, { id: 'InProgress', title: 'In Progress', color: 'bg-yellow-100' }, { 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 [activeItem, setActiveItem] = useState(null); // 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(); 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 item = allWorkItems.find((i) => i.id === event.active.id); setActiveItem(item || null); }; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; setActiveItem(null); if (!over || active.id === over.id) return; const newStatus = over.id as string; const item = allWorkItems.find((i) => i.id === active.id); 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}`); } }; if (isLoading) { return (
); } return (

Kanban Board

Drag and drop to update issue status

{COLUMNS.map((column) => ( ))}
{activeItem && }
); }