From 90e3d2416cb1801304a6f43db97a79490dce9e2c Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 5 Nov 2025 15:03:12 +0100 Subject: [PATCH] feat(frontend): Refactor Kanban board to focus on Story management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactored the Kanban board from a mixed Epic/Story/Task view to focus exclusively on Stories, which are the right granularity for Kanban management. Changes: - Created StoryCard component with Epic breadcrumb, priority badges, and estimated hours display - Updated KanbanColumn to use Story type and display epic names - Created CreateStoryDialog for story creation with epic selection - Added useProjectStories hook to fetch all stories across epics for a project - Refactored Kanban page to show Stories only with drag-and-drop status updates - Updated SignalR event handlers to focus on Story events only - Changed UI text from 'New Issue' to 'New Story' and 'update issue status' to 'update story status' - Implemented story status change via useChangeStoryStatus hook 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/(dashboard)/projects/[id]/kanban/page.tsx | 161 ++++-------- components/features/kanban/KanbanColumn.tsx | 27 +- components/features/kanban/StoryCard.tsx | 143 +++++++++++ .../features/stories/CreateStoryDialog.tsx | 234 ++++++++++++++++++ lib/api/pm.ts | 18 +- lib/hooks/use-stories.ts | 37 +++ 6 files changed, 493 insertions(+), 127 deletions(-) create mode 100644 components/features/kanban/StoryCard.tsx create mode 100644 components/features/stories/CreateStoryDialog.tsx diff --git a/app/(dashboard)/projects/[id]/kanban/page.tsx b/app/(dashboard)/projects/[id]/kanban/page.tsx index 815b7f8..9f26cdb 100644 --- a/app/(dashboard)/projects/[id]/kanban/page.tsx +++ b/app/(dashboard)/projects/[id]/kanban/page.tsx @@ -8,18 +8,18 @@ import { DragStartEvent, closestCorners, } from '@dnd-kit/core'; -import { useState, useMemo, useEffect } from 'react'; +import { useState, useMemo } from 'react'; +import { useProjectStories } from '@/lib/hooks/use-stories'; import { useEpics } from '@/lib/hooks/use-epics'; -import { useStories } from '@/lib/hooks/use-stories'; -import { useTasks } from '@/lib/hooks/use-tasks'; +import { useChangeStoryStatus } from '@/lib/hooks/use-stories'; import { useSignalREvents, useSignalRConnection } 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'; +import { StoryCard } from '@/components/features/kanban/StoryCard'; +import { CreateStoryDialog } from '@/components/features/stories/CreateStoryDialog'; +import type { Story, WorkItemStatus } from '@/types/project'; const COLUMNS = [ { id: 'Backlog', title: 'Backlog', color: 'bg-gray-100' }, @@ -28,144 +28,77 @@ 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; // Optional to match API response - 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); + const [activeStory, setActiveStory] = 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(); + // Fetch all stories for the project and epics for name mapping + const { data: stories = [], isLoading: storiesLoading } = useProjectStories(projectId); + const { data: epics = [], isLoading: epicsLoading } = useEpics(projectId); - const isLoading = epicsLoading || storiesLoading || tasksLoading; + const isLoading = storiesLoading || epicsLoading; // SignalR real-time updates const queryClient = useQueryClient(); const { isConnected } = useSignalRConnection(); + const changeStatusMutation = useChangeStoryStatus(); - // Subscribe to SignalR events for real-time updates (Simplified with useSignalREvents) + // Subscribe to SignalR events for real-time updates useSignalREvents( { - // Epic events (6 events) - 'EpicCreated': (event: any) => { - console.log('[Kanban] Epic created:', event); - queryClient.invalidateQueries({ queryKey: ['epics', projectId] }); - }, - 'EpicUpdated': (event: any) => { - console.log('[Kanban] Epic updated:', event); - queryClient.invalidateQueries({ queryKey: ['epics', projectId] }); - }, - 'EpicDeleted': (event: any) => { - console.log('[Kanban] Epic deleted:', event); - queryClient.invalidateQueries({ queryKey: ['epics', projectId] }); - }, - // Story events (3 events) 'StoryCreated': (event: any) => { console.log('[Kanban] Story created:', event); - queryClient.invalidateQueries({ queryKey: ['stories'] }); + queryClient.invalidateQueries({ queryKey: ['project-stories', projectId] }); }, 'StoryUpdated': (event: any) => { console.log('[Kanban] Story updated:', event); - queryClient.invalidateQueries({ queryKey: ['stories'] }); + queryClient.invalidateQueries({ queryKey: ['project-stories', projectId] }); }, 'StoryDeleted': (event: any) => { console.log('[Kanban] Story deleted:', event); - queryClient.invalidateQueries({ queryKey: ['stories'] }); - }, - - // Task events (4 events) - 'TaskCreated': (event: any) => { - console.log('[Kanban] Task created:', event); - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - }, - 'TaskUpdated': (event: any) => { - console.log('[Kanban] Task updated:', event); - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - }, - 'TaskDeleted': (event: any) => { - console.log('[Kanban] Task deleted:', event); - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - }, - 'TaskAssigned': (event: any) => { - console.log('[Kanban] Task assigned:', event); - queryClient.invalidateQueries({ queryKey: ['tasks'] }); + queryClient.invalidateQueries({ queryKey: ['project-stories', projectId] }); }, }, [projectId, queryClient] ); - // Combine all work items into unified format - const allWorkItems = useMemo(() => { - const items: KanbanWorkItem[] = [ - ...(epics || []).map((e) => ({ - ...e, - title: e.name, // Epic uses 'name', map to 'title' for unified interface - 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]); + // Create epic name mapping for displaying in story cards + const epicNames = useMemo(() => { + const nameMap: Record = {}; + epics.forEach((epic) => { + nameMap[epic.id] = epic.name; + }); + return nameMap; + }, [epics]); - // 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]); + // Group stories by status + const storiesByStatus = useMemo(() => ({ + Backlog: stories.filter((s) => s.status === 'Backlog'), + Todo: stories.filter((s) => s.status === 'Todo'), + InProgress: stories.filter((s) => s.status === 'InProgress'), + Done: stories.filter((s) => s.status === 'Done'), + }), [stories]); const handleDragStart = (event: DragStartEvent) => { - const item = allWorkItems.find((i) => i.id === event.active.id); - setActiveItem(item || null); + const story = stories.find((s) => s.id === event.active.id); + setActiveStory(story || null); }; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; - setActiveItem(null); + setActiveStory(null); if (!over || active.id === over.id) return; - const newStatus = over.id as string; - const item = allWorkItems.find((i) => i.id === active.id); + const newStatus = over.id as WorkItemStatus; + const story = stories.find((s) => s.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 (story && story.status !== newStatus) { + console.log(`[Kanban] Changing story ${story.id} status to ${newStatus}`); + changeStatusMutation.mutate({ id: story.id, status: newStatus }); } }; @@ -183,12 +116,12 @@ export default function KanbanPage() {

