feat(frontend): Implement Epic detail page with Story management

Add comprehensive Epic detail page at /epics/[id] with full CRUD operations.

Changes:
- Created Epic detail page with breadcrumb navigation
- Display Epic details: name, description, status, priority, time estimates
- Show list of Stories belonging to the Epic with card view
- Add Edit Epic functionality (opens dialog with form)
- Add Create/Edit/Delete Story functionality under Epic
- Fix Epic type inconsistency (name vs title) across components
- Update Kanban page to map Epic.name to title for unified interface
- Update epic-form to use 'name' field and add createdBy support
- Update work-item-breadcrumb to use Epic.name instead of title

Technical improvements:
- Use Shadcn UI components for consistent design
- Implement optimistic updates with React Query
- Add loading and error states with skeletons
- Follow Next.js App Router patterns with async params
- Add delete confirmation dialogs for Epic and Stories

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-05 14:56:29 +01:00
parent 04ba00d108
commit 2a0394b5ab
4 changed files with 552 additions and 10 deletions

View File

@@ -0,0 +1,533 @@
'use client';
import { use, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
ArrowLeft,
Plus,
Edit,
Trash2,
Loader2,
Clock,
Calendar,
User,
ListTodo,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useEpic, useDeleteEpic } from '@/lib/hooks/use-epics';
import { useStories, useDeleteStory } from '@/lib/hooks/use-stories';
import { useProject } from '@/lib/hooks/use-projects';
import { EpicForm } from '@/components/epics/epic-form';
import { StoryForm } from '@/components/projects/story-form';
import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
import type { Story, WorkItemStatus, WorkItemPriority } from '@/types/project';
interface EpicDetailPageProps {
params: Promise<{ id: string }>;
}
export default function EpicDetailPage({ params }: EpicDetailPageProps) {
const { id: epicId } = use(params);
const router = useRouter();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isCreateStoryDialogOpen, setIsCreateStoryDialogOpen] = useState(false);
const [editingStory, setEditingStory] = useState<Story | null>(null);
const [deletingStoryId, setDeletingStoryId] = useState<string | null>(null);
const [isDeleteEpicDialogOpen, setIsDeleteEpicDialogOpen] = useState(false);
const { data: epic, isLoading: epicLoading, error: epicError } = useEpic(epicId);
const { data: stories, isLoading: storiesLoading } = useStories(epicId);
const { data: project, isLoading: projectLoading } = useProject(epic?.projectId || '');
const deleteEpic = useDeleteEpic();
const deleteStory = useDeleteStory();
const handleDeleteEpic = async () => {
try {
await deleteEpic.mutateAsync(epicId);
toast.success('Epic deleted successfully');
router.push(`/projects/${epic?.projectId}/epics`);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete epic';
toast.error(message);
}
};
const handleDeleteStory = async () => {
if (!deletingStoryId) return;
try {
await deleteStory.mutateAsync(deletingStoryId);
setDeletingStoryId(null);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete story';
toast.error(message);
}
};
const getStatusColor = (status: WorkItemStatus) => {
switch (status) {
case 'Backlog':
return 'secondary';
case 'Todo':
return 'outline';
case 'InProgress':
return 'default';
case 'Done':
return 'success' as any;
default:
return 'secondary';
}
};
const getPriorityColor = (priority: WorkItemPriority) => {
switch (priority) {
case 'Low':
return 'bg-blue-100 text-blue-700 hover:bg-blue-100';
case 'Medium':
return 'bg-yellow-100 text-yellow-700 hover:bg-yellow-100';
case 'High':
return 'bg-orange-100 text-orange-700 hover:bg-orange-100';
case 'Critical':
return 'bg-red-100 text-red-700 hover:bg-red-100';
default:
return 'secondary';
}
};
if (epicLoading || projectLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-96" />
<div className="flex items-start justify-between">
<div className="space-y-4 flex-1">
<Skeleton className="h-12 w-1/2" />
<Skeleton className="h-20 w-full" />
</div>
<Skeleton className="h-10 w-32" />
</div>
<Skeleton className="h-64 w-full" />
</div>
);
}
if (epicError || !epic) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-destructive">Error Loading Epic</CardTitle>
<CardDescription>
{epicError instanceof Error ? epicError.message : 'Epic not found'}
</CardDescription>
</CardHeader>
<CardContent className="flex gap-2">
<Button onClick={() => router.back()}>Go Back</Button>
<Button onClick={() => window.location.reload()} variant="outline">
Retry
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/projects" className="hover:text-foreground">
Projects
</Link>
<span>/</span>
{project && (
<>
<Link href={`/projects/${project.id}`} className="hover:text-foreground">
{project.name}
</Link>
<span>/</span>
</>
)}
<Link href={`/projects/${epic.projectId}/epics`} className="hover:text-foreground">
Epics
</Link>
<span>/</span>
<span className="text-foreground">{epic.name}</span>
</div>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="space-y-2 flex-1">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex-1">
<h1 className="text-3xl font-bold tracking-tight">{epic.name}</h1>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<Badge variant={getStatusColor(epic.status)}>{epic.status}</Badge>
<Badge className={getPriorityColor(epic.priority)}>{epic.priority}</Badge>
</div>
</div>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setIsEditDialogOpen(true)}>
<Edit className="mr-2 h-4 w-4" />
Edit Epic
</Button>
<Button
variant="destructive"
onClick={() => setIsDeleteEpicDialogOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</div>
</div>
{/* Epic Details Card */}
<Card>
<CardHeader>
<CardTitle>Epic Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{epic.description ? (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
Description
</h3>
<p className="text-sm whitespace-pre-wrap">{epic.description}</p>
</div>
) : (
<p className="text-sm text-muted-foreground italic">No description</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t">
{epic.estimatedHours !== undefined && (
<div className="flex items-start gap-2">
<Clock className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium">Time Estimate</p>
<p className="text-sm text-muted-foreground">
Estimated: {epic.estimatedHours}h
{epic.actualHours !== undefined && (
<> / Actual: {epic.actualHours}h</>
)}
</p>
</div>
</div>
)}
{epic.assigneeId && (
<div className="flex items-start gap-2">
<User className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium">Assignee</p>
<p className="text-sm text-muted-foreground">{epic.assigneeId}</p>
</div>
</div>
)}
<div className="flex items-start gap-2">
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium">Created</p>
<p className="text-sm text-muted-foreground">
{formatDistanceToNow(new Date(epic.createdAt), { addSuffix: true })}
{epic.createdBy && <> by {epic.createdBy}</>}
</p>
</div>
</div>
<div className="flex items-start gap-2">
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium">Last Updated</p>
<p className="text-sm text-muted-foreground">
{formatDistanceToNow(new Date(epic.updatedAt), { addSuffix: true })}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Stories Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold tracking-tight">Stories</h2>
<Button onClick={() => setIsCreateStoryDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New Story
</Button>
</div>
{storiesLoading ? (
<div className="grid gap-4 md:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-1/2 mt-2" />
</CardHeader>
<CardContent>
<Skeleton className="h-16 w-full" />
</CardContent>
</Card>
))}
</div>
) : stories && stories.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2">
{stories.map((story) => (
<Card
key={story.id}
className="group transition-all hover:shadow-lg hover:border-primary"
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="space-y-2 flex-1">
<Link
href={`/stories/${story.id}`}
className="block hover:underline"
>
<CardTitle className="line-clamp-2 text-lg">
{story.title}
</CardTitle>
</Link>
<div className="flex items-center gap-2 flex-wrap">
<Badge variant={getStatusColor(story.status)}>
{story.status}
</Badge>
<Badge className={getPriorityColor(story.priority)}>
{story.priority}
</Badge>
</div>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.preventDefault();
setEditingStory(story);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => {
e.preventDefault();
setDeletingStoryId(story.id);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{story.description ? (
<p className="text-sm text-muted-foreground line-clamp-3">
{story.description}
</p>
) : (
<p className="text-sm text-muted-foreground italic">
No description
</p>
)}
<div className="space-y-2 text-xs text-muted-foreground">
{story.estimatedHours && (
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>Estimated: {story.estimatedHours}h</span>
{story.actualHours && (
<span className="ml-2">
/ Actual: {story.actualHours}h
</span>
)}
</div>
)}
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>
Created{' '}
{formatDistanceToNow(new Date(story.createdAt), {
addSuffix: true,
})}
</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card className="flex flex-col items-center justify-center py-16">
<ListTodo className="h-12 w-12 text-muted-foreground mb-4" />
<CardTitle className="mb-2">No stories yet</CardTitle>
<CardDescription className="mb-4 text-center max-w-md">
Get started by creating your first story to break down this epic
</CardDescription>
<Button onClick={() => setIsCreateStoryDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Story
</Button>
</Card>
)}
</div>
{/* Edit Epic Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Epic</DialogTitle>
<DialogDescription>Update the epic details</DialogDescription>
</DialogHeader>
<EpicForm
projectId={epic.projectId}
epic={epic}
onSuccess={() => setIsEditDialogOpen(false)}
onCancel={() => setIsEditDialogOpen(false)}
/>
</DialogContent>
</Dialog>
{/* Create Story Dialog */}
<Dialog
open={isCreateStoryDialogOpen}
onOpenChange={setIsCreateStoryDialogOpen}
>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create New Story</DialogTitle>
<DialogDescription>
Add a new story under {epic.name}
</DialogDescription>
</DialogHeader>
<StoryForm
epicId={epicId}
projectId={epic.projectId}
onSuccess={() => setIsCreateStoryDialogOpen(false)}
onCancel={() => setIsCreateStoryDialogOpen(false)}
/>
</DialogContent>
</Dialog>
{/* Edit Story Dialog */}
<Dialog open={!!editingStory} onOpenChange={() => setEditingStory(null)}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Story</DialogTitle>
<DialogDescription>Update the story details</DialogDescription>
</DialogHeader>
{editingStory && (
<StoryForm
story={editingStory}
projectId={epic.projectId}
onSuccess={() => setEditingStory(null)}
onCancel={() => setEditingStory(null)}
/>
)}
</DialogContent>
</Dialog>
{/* Delete Epic Confirmation Dialog */}
<AlertDialog
open={isDeleteEpicDialogOpen}
onOpenChange={setIsDeleteEpicDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the epic
and all its associated stories and tasks.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteEpic}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteEpic.isPending}
>
{deleteEpic.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
'Delete Epic'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete Story Confirmation Dialog */}
<AlertDialog
open={!!deletingStoryId}
onOpenChange={() => setDeletingStoryId(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the story
and all its associated tasks.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteStory}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteStory.isPending}
>
{deleteStory.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
'Delete Story'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -125,6 +125,7 @@ export default function KanbanPage() {
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) => ({