diff --git a/app/(dashboard)/stories/[id]/error.tsx b/app/(dashboard)/stories/[id]/error.tsx new file mode 100644 index 0000000..198453d --- /dev/null +++ b/app/(dashboard)/stories/[id]/error.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { AlertCircle } from 'lucide-react'; + +export default function StoryDetailError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + // Log the error to an error reporting service + console.error('Story detail page error:', error); + }, [error]); + + return ( +
+ + +
+ + Error Loading Story +
+ + {error.message || 'An unexpected error occurred while loading the story.'} + +
+ + + + +
+
+ ); +} diff --git a/app/(dashboard)/stories/[id]/loading.tsx b/app/(dashboard)/stories/[id]/loading.tsx new file mode 100644 index 0000000..f2e6f5e --- /dev/null +++ b/app/(dashboard)/stories/[id]/loading.tsx @@ -0,0 +1,66 @@ +import { Skeleton } from '@/components/ui/skeleton'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; + +export default function StoryDetailLoading() { + return ( +
+ {/* Breadcrumb Skeleton */} + + + {/* Header Skeleton */} +
+
+ +
+ + +
+
+
+ + +
+
+ + {/* Two-column layout Skeleton */} +
+ {/* Main Content */} +
+ + + + + + + + + + + + + + + + + + + +
+ + {/* Sidebar */} +
+ {Array.from({ length: 5 }).map((_, i) => ( + + + + + + + + + ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/stories/[id]/page.tsx b/app/(dashboard)/stories/[id]/page.tsx new file mode 100644 index 0000000..54c1f77 --- /dev/null +++ b/app/(dashboard)/stories/[id]/page.tsx @@ -0,0 +1,478 @@ +'use client'; + +import { use, useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { + ArrowLeft, + Edit, + Trash2, + Loader2, + Clock, + Calendar, + User, + Layers, +} 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useStory, useUpdateStory, useDeleteStory, useChangeStoryStatus } from '@/lib/hooks/use-stories'; +import { useEpic } from '@/lib/hooks/use-epics'; +import { useProject } from '@/lib/hooks/use-projects'; +import { StoryForm } from '@/components/projects/story-form'; +import { formatDistanceToNow } from 'date-fns'; +import { toast } from 'sonner'; +import type { WorkItemStatus, WorkItemPriority } from '@/types/project'; + +interface StoryDetailPageProps { + params: Promise<{ id: string }>; +} + +export default function StoryDetailPage({ params }: StoryDetailPageProps) { + const { id: storyId } = use(params); + const router = useRouter(); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + const { data: story, isLoading: storyLoading, error: storyError } = useStory(storyId); + const { data: epic, isLoading: epicLoading } = useEpic(story?.epicId || ''); + const { data: project, isLoading: projectLoading } = useProject(story?.projectId || ''); + const updateStory = useUpdateStory(); + const deleteStory = useDeleteStory(); + const changeStatus = useChangeStoryStatus(); + + const handleDeleteStory = async () => { + try { + await deleteStory.mutateAsync(storyId); + toast.success('Story deleted successfully'); + // Navigate back to epic detail page + router.push(`/epics/${story?.epicId}`); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to delete story'; + toast.error(message); + } + }; + + const handleStatusChange = async (status: WorkItemStatus) => { + if (!story) return; + try { + await changeStatus.mutateAsync({ id: storyId, status }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update status'; + toast.error(message); + } + }; + + const handlePriorityChange = async (priority: WorkItemPriority) => { + if (!story) return; + try { + await updateStory.mutateAsync({ + id: storyId, + data: { priority }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update priority'; + 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'; + } + }; + + // Loading state + if (storyLoading || epicLoading || projectLoading) { + return ( +
+ +
+
+ + +
+ +
+ +
+ ); + } + + // Error state + if (storyError || !story) { + return ( +
+ + + Error Loading Story + + {storyError instanceof Error ? storyError.message : 'Story not found'} + + + + + + + +
+ ); + } + + return ( +
+ {/* Breadcrumb Navigation */} +
+ + Projects + + / + {project && ( + <> + + {project.name} + + / + + )} + + Epics + + / + {epic && ( + <> + + {epic.name} + + / + + )} + Stories + / + + {story.title} + +
+ + {/* Header */} +
+
+
+ +
+

{story.title}

+
+ {story.status} + {story.priority} +
+
+
+
+
+ + +
+
+ + {/* Two-column layout */} +
+ {/* Main Content Area (2/3 width) */} +
+ {/* Story Details Card */} + + + Story Details + + + {story.description ? ( +
+

+ Description +

+

{story.description}

+
+ ) : ( +

No description

+ )} +
+
+ + {/* Tasks Section - Placeholder for Story 2 */} + + +
+ Tasks + +
+
+ +

+ Task management will be available in the next update. +

+
+
+
+ + {/* Metadata Sidebar (1/3 width) */} +
+ {/* Status */} + + + Status + + + + + + + {/* Priority */} + + + Priority + + + + + + + {/* Assignee */} + {story.assigneeId && ( + + + Assignee + + +
+ + {story.assigneeId} +
+
+
+ )} + + {/* Time Tracking */} + {(story.estimatedHours !== undefined || story.actualHours !== undefined) && ( + + + Time Tracking + + + {story.estimatedHours !== undefined && ( +
+ + Estimated: {story.estimatedHours}h +
+ )} + {story.actualHours !== undefined && ( +
+ + Actual: {story.actualHours}h +
+ )} +
+
+ )} + + {/* Dates */} + + + Dates + + +
+ +
+

Created

+

+ {formatDistanceToNow(new Date(story.createdAt), { addSuffix: true })} +

+
+
+
+ +
+

Updated

+

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

+
+
+
+
+ + {/* Parent Epic Card */} + {epic && ( + + + Parent Epic + + + +
+ + {epic.name} +
+
+ + {epic.status} + + + {epic.priority} + +
+ +
+
+ )} +
+
+ + {/* Edit Story Dialog */} + + + + Edit Story + Update the story details + + setIsEditDialogOpen(false)} + onCancel={() => setIsEditDialogOpen(false)} + /> + + + + {/* Delete Story Confirmation Dialog */} + + + + 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/lib/api/pm.ts b/lib/api/pm.ts index 65c780b..3a02796 100644 --- a/lib/api/pm.ts +++ b/lib/api/pm.ts @@ -28,7 +28,7 @@ export const epicsApi = { create: async (data: CreateEpicDto): Promise => { console.log('[epicsApi.create] Sending request', { url: '/api/v1/epics', data }); try { - const result = await api.post('/api/v1/epics', data); + const result = await api.post('/api/v1/epics', data); console.log('[epicsApi.create] Request successful', result); return result; } catch (error) {