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:
218
components/projects/epic-form.tsx
Normal file
218
components/projects/epic-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
334
components/projects/hierarchy-tree.tsx
Normal file
334
components/projects/hierarchy-tree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
components/projects/index.ts
Normal file
6
components/projects/index.ts
Normal 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';
|
||||
269
components/projects/story-form.tsx
Normal file
269
components/projects/story-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
271
components/projects/task-form.tsx
Normal file
271
components/projects/task-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
components/projects/work-item-breadcrumb.tsx
Normal file
110
components/projects/work-item-breadcrumb.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user