feat(frontend): Implement Phase 1 - ProjectManagement API Client & Hooks

Add complete API integration for ProjectManagement module:
- Epics, Stories, Tasks API clients
- React Query hooks for all entities
- Updated type definitions to match backend API
- API test page for connection verification

Changes:
- Update lib/api/config.ts: Add all ProjectManagement endpoints
- Update types/project.ts: Match backend API models (Epic, Story, Task)
- Create lib/api/pm.ts: API clients for Epics, Stories, Tasks
- Create lib/hooks/use-epics.ts: React Query hooks for Epic CRUD
- Create lib/hooks/use-stories.ts: React Query hooks for Story CRUD
- Create lib/hooks/use-tasks.ts: React Query hooks for Task CRUD
- Create app/(dashboard)/api-test/page.tsx: API connection test page

Features:
- Full CRUD operations for Epics, Stories, Tasks
- Status change and assignment operations
- Optimistic updates for better UX
- Error handling with toast notifications
- Query invalidation for cache consistency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-04 20:58:59 +01:00
parent de697d436b
commit e52c8300de
7 changed files with 901 additions and 39 deletions

View File

@@ -0,0 +1,203 @@
'use client';
import { useProjects } from '@/lib/hooks/use-projects';
import { useEpics } from '@/lib/hooks/use-epics';
import { useStories } from '@/lib/hooks/use-stories';
import { useTasks } from '@/lib/hooks/use-tasks';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
export default function ApiTestPage() {
const { data: projects, isLoading: projectsLoading, error: projectsError } = useProjects();
const { data: epics, isLoading: epicsLoading, error: epicsError } = useEpics();
const { data: stories, isLoading: storiesLoading, error: storiesError } = useStories();
const { data: tasks, isLoading: tasksLoading, error: tasksError } = useTasks();
return (
<div className="container py-6 space-y-8">
<div>
<h1 className="text-3xl font-bold mb-2">API Connection Test</h1>
<p className="text-muted-foreground">
This page tests the connection to ProjectManagement API endpoints
</p>
</div>
{/* Projects Section */}
<Section
title="Projects"
count={projects?.length}
loading={projectsLoading}
error={projectsError}
>
{projects && projects.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{projects.map((project) => (
<Card key={project.id} className="p-4">
<h3 className="font-semibold">{project.name}</h3>
<p className="text-sm text-muted-foreground">{project.key}</p>
{project.description && (
<p className="text-sm text-gray-600 mt-2">{project.description}</p>
)}
<div className="mt-2 flex gap-2">
<Badge variant="outline">ID: {project.id.substring(0, 8)}...</Badge>
</div>
</Card>
))}
</div>
) : (
<EmptyState message="No projects found" />
)}
</Section>
{/* Epics Section */}
<Section
title="Epics"
count={epics?.length}
loading={epicsLoading}
error={epicsError}
>
{epics && epics.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{epics.map((epic) => (
<Card key={epic.id} className="p-4">
<h3 className="font-semibold">{epic.title}</h3>
{epic.description && (
<p className="text-sm text-gray-600 mt-1">{epic.description}</p>
)}
<div className="mt-2 flex gap-2 flex-wrap">
<Badge variant="default">{epic.status}</Badge>
<Badge variant="outline">{epic.priority}</Badge>
{epic.estimatedHours && (
<Badge variant="secondary">{epic.estimatedHours}h</Badge>
)}
</div>
</Card>
))}
</div>
) : (
<EmptyState message="No epics found" />
)}
</Section>
{/* Stories Section */}
<Section
title="Stories"
count={stories?.length}
loading={storiesLoading}
error={storiesError}
>
{stories && stories.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{stories.map((story) => (
<Card key={story.id} className="p-4">
<h3 className="font-semibold">{story.title}</h3>
{story.description && (
<p className="text-sm text-gray-600 mt-1">{story.description}</p>
)}
<div className="mt-2 flex gap-2 flex-wrap">
<Badge variant="default">{story.status}</Badge>
<Badge variant="outline">{story.priority}</Badge>
{story.estimatedHours && (
<Badge variant="secondary">{story.estimatedHours}h</Badge>
)}
</div>
</Card>
))}
</div>
) : (
<EmptyState message="No stories found" />
)}
</Section>
{/* Tasks Section */}
<Section
title="Tasks"
count={tasks?.length}
loading={tasksLoading}
error={tasksError}
>
{tasks && tasks.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{tasks.map((task) => (
<Card key={task.id} className="p-4">
<h3 className="font-semibold">{task.title}</h3>
{task.description && (
<p className="text-sm text-gray-600 mt-1">{task.description}</p>
)}
<div className="mt-2 flex gap-2 flex-wrap">
<Badge variant="default">{task.status}</Badge>
<Badge variant="outline">{task.priority}</Badge>
{task.estimatedHours && (
<Badge variant="secondary">{task.estimatedHours}h</Badge>
)}
</div>
</Card>
))}
</div>
) : (
<EmptyState message="No tasks found" />
)}
</Section>
</div>
);
}
// Helper Components
interface SectionProps {
title: string;
count?: number;
loading: boolean;
error: any;
children: React.ReactNode;
}
function Section({ title, count, loading, error, children }: SectionProps) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">
{title}
{count !== undefined && (
<span className="ml-2 text-muted-foreground text-lg">({count})</span>
)}
</h2>
{loading && <Badge variant="secondary">Loading...</Badge>}
{error && <Badge variant="destructive">Error</Badge>}
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
) : error ? (
<Card className="p-6 border-destructive">
<h3 className="text-lg font-semibold text-destructive mb-2">Error Loading {title}</h3>
<p className="text-sm text-muted-foreground">
{error.message || 'Unknown error occurred'}
</p>
{error.response?.status && (
<p className="text-sm text-muted-foreground mt-2">
Status Code: {error.response.status}
</p>
)}
</Card>
) : (
children
)}
</div>
);
}
function EmptyState({ message }: { message: string }) {
return (
<Card className="p-8 text-center">
<p className="text-muted-foreground">{message}</p>
<p className="text-sm text-muted-foreground mt-2">
Try creating some data via the API or check your authentication
</p>
</Card>
);
}

