Fix frontend-backend API field mismatches for Epic entity by: 1. Changed Epic.title to Epic.name in type definitions 2. Added Epic.createdBy field (required by backend) 3. Updated all Epic references from epic.title to epic.name 4. Fixed Epic form to use name field and include createdBy Files modified: - types/project.ts: Updated Epic, CreateEpicDto, UpdateEpicDto interfaces - components/epics/epic-form.tsx: Fixed defaultValues to use epic.name - components/projects/hierarchy-tree.tsx: Replaced epic.title with epic.name - components/projects/story-form.tsx: Fixed epic dropdown to show epic.name - app/(dashboard)/projects/[id]/epics/page.tsx: Display epic.name in list - app/(dashboard)/projects/[id]/page.tsx: Display epic.name in preview - app/(dashboard)/api-test/page.tsx: Display epic.name in test page This resolves the 400 Bad Request error when creating Epics caused by missing 'Name' field (was sending 'title' instead) and missing 'CreatedBy' field. 🤖 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.name}
|
|
</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>
|
|
);
|
|
}
|