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 { use, useState } from "react";
import Link from 'next/link'; import Link from "next/link";
import { useRouter } from 'next/navigation'; import { useRouter } from "next/navigation";
import { import {
ArrowLeft, ArrowLeft,
Edit, Edit,
@@ -12,24 +12,21 @@ import {
Calendar, Calendar,
User, User,
Layers, Layers,
} from 'lucide-react'; CheckCircle2,
import { Button } from '@/components/ui/button'; Tag,
import { Badge } from '@/components/ui/badge'; Target,
import { Skeleton } from '@/components/ui/skeleton'; } from "lucide-react";
import { import { Button } from "@/components/ui/button";
Card, import { Badge } from "@/components/ui/badge";
CardContent, import { Skeleton } from "@/components/ui/skeleton";
CardDescription, import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from "@/components/ui/dialog";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -39,22 +36,27 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from "@/components/ui/alert-dialog";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from "@/components/ui/select";
import { useStory, useUpdateStory, useDeleteStory, useChangeStoryStatus } from '@/lib/hooks/use-stories'; import {
import { useEpic } from '@/lib/hooks/use-epics'; useStory,
import { useProject } from '@/lib/hooks/use-projects'; useUpdateStory,
import { StoryForm } from '@/components/projects/story-form'; useDeleteStory,
import { TaskList } from '@/components/tasks/task-list'; useChangeStoryStatus,
import { formatDistanceToNow } from 'date-fns'; } from "@/lib/hooks/use-stories";
import { toast } from 'sonner'; import { useEpic } from "@/lib/hooks/use-epics";
import type { WorkItemStatus, WorkItemPriority } from '@/types/project'; 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 { interface StoryDetailPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -67,8 +69,8 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const { data: story, isLoading: storyLoading, error: storyError } = useStory(storyId); const { data: story, isLoading: storyLoading, error: storyError } = useStory(storyId);
const { data: epic, isLoading: epicLoading } = useEpic(story?.epicId || ''); const { data: epic, isLoading: epicLoading } = useEpic(story?.epicId || "");
const { data: project, isLoading: projectLoading } = useProject(story?.projectId || ''); const { data: project, isLoading: projectLoading } = useProject(story?.projectId || "");
const updateStory = useUpdateStory(); const updateStory = useUpdateStory();
const deleteStory = useDeleteStory(); const deleteStory = useDeleteStory();
const changeStatus = useChangeStoryStatus(); const changeStatus = useChangeStoryStatus();
@@ -76,11 +78,11 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
const handleDeleteStory = async () => { const handleDeleteStory = async () => {
try { try {
await deleteStory.mutateAsync(storyId); await deleteStory.mutateAsync(storyId);
toast.success('Story deleted successfully'); toast.success("Story deleted successfully");
// Navigate back to epic detail page // Navigate back to epic detail page
router.push(`/epics/${story?.epicId}`); router.push(`/epics/${story?.epicId}`);
} catch (error) { } 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); toast.error(message);
} }
}; };
@@ -90,7 +92,7 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
try { try {
await changeStatus.mutateAsync({ id: storyId, status }); await changeStatus.mutateAsync({ id: storyId, status });
} catch (error) { } 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); toast.error(message);
} }
}; };
@@ -103,38 +105,38 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
data: { priority }, data: { priority },
}); });
} catch (error) { } 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); toast.error(message);
} }
}; };
const getStatusColor = (status: WorkItemStatus) => { const getStatusColor = (status: WorkItemStatus) => {
switch (status) { switch (status) {
case 'Backlog': case "Backlog":
return 'secondary'; return "secondary";
case 'Todo': case "Todo":
return 'outline'; return "outline";
case 'InProgress': case "InProgress":
return 'default'; return "default";
case 'Done': case "Done":
return 'success' as any; return "default";
default: default:
return 'secondary'; return "secondary";
} }
}; };
const getPriorityColor = (priority: WorkItemPriority) => { const getPriorityColor = (priority: WorkItemPriority) => {
switch (priority) { switch (priority) {
case 'Low': case "Low":
return 'bg-blue-100 text-blue-700 hover:bg-blue-100'; return "bg-blue-100 text-blue-700 hover:bg-blue-100";
case 'Medium': case "Medium":
return 'bg-yellow-100 text-yellow-700 hover:bg-yellow-100'; return "bg-yellow-100 text-yellow-700 hover:bg-yellow-100";
case 'High': case "High":
return 'bg-orange-100 text-orange-700 hover:bg-orange-100'; return "bg-orange-100 text-orange-700 hover:bg-orange-100";
case 'Critical': case "Critical":
return 'bg-red-100 text-red-700 hover:bg-red-100'; return "bg-red-100 text-red-700 hover:bg-red-100";
default: default:
return 'secondary'; return "secondary";
} }
}; };
@@ -144,7 +146,7 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
<div className="space-y-6"> <div className="space-y-6">
<Skeleton className="h-10 w-96" /> <Skeleton className="h-10 w-96" />
<div className="flex items-start justify-between"> <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-12 w-1/2" />
<Skeleton className="h-20 w-full" /> <Skeleton className="h-20 w-full" />
</div> </div>
@@ -158,12 +160,12 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
// Error state // Error state
if (storyError || !story) { if (storyError || !story) {
return ( 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"> <Card className="w-full max-w-md">
<CardHeader> <CardHeader>
<CardTitle className="text-destructive">Error Loading Story</CardTitle> <CardTitle className="text-destructive">Error Loading Story</CardTitle>
<CardDescription> <CardDescription>
{storyError instanceof Error ? storyError.message : 'Story not found'} {storyError instanceof Error ? storyError.message : "Story not found"}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex gap-2"> <CardContent className="flex gap-2">
@@ -180,7 +182,7 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Breadcrumb Navigation */} {/* 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"> <Link href="/projects" className="hover:text-foreground">
Projects Projects
</Link> </Link>
@@ -207,14 +209,14 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
)} )}
<span className="text-foreground">Stories</span> <span className="text-foreground">Stories</span>
<span>/</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} {story.title}
</span> </span>
</div> </div>
{/* Header */} {/* Header */}
<div className="flex items-start justify-between gap-4"> <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"> <div className="flex items-center gap-3">
<Button <Button
variant="ghost" variant="ghost"
@@ -226,7 +228,7 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
</Button> </Button>
<div className="flex-1"> <div className="flex-1">
<h1 className="text-3xl font-bold tracking-tight">{story.title}</h1> <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 variant={getStatusColor(story.status)}>{story.status}</Badge>
<Badge className={getPriorityColor(story.priority)}>{story.priority}</Badge> <Badge className={getPriorityColor(story.priority)}>{story.priority}</Badge>
</div> </div>
@@ -246,9 +248,9 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
</div> </div>
{/* Two-column layout */} {/* 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) */} {/* 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 */} {/* Story Details Card */}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -257,13 +259,11 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{story.description ? ( {story.description ? (
<div> <div>
<h3 className="text-sm font-medium text-muted-foreground mb-2"> <h3 className="text-muted-foreground mb-2 text-sm font-medium">Description</h3>
Description
</h3>
<p className="text-sm whitespace-pre-wrap">{story.description}</p> <p className="text-sm whitespace-pre-wrap">{story.description}</p>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground italic">No description</p> <p className="text-muted-foreground text-sm italic">No description</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -320,6 +320,21 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
</CardContent> </CardContent>
</Card> </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 */} {/* Assignee */}
{story.assigneeId && ( {story.assigneeId && (
<Card> <Card>
@@ -328,13 +343,32 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center gap-2"> <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> <span className="text-sm">{story.assigneeId}</span>
</div> </div>
</CardContent> </CardContent>
</Card> </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 */} {/* Time Tracking */}
{(story.estimatedHours !== undefined || story.actualHours !== undefined) && ( {(story.estimatedHours !== undefined || story.actualHours !== undefined) && (
<Card> <Card>
@@ -344,13 +378,13 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
<CardContent className="space-y-2"> <CardContent className="space-y-2">
{story.estimatedHours !== undefined && ( {story.estimatedHours !== undefined && (
<div className="flex items-center gap-2 text-sm"> <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> <span>Estimated: {story.estimatedHours}h</span>
</div> </div>
)} )}
{story.actualHours !== undefined && ( {story.actualHours !== undefined && (
<div className="flex items-center gap-2 text-sm"> <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> <span>Actual: {story.actualHours}h</span>
</div> </div>
)} )}
@@ -358,6 +392,23 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
</Card> </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 */} {/* Dates */}
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -365,7 +416,7 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
<div className="flex items-start gap-2 text-sm"> <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"> <div className="flex-1">
<p className="font-medium">Created</p> <p className="font-medium">Created</p>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
@@ -374,7 +425,7 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
</div> </div>
</div> </div>
<div className="flex items-start gap-2 text-sm"> <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"> <div className="flex-1">
<p className="font-medium">Updated</p> <p className="font-medium">Updated</p>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
@@ -387,18 +438,18 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
{/* Parent Epic Card */} {/* Parent Epic Card */}
{epic && ( {epic && (
<Card className="hover:shadow-lg transition-shadow"> <Card className="transition-shadow hover:shadow-lg">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Parent Epic</CardTitle> <CardTitle className="text-sm font-medium">Parent Epic</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Link <Link
href={`/epics/${epic.id}`} 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"> <div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" /> <Layers className="text-muted-foreground h-4 w-4" />
<span className="font-medium text-sm">{epic.name}</span> <span className="text-sm font-medium">{epic.name}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant={getStatusColor(epic.status)} className="text-xs"> <Badge variant={getStatusColor(epic.status)} className="text-xs">
@@ -417,7 +468,7 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
{/* Edit Story Dialog */} {/* Edit Story Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}> <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> <DialogHeader>
<DialogTitle>Edit Story</DialogTitle> <DialogTitle>Edit Story</DialogTitle>
<DialogDescription>Update the story details</DialogDescription> <DialogDescription>Update the story details</DialogDescription>
@@ -437,8 +488,8 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle> <AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone. This will permanently delete the story This action cannot be undone. This will permanently delete the story and all its
and all its associated tasks. associated tasks.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@@ -454,7 +505,7 @@ export default function StoryDetailPage({ params }: StoryDetailPageProps) {
Deleting... Deleting...
</> </>
) : ( ) : (
'Delete Story' "Delete Story"
)} )}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View File

@@ -1,9 +1,9 @@
'use client'; "use client";
import { useForm } from 'react-hook-form'; import { useForm } from "react-hook-form";
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from "@hookform/resolvers/zod";
import * as z from 'zod'; import * as z from "zod";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { import {
Form, Form,
FormControl, FormControl,
@@ -12,51 +12,45 @@ import {
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@/components/ui/form'; } from "@/components/ui/form";
import { Input } from '@/components/ui/input'; import { Input } from "@/components/ui/input";
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from "@/components/ui/textarea";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from "@/components/ui/select";
import { useCreateStory, useUpdateStory } from '@/lib/hooks/use-stories'; import { useCreateStory, useUpdateStory } from "@/lib/hooks/use-stories";
import { useEpics } from '@/lib/hooks/use-epics'; import { useEpics } from "@/lib/hooks/use-epics";
import type { Story, WorkItemPriority } from '@/types/project'; import type { Story } from "@/types/project";
import { toast } from 'sonner'; import { toast } from "sonner";
import { Loader2 } from 'lucide-react'; import { Loader2 } from "lucide-react";
import { useAuthStore } from '@/stores/authStore'; import { useAuthStore } from "@/stores/authStore";
import { AcceptanceCriteriaEditor } from './acceptance-criteria-editor'; import { AcceptanceCriteriaEditor } from "./acceptance-criteria-editor";
import { TagsInput } from './tags-input'; import { TagsInput } from "./tags-input";
const storySchema = z.object({ const storySchema = z.object({
epicId: z.string().min(1, 'Parent Epic is required'), epicId: z.string().min(1, "Parent Epic is required"),
title: z title: z.string().min(1, "Title is required").max(200, "Title must be less than 200 characters"),
.string() description: z.string().max(2000, "Description must be less than 2000 characters").optional(),
.min(1, 'Title is required') priority: z.enum(["Low", "Medium", "High", "Critical"]),
.max(200, 'Title must be less than 200 characters'),
description: z
.string()
.max(2000, 'Description must be less than 2000 characters')
.optional(),
priority: z.enum(['Low', 'Medium', 'High', 'Critical']),
estimatedHours: z estimatedHours: z
.number() .number()
.min(0, 'Estimated hours must be positive') .min(0, "Estimated hours must be positive")
.optional() .optional()
.or(z.literal('')), .or(z.literal("")),
// Sprint 4 Story 3: New fields // Sprint 4 Story 3: New fields
acceptanceCriteria: z.array(z.string()).default([]), acceptanceCriteria: z.array(z.string()).optional(),
assigneeId: z.string().optional(), assigneeId: z.string().optional(),
tags: z.array(z.string()).default([]), tags: z.array(z.string()).optional(),
storyPoints: z storyPoints: z
.number() .number()
.min(0, 'Story points must be positive') .min(0, "Story points must be positive")
.max(100, 'Story points must be less than 100') .max(100, "Story points must be less than 100")
.optional() .optional()
.or(z.literal('')), .or(z.literal("")),
}); });
type StoryFormValues = z.infer<typeof storySchema>; type StoryFormValues = z.infer<typeof storySchema>;
@@ -69,13 +63,7 @@ interface StoryFormProps {
onCancel?: () => void; onCancel?: () => void;
} }
export function StoryForm({ export function StoryForm({ story, epicId, projectId, onSuccess, onCancel }: StoryFormProps) {
story,
epicId,
projectId,
onSuccess,
onCancel,
}: StoryFormProps) {
const isEditing = !!story; const isEditing = !!story;
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const createStory = useCreateStory(); const createStory = useCreateStory();
@@ -87,16 +75,16 @@ export function StoryForm({
const form = useForm<StoryFormValues>({ const form = useForm<StoryFormValues>({
resolver: zodResolver(storySchema), resolver: zodResolver(storySchema),
defaultValues: { defaultValues: {
epicId: story?.epicId || epicId || '', epicId: story?.epicId || epicId || "",
title: story?.title || '', title: story?.title || "",
description: story?.description || '', description: story?.description || "",
priority: story?.priority || 'Medium', priority: story?.priority || "Medium",
estimatedHours: story?.estimatedHours || ('' as any), estimatedHours: story?.estimatedHours || ("" as const),
// Sprint 4 Story 3: New field defaults // Sprint 4 Story 3: New field defaults
acceptanceCriteria: story?.acceptanceCriteria || [], acceptanceCriteria: story?.acceptanceCriteria || [],
assigneeId: story?.assigneeId || '', assigneeId: story?.assigneeId || "",
tags: story?.tags || [], tags: story?.tags || [],
storyPoints: story?.storyPoints || ('' as any), storyPoints: story?.storyPoints || ("" as const),
}, },
}); });
@@ -110,23 +98,22 @@ export function StoryForm({
description: data.description, description: data.description,
priority: data.priority, priority: data.priority,
estimatedHours: estimatedHours:
typeof data.estimatedHours === 'number' ? data.estimatedHours : undefined, typeof data.estimatedHours === "number" ? data.estimatedHours : undefined,
// Sprint 4 Story 3: New fields // Sprint 4 Story 3: New fields
acceptanceCriteria: data.acceptanceCriteria, acceptanceCriteria: data.acceptanceCriteria,
assigneeId: data.assigneeId || undefined, assigneeId: data.assigneeId || undefined,
tags: data.tags, tags: data.tags,
storyPoints: storyPoints: typeof data.storyPoints === "number" ? data.storyPoints : undefined,
typeof data.storyPoints === 'number' ? data.storyPoints : undefined,
}, },
}); });
toast.success('Story updated successfully'); toast.success("Story updated successfully");
} else { } else {
if (!user?.id) { if (!user?.id) {
toast.error('User not authenticated'); toast.error("User not authenticated");
return; return;
} }
if (!projectId) { if (!projectId) {
toast.error('Project ID is required'); toast.error("Project ID is required");
return; return;
} }
await createStory.mutateAsync({ await createStory.mutateAsync({
@@ -135,21 +122,19 @@ export function StoryForm({
title: data.title, title: data.title,
description: data.description, description: data.description,
priority: data.priority, priority: data.priority,
estimatedHours: estimatedHours: typeof data.estimatedHours === "number" ? data.estimatedHours : undefined,
typeof data.estimatedHours === 'number' ? data.estimatedHours : undefined,
createdBy: user.id, createdBy: user.id,
// Sprint 4 Story 3: New fields // Sprint 4 Story 3: New fields
acceptanceCriteria: data.acceptanceCriteria, acceptanceCriteria: data.acceptanceCriteria,
assigneeId: data.assigneeId || undefined, assigneeId: data.assigneeId || undefined,
tags: data.tags, tags: data.tags,
storyPoints: storyPoints: typeof data.storyPoints === "number" ? data.storyPoints : undefined,
typeof data.storyPoints === 'number' ? data.storyPoints : undefined,
}); });
toast.success('Story created successfully'); toast.success("Story created successfully");
} }
onSuccess?.(); onSuccess?.();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Operation failed'; const message = error instanceof Error ? error.message : "Operation failed";
toast.error(message); toast.error(message);
} }
} }
@@ -177,11 +162,9 @@ export function StoryForm({
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{epicsLoading ? ( {epicsLoading ? (
<div className="p-2 text-sm text-muted-foreground">Loading epics...</div> <div className="text-muted-foreground p-2 text-sm">Loading epics...</div>
) : epics.length === 0 ? ( ) : epics.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground"> <div className="text-muted-foreground p-2 text-sm">No epics available</div>
No epics available
</div>
) : ( ) : (
epics.map((epic) => ( epics.map((epic) => (
<SelectItem key={epic.id} value={epic.id}> <SelectItem key={epic.id} value={epic.id}>
@@ -192,7 +175,7 @@ export function StoryForm({
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription> <FormDescription>
{isEditing ? 'Parent epic cannot be changed' : 'Select the parent epic'} {isEditing ? "Parent epic cannot be changed" : "Select the parent epic"}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -228,9 +211,7 @@ export function StoryForm({
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>Optional detailed description (max 2000 characters)</FormDescription>
Optional detailed description (max 2000 characters)
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -276,9 +257,9 @@ export function StoryForm({
{...field} {...field}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
field.onChange(value === '' ? '' : parseFloat(value)); field.onChange(value === "" ? "" : parseFloat(value));
}} }}
value={field.value === undefined ? '' : field.value} value={field.value === undefined ? "" : field.value}
/> />
</FormControl> </FormControl>
<FormDescription>Optional time estimate</FormDescription> <FormDescription>Optional time estimate</FormDescription>
@@ -297,7 +278,7 @@ export function StoryForm({
<FormLabel>Acceptance Criteria</FormLabel> <FormLabel>Acceptance Criteria</FormLabel>
<FormControl> <FormControl>
<AcceptanceCriteriaEditor <AcceptanceCriteriaEditor
criteria={field.value} criteria={field.value || []}
onChange={field.onChange} onChange={field.onChange}
disabled={isLoading} disabled={isLoading}
/> />
@@ -318,11 +299,7 @@ export function StoryForm({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Assignee</FormLabel> <FormLabel>Assignee</FormLabel>
<Select <Select onValueChange={field.onChange} value={field.value} disabled={isLoading}>
onValueChange={field.onChange}
value={field.value}
disabled={isLoading}
>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Unassigned" /> <SelectValue placeholder="Unassigned" />
@@ -330,9 +307,7 @@ export function StoryForm({
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="">Unassigned</SelectItem> <SelectItem value="">Unassigned</SelectItem>
{user?.id && ( {user?.id && <SelectItem value={user.id}>{user.fullName || "Me"}</SelectItem>}
<SelectItem value={user.id}>{user.fullName || 'Me'}</SelectItem>
)}
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription>Assign to team member</FormDescription> <FormDescription>Assign to team member</FormDescription>
@@ -357,9 +332,9 @@ export function StoryForm({
{...field} {...field}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
field.onChange(value === '' ? '' : parseInt(value)); field.onChange(value === "" ? "" : parseInt(value));
}} }}
value={field.value === undefined ? '' : field.value} value={field.value === undefined ? "" : field.value}
disabled={isLoading} disabled={isLoading}
/> />
</FormControl> </FormControl>
@@ -379,7 +354,7 @@ export function StoryForm({
<FormLabel>Tags</FormLabel> <FormLabel>Tags</FormLabel>
<FormControl> <FormControl>
<TagsInput <TagsInput
tags={field.value} tags={field.value || []}
onChange={field.onChange} onChange={field.onChange}
disabled={isLoading} disabled={isLoading}
placeholder="Add tags (press Enter)..." placeholder="Add tags (press Enter)..."
@@ -395,18 +370,13 @@ export function StoryForm({
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
{onCancel && ( {onCancel && (
<Button <Button type="button" variant="outline" onClick={onCancel} disabled={isLoading}>
type="button"
variant="outline"
onClick={onCancel}
disabled={isLoading}
>
Cancel Cancel
</Button> </Button>
)} )}
<Button type="submit" disabled={isLoading}> <Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEditing ? 'Update Story' : 'Create Story'} {isEditing ? "Update Story" : "Create Story"}
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -1,29 +1,21 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import { Task, WorkItemStatus } from '@/types/project'; import { Task, WorkItemStatus } from "@/types/project";
import { useChangeTaskStatus, useUpdateTask, useDeleteTask } from '@/lib/hooks/use-tasks'; import { useChangeTaskStatus, useUpdateTask, useDeleteTask } from "@/lib/hooks/use-tasks";
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from '@/components/ui/badge'; import { Badge } from "@/components/ui/badge";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from "@/components/ui/dropdown-menu";
import { import { MoreHorizontal, Pencil, Trash2, Clock, User, CheckCircle2, Circle } from "lucide-react";
MoreHorizontal, import { cn } from "@/lib/utils";
Pencil, import { TaskEditDialog } from "./task-edit-dialog";
Trash2,
Clock,
User,
CheckCircle2,
Circle
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { TaskEditDialog } from './task-edit-dialog';
interface TaskCardProps { interface TaskCardProps {
task: Task; task: Task;
@@ -31,17 +23,18 @@ interface TaskCardProps {
} }
const priorityColors = { const priorityColors = {
Critical: 'bg-red-500 text-white', Critical: "bg-red-500 text-white",
High: 'bg-orange-500 text-white', High: "bg-orange-500 text-white",
Medium: 'bg-yellow-500 text-white', Medium: "bg-yellow-500 text-white",
Low: 'bg-blue-500 text-white', Low: "bg-blue-500 text-white",
}; };
const statusColors = { const statusColors = {
Todo: 'text-gray-500', Backlog: "text-slate-500",
InProgress: 'text-blue-500', Todo: "text-gray-500",
Done: 'text-green-500', InProgress: "text-blue-500",
Blocked: 'text-red-500', Done: "text-green-500",
Blocked: "text-red-500",
}; };
export function TaskCard({ task, storyId }: TaskCardProps) { export function TaskCard({ task, storyId }: TaskCardProps) {
@@ -51,15 +44,15 @@ export function TaskCard({ task, storyId }: TaskCardProps) {
const updateTask = useUpdateTask(); const updateTask = useUpdateTask();
const deleteTask = useDeleteTask(); const deleteTask = useDeleteTask();
const isDone = task.status === 'Done'; const isDone = task.status === "Done";
const handleCheckboxChange = (checked: boolean) => { const handleCheckboxChange = (checked: boolean) => {
const newStatus: WorkItemStatus = checked ? 'Done' : 'Todo'; const newStatus: WorkItemStatus = checked ? "Done" : "Todo";
changeStatus.mutate({ id: task.id, status: newStatus }); changeStatus.mutate({ id: task.id, status: newStatus });
}; };
const handleDelete = () => { const handleDelete = () => {
if (confirm('Are you sure you want to delete this task?')) { if (confirm("Are you sure you want to delete this task?")) {
deleteTask.mutate(task.id); deleteTask.mutate(task.id);
} }
}; };
@@ -67,7 +60,7 @@ export function TaskCard({ task, storyId }: TaskCardProps) {
return ( return (
<Card <Card
className={cn( className={cn(
"transition-all duration-200 hover:shadow-md cursor-pointer", "cursor-pointer transition-all duration-200 hover:shadow-md",
isDone && "opacity-60" isDone && "opacity-60"
)} )}
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
@@ -85,51 +78,44 @@ export function TaskCard({ task, storyId }: TaskCardProps) {
</div> </div>
{/* Task Content */} {/* Task Content */}
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-2"> <div className="mb-2 flex items-center gap-2">
<h4 className={cn( <h4
"font-medium text-sm", className={cn(
isDone && "line-through text-muted-foreground" "text-sm font-medium",
)}> isDone && "text-muted-foreground line-through"
)}
>
{task.title} {task.title}
</h4> </h4>
<Badge <Badge variant="secondary" className={cn("text-xs", priorityColors[task.priority])}>
variant="secondary"
className={cn("text-xs", priorityColors[task.priority])}
>
{task.priority} {task.priority}
</Badge> </Badge>
</div> </div>
{/* Metadata */} {/* Metadata */}
<div className="flex items-center gap-4 text-xs text-muted-foreground"> <div className="text-muted-foreground flex items-center gap-4 text-xs">
{task.estimatedHours && ( {task.estimatedHours && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Clock className="w-3 h-3" /> <Clock className="h-3 w-3" />
<span>{task.estimatedHours}h</span> <span>{task.estimatedHours}h</span>
</div> </div>
)} )}
{task.assigneeId && ( {task.assigneeId && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<User className="w-3 h-3" /> <User className="h-3 w-3" />
<span>Assigned</span> <span>Assigned</span>
</div> </div>
)} )}
<div className={cn("flex items-center gap-1", statusColors[task.status])}> <div className={cn("flex items-center gap-1", statusColors[task.status])}>
{isDone ? ( {isDone ? <CheckCircle2 className="h-3 w-3" /> : <Circle className="h-3 w-3" />}
<CheckCircle2 className="w-3 h-3" />
) : (
<Circle className="w-3 h-3" />
)}
<span>{task.status}</span> <span>{task.status}</span>
</div> </div>
</div> </div>
{/* Description (expanded) */} {/* Description (expanded) */}
{isExpanded && task.description && ( {isExpanded && task.description && (
<div className="mt-3 text-sm text-muted-foreground"> <div className="text-muted-foreground mt-3 text-sm">{task.description}</div>
{task.description}
</div>
)} )}
</div> </div>
@@ -137,24 +123,17 @@ export function TaskCard({ task, storyId }: TaskCardProps) {
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button variant="ghost" size="sm" className="h-8 w-8 p-0">
variant="ghost" <MoreHorizontal className="h-4 w-4" />
size="sm"
className="h-8 w-8 p-0"
>
<MoreHorizontal className="w-4 h-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setIsEditDialogOpen(true)}> <DropdownMenuItem onClick={() => setIsEditDialogOpen(true)}>
<Pencil className="w-4 h-4 mr-2" /> <Pencil className="mr-2 h-4 w-4" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem onClick={handleDelete} className="text-destructive">
onClick={handleDelete} <Trash2 className="mr-2 h-4 w-4" />
className="text-destructive"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@@ -164,11 +143,7 @@ export function TaskCard({ task, storyId }: TaskCardProps) {
</CardHeader> </CardHeader>
{/* Edit Dialog */} {/* Edit Dialog */}
<TaskEditDialog <TaskEditDialog task={task} open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} />
task={task}
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
/>
</Card> </Card>
); );
} }

View File

@@ -1,14 +1,20 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import { useForm } from 'react-hook-form'; import { useForm } from "react-hook-form";
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from 'zod'; import { z } from "zod";
import { useCreateTask } from '@/lib/hooks/use-tasks'; import { useCreateTask } from "@/lib/hooks/use-tasks";
import { CreateTaskDto, WorkItemPriority } from '@/types/project'; import { CreateTaskDto, WorkItemPriority } from "@/types/project";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { Input } from '@/components/ui/input'; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Form, Form,
FormControl, FormControl,
@@ -16,18 +22,18 @@ import {
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@/components/ui/form'; } from "@/components/ui/form";
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from "@/components/ui/card";
import { Plus, X } from 'lucide-react'; import { Plus, X } from "lucide-react";
interface TaskQuickAddProps { interface TaskQuickAddProps {
storyId: string; storyId: string;
} }
const taskSchema = z.object({ const taskSchema = z.object({
title: z.string().min(1, 'Title is required').max(200, 'Title too long'), title: z.string().min(1, "Title is required").max(200, "Title too long"),
priority: z.enum(['Critical', 'High', 'Medium', 'Low']), priority: z.enum(["Critical", "High", "Medium", "Low"]),
estimatedHours: z.coerce.number().min(0).optional(), estimatedHours: z.number().min(0).optional().or(z.literal("")),
}); });
type TaskFormData = z.infer<typeof taskSchema>; type TaskFormData = z.infer<typeof taskSchema>;
@@ -39,8 +45,8 @@ export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
const form = useForm<TaskFormData>({ const form = useForm<TaskFormData>({
resolver: zodResolver(taskSchema), resolver: zodResolver(taskSchema),
defaultValues: { defaultValues: {
title: '', title: "",
priority: 'Medium', priority: "Medium",
estimatedHours: undefined, estimatedHours: undefined,
}, },
}); });
@@ -50,7 +56,7 @@ export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
storyId, storyId,
title: data.title, title: data.title,
priority: data.priority as WorkItemPriority, priority: data.priority as WorkItemPriority,
estimatedHours: data.estimatedHours, estimatedHours: typeof data.estimatedHours === "number" ? data.estimatedHours : undefined,
}; };
createTask.mutate(taskData, { createTask.mutate(taskData, {
@@ -68,13 +74,8 @@ export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
if (!isOpen) { if (!isOpen) {
return ( return (
<Button <Button onClick={() => setIsOpen(true)} variant="outline" className="w-full" size="sm">
onClick={() => setIsOpen(true)} <Plus className="mr-2 h-4 w-4" />
variant="outline"
className="w-full"
size="sm"
>
<Plus className="w-4 h-4 mr-2" />
Add Task Add Task
</Button> </Button>
); );
@@ -85,7 +86,7 @@ export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
<CardContent className="pt-4"> <CardContent className="pt-4">
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="flex items-center justify-between mb-2"> <div className="mb-2 flex items-center justify-between">
<h4 className="text-sm font-medium">Quick Add Task</h4> <h4 className="text-sm font-medium">Quick Add Task</h4>
<Button <Button
type="button" type="button"
@@ -94,7 +95,7 @@ export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
onClick={handleCancel} onClick={handleCancel}
className="h-6 w-6 p-0" className="h-6 w-6 p-0"
> >
<X className="w-4 h-4" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -105,11 +106,7 @@ export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
<FormItem> <FormItem>
<FormLabel>Title *</FormLabel> <FormLabel>Title *</FormLabel>
<FormControl> <FormControl>
<Input <Input placeholder="e.g., Implement login API" {...field} />
placeholder="e.g., Implement login API"
{...field}
autoFocus
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -123,10 +120,7 @@ export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Priority</FormLabel> <FormLabel>Priority</FormLabel>
<Select <Select onValueChange={field.onChange} defaultValue={field.value}>
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select priority" /> <SelectValue placeholder="Select priority" />
@@ -154,8 +148,11 @@ export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
<Input <Input
type="number" type="number"
placeholder="8" placeholder="8"
{...field} onChange={(e) => {
value={field.value ?? ''} const value = e.target.value;
field.onChange(value === "" ? "" : parseFloat(value));
}}
value={field.value === undefined ? "" : field.value}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -165,20 +162,10 @@ export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button type="submit" size="sm" disabled={createTask.isPending} className="flex-1">
type="submit" {createTask.isPending ? "Creating..." : "Add Task"}
size="sm"
disabled={createTask.isPending}
className="flex-1"
>
{createTask.isPending ? 'Creating...' : 'Add Task'}
</Button> </Button>
<Button <Button type="button" variant="outline" size="sm" onClick={handleCancel}>
type="button"
variant="outline"
size="sm"
onClick={handleCancel}
>
Cancel Cancel
</Button> </Button>
</div> </div>