diff --git a/app/(dashboard)/epics/[id]/page.tsx b/app/(dashboard)/epics/[id]/page.tsx new file mode 100644 index 0000000..3de19b3 --- /dev/null +++ b/app/(dashboard)/epics/[id]/page.tsx @@ -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(null); + const [deletingStoryId, setDeletingStoryId] = useState(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 ( +
+ +
+
+ + +
+ +
+ +
+ ); + } + + if (epicError || !epic) { + return ( +
+ + + Error Loading Epic + + {epicError instanceof Error ? epicError.message : 'Epic not found'} + + + + + + + +
+ ); + } + + return ( +
+ {/* Breadcrumb */} +
+ + Projects + + / + {project && ( + <> + + {project.name} + + / + + )} + + Epics + + / + {epic.name} +
+ + {/* Header */} +
+
+
+ +
+

{epic.name}

+
+ {epic.status} + {epic.priority} +
+
+
+
+
+ + +
+
+ + {/* Epic Details Card */} + + + Epic Details + + + {epic.description ? ( +
+

+ Description +

+

{epic.description}

+
+ ) : ( +

No description

+ )} + +
+ {epic.estimatedHours !== undefined && ( +
+ +
+

Time Estimate

+

+ Estimated: {epic.estimatedHours}h + {epic.actualHours !== undefined && ( + <> / Actual: {epic.actualHours}h + )} +

+
+
+ )} + + {epic.assigneeId && ( +
+ +
+

Assignee

+

{epic.assigneeId}

+
+
+ )} + +
+ +
+

Created

+

+ {formatDistanceToNow(new Date(epic.createdAt), { addSuffix: true })} + {epic.createdBy && <> by {epic.createdBy}} +

+
+
+ +
+ +
+

Last Updated

+

+ {formatDistanceToNow(new Date(epic.updatedAt), { addSuffix: true })} +

+
+
+
+
+
+ + {/* Stories Section */} +
+
+

Stories

+ +
+ + {storiesLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + + ))} +
+ ) : stories && stories.length > 0 ? ( +
+ {stories.map((story) => ( + + +
+
+ + + {story.title} + + +
+ + {story.status} + + + {story.priority} + +
+
+
+ + +
+
+
+ + {story.description ? ( +

+ {story.description} +

+ ) : ( +

+ No description +

+ )} +
+ {story.estimatedHours && ( +
+ + Estimated: {story.estimatedHours}h + {story.actualHours && ( + + / Actual: {story.actualHours}h + + )} +
+ )} +
+ + + Created{' '} + {formatDistanceToNow(new Date(story.createdAt), { + addSuffix: true, + })} + +
+
+
+
+ ))} +
+ ) : ( + + + No stories yet + + Get started by creating your first story to break down this epic + + + + )} +
+ + {/* Edit Epic Dialog */} + + + + Edit Epic + Update the epic details + + setIsEditDialogOpen(false)} + onCancel={() => setIsEditDialogOpen(false)} + /> + + + + {/* Create Story Dialog */} + + + + Create New Story + + Add a new story under {epic.name} + + + setIsCreateStoryDialogOpen(false)} + onCancel={() => setIsCreateStoryDialogOpen(false)} + /> + + + + {/* Edit Story Dialog */} + setEditingStory(null)}> + + + Edit Story + Update the story details + + {editingStory && ( + setEditingStory(null)} + onCancel={() => setEditingStory(null)} + /> + )} + + + + {/* Delete Epic Confirmation Dialog */} + + + + Are you sure? + + This action cannot be undone. This will permanently delete the epic + and all its associated stories and tasks. + + + + Cancel + + {deleteEpic.isPending ? ( + <> + + Deleting... + + ) : ( + 'Delete Epic' + )} + + + + + + {/* Delete Story Confirmation Dialog */} + setDeletingStoryId(null)} + > + + + Are you sure? + + This action cannot be undone. This will permanently delete the story + and all its associated tasks. + + + + Cancel + + {deleteStory.isPending ? ( + <> + + Deleting... + + ) : ( + 'Delete Story' + )} + + + + +
+ ); +} diff --git a/app/(dashboard)/projects/[id]/kanban/page.tsx b/app/(dashboard)/projects/[id]/kanban/page.tsx index 8c0633d..815b7f8 100644 --- a/app/(dashboard)/projects/[id]/kanban/page.tsx +++ b/app/(dashboard)/projects/[id]/kanban/page.tsx @@ -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) => ({ diff --git a/components/projects/epic-form.tsx b/components/projects/epic-form.tsx index b27545a..3ec0536 100644 --- a/components/projects/epic-form.tsx +++ b/components/projects/epic-form.tsx @@ -26,13 +26,14 @@ import { useCreateEpic, useUpdateEpic } from '@/lib/hooks/use-epics'; import type { Epic, WorkItemPriority } from '@/types/project'; import { toast } from 'sonner'; import { Loader2 } from 'lucide-react'; +import { useAuthStore } from '@/stores/authStore'; const epicSchema = z.object({ projectId: z.string().min(1, 'Project is required'), - title: z + name: z .string() - .min(1, 'Title is required') - .max(200, 'Title must be less than 200 characters'), + .min(1, 'Name is required') + .max(200, 'Name must be less than 200 characters'), description: z .string() .max(2000, 'Description must be less than 2000 characters') @@ -58,12 +59,13 @@ export function EpicForm({ epic, projectId, onSuccess, onCancel }: EpicFormProps const isEditing = !!epic; const createEpic = useCreateEpic(); const updateEpic = useUpdateEpic(); + const user = useAuthStore((state) => state.user); const form = useForm({ resolver: zodResolver(epicSchema), defaultValues: { projectId: epic?.projectId || projectId || '', - title: epic?.title || '', + name: epic?.name || '', description: epic?.description || '', priority: epic?.priority || 'Medium', estimatedHours: epic?.estimatedHours || ('' as any), @@ -72,11 +74,16 @@ export function EpicForm({ epic, projectId, onSuccess, onCancel }: EpicFormProps async function onSubmit(data: EpicFormValues) { try { + if (!user?.id) { + toast.error('User not authenticated'); + return; + } + if (isEditing && epic) { await updateEpic.mutateAsync({ id: epic.id, data: { - title: data.title, + name: data.name, description: data.description, priority: data.priority, estimatedHours: @@ -87,11 +94,12 @@ export function EpicForm({ epic, projectId, onSuccess, onCancel }: EpicFormProps } else { await createEpic.mutateAsync({ projectId: data.projectId, - title: data.title, + name: data.name, description: data.description, priority: data.priority, estimatedHours: typeof data.estimatedHours === 'number' ? data.estimatedHours : undefined, + createdBy: user.id, }); toast.success('Epic created successfully'); } @@ -109,14 +117,14 @@ export function EpicForm({ epic, projectId, onSuccess, onCancel }: EpicFormProps
( - Epic Title * + Epic Name * - A clear, concise title for this epic + A clear, concise name for this epic )} diff --git a/components/projects/work-item-breadcrumb.tsx b/components/projects/work-item-breadcrumb.tsx index fa04bbc..4f45d85 100644 --- a/components/projects/work-item-breadcrumb.tsx +++ b/components/projects/work-item-breadcrumb.tsx @@ -76,7 +76,7 @@ export function WorkItemBreadcrumb({ className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors" > - {finalEpic.title} + {finalEpic.name} )}