Kanban Board

- Drag and drop to update issue status + Drag and drop to update story status

@@ -203,17 +136,23 @@ export default function KanbanPage() { key={column.id} id={column.id} title={column.title} - issues={itemsByStatus[column.id as keyof typeof itemsByStatus] as any} + stories={storiesByStatus[column.id as keyof typeof storiesByStatus]} + epicNames={epicNames} /> ))} - {activeItem && } + {activeStory && ( + + )} - ; // Map of epicId -> epicName + taskCounts?: Record; // Map of storyId -> taskCount } -export function KanbanColumn({ id, title, issues }: KanbanColumnProps) { +export function KanbanColumn({ id, title, stories, epicNames = {}, taskCounts = {} }: KanbanColumnProps) { const { setNodeRef } = useDroppable({ id }); return ( @@ -20,21 +22,26 @@ export function KanbanColumn({ id, title, issues }: KanbanColumnProps) { {title} - {issues.length} + {stories.length} i.id)} + items={stories.map((s) => s.id)} strategy={verticalListSortingStrategy} > - {issues.map((issue) => ( - + {stories.map((story) => ( + ))} - {issues.length === 0 && ( + {stories.length === 0 && (
-

No issues

+

No stories

)}
diff --git a/components/features/kanban/StoryCard.tsx b/components/features/kanban/StoryCard.tsx new file mode 100644 index 0000000..9eff9b6 --- /dev/null +++ b/components/features/kanban/StoryCard.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { Story } from '@/types/project'; +import { FileText, FolderKanban, Clock, CheckSquare } from 'lucide-react'; +import { useMemo } from 'react'; + +interface StoryCardProps { + story: Story; + epicName?: string; + taskCount?: number; +} + +export function StoryCard({ story, epicName, taskCount }: StoryCardProps) { + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id: story.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const priorityColors = { + Low: 'bg-gray-100 text-gray-700 border-gray-300', + Medium: 'bg-blue-100 text-blue-700 border-blue-300', + High: 'bg-orange-100 text-orange-700 border-orange-300', + Critical: 'bg-red-100 text-red-700 border-red-300', + }; + + const statusColors = { + Backlog: 'bg-gray-100 text-gray-600', + Todo: 'bg-blue-100 text-blue-600', + InProgress: 'bg-yellow-100 text-yellow-700', + Done: 'bg-green-100 text-green-700', + }; + + // Get assignee initials + const assigneeInitials = useMemo(() => { + if (!story.assigneeId) return null; + // For now, just use first two letters. In real app, fetch user data + return story.assigneeId.substring(0, 2).toUpperCase(); + }, [story.assigneeId]); + + // Calculate progress (if both estimated and actual hours exist) + const hoursDisplay = useMemo(() => { + if (story.estimatedHours) { + if (story.actualHours) { + return `${story.actualHours}/${story.estimatedHours}h`; + } + return `0/${story.estimatedHours}h`; + } + return null; + }, [story.estimatedHours, story.actualHours]); + + return ( + + + {/* Header: Story icon + Task count */} +
+
+ + Story +
+ {taskCount !== undefined && taskCount > 0 && ( + + + {taskCount} {taskCount === 1 ? 'task' : 'tasks'} + + )} +
+ + {/* Epic breadcrumb */} + {epicName && ( +
+ + + {epicName} + +
+ )} + + {/* Title */} +

+ {story.title} +

+ + {/* Description (if available) */} + {story.description && ( +

+ {story.description} +

+ )} + + {/* Footer: Priority, Hours, Assignee */} +
+
+ + {story.priority} + + + {story.status} + +
+ +
+ {/* Hours display */} + {hoursDisplay && ( +
+ + {hoursDisplay} +
+ )} + + {/* Assignee avatar */} + {assigneeInitials && ( + + + {assigneeInitials} + + + )} +
+
+
+
+ ); +} diff --git a/components/features/stories/CreateStoryDialog.tsx b/components/features/stories/CreateStoryDialog.tsx new file mode 100644 index 0000000..8acdb17 --- /dev/null +++ b/components/features/stories/CreateStoryDialog.tsx @@ -0,0 +1,234 @@ +'use client'; + +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useCreateStory } from '@/lib/hooks/use-stories'; +import { useEpics } from '@/lib/hooks/use-epics'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Loader2 } from 'lucide-react'; + +const createStorySchema = z.object({ + epicId: z.string().min(1, 'Epic is required'), + title: z.string().min(1, 'Title is required'), + description: z.string().optional(), + priority: z.enum(['Low', 'Medium', 'High', 'Critical']), + estimatedHours: z.number().min(0).optional(), +}); + +interface CreateStoryDialogProps { + projectId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CreateStoryDialog({ + projectId, + open, + onOpenChange, +}: CreateStoryDialogProps) { + const form = useForm({ + resolver: zodResolver(createStorySchema), + defaultValues: { + epicId: '', + title: '', + description: '', + priority: 'Medium' as const, + estimatedHours: 0, + }, + }); + + const { data: epics, isLoading: epicsLoading } = useEpics(projectId); + const createMutation = useCreateStory(); + + const onSubmit = (data: z.infer) => { + createMutation.mutate(data, { + onSuccess: () => { + form.reset(); + onOpenChange(false); + }, + }); + }; + + return ( + + + + Create New Story + +
+ + {/* Epic Selection */} + ( + + Epic + + + + )} + /> + + {/* Title */} + ( + + Title + + + + + + )} + /> + + {/* Description */} + ( + + Description + +