View File

@@ -1,4 +1,4 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000';
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5167';
export const API_ENDPOINTS = {
// Auth
@@ -17,7 +17,25 @@ export const API_ENDPOINTS = {
ASSIGN_ROLE: (tenantId: string, userId: string) =>
`/api/tenants/${tenantId}/users/${userId}/role`,
// Projects (to be implemented)
PROJECTS: '/api/projects',
PROJECT: (id: string) => `/api/projects/${id}`,
// Projects
PROJECTS: '/api/v1/projects',
PROJECT: (id: string) => `/api/v1/projects/${id}`,
// Epics
EPICS: '/api/v1/epics',
EPIC: (id: string) => `/api/v1/epics/${id}`,
EPIC_STATUS: (id: string) => `/api/v1/epics/${id}/status`,
EPIC_ASSIGN: (id: string) => `/api/v1/epics/${id}/assign`,
// Stories
STORIES: '/api/v1/stories',
STORY: (id: string) => `/api/v1/stories/${id}`,
STORY_STATUS: (id: string) => `/api/v1/stories/${id}/status`,
STORY_ASSIGN: (id: string) => `/api/v1/stories/${id}/assign`,
// Tasks
TASKS: '/api/v1/tasks',
TASK: (id: string) => `/api/v1/tasks/${id}`,
TASK_STATUS: (id: string) => `/api/v1/tasks/${id}/status`,
TASK_ASSIGN: (id: string) => `/api/v1/tasks/${id}/assign`,
};

109
lib/api/pm.ts Normal file
View File

@@ -0,0 +1,109 @@
import { api } from './client';
import type {
Epic,
CreateEpicDto,
UpdateEpicDto,
Story,
CreateStoryDto,
UpdateStoryDto,
Task,
CreateTaskDto,
UpdateTaskDto,
WorkItemStatus,
} from '@/types/project';
// ==================== Epics API ====================
export const epicsApi = {
list: async (projectId?: string): Promise<Epic[]> => {
const params = projectId ? { projectId } : undefined;
return api.get('/api/v1/epics', { params });
},
get: async (id: string): Promise<Epic> => {
return api.get(`/api/v1/epics/${id}`);
},
create: async (data: CreateEpicDto): Promise<Epic> => {
return api.post('/api/v1/epics', data);
},
update: async (id: string, data: UpdateEpicDto): Promise<Epic> => {
return api.put(`/api/v1/epics/${id}`, data);
},
delete: async (id: string): Promise<void> => {
return api.delete(`/api/v1/epics/${id}`);
},
changeStatus: async (id: string, status: WorkItemStatus): Promise<Epic> => {
return api.put(`/api/v1/epics/${id}/status`, { status });
},
assign: async (id: string, assigneeId: string): Promise<Epic> => {
return api.put(`/api/v1/epics/${id}/assign`, { assigneeId });
},
};
// ==================== Stories API ====================
export const storiesApi = {
list: async (epicId?: string): Promise<Story[]> => {
const params = epicId ? { epicId } : undefined;
return api.get('/api/v1/stories', { params });
},
get: async (id: string): Promise<Story> => {
return api.get(`/api/v1/stories/${id}`);
},
create: async (data: CreateStoryDto): Promise<Story> => {
return api.post('/api/v1/stories', data);
},
update: async (id: string, data: UpdateStoryDto): Promise<Story> => {
return api.put(`/api/v1/stories/${id}`, data);
},
delete: async (id: string): Promise<void> => {
return api.delete(`/api/v1/stories/${id}`);
},
changeStatus: async (id: string, status: WorkItemStatus): Promise<Story> => {
return api.put(`/api/v1/stories/${id}/status`, { status });
},
assign: async (id: string, assigneeId: string): Promise<Story> => {
return api.put(`/api/v1/stories/${id}/assign`, { assigneeId });
},
};
// ==================== Tasks API ====================
export const tasksApi = {
list: async (storyId?: string): Promise<Task[]> => {
const params = storyId ? { storyId } : undefined;
return api.get('/api/v1/tasks', { params });
},
get: async (id: string): Promise<Task> => {
return api.get(`/api/v1/tasks/${id}`);
},
create: async (data: CreateTaskDto): Promise<Task> => {
return api.post('/api/v1/tasks', data);
},
update: async (id: string, data: UpdateTaskDto): Promise<Task> => {
return api.put(`/api/v1/tasks/${id}`, data);
},
delete: async (id: string): Promise<void> => {
return api.delete(`/api/v1/tasks/${id}`);
},
changeStatus: async (id: string, status: WorkItemStatus): Promise<Task> => {
return api.put(`/api/v1/tasks/${id}/status`, { status });
},
assign: async (id: string, assigneeId: string): Promise<Task> => {
return api.put(`/api/v1/tasks/${id}/assign`, { assigneeId });
},
};

167
lib/hooks/use-epics.ts Normal file
View File

@@ -0,0 +1,167 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { epicsApi } from '@/lib/api/pm';
import type { Epic, CreateEpicDto, UpdateEpicDto, WorkItemStatus } from '@/types/project';
import { toast } from 'sonner';
// ==================== Query Hooks ====================
export function useEpics(projectId?: string) {
return useQuery<Epic[]>({
queryKey: ['epics', projectId],
queryFn: async () => {
console.log('[useEpics] Fetching epics...', { projectId });
try {
const result = await epicsApi.list(projectId);
console.log('[useEpics] Fetch successful:', result);
return result;
} catch (error) {
console.error('[useEpics] Fetch failed:', error);
throw error;
}
},
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
});
}
export function useEpic(id: string) {
return useQuery<Epic>({
queryKey: ['epics', id],
queryFn: () => epicsApi.get(id),
enabled: !!id,
});
}
// ==================== Mutation Hooks ====================
export function useCreateEpic() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateEpicDto) => epicsApi.create(data),
onSuccess: (newEpic) => {
// Invalidate all epic queries (including filtered by projectId)
queryClient.invalidateQueries({ queryKey: ['epics'] });
// Also invalidate project details if exists
queryClient.invalidateQueries({ queryKey: ['projects', newEpic.projectId] });
toast.success('Epic created successfully!');
},
onError: (error: any) => {
console.error('[useCreateEpic] Error:', error);
toast.error(error.response?.data?.detail || 'Failed to create epic');
},
});
}
export function useUpdateEpic() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateEpicDto }) =>
epicsApi.update(id, data),
onMutate: async ({ id, data }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['epics', id] });
// Snapshot previous value
const previousEpic = queryClient.getQueryData<Epic>(['epics', id]);
// Optimistically update
queryClient.setQueryData<Epic>(['epics', id], (old) => ({
...old!,
...data,
}));
return { previousEpic };
},
onError: (error: any, variables, context) => {
console.error('[useUpdateEpic] Error:', error);
// Rollback
if (context?.previousEpic) {
queryClient.setQueryData(['epics', variables.id], context.previousEpic);
}
toast.error(error.response?.data?.detail || 'Failed to update epic');
},
onSuccess: (updatedEpic) => {
toast.success('Epic updated successfully!');
},
onSettled: (_, __, variables) => {
queryClient.invalidateQueries({ queryKey: ['epics', variables.id] });
queryClient.invalidateQueries({ queryKey: ['epics'] });
},
});
}
export function useDeleteEpic() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => epicsApi.delete(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: ['epics'] });
queryClient.removeQueries({ queryKey: ['epics', id] });
toast.success('Epic deleted successfully!');
},
onError: (error: any) => {
console.error('[useDeleteEpic] Error:', error);
toast.error(error.response?.data?.detail || 'Failed to delete epic');
},
});
}
export function useChangeEpicStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status }: { id: string; status: WorkItemStatus }) =>
epicsApi.changeStatus(id, status),
onMutate: async ({ id, status }) => {
await queryClient.cancelQueries({ queryKey: ['epics', id] });
const previousEpic = queryClient.getQueryData<Epic>(['epics', id]);
queryClient.setQueryData<Epic>(['epics', id], (old) => ({
...old!,
status,
}));
return { previousEpic };
},
onError: (error: any, variables, context) => {
console.error('[useChangeEpicStatus] Error:', error);
if (context?.previousEpic) {
queryClient.setQueryData(['epics', variables.id], context.previousEpic);
}
toast.error(error.response?.data?.detail || 'Failed to change epic status');
},
onSuccess: () => {
toast.success('Epic status changed successfully!');
},
onSettled: (_, __, variables) => {
queryClient.invalidateQueries({ queryKey: ['epics', variables.id] });
queryClient.invalidateQueries({ queryKey: ['epics'] });
},
});
}
export function useAssignEpic() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, assigneeId }: { id: string; assigneeId: string }) =>
epicsApi.assign(id, assigneeId),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['epics', variables.id] });
queryClient.invalidateQueries({ queryKey: ['epics'] });
toast.success('Epic assigned successfully!');
},
onError: (error: any) => {
console.error('[useAssignEpic] Error:', error);
toast.error(error.response?.data?.detail || 'Failed to assign epic');
},
});
}

