feat(frontend): Implement Epic/Story/Task Management UI (Story 2)
Complete implementation of Sprint 1 Story 2 with full CRUD operations for Epic/Story/Task entities including forms, hierarchy visualization, and breadcrumb navigation. Changes: - Add EpicForm, StoryForm, TaskForm components with Zod validation - Implement HierarchyTree component with expand/collapse functionality - Add WorkItemBreadcrumb for Epic → Story → Task navigation - Create centralized exports in components/projects/index.ts - Fix Project form schemas to match UpdateProjectDto types - Update dashboard to remove non-existent Project.status field API Client & Hooks (already completed): - epicsApi, storiesApi, tasksApi with full CRUD operations - React Query hooks with optimistic updates and invalidation - Error handling and JWT authentication integration Technical Implementation: - TypeScript type safety throughout - Zod schema validation for all forms - React Query optimistic updates - Hierarchical data loading (lazy loading on expand) - Responsive UI with Tailwind CSS - Loading states and error handling Story Points: 8 SP Estimated Hours: 16h Status: Completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
334
components/projects/hierarchy-tree.tsx
Normal file
334
components/projects/hierarchy-tree.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ChevronRight, ChevronDown, Folder, FileText, CheckSquare } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useEpics } from '@/lib/hooks/use-epics';
|
||||
import { useStories } from '@/lib/hooks/use-stories';
|
||||
import { useTasks } from '@/lib/hooks/use-tasks';
|
||||
import type { Epic, Story, Task, WorkItemStatus, WorkItemPriority } from '@/types/project';
|
||||
|
||||
interface HierarchyTreeProps {
|
||||
projectId: string;
|
||||
onEpicClick?: (epic: Epic) => void;
|
||||
onStoryClick?: (story: Story) => void;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
}
|
||||
|
||||
export function HierarchyTree({
|
||||
projectId,
|
||||
onEpicClick,
|
||||
onStoryClick,
|
||||
onTaskClick,
|
||||
}: HierarchyTreeProps) {
|
||||
const { data: epics = [], isLoading: epicsLoading } = useEpics(projectId);
|
||||
|
||||
if (epicsLoading) {
|
||||
return <HierarchyTreeSkeleton />;
|
||||
}
|
||||
|
||||
if (epics.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<Folder className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No Epics Found</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create your first epic to start organizing work
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{epics.map((epic) => (
|
||||
<EpicNode
|
||||
key={epic.id}
|
||||
epic={epic}
|
||||
onEpicClick={onEpicClick}
|
||||
onStoryClick={onStoryClick}
|
||||
onTaskClick={onTaskClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EpicNodeProps {
|
||||
epic: Epic;
|
||||
onEpicClick?: (epic: Epic) => void;
|
||||
onStoryClick?: (story: Story) => void;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
}
|
||||
|
||||
function EpicNode({ epic, onEpicClick, onStoryClick, onTaskClick }: EpicNodeProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { data: stories = [], isLoading: storiesLoading } = useStories(
|
||||
isExpanded ? epic.id : undefined
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg">
|
||||
<div
|
||||
className="flex items-center gap-2 p-3 hover:bg-accent cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Folder className="h-5 w-5 text-blue-500" />
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="font-semibold hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEpicClick?.(epic);
|
||||
}}
|
||||
>
|
||||
{epic.title}
|
||||
</span>
|
||||
<StatusBadge status={epic.status} />
|
||||
<PriorityBadge priority={epic.priority} />
|
||||
</div>
|
||||
{epic.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-1">
|
||||
{epic.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{epic.estimatedHours && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{epic.estimatedHours}h
|
||||
{epic.actualHours && ` / ${epic.actualHours}h`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="pl-8 pr-3 pb-3 space-y-2">
|
||||
{storiesLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : stories.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground border-l-2 border-muted">
|
||||
No stories in this epic
|
||||
</div>
|
||||
) : (
|
||||
stories.map((story) => (
|
||||
<StoryNode
|
||||
key={story.id}
|
||||
story={story}
|
||||
onStoryClick={onStoryClick}
|
||||
onTaskClick={onTaskClick}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StoryNodeProps {
|
||||
story: Story;
|
||||
onStoryClick?: (story: Story) => void;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
}
|
||||
|
||||
function StoryNode({ story, onStoryClick, onTaskClick }: StoryNodeProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { data: tasks = [], isLoading: tasksLoading } = useTasks(
|
||||
isExpanded ? story.id : undefined
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-l-2 border-muted pl-3">
|
||||
<div
|
||||
className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<FileText className="h-4 w-4 text-green-500" />
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="font-medium hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStoryClick?.(story);
|
||||
}}
|
||||
>
|
||||
{story.title}
|
||||
</span>
|
||||
<StatusBadge status={story.status} size="sm" />
|
||||
<PriorityBadge priority={story.priority} size="sm" />
|
||||
</div>
|
||||
{story.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">
|
||||
{story.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{story.estimatedHours && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{story.estimatedHours}h
|
||||
{story.actualHours && ` / ${story.actualHours}h`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="ml-6 mt-2 space-y-1">
|
||||
{tasksLoading ? (
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="p-3 text-center text-xs text-muted-foreground border-l-2 border-muted">
|
||||
No tasks in this story
|
||||
</div>
|
||||
) : (
|
||||
tasks.map((task) => <TaskNode key={task.id} task={task} onTaskClick={onTaskClick} />)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TaskNodeProps {
|
||||
task: Task;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
}
|
||||
|
||||
function TaskNode({ task, onTaskClick }: TaskNodeProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer border-l-2 border-muted pl-3"
|
||||
onClick={() => onTaskClick?.(task)}
|
||||
>
|
||||
<CheckSquare className="h-4 w-4 text-purple-500" />
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium hover:underline">{task.title}</span>
|
||||
<StatusBadge status={task.status} size="xs" />
|
||||
<PriorityBadge priority={task.priority} size="xs" />
|
||||
</div>
|
||||
{task.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">{task.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.estimatedHours && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{task.estimatedHours}h
|
||||
{task.actualHours && ` / ${task.actualHours}h`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: WorkItemStatus;
|
||||
size?: 'default' | 'sm' | 'xs';
|
||||
}
|
||||
|
||||
function StatusBadge({ status, size = 'default' }: StatusBadgeProps) {
|
||||
const variants: Record<WorkItemStatus, 'default' | 'secondary' | 'outline' | 'destructive'> = {
|
||||
Backlog: 'secondary',
|
||||
Todo: 'outline',
|
||||
InProgress: 'default',
|
||||
Done: 'outline',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
default: 'text-xs',
|
||||
sm: 'text-xs px-1.5 py-0',
|
||||
xs: 'text-[10px] px-1 py-0',
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status]} className={sizeClasses[size]}>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
interface PriorityBadgeProps {
|
||||
priority: WorkItemPriority;
|
||||
size?: 'default' | 'sm' | 'xs';
|
||||
}
|
||||
|
||||
function PriorityBadge({ priority, size = 'default' }: PriorityBadgeProps) {
|
||||
const colors: Record<WorkItemPriority, string> = {
|
||||
Low: 'bg-gray-100 text-gray-700',
|
||||
Medium: 'bg-blue-100 text-blue-700',
|
||||
High: 'bg-orange-100 text-orange-700',
|
||||
Critical: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
default: 'text-xs',
|
||||
sm: 'text-xs px-1.5 py-0',
|
||||
xs: 'text-[10px] px-1 py-0',
|
||||
};
|
||||
|
||||
return <Badge className={`${colors[priority]} ${sizeClasses[size]}`}>{priority}</Badge>;
|
||||
}
|
||||
|
||||
function HierarchyTreeSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="border rounded-lg p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-6 w-6" />
|
||||
<Skeleton className="h-5 w-5" />
|
||||
<Skeleton className="h-6 flex-1" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user