feat(frontend): Refactor Kanban board to focus on Story management
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 <noreply@anthropic.com>
This commit is contained in:
@@ -8,18 +8,18 @@ import {
|
|||||||
DragStartEvent,
|
DragStartEvent,
|
||||||
closestCorners,
|
closestCorners,
|
||||||
} from '@dnd-kit/core';
|
} 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 { useEpics } from '@/lib/hooks/use-epics';
|
||||||
import { useStories } from '@/lib/hooks/use-stories';
|
import { useChangeStoryStatus } from '@/lib/hooks/use-stories';
|
||||||
import { useTasks } from '@/lib/hooks/use-tasks';
|
|
||||||
import { useSignalREvents, useSignalRConnection } from '@/lib/signalr/SignalRContext';
|
import { useSignalREvents, useSignalRConnection } from '@/lib/signalr/SignalRContext';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Plus, Loader2 } from 'lucide-react';
|
import { Plus, Loader2 } from 'lucide-react';
|
||||||
import { KanbanColumn } from '@/components/features/kanban/KanbanColumn';
|
import { KanbanColumn } from '@/components/features/kanban/KanbanColumn';
|
||||||
import { IssueCard } from '@/components/features/kanban/IssueCard';
|
import { StoryCard } from '@/components/features/kanban/StoryCard';
|
||||||
import { CreateIssueDialog } from '@/components/features/issues/CreateIssueDialog';
|
import { CreateStoryDialog } from '@/components/features/stories/CreateStoryDialog';
|
||||||
import type { Epic, Story, Task } from '@/types/project';
|
import type { Story, WorkItemStatus } from '@/types/project';
|
||||||
|
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
{ id: 'Backlog', title: 'Backlog', color: 'bg-gray-100' },
|
{ id: 'Backlog', title: 'Backlog', color: 'bg-gray-100' },
|
||||||
@@ -28,144 +28,77 @@ const COLUMNS = [
|
|||||||
{ id: 'Done', title: 'Done', color: 'bg-green-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; // 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() {
|
export default function KanbanPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const projectId = params.id as string;
|
const projectId = params.id as string;
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
const [activeItem, setActiveItem] = useState<KanbanWorkItem | null>(null);
|
const [activeStory, setActiveStory] = useState<Story | null>(null);
|
||||||
|
|
||||||
// Fetch Epic/Story/Task from ProjectManagement API
|
// Fetch all stories for the project and epics for name mapping
|
||||||
const { data: epics, isLoading: epicsLoading } = useEpics(projectId);
|
const { data: stories = [], isLoading: storiesLoading } = useProjectStories(projectId);
|
||||||
const { data: stories, isLoading: storiesLoading } = useStories();
|
const { data: epics = [], isLoading: epicsLoading } = useEpics(projectId);
|
||||||
const { data: tasks, isLoading: tasksLoading } = useTasks();
|
|
||||||
|
|
||||||
const isLoading = epicsLoading || storiesLoading || tasksLoading;
|
const isLoading = storiesLoading || epicsLoading;
|
||||||
|
|
||||||
// SignalR real-time updates
|
// SignalR real-time updates
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { isConnected } = useSignalRConnection();
|
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(
|
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)
|
// Story events (3 events)
|
||||||
'StoryCreated': (event: any) => {
|
'StoryCreated': (event: any) => {
|
||||||
console.log('[Kanban] Story created:', event);
|
console.log('[Kanban] Story created:', event);
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
queryClient.invalidateQueries({ queryKey: ['project-stories', projectId] });
|
||||||
},
|
},
|
||||||
'StoryUpdated': (event: any) => {
|
'StoryUpdated': (event: any) => {
|
||||||
console.log('[Kanban] Story updated:', event);
|
console.log('[Kanban] Story updated:', event);
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
queryClient.invalidateQueries({ queryKey: ['project-stories', projectId] });
|
||||||
},
|
},
|
||||||
'StoryDeleted': (event: any) => {
|
'StoryDeleted': (event: any) => {
|
||||||
console.log('[Kanban] Story deleted:', event);
|
console.log('[Kanban] Story deleted:', event);
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
queryClient.invalidateQueries({ queryKey: ['project-stories', projectId] });
|
||||||
},
|
|
||||||
|
|
||||||
// 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'] });
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[projectId, queryClient]
|
[projectId, queryClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Combine all work items into unified format
|
// Create epic name mapping for displaying in story cards
|
||||||
const allWorkItems = useMemo(() => {
|
const epicNames = useMemo(() => {
|
||||||
const items: KanbanWorkItem[] = [
|
const nameMap: Record<string, string> = {};
|
||||||
...(epics || []).map((e) => ({
|
epics.forEach((epic) => {
|
||||||
...e,
|
nameMap[epic.id] = epic.name;
|
||||||
title: e.name, // Epic uses 'name', map to 'title' for unified interface
|
});
|
||||||
type: 'Epic' as const,
|
return nameMap;
|
||||||
})),
|
}, [epics]);
|
||||||
...(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
|
// Group stories by status
|
||||||
const itemsByStatus = useMemo(() => ({
|
const storiesByStatus = useMemo(() => ({
|
||||||
Backlog: allWorkItems.filter((i) => i.status === 'Backlog'),
|
Backlog: stories.filter((s) => s.status === 'Backlog'),
|
||||||
Todo: allWorkItems.filter((i) => i.status === 'Todo'),
|
Todo: stories.filter((s) => s.status === 'Todo'),
|
||||||
InProgress: allWorkItems.filter((i) => i.status === 'InProgress'),
|
InProgress: stories.filter((s) => s.status === 'InProgress'),
|
||||||
Done: allWorkItems.filter((i) => i.status === 'Done'),
|
Done: stories.filter((s) => s.status === 'Done'),
|
||||||
}), [allWorkItems]);
|
}), [stories]);
|
||||||
|
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
const item = allWorkItems.find((i) => i.id === event.active.id);
|
const story = stories.find((s) => s.id === event.active.id);
|
||||||
setActiveItem(item || null);
|
setActiveStory(story || null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
setActiveItem(null);
|
setActiveStory(null);
|
||||||
|
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
const newStatus = over.id as string;
|
const newStatus = over.id as WorkItemStatus;
|
||||||
const item = allWorkItems.find((i) => i.id === active.id);
|
const story = stories.find((s) => s.id === active.id);
|
||||||
|
|
||||||
if (item && item.status !== newStatus) {
|
if (story && story.status !== newStatus) {
|
||||||
// TODO: Implement status change mutation for Epic/Story/Task
|
console.log(`[Kanban] Changing story ${story.id} status to ${newStatus}`);
|
||||||
// For now, we'll skip the mutation as we need to implement these hooks
|
changeStatusMutation.mutate({ id: story.id, status: newStatus });
|
||||||
console.log(`TODO: Change ${item.type} ${item.id} status to ${newStatus}`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -183,12 +116,12 @@ export default function KanbanPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Kanban Board</h1>
|
<h1 className="text-3xl font-bold">Kanban Board</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Drag and drop to update issue status
|
Drag and drop to update story status
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
New Issue
|
New Story
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -203,17 +136,23 @@ export default function KanbanPage() {
|
|||||||
key={column.id}
|
key={column.id}
|
||||||
id={column.id}
|
id={column.id}
|
||||||
title={column.title}
|
title={column.title}
|
||||||
issues={itemsByStatus[column.id as keyof typeof itemsByStatus] as any}
|
stories={storiesByStatus[column.id as keyof typeof storiesByStatus]}
|
||||||
|
epicNames={epicNames}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
{activeItem && <IssueCard issue={activeItem as any} />}
|
{activeStory && (
|
||||||
|
<StoryCard
|
||||||
|
story={activeStory}
|
||||||
|
epicName={epicNames[activeStory.epicId]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
|
||||||
<CreateIssueDialog
|
<CreateStoryDialog
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
open={isCreateDialogOpen}
|
open={isCreateDialogOpen}
|
||||||
onOpenChange={setIsCreateDialogOpen}
|
onOpenChange={setIsCreateDialogOpen}
|
||||||
|
|||||||
@@ -3,16 +3,18 @@
|
|||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Issue } from '@/lib/api/issues';
|
import { Story } from '@/types/project';
|
||||||
import { IssueCard } from './IssueCard';
|
import { StoryCard } from './StoryCard';
|
||||||
|
|
||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
issues: Issue[];
|
stories: Story[];
|
||||||
|
epicNames?: Record<string, string>; // Map of epicId -> epicName
|
||||||
|
taskCounts?: Record<string, number>; // Map of storyId -> taskCount
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KanbanColumn({ id, title, issues }: KanbanColumnProps) {
|
export function KanbanColumn({ id, title, stories, epicNames = {}, taskCounts = {} }: KanbanColumnProps) {
|
||||||
const { setNodeRef } = useDroppable({ id });
|
const { setNodeRef } = useDroppable({ id });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -20,21 +22,26 @@ export function KanbanColumn({ id, title, issues }: KanbanColumnProps) {
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
<span className="text-muted-foreground">{issues.length}</span>
|
<span className="text-muted-foreground">{stories.length}</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent ref={setNodeRef} className="space-y-2 min-h-[400px]">
|
<CardContent ref={setNodeRef} className="space-y-2 min-h-[400px]">
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={issues.map((i) => i.id)}
|
items={stories.map((s) => s.id)}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
{issues.map((issue) => (
|
{stories.map((story) => (
|
||||||
<IssueCard key={issue.id} issue={issue} />
|
<StoryCard
|
||||||
|
key={story.id}
|
||||||
|
story={story}
|
||||||
|
epicName={epicNames[story.epicId]}
|
||||||
|
taskCount={taskCounts[story.id]}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
{issues.length === 0 && (
|
{stories.length === 0 && (
|
||||||
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25">
|
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25">
|
||||||
<p className="text-sm text-muted-foreground">No issues</p>
|
<p className="text-sm text-muted-foreground">No stories</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
143
components/features/kanban/StoryCard.tsx
Normal file
143
components/features/kanban/StoryCard.tsx
Normal file
@@ -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 (
|
||||||
|
<Card
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="cursor-grab active:cursor-grabbing hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<CardContent className="p-3 space-y-2">
|
||||||
|
{/* Header: Story icon + Task count */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4 text-green-600" />
|
||||||
|
<span className="text-xs font-medium text-gray-600">Story</span>
|
||||||
|
</div>
|
||||||
|
{taskCount !== undefined && taskCount > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
<CheckSquare className="w-3 h-3 mr-1" />
|
||||||
|
{taskCount} {taskCount === 1 ? 'task' : 'tasks'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Epic breadcrumb */}
|
||||||
|
{epicName && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<FolderKanban className="w-3 h-3" />
|
||||||
|
<span className="truncate max-w-[200px]" title={epicName}>
|
||||||
|
{epicName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="text-sm font-medium line-clamp-2" title={story.title}>
|
||||||
|
{story.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Description (if available) */}
|
||||||
|
{story.description && (
|
||||||
|
<p className="text-xs text-gray-600 line-clamp-2" title={story.description}>
|
||||||
|
{story.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer: Priority, Hours, Assignee */}
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`${priorityColors[story.priority]} text-xs`}
|
||||||
|
>
|
||||||
|
{story.priority}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`${statusColors[story.status]} text-xs`}
|
||||||
|
>
|
||||||
|
{story.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Hours display */}
|
||||||
|
{hoursDisplay && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span>{hoursDisplay}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Assignee avatar */}
|
||||||
|
{assigneeInitials && (
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{assigneeInitials}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
234
components/features/stories/CreateStoryDialog.tsx
Normal file
234
components/features/stories/CreateStoryDialog.tsx
Normal file
@@ -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<typeof createStorySchema>) => {
|
||||||
|
createMutation.mutate(data, {
|
||||||
|
onSuccess: () => {
|
||||||
|
form.reset();
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Story</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{/* Epic Selection */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="epicId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Epic</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
disabled={epicsLoading}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select an epic..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{epicsLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : epics && epics.length > 0 ? (
|
||||||
|
epics.map((epic) => (
|
||||||
|
<SelectItem key={epic.id} value={epic.id}>
|
||||||
|
{epic.name}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="p-2 text-sm text-muted-foreground">
|
||||||
|
No epics available. Create an epic first.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Title</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Story title..." {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Describe the story..."
|
||||||
|
rows={4}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Priority and Estimated Hours */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="priority"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Priority</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Low">Low</SelectItem>
|
||||||
|
<SelectItem value="Medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="High">High</SelectItem>
|
||||||
|
<SelectItem value="Critical">Critical</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="estimatedHours"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Estimated Hours</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.5"
|
||||||
|
placeholder="0"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={createMutation.isPending}>
|
||||||
|
{createMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Create Story'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,8 +15,10 @@ import type {
|
|||||||
// ==================== Epics API ====================
|
// ==================== Epics API ====================
|
||||||
export const epicsApi = {
|
export const epicsApi = {
|
||||||
list: async (projectId?: string): Promise<Epic[]> => {
|
list: async (projectId?: string): Promise<Epic[]> => {
|
||||||
const params = projectId ? { projectId } : undefined;
|
if (!projectId) {
|
||||||
return api.get('/api/v1/epics', { params });
|
throw new Error('projectId is required for listing epics');
|
||||||
|
}
|
||||||
|
return api.get(`/api/v1/projects/${projectId}/epics`);
|
||||||
},
|
},
|
||||||
|
|
||||||
get: async (id: string): Promise<Epic> => {
|
get: async (id: string): Promise<Epic> => {
|
||||||
@@ -47,8 +49,10 @@ export const epicsApi = {
|
|||||||
// ==================== Stories API ====================
|
// ==================== Stories API ====================
|
||||||
export const storiesApi = {
|
export const storiesApi = {
|
||||||
list: async (epicId?: string): Promise<Story[]> => {
|
list: async (epicId?: string): Promise<Story[]> => {
|
||||||
const params = epicId ? { epicId } : undefined;
|
if (!epicId) {
|
||||||
return api.get('/api/v1/stories', { params });
|
throw new Error('epicId is required for listing stories');
|
||||||
|
}
|
||||||
|
return api.get(`/api/v1/epics/${epicId}/stories`);
|
||||||
},
|
},
|
||||||
|
|
||||||
get: async (id: string): Promise<Story> => {
|
get: async (id: string): Promise<Story> => {
|
||||||
@@ -79,8 +83,10 @@ export const storiesApi = {
|
|||||||
// ==================== Tasks API ====================
|
// ==================== Tasks API ====================
|
||||||
export const tasksApi = {
|
export const tasksApi = {
|
||||||
list: async (storyId?: string): Promise<Task[]> => {
|
list: async (storyId?: string): Promise<Task[]> => {
|
||||||
const params = storyId ? { storyId } : undefined;
|
if (!storyId) {
|
||||||
return api.get('/api/v1/tasks', { params });
|
throw new Error('storyId is required for listing tasks');
|
||||||
|
}
|
||||||
|
return api.get(`/api/v1/stories/${storyId}/tasks`);
|
||||||
},
|
},
|
||||||
|
|
||||||
get: async (id: string): Promise<Task> => {
|
get: async (id: string): Promise<Task> => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { storiesApi } from '@/lib/api/pm';
|
import { storiesApi } from '@/lib/api/pm';
|
||||||
|
import { epicsApi } from '@/lib/api/pm';
|
||||||
import type { Story, CreateStoryDto, UpdateStoryDto, WorkItemStatus } from '@/types/project';
|
import type { Story, CreateStoryDto, UpdateStoryDto, WorkItemStatus } from '@/types/project';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -23,6 +24,42 @@ export function useStories(epicId?: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch all stories for a project (by fetching epics first, then all stories)
|
||||||
|
export function useProjectStories(projectId?: string) {
|
||||||
|
return useQuery<Story[]>({
|
||||||
|
queryKey: ['project-stories', projectId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!projectId) {
|
||||||
|
throw new Error('projectId is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[useProjectStories] Fetching all stories for project...', { projectId });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First fetch all epics for the project
|
||||||
|
const epics = await epicsApi.list(projectId);
|
||||||
|
console.log('[useProjectStories] Epics fetched:', epics.length);
|
||||||
|
|
||||||
|
// Then fetch stories for each epic
|
||||||
|
const storiesPromises = epics.map((epic) => storiesApi.list(epic.id));
|
||||||
|
const storiesArrays = await Promise.all(storiesPromises);
|
||||||
|
|
||||||
|
// Flatten the array of arrays into a single array
|
||||||
|
const allStories = storiesArrays.flat();
|
||||||
|
console.log('[useProjectStories] Total stories fetched:', allStories.length);
|
||||||
|
|
||||||
|
return allStories;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useProjectStories] Fetch failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!projectId,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useStory(id: string) {
|
export function useStory(id: string) {
|
||||||
return useQuery<Story>({
|
return useQuery<Story>({
|
||||||
queryKey: ['stories', id],
|
queryKey: ['stories', id],
|
||||||
|
|||||||
Reference in New Issue
Block a user