163
lib/hooks/use-stories.ts Normal file
View File

@@ -0,0 +1,163 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { storiesApi } from '@/lib/api/pm';
import type { Story, CreateStoryDto, UpdateStoryDto, WorkItemStatus } from '@/types/project';
import { toast } from 'sonner';
// ==================== Query Hooks ====================
export function useStories(epicId?: string) {
return useQuery<Story[]>({
queryKey: ['stories', epicId],
queryFn: async () => {
console.log('[useStories] Fetching stories...', { epicId });
try {
const result = await storiesApi.list(epicId);
console.log('[useStories] Fetch successful:', result);
return result;
} catch (error) {
console.error('[useStories] Fetch failed:', error);
throw error;
}
},
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
});
}
export function useStory(id: string) {
return useQuery<Story>({
queryKey: ['stories', id],
queryFn: () => storiesApi.get(id),
enabled: !!id,
});
}
// ==================== Mutation Hooks ====================
export function useCreateStory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateStoryDto) => storiesApi.create(data),
onSuccess: (newStory) => {
// Invalidate all story queries
queryClient.invalidateQueries({ queryKey: ['stories'] });
// Also invalidate epic details
queryClient.invalidateQueries({ queryKey: ['epics', newStory.epicId] });
toast.success('Story created successfully!');
},
onError: (error: any) => {
console.error('[useCreateStory] Error:', error);
toast.error(error.response?.data?.detail || 'Failed to create story');
},
});
}
export function useUpdateStory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateStoryDto }) =>
storiesApi.update(id, data),
onMutate: async ({ id, data }) => {
await queryClient.cancelQueries({ queryKey: ['stories', id] });
const previousStory = queryClient.getQueryData<Story>(['stories', id]);
queryClient.setQueryData<Story>(['stories', id], (old) => ({
...old!,
...data,
}));
return { previousStory };
},
onError: (error: any, variables, context) => {
console.error('[useUpdateStory] Error:', error);
if (context?.previousStory) {
queryClient.setQueryData(['stories', variables.id], context.previousStory);
}
toast.error(error.response?.data?.detail || 'Failed to update story');
},
onSuccess: () => {
toast.success('Story updated successfully!');
},
onSettled: (_, __, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories', variables.id] });
queryClient.invalidateQueries({ queryKey: ['stories'] });
},
});
}
export function useDeleteStory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => storiesApi.delete(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.removeQueries({ queryKey: ['stories', id] });
toast.success('Story deleted successfully!');
},
onError: (error: any) => {
console.error('[useDeleteStory] Error:', error);
toast.error(error.response?.data?.detail || 'Failed to delete story');
},
});
}
export function useChangeStoryStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status }: { id: string; status: WorkItemStatus }) =>
storiesApi.changeStatus(id, status),
onMutate: async ({ id, status }) => {
await queryClient.cancelQueries({ queryKey: ['stories', id] });
const previousStory = queryClient.getQueryData<Story>(['stories', id]);
queryClient.setQueryData<Story>(['stories', id], (old) => ({
...old!,
status,
}));
return { previousStory };
},
onError: (error: any, variables, context) => {
console.error('[useChangeStoryStatus] Error:', error);
if (context?.previousStory) {
queryClient.setQueryData(['stories', variables.id], context.previousStory);
}
toast.error(error.response?.data?.detail || 'Failed to change story status');
},
onSuccess: () => {
toast.success('Story status changed successfully!');
},
onSettled: (_, __, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories', variables.id] });
queryClient.invalidateQueries({ queryKey: ['stories'] });
},
});
}
export function useAssignStory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, assigneeId }: { id: string; assigneeId: string }) =>
storiesApi.assign(id, assigneeId),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories', variables.id] });
queryClient.invalidateQueries({ queryKey: ['stories'] });
toast.success('Story assigned successfully!');
},
onError: (error: any) => {
console.error('[useAssignStory] Error:', error);
toast.error(error.response?.data?.detail || 'Failed to assign story');
},
});
}

