feat(frontend): Implement Epic/Story/Task Management UI (Story 2)

Complete implementation of Sprint 1 Story 2 with full CRUD operations
for Epic/Story/Task entities including forms, hierarchy visualization,
and breadcrumb navigation.

Changes:
- Add EpicForm, StoryForm, TaskForm components with Zod validation
- Implement HierarchyTree component with expand/collapse functionality
- Add WorkItemBreadcrumb for Epic → Story → Task navigation
- Create centralized exports in components/projects/index.ts
- Fix Project form schemas to match UpdateProjectDto types
- Update dashboard to remove non-existent Project.status field

API Client & Hooks (already completed):
- epicsApi, storiesApi, tasksApi with full CRUD operations
- React Query hooks with optimistic updates and invalidation
- Error handling and JWT authentication integration

Technical Implementation:
- TypeScript type safety throughout
- Zod schema validation for all forms
- React Query optimistic updates
- Hierarchical data loading (lazy loading on expand)
- Responsive UI with Tailwind CSS
- Loading states and error handling

Story Points: 8 SP
Estimated Hours: 16h
Status: Completed

🤖 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-04 22:58:44 +01:00
parent 01132ee6e4
commit bfcbf6e350
9 changed files with 1244 additions and 8 deletions

View File

@@ -17,8 +17,8 @@ export default function DashboardPage() {
// Calculate statistics
const stats = {
totalProjects: projects?.length || 0,
activeProjects: projects?.filter(p => p.status === 'Active').length || 0,
archivedProjects: projects?.filter(p => p.status === 'Archived').length || 0,
activeProjects: projects?.length || 0, // TODO: Add status field to Project model
archivedProjects: 0, // TODO: Add status field to Project model
};
// Get recent projects (sort by creation time, take first 5)
@@ -142,12 +142,10 @@ export default function DashboardPage() {
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{project.name}</h3>
<Badge variant={project.status === 'Active' ? 'default' : 'secondary'}>
{project.status}
</Badge>
<Badge variant="default">{project.key}</Badge>
</div>
<p className="text-sm text-muted-foreground">
{project.key} {project.description || 'No description'}
{project.description || 'No description'}
</p>
</div>
<div className="text-sm text-muted-foreground">

View File

@@ -27,7 +27,7 @@ import type { CreateProjectDto } from '@/types/project';
const projectSchema = z.object({
name: z.string().min(1, 'Project name is required').max(200, 'Project name cannot exceed 200 characters'),
description: z.string().max(2000, 'Description cannot exceed 2000 characters'),
description: z.string().max(2000, 'Description cannot exceed 2000 characters').optional(),
key: z
.string()
.min(2, 'Project key must be at least 2 characters')

View File

@@ -31,9 +31,15 @@ const updateProjectSchema = z.object({
.string()
.min(1, 'Project name is required')
.max(200, 'Project name cannot exceed 200 characters'),
key: z
.string()
.min(2, 'Project key must be at least 2 characters')
.max(10, 'Project key cannot exceed 10 characters')
.regex(/^[A-Z]+$/, 'Project key must contain only uppercase letters'),
description: z
.string()
.max(2000, 'Description cannot exceed 2000 characters'),
.max(2000, 'Description cannot exceed 2000 characters')
.optional(),
});
type UpdateProjectFormData = z.infer<typeof updateProjectSchema>;
@@ -55,6 +61,7 @@ export function EditProjectDialog({
resolver: zodResolver(updateProjectSchema),
defaultValues: {
name: project.name,
key: project.key,
description: project.description,
},
});
@@ -99,6 +106,29 @@ export function EditProjectDialog({
)}
/>
<FormField
control={form.control}
name="key"
render={({ field }) => (
<FormItem>
<FormLabel>Project Key</FormLabel>
<FormControl>
<Input
placeholder="MAP"
{...field}
onChange={(e) => {
field.onChange(e.target.value.toUpperCase());
}}
/>
</FormControl>
<FormDescription>
A unique identifier for the project (2-10 uppercase letters).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"

View File

@@ -0,0 +1,218 @@
'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 {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} 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 { useCreateEpic, useUpdateEpic } from '@/lib/hooks/use-epics';
import type { Epic, WorkItemPriority } from '@/types/project';
import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
const epicSchema = z.object({
projectId: z.string().min(1, 'Project 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')
.optional()
.or(z.literal('')),
});
type EpicFormValues = z.infer<typeof epicSchema>;
interface EpicFormProps {
epic?: Epic;
projectId?: string;
onSuccess?: () => void;
onCancel?: () => void;
}
export function EpicForm({ epic, projectId, onSuccess, onCancel }: EpicFormProps) {
const isEditing = !!epic;
const createEpic = useCreateEpic();
const updateEpic = useUpdateEpic();
const form = useForm<EpicFormValues>({
resolver: zodResolver(epicSchema),
defaultValues: {
projectId: epic?.projectId || projectId || '',
title: epic?.title || '',
description: epic?.description || '',
priority: epic?.priority || 'Medium',
estimatedHours: epic?.estimatedHours || ('' as any),
},
});
async function onSubmit(data: EpicFormValues) {
try {
if (isEditing && epic) {
await updateEpic.mutateAsync({
id: epic.id,
data: {
title: data.title,
description: data.description,
priority: data.priority,
estimatedHours:
typeof data.estimatedHours === 'number' ? data.estimatedHours : undefined,
},
});
toast.success('Epic updated successfully');
} else {
await createEpic.mutateAsync({
projectId: data.projectId,
title: data.title,
description: data.description,
priority: data.priority,
estimatedHours:
typeof data.estimatedHours === 'number' ? data.estimatedHours : undefined,
});
toast.success('Epic created successfully');
}
onSuccess?.();
} catch (error) {
const message = error instanceof Error ? error.message : 'Operation failed';
toast.error(message);
}
}
const isLoading = createEpic.isPending || updateEpic.isPending;
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Epic Title *</FormLabel>
<FormControl>
<Input placeholder="e.g., User Authentication System" {...field} />
</FormControl>
<FormDescription>A clear, concise title for this epic</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Detailed description of the epic..."
className="resize-none"
rows={6}
{...field}
/>
</FormControl>
<FormDescription>
Optional detailed description (max 2000 characters)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority *</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Low">Low</SelectItem>
<SelectItem value="Medium">Medium</SelectItem>
<SelectItem value="High">High</SelectItem>
<SelectItem value="Critical">Critical</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="estimatedHours"
render={({ field }) => (
<FormItem>
<FormLabel>Estimated Hours</FormLabel>
<FormControl>
<Input
type="number"
placeholder="e.g., 40"
min="0"
step="0.5"
{...field}
onChange={(e) => {
const value = e.target.value;
field.onChange(value === '' ? '' : parseFloat(value));
}}
value={field.value === undefined ? '' : field.value}
/>
</FormControl>
<FormDescription>Optional time estimate</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end gap-3">
{onCancel && (
<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 Epic' : 'Create Epic'}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,334 @@
'use client';
import { useState } from 'react';
import { ChevronRight, ChevronDown, Folder, FileText, CheckSquare } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { useEpics } from '@/lib/hooks/use-epics';
import { useStories } from '@/lib/hooks/use-stories';
import { useTasks } from '@/lib/hooks/use-tasks';
import type { Epic, Story, Task, WorkItemStatus, WorkItemPriority } from '@/types/project';
interface HierarchyTreeProps {
projectId: string;
onEpicClick?: (epic: Epic) => void;
onStoryClick?: (story: Story) => void;
onTaskClick?: (task: Task) => void;
}
export function HierarchyTree({
projectId,
onEpicClick,
onStoryClick,
onTaskClick,
}: HierarchyTreeProps) {
const { data: epics = [], isLoading: epicsLoading } = useEpics(projectId);
if (epicsLoading) {
return <HierarchyTreeSkeleton />;
}
if (epics.length === 0) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<Folder className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No Epics Found</h3>
<p className="text-sm text-muted-foreground">
Create your first epic to start organizing work
</p>
</div>
);
}
return (
<div className="space-y-2">
{epics.map((epic) => (
<EpicNode
key={epic.id}
epic={epic}
onEpicClick={onEpicClick}
onStoryClick={onStoryClick}
onTaskClick={onTaskClick}
/>
))}
</div>
);
}
interface EpicNodeProps {
epic: Epic;
onEpicClick?: (epic: Epic) => void;
onStoryClick?: (story: Story) => void;
onTaskClick?: (task: Task) => void;
}
function EpicNode({ epic, onEpicClick, onStoryClick, onTaskClick }: EpicNodeProps) {
const [isExpanded, setIsExpanded] = useState(false);
const { data: stories = [], isLoading: storiesLoading } = useStories(
isExpanded ? epic.id : undefined
);
return (
<div className="border rounded-lg">
<div
className="flex items-center gap-2 p-3 hover:bg-accent cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<Folder className="h-5 w-5 text-blue-500" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span
className="font-semibold hover:underline"
onClick={(e) => {
e.stopPropagation();
onEpicClick?.(epic);
}}
>
{epic.title}
</span>
<StatusBadge status={epic.status} />
<PriorityBadge priority={epic.priority} />
</div>
{epic.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-1">
{epic.description}
</p>
)}
</div>
{epic.estimatedHours && (
<div className="text-sm text-muted-foreground">
{epic.estimatedHours}h
{epic.actualHours && ` / ${epic.actualHours}h`}
</div>
)}
</div>
{isExpanded && (
<div className="pl-8 pr-3 pb-3 space-y-2">
{storiesLoading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : stories.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground border-l-2 border-muted">
No stories in this epic
</div>
) : (
stories.map((story) => (
<StoryNode
key={story.id}
story={story}
onStoryClick={onStoryClick}
onTaskClick={onTaskClick}
/>
))
)}
</div>
)}
</div>
);
}
interface StoryNodeProps {
story: Story;
onStoryClick?: (story: Story) => void;
onTaskClick?: (task: Task) => void;
}
function StoryNode({ story, onStoryClick, onTaskClick }: StoryNodeProps) {
const [isExpanded, setIsExpanded] = useState(false);
const { data: tasks = [], isLoading: tasksLoading } = useTasks(
isExpanded ? story.id : undefined
);
return (
<div className="border-l-2 border-muted pl-3">
<div
className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<FileText className="h-4 w-4 text-green-500" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span
className="font-medium hover:underline"
onClick={(e) => {
e.stopPropagation();
onStoryClick?.(story);
}}
>
{story.title}
</span>
<StatusBadge status={story.status} size="sm" />
<PriorityBadge priority={story.priority} size="sm" />
</div>
{story.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">
{story.description}
</p>
)}
</div>
{story.estimatedHours && (
<div className="text-xs text-muted-foreground">
{story.estimatedHours}h
{story.actualHours && ` / ${story.actualHours}h`}
</div>
)}
</div>
{isExpanded && (
<div className="ml-6 mt-2 space-y-1">
{tasksLoading ? (
<div className="space-y-1">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : tasks.length === 0 ? (
<div className="p-3 text-center text-xs text-muted-foreground border-l-2 border-muted">
No tasks in this story
</div>
) : (
tasks.map((task) => <TaskNode key={task.id} task={task} onTaskClick={onTaskClick} />)
)}
</div>
)}
</div>
);
}
interface TaskNodeProps {
task: Task;
onTaskClick?: (task: Task) => void;
}
function TaskNode({ task, onTaskClick }: TaskNodeProps) {
return (
<div
className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer border-l-2 border-muted pl-3"
onClick={() => onTaskClick?.(task)}
>
<CheckSquare className="h-4 w-4 text-purple-500" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium hover:underline">{task.title}</span>
<StatusBadge status={task.status} size="xs" />
<PriorityBadge priority={task.priority} size="xs" />
</div>
{task.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">{task.description}</p>
)}
</div>
{task.estimatedHours && (
<div className="text-xs text-muted-foreground">
{task.estimatedHours}h
{task.actualHours && ` / ${task.actualHours}h`}
</div>
)}
</div>
);
}
interface StatusBadgeProps {
status: WorkItemStatus;
size?: 'default' | 'sm' | 'xs';
}
function StatusBadge({ status, size = 'default' }: StatusBadgeProps) {
const variants: Record<WorkItemStatus, 'default' | 'secondary' | 'outline' | 'destructive'> = {
Backlog: 'secondary',
Todo: 'outline',
InProgress: 'default',
Done: 'outline',
};
const sizeClasses = {
default: 'text-xs',
sm: 'text-xs px-1.5 py-0',
xs: 'text-[10px] px-1 py-0',
};
return (
<Badge variant={variants[status]} className={sizeClasses[size]}>
{status}
</Badge>
);
}
interface PriorityBadgeProps {
priority: WorkItemPriority;
size?: 'default' | 'sm' | 'xs';
}
function PriorityBadge({ priority, size = 'default' }: PriorityBadgeProps) {
const colors: Record<WorkItemPriority, string> = {
Low: 'bg-gray-100 text-gray-700',
Medium: 'bg-blue-100 text-blue-700',
High: 'bg-orange-100 text-orange-700',
Critical: 'bg-red-100 text-red-700',
};
const sizeClasses = {
default: 'text-xs',
sm: 'text-xs px-1.5 py-0',
xs: 'text-[10px] px-1 py-0',
};
return <Badge className={`${colors[priority]} ${sizeClasses[size]}`}>{priority}</Badge>;
}
function HierarchyTreeSkeleton() {
return (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="border rounded-lg p-3">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-6" />
<Skeleton className="h-5 w-5" />
<Skeleton className="h-6 flex-1" />
<Skeleton className="h-5 w-16" />
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,6 @@
export { ProjectForm } from './project-form';
export { EpicForm } from './epic-form';
export { StoryForm } from './story-form';
export { TaskForm } from './task-form';
export { HierarchyTree } from './hierarchy-tree';
export { WorkItemBreadcrumb } from './work-item-breadcrumb';

View File

@@ -0,0 +1,269 @@
'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 {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} 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';
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']),
estimatedHours: z
.number()
.min(0, 'Estimated hours must be positive')
.optional()
.or(z.literal('')),
});
type StoryFormValues = z.infer<typeof storySchema>;
interface StoryFormProps {
story?: Story;
epicId?: string;
projectId?: string;
onSuccess?: () => void;
onCancel?: () => void;
}
export function StoryForm({
story,
epicId,
projectId,
onSuccess,
onCancel,
}: StoryFormProps) {
const isEditing = !!story;
const createStory = useCreateStory();
const updateStory = useUpdateStory();
// Fetch epics for parent epic selection
const { data: epics = [], isLoading: epicsLoading } = useEpics(projectId);
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),
},
});
async function onSubmit(data: StoryFormValues) {
try {
if (isEditing && story) {
await updateStory.mutateAsync({
id: story.id,
data: {
title: data.title,
description: data.description,
priority: data.priority,
estimatedHours:
typeof data.estimatedHours === 'number' ? data.estimatedHours : undefined,
},
});
toast.success('Story updated successfully');
} else {
await createStory.mutateAsync({
epicId: data.epicId,
title: data.title,
description: data.description,
priority: data.priority,
estimatedHours:
typeof data.estimatedHours === 'number' ? data.estimatedHours : undefined,
});
toast.success('Story created successfully');
}
onSuccess?.();
} catch (error) {
const message = error instanceof Error ? error.message : 'Operation failed';
toast.error(message);
}
}
const isLoading = createStory.isPending || updateStory.isPending;
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="epicId"
render={({ field }) => (
<FormItem>
<FormLabel>Parent Epic *</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
disabled={isEditing || !!epicId}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select parent epic" />
</SelectTrigger>
</FormControl>
<SelectContent>
{epicsLoading ? (
<div className="p-2 text-sm text-muted-foreground">Loading epics...</div>
) : epics.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground">
No epics available
</div>
) : (
epics.map((epic) => (
<SelectItem key={epic.id} value={epic.id}>
{epic.title}
</SelectItem>
))
)}
</SelectContent>
</Select>
<FormDescription>
{isEditing ? 'Parent epic cannot be changed' : 'Select the parent epic'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Story Title *</FormLabel>
<FormControl>
<Input placeholder="e.g., Login page with OAuth support" {...field} />
</FormControl>
<FormDescription>A clear, concise title for this story</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Detailed description of the story..."
className="resize-none"
rows={6}
{...field}
/>
</FormControl>
<FormDescription>
Optional detailed description (max 2000 characters)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority *</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Low">Low</SelectItem>
<SelectItem value="Medium">Medium</SelectItem>
<SelectItem value="High">High</SelectItem>
<SelectItem value="Critical">Critical</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="estimatedHours"
render={({ field }) => (
<FormItem>
<FormLabel>Estimated Hours</FormLabel>
<FormControl>
<Input
type="number"
placeholder="e.g., 8"
min="0"
step="0.5"
{...field}
onChange={(e) => {
const value = e.target.value;
field.onChange(value === '' ? '' : parseFloat(value));
}}
value={field.value === undefined ? '' : field.value}
/>
</FormControl>
<FormDescription>Optional time estimate</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end gap-3">
{onCancel && (
<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'}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,271 @@
'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 {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} 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 { useCreateTask, useUpdateTask } from '@/lib/hooks/use-tasks';
import { useStories } from '@/lib/hooks/use-stories';
import type { Task, WorkItemPriority } from '@/types/project';
import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
const taskSchema = z.object({
storyId: z.string().min(1, 'Parent Story 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')
.optional()
.or(z.literal('')),
});
type TaskFormValues = z.infer<typeof taskSchema>;
interface TaskFormProps {
task?: Task;
storyId?: string;
epicId?: string;
onSuccess?: () => void;
onCancel?: () => void;
}
export function TaskForm({
task,
storyId,
epicId,
onSuccess,
onCancel,
}: TaskFormProps) {
const isEditing = !!task;
const createTask = useCreateTask();
const updateTask = useUpdateTask();
// Fetch stories for parent story selection
const { data: stories = [], isLoading: storiesLoading } = useStories(epicId);
const form = useForm<TaskFormValues>({
resolver: zodResolver(taskSchema),
defaultValues: {
storyId: task?.storyId || storyId || '',
title: task?.title || '',
description: task?.description || '',
priority: task?.priority || 'Medium',
estimatedHours: task?.estimatedHours || ('' as any),
},
});
async function onSubmit(data: TaskFormValues) {
try {
if (isEditing && task) {
await updateTask.mutateAsync({
id: task.id,
data: {
title: data.title,
description: data.description,
priority: data.priority,
estimatedHours:
typeof data.estimatedHours === 'number' ? data.estimatedHours : undefined,
},
});
toast.success('Task updated successfully');
} else {
await createTask.mutateAsync({
storyId: data.storyId,
title: data.title,
description: data.description,
priority: data.priority,
estimatedHours:
typeof data.estimatedHours === 'number' ? data.estimatedHours : undefined,
});
toast.success('Task created successfully');
}
onSuccess?.();
} catch (error) {
const message = error instanceof Error ? error.message : 'Operation failed';
toast.error(message);
}
}
const isLoading = createTask.isPending || updateTask.isPending;
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="storyId"
render={({ field }) => (
<FormItem>
<FormLabel>Parent Story *</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
disabled={isEditing || !!storyId}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select parent story" />
</SelectTrigger>
</FormControl>
<SelectContent>
{storiesLoading ? (
<div className="p-2 text-sm text-muted-foreground">
Loading stories...
</div>
) : stories.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground">
No stories available
</div>
) : (
stories.map((story) => (
<SelectItem key={story.id} value={story.id}>
{story.title}
</SelectItem>
))
)}
</SelectContent>
</Select>
<FormDescription>
{isEditing ? 'Parent story cannot be changed' : 'Select the parent story'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Task Title *</FormLabel>
<FormControl>
<Input placeholder="e.g., Implement JWT token validation" {...field} />
</FormControl>
<FormDescription>A clear, concise title for this task</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Detailed description of the task..."
className="resize-none"
rows={6}
{...field}
/>
</FormControl>
<FormDescription>
Optional detailed description (max 2000 characters)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority *</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Low">Low</SelectItem>
<SelectItem value="Medium">Medium</SelectItem>
<SelectItem value="High">High</SelectItem>
<SelectItem value="Critical">Critical</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="estimatedHours"
render={({ field }) => (
<FormItem>
<FormLabel>Estimated Hours</FormLabel>
<FormControl>
<Input
type="number"
placeholder="e.g., 2"
min="0"
step="0.5"
{...field}
onChange={(e) => {
const value = e.target.value;
field.onChange(value === '' ? '' : parseFloat(value));
}}
value={field.value === undefined ? '' : field.value}
/>
</FormControl>
<FormDescription>Optional time estimate</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end gap-3">
{onCancel && (
<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 Task' : 'Create Task'}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,110 @@
'use client';
import { ChevronRight, Folder, FileText, CheckSquare, Home } from 'lucide-react';
import { useEpic } from '@/lib/hooks/use-epics';
import { useStory } from '@/lib/hooks/use-stories';
import { Skeleton } from '@/components/ui/skeleton';
import Link from 'next/link';
import type { Epic, Story, Task } from '@/types/project';
interface WorkItemBreadcrumbProps {
projectId: string;
projectName?: string;
epic?: Epic;
story?: Story;
task?: Task;
epicId?: string;
storyId?: string;
}
export function WorkItemBreadcrumb({
projectId,
projectName,
epic,
story,
task,
epicId,
storyId,
}: WorkItemBreadcrumbProps) {
// Fetch epic if only epicId provided
const { data: fetchedEpic, isLoading: epicLoading } = useEpic(
epicId && !epic ? epicId : ''
);
const effectiveEpic = epic || fetchedEpic;
// Fetch story if only storyId provided
const { data: fetchedStory, isLoading: storyLoading } = useStory(
storyId && !story ? storyId : ''
);
const effectiveStory = story || fetchedStory;
// If we need to fetch parent epic from story
const { data: parentEpic, isLoading: parentEpicLoading } = useEpic(
effectiveStory && !effectiveEpic ? effectiveStory.epicId : ''
);
const finalEpic = effectiveEpic || parentEpic;
const isLoading = epicLoading || storyLoading || parentEpicLoading;
if (isLoading) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Skeleton className="h-4 w-24" />
<ChevronRight className="h-4 w-4" />
<Skeleton className="h-4 w-32" />
</div>
);
}
return (
<nav className="flex items-center gap-2 text-sm" aria-label="Breadcrumb">
{/* Project */}
<Link
href={`/projects/${projectId}`}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<Home className="h-4 w-4" />
{projectName && <span>{projectName}</span>}
</Link>
{/* Epic */}
{finalEpic && (
<>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
<Link
href={`/projects/${projectId}/epics/${finalEpic.id}`}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<Folder className="h-4 w-4 text-blue-500" />
<span className="max-w-[200px] truncate">{finalEpic.title}</span>
</Link>
</>
)}
{/* Story */}
{effectiveStory && (
<>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
<Link
href={`/projects/${projectId}/stories/${effectiveStory.id}`}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<FileText className="h-4 w-4 text-green-500" />
<span className="max-w-[200px] truncate">{effectiveStory.title}</span>
</Link>
</>
)}
{/* Task */}
{task && (
<>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center gap-1 font-medium">
<CheckSquare className="h-4 w-4 text-purple-500" />
<span className="max-w-[200px] truncate">{task.title}</span>
</div>
</>
)}
</nav>
);
}