feat(frontend): Implement Epic management page with full CRUD operations

Add comprehensive Epic management functionality at /projects/{projectId}/epics route.

Changes:
- Created EpicForm component with validation (title, description, priority, estimated hours)
- Implemented Epics list page with Create/Edit/Delete operations
- Added breadcrumb navigation (Projects > Project Name > Epics)
- Included loading states with Skeletons
- Added error handling and user feedback with toast notifications
- Implemented responsive grid layout (mobile/tablet/desktop)
- Added hover effects and inline edit/delete actions
- Integrated with existing hooks (useEpics, useCreateEpic, useUpdateEpic, useDeleteEpic)
- Used shadcn/ui components (Card, Dialog, AlertDialog, Badge, Select)
- Added status and priority color coding
- Displayed estimated/actual hours and creation time
- Implemented empty state for projects with no epics

Technical details:
- Used react-hook-form with zod validation
- Implemented optimistic UI updates
- Followed existing patterns from Projects page
- Full TypeScript type safety

🤖 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 13:11:35 +01:00
parent 313989cb9e
commit 71895f328d
2 changed files with 587 additions and 0 deletions

View File

@@ -0,0 +1,366 @@
'use client';
import { use, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
ArrowLeft,
Plus,
Edit,
Trash2,
Loader2,
ListTodo,
Calendar,
Clock,
} 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 { useProject } from '@/lib/hooks/use-projects';
import { useEpics, useDeleteEpic } from '@/lib/hooks/use-epics';
import { EpicForm } from '@/components/epics/epic-form';
import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
import type { Epic, WorkItemStatus, WorkItemPriority } from '@/types/project';
interface EpicsPageProps {
params: Promise<{ id: string }>;
}
export default function EpicsPage({ params }: EpicsPageProps) {
const { id: projectId } = use(params);
const router = useRouter();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingEpic, setEditingEpic] = useState<Epic | null>(null);
const [deletingEpicId, setDeletingEpicId] = useState<string | null>(null);
const { data: project, isLoading: projectLoading } = useProject(projectId);
const { data: epics, isLoading: epicsLoading, error } = useEpics(projectId);
const deleteEpic = useDeleteEpic();
const handleDelete = async () => {
if (!deletingEpicId) return;
try {
await deleteEpic.mutateAsync(deletingEpicId);
setDeletingEpicId(null);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete epic';
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 (projectLoading || epicsLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-32" />
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-5 w-64" />
</div>
<Skeleton className="h-10 w-32" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).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>
</div>
);
}
if (error || !project) {
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 Epics</CardTitle>
<CardDescription>
{error instanceof Error ? error.message : 'Failed to load epics'}
</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>
<Link href={`/projects/${projectId}`} className="hover:text-foreground">
{project.name}
</Link>
<span>/</span>
<span className="text-foreground">Epics</span>
</div>
{/* Header */}
<div className="flex items-start justify-between">
<div className="space-y-2">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link href={`/projects/${projectId}`}>
<ArrowLeft className="h-5 w-5" />
</Link>
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight">Epics</h1>
<p className="text-muted-foreground mt-1">
Manage epics for {project.name}
</p>
</div>
</div>
</div>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New Epic
</Button>
</div>
{/* Epics Grid */}
{epics && epics.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{epics.map((epic) => (
<Card
key={epic.id}
className="group transition-all hover:shadow-lg hover:border-primary relative"
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="space-y-2 flex-1">
<Link
href={`/epics/${epic.id}`}
className="block hover:underline"
>
<CardTitle className="line-clamp-2 text-lg">
{epic.title}
</CardTitle>
</Link>
<div className="flex items-center gap-2 flex-wrap">
<Badge variant={getStatusColor(epic.status)}>
{epic.status}
</Badge>
<Badge className={getPriorityColor(epic.priority)}>
{epic.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();
setEditingEpic(epic);
}}
>
<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();
setDeletingEpicId(epic.id);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{epic.description ? (
<p className="text-sm text-muted-foreground line-clamp-3">
{epic.description}
</p>
) : (
<p className="text-sm text-muted-foreground italic">
No description
</p>
)}
<div className="space-y-2 text-xs text-muted-foreground">
{epic.estimatedHours && (
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>Estimated: {epic.estimatedHours}h</span>
{epic.actualHours && (
<span className="ml-2">
/ Actual: {epic.actualHours}h
</span>
)}
</div>
)}
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>
Created {formatDistanceToNow(new Date(epic.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 epics yet</CardTitle>
<CardDescription className="mb-4 text-center max-w-md">
Get started by creating your first epic to organize major features and initiatives
</CardDescription>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Epic
</Button>
</Card>
)}
{/* Create Epic Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create New Epic</DialogTitle>
<DialogDescription>
Add a new epic to organize major features and initiatives
</DialogDescription>
</DialogHeader>
<EpicForm
projectId={projectId}
onSuccess={() => setIsCreateDialogOpen(false)}
onCancel={() => setIsCreateDialogOpen(false)}
/>
</DialogContent>
</Dialog>
{/* Edit Epic Dialog */}
<Dialog open={!!editingEpic} onOpenChange={() => setEditingEpic(null)}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Epic</DialogTitle>
<DialogDescription>
Update the epic details
</DialogDescription>
</DialogHeader>
{editingEpic && (
<EpicForm
projectId={projectId}
epic={editingEpic}
onSuccess={() => setEditingEpic(null)}
onCancel={() => setEditingEpic(null)}
/>
)}
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog
open={!!deletingEpicId}
onOpenChange={() => setDeletingEpicId(null)}
>
<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={handleDelete}
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>
</div>
);
}