163
lib/hooks/use-tasks.ts Normal file
View File

@@ -0,0 +1,163 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { tasksApi } from '@/lib/api/pm';
import type { Task, CreateTaskDto, UpdateTaskDto, WorkItemStatus } from '@/types/project';
import { toast } from 'sonner';
// ==================== Query Hooks ====================
export function useTasks(storyId?: string) {
return useQuery<Task[]>({
queryKey: ['tasks', storyId],
queryFn: async () => {
console.log('[useTasks] Fetching tasks...', { storyId });
try {
const result = await tasksApi.list(storyId);
console.log('[useTasks] Fetch successful:', result);
return result;
} catch (error) {
console.error('[useTasks] Fetch failed:', error);
throw error;
}
},
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
});
}
export function useTask(id: string) {
return useQuery<Task>({
queryKey: ['tasks', id],
queryFn: () => tasksApi.get(id),
enabled: !!id,
});
}
// ==================== Mutation Hooks ====================
export function useCreateTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateTaskDto) => tasksApi.create(data),
onSuccess: (newTask) => {
// Invalidate all task queries
queryClient.invalidateQueries({ queryKey: ['tasks'] });
// Also invalidate story details
queryClient.invalidateQueries({ queryKey: ['stories', newTask.storyId] });
toast.success('Task created successfully!');
},
onError: (error: any) => {
console.error('[useCreateTask] Error:', error);
toast.error(error.response?.data?.detail || 'Failed to create task');
},
});
}
export function useUpdateTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateTaskDto }) =>
tasksApi.update(id, data),
onMutate: async ({ id, data }) => {
await queryClient.cancelQueries({ queryKey: ['tasks', id] });
const previousTask = queryClient.getQueryData<Task>(['tasks', id]);
queryClient.setQueryData<Task>(['tasks', id], (old) => ({
...old!,
...data,
}));
return { previousTask };
},
onError: (error: any, variables, context) => {
console.error('[useUpdateTask] Error:', error);
if (context?.previousTask) {
queryClient.setQueryData(['tasks', variables.id], context.previousTask);
}
toast.error(error.response?.data?.detail || 'Failed to update task');
},
onSuccess: () => {
toast.success('Task updated successfully!');
},
onSettled: (_, __, variables) => {
queryClient.invalidateQueries({ queryKey: ['tasks', variables.id] });
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
}
export function useDeleteTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => tasksApi.delete(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.removeQueries({ queryKey: ['tasks', id] });
toast.success('Task deleted successfully!');
},
onError: (error: any) => {
console.error('[useDeleteTask] Error:', error);
toast.error(error.response?.data?.detail || 'Failed to delete task');
},
});
}
export function useChangeTaskStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status }: { id: string; status: WorkItemStatus }) =>
tasksApi.changeStatus(id, status),
onMutate: async ({ id, status }) => {
await queryClient.cancelQueries({ queryKey: ['tasks', id] });
const previousTask = queryClient.getQueryData<Task>(['tasks', id]);
queryClient.setQueryData<Task>(['tasks', id], (old) => ({
...old!,
status,
}));
return { previousTask };
},
onError: (error: any, variables, context) => {
console.error('[useChangeTaskStatus] Error:', error);
if (context?.previousTask) {
queryClient.setQueryData(['tasks', variables.id], context.previousTask);
}
toast.error(error.response?.data?.detail || 'Failed to change task status');
},
onSuccess: () => {
toast.success('Task status changed successfully!');
},
onSettled: (_, __, variables) => {
queryClient.invalidateQueries({ queryKey: ['tasks', variables.id] });
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
}
export function useAssignTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, assigneeId }: { id: string; assigneeId: string }) =>
tasksApi.assign(id, assigneeId),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['tasks', variables.id] });
queryClient.invalidateQueries({ queryKey: ['tasks'] });
toast.success('Task assigned successfully!');
},
onError: (error: any) => {
console.error('[useAssignTask] Error:', error);
toast.error(error.response?.data?.detail || 'Failed to assign task');
},
});
}

