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>

View File

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

View File

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

View File

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