diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx
index f53b7ed..7ad275c 100644
--- a/app/(dashboard)/dashboard/page.tsx
+++ b/app/(dashboard)/dashboard/page.tsx
@@ -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() {
{project.name}
-
- {project.status}
-
+ {project.key}
- {project.key} • {project.description || 'No description'}
+ {project.description || 'No description'}
diff --git a/components/features/projects/CreateProjectDialog.tsx b/components/features/projects/CreateProjectDialog.tsx
index d7256ee..d7efd3c 100644
--- a/components/features/projects/CreateProjectDialog.tsx
+++ b/components/features/projects/CreateProjectDialog.tsx
@@ -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')
diff --git a/components/features/projects/EditProjectDialog.tsx b/components/features/projects/EditProjectDialog.tsx
index c764c61..c589233 100644
--- a/components/features/projects/EditProjectDialog.tsx
+++ b/components/features/projects/EditProjectDialog.tsx
@@ -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
;
@@ -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({
)}
/>
+ (
+
+ Project Key
+
+ {
+ field.onChange(e.target.value.toUpperCase());
+ }}
+ />
+
+
+ A unique identifier for the project (2-10 uppercase letters).
+
+
+
+ )}
+ />
+
;
+
+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({
+ 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 (
+
+
+ );
+}
diff --git a/components/projects/hierarchy-tree.tsx b/components/projects/hierarchy-tree.tsx
new file mode 100644
index 0000000..0d5d78b
--- /dev/null
+++ b/components/projects/hierarchy-tree.tsx
@@ -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 ;
+ }
+
+ if (epics.length === 0) {
+ return (
+
+
+
No Epics Found
+
+ Create your first epic to start organizing work
+
+
+ );
+ }
+
+ return (
+
+ {epics.map((epic) => (
+
+ ))}
+
+ );
+}
+
+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 (
+
+
setIsExpanded(!isExpanded)}
+ >
+
+
+
+
+
+
+
{
+ e.stopPropagation();
+ onEpicClick?.(epic);
+ }}
+ >
+ {epic.title}
+
+
+
+
+ {epic.description && (
+
+ {epic.description}
+
+ )}
+
+
+ {epic.estimatedHours && (
+
+ {epic.estimatedHours}h
+ {epic.actualHours && ` / ${epic.actualHours}h`}
+
+ )}
+
+
+ {isExpanded && (
+
+ {storiesLoading ? (
+
+
+
+
+ ) : stories.length === 0 ? (
+
+ No stories in this epic
+
+ ) : (
+ stories.map((story) => (
+
+ ))
+ )}
+
+ )}
+
+ );
+}
+
+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 (
+
+
setIsExpanded(!isExpanded)}
+ >
+
+
+
+
+
+
+
{
+ e.stopPropagation();
+ onStoryClick?.(story);
+ }}
+ >
+ {story.title}
+
+
+
+
+ {story.description && (
+
+ {story.description}
+
+ )}
+
+
+ {story.estimatedHours && (
+
+ {story.estimatedHours}h
+ {story.actualHours && ` / ${story.actualHours}h`}
+
+ )}
+
+
+ {isExpanded && (
+
+ {tasksLoading ? (
+
+
+
+
+ ) : tasks.length === 0 ? (
+
+ No tasks in this story
+
+ ) : (
+ tasks.map((task) =>
)
+ )}
+
+ )}
+
+ );
+}
+
+interface TaskNodeProps {
+ task: Task;
+ onTaskClick?: (task: Task) => void;
+}
+
+function TaskNode({ task, onTaskClick }: TaskNodeProps) {
+ return (
+ onTaskClick?.(task)}
+ >
+
+
+
+
+ {task.description && (
+
{task.description}
+ )}
+
+
+ {task.estimatedHours && (
+
+ {task.estimatedHours}h
+ {task.actualHours && ` / ${task.actualHours}h`}
+
+ )}
+
+ );
+}
+
+interface StatusBadgeProps {
+ status: WorkItemStatus;
+ size?: 'default' | 'sm' | 'xs';
+}
+
+function StatusBadge({ status, size = 'default' }: StatusBadgeProps) {
+ const variants: Record = {
+ 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 (
+
+ {status}
+
+ );
+}
+
+interface PriorityBadgeProps {
+ priority: WorkItemPriority;
+ size?: 'default' | 'sm' | 'xs';
+}
+
+function PriorityBadge({ priority, size = 'default' }: PriorityBadgeProps) {
+ const colors: Record = {
+ 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 {priority};
+}
+
+function HierarchyTreeSkeleton() {
+ return (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ );
+}
diff --git a/components/projects/index.ts b/components/projects/index.ts
new file mode 100644
index 0000000..ef8e2f2
--- /dev/null
+++ b/components/projects/index.ts
@@ -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';
diff --git a/components/projects/story-form.tsx b/components/projects/story-form.tsx
new file mode 100644
index 0000000..b2bd0f5
--- /dev/null
+++ b/components/projects/story-form.tsx
@@ -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;
+
+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({
+ 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 (
+
+
+ );
+}
diff --git a/components/projects/task-form.tsx b/components/projects/task-form.tsx
new file mode 100644
index 0000000..36d077f
--- /dev/null
+++ b/components/projects/task-form.tsx
@@ -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;
+
+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({
+ 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 (
+
+
+ );
+}
diff --git a/components/projects/work-item-breadcrumb.tsx b/components/projects/work-item-breadcrumb.tsx
new file mode 100644
index 0000000..fa04bbc
--- /dev/null
+++ b/components/projects/work-item-breadcrumb.tsx
@@ -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 (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}