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

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');
},
});
}