feat(frontend): Add Sprint 4 new fields to Story Detail page sidebar

Add three new cards to Story Detail sidebar to display Sprint 4 Story 3 fields:
- Story Points card with Target icon
- Tags card with Tag badges
- Acceptance Criteria card with CheckCircle2 icons

🤖 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 23:18:39 +01:00
parent 79f210d0ee
commit f2aa3b03b6
4 changed files with 270 additions and 287 deletions

View File

@@ -1,8 +1,8 @@
'use client';
"use client";
import { use, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { use, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ArrowLeft,
Edit,
@@ -12,24 +12,21 @@ import {
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';
CheckCircle2,
Tag,
Target,
} 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';
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
@@ -39,22 +36,27 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
} 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 { TaskList } from '@/components/tasks/task-list';
import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
import type { WorkItemStatus, WorkItemPriority } from '@/types/project';
} 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 { TaskList } from "@/components/tasks/task-list";
import { formatDistanceToNow } from "date-fns";
import { toast } from "sonner";
import type { WorkItemStatus, WorkItemPriority } from "@/types/project";
interface StoryDetailPageProps {
params: Promise<{ id: string }>;
@@ -67,8 +69,8 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
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 { data: epic, isLoading: epicLoading } = useEpic(story?.epicId || "");
const { data: project, isLoading: projectLoading } = useProject(story?.projectId || "");
const updateStory = useUpdateStory();
const deleteStory = useDeleteStory();
const changeStatus = useChangeStoryStatus();
@@ -76,11 +78,11 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
const handleDeleteStory = async () => {
try {
await deleteStory.mutateAsync(storyId);
toast.success('Story deleted successfully');
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';
const message = error instanceof Error ? error.message : "Failed to delete story";
toast.error(message);
}
};
@@ -90,7 +92,7 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
try {
await changeStatus.mutateAsync({ id: storyId, status });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to update status';
const message = error instanceof Error ? error.message : "Failed to update status";
toast.error(message);
}
};
@@ -103,38 +105,38 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
data: { priority },
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to update priority';
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;
case "Backlog":
return "secondary";
case "Todo":
return "outline";
case "InProgress":
return "default";
case "Done":
return "default";
default:
return 'secondary';
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';
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';
return "secondary";
}
};
@@ -144,7 +146,7 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
<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">
<div className="flex-1 space-y-4">
<Skeleton className="h-12 w-1/2" />
<Skeleton className="h-20 w-full" />
</div>
@@ -158,12 +160,12 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
// Error state
if (storyError || !story) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex min-h-[400px] items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-destructive">Error Loading Story</CardTitle>
<CardDescription>
{storyError instanceof Error ? storyError.message : 'Story not found'}
{storyError instanceof Error ? storyError.message : "Story not found"}
</CardDescription>
</CardHeader>
<CardContent className="flex gap-2">
@@ -180,7 +182,7 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
return (
<div className="space-y-6">
{/* Breadcrumb Navigation */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Link href="/projects" className="hover:text-foreground">
Projects
</Link>
@@ -207,14 +209,14 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
)}
<span className="text-foreground">Stories</span>
<span>/</span>
<span className="text-foreground truncate max-w-[200px]" title={story.title}>
<span className="text-foreground max-w-[200px] truncate" title={story.title}>
{story.title}
</span>
</div>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="space-y-2 flex-1">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-3">
<Button
variant="ghost"
@@ -226,7 +228,7 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
</Button>
<div className="flex-1">
<h1 className="text-3xl font-bold tracking-tight">{story.title}</h1>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<div className="mt-2 flex flex-wrap items-center gap-2">
<Badge variant={getStatusColor(story.status)}>{story.status}</Badge>
<Badge className={getPriorityColor(story.priority)}>{story.priority}</Badge>
</div>
@@ -246,9 +248,9 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
</div>
{/* Two-column layout */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main Content Area (2/3 width) */}
<div className="lg:col-span-2 space-y-6">
<div className="space-y-6 lg:col-span-2">
{/* Story Details Card */}
<Card>
<CardHeader>
@@ -257,13 +259,11 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
<CardContent className="space-y-4">
{story.description ? (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
Description
</h3>
<h3 className="text-muted-foreground mb-2 text-sm font-medium">Description</h3>
<p className="text-sm whitespace-pre-wrap">{story.description}</p>
</div>
) : (
<p className="text-sm text-muted-foreground italic">No description</p>
<p className="text-muted-foreground text-sm italic">No description</p>
)}
</CardContent>
</Card>
@@ -320,6 +320,21 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
</CardContent>
</Card>
{/* Story Points - Sprint 4 Story 3 */}
{story.storyPoints && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Story Points</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Target className="text-muted-foreground h-4 w-4" />
<span className="text-2xl font-semibold">{story.storyPoints}</span>
</div>
</CardContent>
</Card>
)}
{/* Assignee */}
{story.assigneeId && (
<Card>
@@ -328,13 +343,32 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<User className="text-muted-foreground h-4 w-4" />
<span className="text-sm">{story.assigneeId}</span>
</div>
</CardContent>
</Card>
)}
{/* Tags - Sprint 4 Story 3 */}
{story.tags && story.tags.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Tags</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{story.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
<Tag className="mr-1 h-3 w-3" />
{tag}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Time Tracking */}
{(story.estimatedHours !== undefined || story.actualHours !== undefined) && (
<Card>
@@ -344,13 +378,13 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
<CardContent className="space-y-2">
{story.estimatedHours !== undefined && (
<div className="flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
<Clock className="text-muted-foreground h-4 w-4" />
<span>Estimated: {story.estimatedHours}h</span>
</div>
)}
{story.actualHours !== undefined && (
<div className="flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
<Clock className="text-muted-foreground h-4 w-4" />
<span>Actual: {story.actualHours}h</span>
</div>
)}
@@ -358,6 +392,23 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
</Card>
)}
{/* Acceptance Criteria - Sprint 4 Story 3 */}
{story.acceptanceCriteria && story.acceptanceCriteria.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Acceptance Criteria</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{story.acceptanceCriteria.map((criterion, index) => (
<div key={index} className="flex items-start gap-2 text-sm">
<CheckCircle2 className="text-muted-foreground mt-0.5 h-4 w-4 flex-shrink-0" />
<span>{criterion}</span>
</div>
))}
</CardContent>
</Card>
)}
{/* Dates */}
<Card>
<CardHeader className="pb-3">
@@ -365,7 +416,7 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-start gap-2 text-sm">
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
<Calendar className="text-muted-foreground mt-0.5 h-4 w-4" />
<div className="flex-1">
<p className="font-medium">Created</p>
<p className="text-muted-foreground">
@@ -374,7 +425,7 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
</div>
</div>
<div className="flex items-start gap-2 text-sm">
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
<Calendar className="text-muted-foreground mt-0.5 h-4 w-4" />
<div className="flex-1">
<p className="font-medium">Updated</p>
<p className="text-muted-foreground">
@@ -387,18 +438,18 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
{/* Parent Epic Card */}
{epic && (
<Card className="hover:shadow-lg transition-shadow">
<Card className="transition-shadow hover:shadow-lg">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Parent Epic</CardTitle>
</CardHeader>
<CardContent>
<Link
href={`/epics/${epic.id}`}
className="block space-y-2 p-3 rounded-md border hover:bg-accent transition-colors"
className="hover:bg-accent block space-y-2 rounded-md border p-3 transition-colors"
>
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-sm">{epic.name}</span>
<Layers className="text-muted-foreground h-4 w-4" />
<span className="text-sm font-medium">{epic.name}</span>
</div>
<div className="flex items-center gap-2">
<Badge variant={getStatusColor(epic.status)} className="text-xs">
@@ -417,7 +468,7 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
{/* Edit Story Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Story</DialogTitle>
<DialogDescription>Update the story details</DialogDescription>
@@ -437,8 +488,8 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the story
and all its associated tasks.
This action cannot be undone. This will permanently delete the story and all its
associated tasks.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -454,7 +505,7 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
Deleting...
</>
) : (
'Delete Story'
"Delete Story"
)}
</AlertDialogAction>
</AlertDialogFooter>