View File

@@ -1,90 +1,129 @@
export type ProjectStatus = 'Active' | 'Archived' | 'OnHold';
// ==================== Common Types ====================
export type WorkItemStatus = 'Backlog' | 'Todo' | 'InProgress' | 'Done';
export type WorkItemPriority = 'Low' | 'Medium' | 'High' | 'Critical';
// ==================== Project ====================
export interface Project {
id: string;
name: string;
description: string;
key: string;
status: ProjectStatus;
ownerId: string;
description?: string;
tenantId: string;
createdAt: string;
updatedAt?: string;
updatedAt: string;
}
export interface CreateProjectDto {
name: string;
description: string;
key: string;
ownerId?: string; // Optional in form, will be set automatically
description?: string;
}
export interface UpdateProjectDto {
name?: string;
name: string;
key: string;
description?: string;
status?: ProjectStatus;
}
// ==================== Epic ====================
export interface Epic {
id: string;
name: string;
description: string;
title: string;
description?: string;
projectId: string;
status: TaskStatus;
priority: TaskPriority;
status: WorkItemStatus;
priority: WorkItemPriority;
estimatedHours?: number;
actualHours?: number;
assigneeId?: string;
tenantId: string;
createdAt: string;
createdBy: string;
updatedAt: string;
}
export type TaskStatus = 'ToDo' | 'InProgress' | 'InReview' | 'Done' | 'Blocked';
export type TaskPriority = 'Low' | 'Medium' | 'High' | 'Urgent';
export interface CreateEpicDto {
projectId: string;
title: string;
description?: string;
priority: WorkItemPriority;
estimatedHours?: number;
}
export interface UpdateEpicDto {
title?: string;
description?: string;
priority?: WorkItemPriority;
estimatedHours?: number;
actualHours?: number;
}
// ==================== Story ====================
export interface Story {
id: string;
title: string;
description: string;
description?: string;
epicId: string;
status: TaskStatus;
priority: TaskPriority;
projectId: string;
status: WorkItemStatus;
priority: WorkItemPriority;
estimatedHours?: number;
actualHours?: number;
assigneeId?: string;
createdBy: string;
tenantId: string;
createdAt: string;
updatedAt?: string;
updatedAt: string;
}
export interface CreateStoryDto {
epicId: string;
title: string;
description?: string;
priority: WorkItemPriority;
estimatedHours?: number;
}
export interface UpdateStoryDto {
title?: string;
description?: string;
priority?: WorkItemPriority;
estimatedHours?: number;
actualHours?: number;
}
// ==================== Task ====================
export interface Task {
id: string;
title: string;
description: string;
description?: string;
storyId: string;
status: TaskStatus;
priority: TaskPriority;
projectId: string;
status: WorkItemStatus;
priority: WorkItemPriority;
estimatedHours?: number;
actualHours?: number;
assigneeId?: string;
customFields?: Record<string, any>;
createdBy: string;
tenantId: string;
createdAt: string;
updatedAt?: string;
updatedAt: string;
}
export interface CreateTaskDto {
title: string;
description: string;
storyId: string;
priority: TaskPriority;
title: string;
description?: string;
priority: WorkItemPriority;
estimatedHours?: number;
assigneeId?: string;
}
export interface UpdateTaskDto {
title?: string;
description?: string;
status?: TaskStatus;
priority?: TaskPriority;
priority?: WorkItemPriority;
estimatedHours?: number;
actualHours?: number;
assigneeId?: string;
customFields?: Record<string, any>;
}
// ==================== Legacy Types (for backward compatibility) ====================
// Keep old type names as aliases for gradual migration
export type TaskStatus = WorkItemStatus;
export type TaskPriority = WorkItemPriority;