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>
335 lines
9.4 KiB
TypeScript
335 lines
9.4 KiB
TypeScript
'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>
|
|
);
|
|
}
|