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:
203
app/(dashboard)/api-test/page.tsx
Normal file
203
app/(dashboard)/api-test/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 = {
|
export const API_ENDPOINTS = {
|
||||||
// Auth
|
// Auth
|
||||||
@@ -17,7 +17,25 @@ export const API_ENDPOINTS = {
|
|||||||
ASSIGN_ROLE: (tenantId: string, userId: string) =>
|
ASSIGN_ROLE: (tenantId: string, userId: string) =>
|
||||||
`/api/tenants/${tenantId}/users/${userId}/role`,
|
`/api/tenants/${tenantId}/users/${userId}/role`,
|
||||||
|
|
||||||
// Projects (to be implemented)
|
// Projects
|
||||||
PROJECTS: '/api/projects',
|
PROJECTS: '/api/v1/projects',
|
||||||
PROJECT: (id: string) => `/api/projects/${id}`,
|
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
109
lib/api/pm.ts
Normal 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
167
lib/hooks/use-epics.ts
Normal 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
163
lib/hooks/use-stories.ts
Normal 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
163
lib/hooks/use-tasks.ts
Normal 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');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
109
types/project.ts
109
types/project.ts
@@ -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 {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
|
||||||
key: string;
|
key: string;
|
||||||
status: ProjectStatus;
|
description?: string;
|
||||||
ownerId: string;
|
tenantId: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateProjectDto {
|
export interface CreateProjectDto {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
|
||||||
key: string;
|
key: string;
|
||||||
ownerId?: string; // Optional in form, will be set automatically
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateProjectDto {
|
export interface UpdateProjectDto {
|
||||||
name?: string;
|
name: string;
|
||||||
|
key: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
status?: ProjectStatus;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Epic ====================
|
||||||
export interface Epic {
|
export interface Epic {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
title: string;
|
||||||
description: string;
|
description?: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
status: TaskStatus;
|
status: WorkItemStatus;
|
||||||
priority: TaskPriority;
|
priority: WorkItemPriority;
|
||||||
|
estimatedHours?: number;
|
||||||
|
actualHours?: number;
|
||||||
|
assigneeId?: string;
|
||||||
|
tenantId: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
createdBy: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskStatus = 'ToDo' | 'InProgress' | 'InReview' | 'Done' | 'Blocked';
|
export interface CreateEpicDto {
|
||||||
export type TaskPriority = 'Low' | 'Medium' | 'High' | 'Urgent';
|
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 {
|
export interface Story {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description?: string;
|
||||||
epicId: string;
|
epicId: string;
|
||||||
status: TaskStatus;
|
projectId: string;
|
||||||
priority: TaskPriority;
|
status: WorkItemStatus;
|
||||||
|
priority: WorkItemPriority;
|
||||||
estimatedHours?: number;
|
estimatedHours?: number;
|
||||||
actualHours?: number;
|
actualHours?: number;
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
createdBy: string;
|
tenantId: string;
|
||||||
createdAt: 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 {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description?: string;
|
||||||
storyId: string;
|
storyId: string;
|
||||||
status: TaskStatus;
|
projectId: string;
|
||||||
priority: TaskPriority;
|
status: WorkItemStatus;
|
||||||
|
priority: WorkItemPriority;
|
||||||
estimatedHours?: number;
|
estimatedHours?: number;
|
||||||
actualHours?: number;
|
actualHours?: number;
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
customFields?: Record<string, any>;
|
tenantId: string;
|
||||||
createdBy: string;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateTaskDto {
|
export interface CreateTaskDto {
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
storyId: string;
|
storyId: string;
|
||||||
priority: TaskPriority;
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
priority: WorkItemPriority;
|
||||||
estimatedHours?: number;
|
estimatedHours?: number;
|
||||||
assigneeId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateTaskDto {
|
export interface UpdateTaskDto {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
status?: TaskStatus;
|
priority?: WorkItemPriority;
|
||||||
priority?: TaskPriority;
|
|
||||||
estimatedHours?: number;
|
estimatedHours?: number;
|
||||||
actualHours?: 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user