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:
@@ -17,8 +17,8 @@ export default function DashboardPage() {
|
|||||||
// Calculate statistics
|
// Calculate statistics
|
||||||
const stats = {
|
const stats = {
|
||||||
totalProjects: projects?.length || 0,
|
totalProjects: projects?.length || 0,
|
||||||
activeProjects: projects?.filter(p => p.status === 'Active').length || 0,
|
activeProjects: projects?.length || 0, // TODO: Add status field to Project model
|
||||||
archivedProjects: projects?.filter(p => p.status === 'Archived').length || 0,
|
archivedProjects: 0, // TODO: Add status field to Project model
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get recent projects (sort by creation time, take first 5)
|
// 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="space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-semibold">{project.name}</h3>
|
<h3 className="font-semibold">{project.name}</h3>
|
||||||
<Badge variant={project.status === 'Active' ? 'default' : 'secondary'}>
|
<Badge variant="default">{project.key}</Badge>
|
||||||
{project.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{project.key} • {project.description || 'No description'}
|
{project.description || 'No description'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import type { CreateProjectDto } from '@/types/project';
|
|||||||
|
|
||||||
const projectSchema = z.object({
|
const projectSchema = z.object({
|
||||||
name: z.string().min(1, 'Project name is required').max(200, 'Project name cannot exceed 200 characters'),
|
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
|
key: z
|
||||||
.string()
|
.string()
|
||||||
.min(2, 'Project key must be at least 2 characters')
|
.min(2, 'Project key must be at least 2 characters')
|
||||||
|
|||||||
@@ -31,9 +31,15 @@ const updateProjectSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.min(1, 'Project name is required')
|
.min(1, 'Project name is required')
|
||||||
.max(200, 'Project name cannot exceed 200 characters'),
|
.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
|
description: z
|
||||||
.string()
|
.string()
|
||||||
.max(2000, 'Description cannot exceed 2000 characters'),
|
.max(2000, 'Description cannot exceed 2000 characters')
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type UpdateProjectFormData = z.infer<typeof updateProjectSchema>;
|
type UpdateProjectFormData = z.infer<typeof updateProjectSchema>;
|
||||||
@@ -55,6 +61,7 @@ export function EditProjectDialog({
|
|||||||
resolver: zodResolver(updateProjectSchema),
|
resolver: zodResolver(updateProjectSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: project.name,
|
name: project.name,
|
||||||
|
key: project.key,
|
||||||
description: project.description,
|
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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="description"
|
name="description"
|
||||||
|
|||||||
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