feat(ui): add task management UI with list, detail, and create pages

Implements Task 19: Task List + Task Detail + Status Transitions UI

New components:
- useTasks hook: TanStack Query hooks (useTasks, useTask, useCreateTask, useUpdateTask)
- Task list page: shadcn Table with status filter, pagination, status badges
- Task detail page: Full task info with valid status transition buttons
- New task form: Create task with title, description, assigneeId, dueDate

Key features:
- Status transitions match backend logic: Open→Assigned→InProgress→Review→Done
- Review status allows back-transition to InProgress (only bidirectional)
- Only valid next states shown as buttons (VALID_TRANSITIONS map)
- Status badge colors: Open=gray, Assigned=blue, InProgress=yellow, Review=red, Done=green
- TanStack Query with automatic cache invalidation on mutations
- Next.js 15+ async params pattern (use() hook)

TDD:
- 3 task list tests (renders rows, status badges, new task button)
- 3 task detail tests (Open→Assigned, InProgress→Review, Review→Done+InProgress)

All tests pass (31/31). Build succeeds.
This commit is contained in:
WorkClub Automation
2026-03-03 20:12:31 +01:00
parent 46bbac355b
commit c8ae47c0bc
8 changed files with 654 additions and 1 deletions

View File

@@ -0,0 +1,122 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTenant } from '@/contexts/tenant-context';
import { apiClient } from '@/lib/api';
export interface TaskListDto {
items: TaskListItemDto[];
total: number;
page: number;
pageSize: number;
}
export interface TaskListItemDto {
id: string;
title: string;
status: string;
assigneeId: string | null;
createdAt: string;
}
export interface TaskDetailDto {
id: string;
title: string;
description: string | null;
status: string;
assigneeId: string | null;
createdById: string;
clubId: string;
dueDate: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateTaskRequest {
title: string;
description?: string;
clubId: string;
assigneeId?: string;
dueDate?: string;
}
export interface UpdateTaskRequest {
title?: string;
description?: string;
status?: string;
assigneeId?: string;
dueDate?: string;
}
export function useTasks(filters?: { status?: string; page?: number }) {
const { activeClubId } = useTenant();
return useQuery<TaskListDto>({
queryKey: ['tasks', activeClubId, filters],
queryFn: async () => {
const params = new URLSearchParams();
if (filters?.status) params.append('status', filters.status);
if (filters?.page) params.append('page', filters.page.toString());
const res = await apiClient(`/api/tasks?${params}`);
if (!res.ok) throw new Error('Failed to fetch tasks');
return res.json();
},
enabled: !!activeClubId,
});
}
export function useTask(id: string) {
const { activeClubId } = useTenant();
return useQuery<TaskDetailDto>({
queryKey: ['tasks', activeClubId, id],
queryFn: async () => {
const res = await apiClient(`/api/tasks/${id}`);
if (!res.ok) throw new Error('Failed to fetch task');
return res.json();
},
enabled: !!activeClubId && !!id,
});
}
export function useCreateTask() {
const queryClient = useQueryClient();
const { activeClubId } = useTenant();
return useMutation({
mutationFn: async (data: Omit<CreateTaskRequest, 'clubId'>) => {
const payload: CreateTaskRequest = {
...data,
clubId: activeClubId!,
};
const res = await apiClient('/api/tasks', {
method: 'POST',
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Failed to create task');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks', activeClubId] });
},
});
}
export function useUpdateTask() {
const queryClient = useQueryClient();
const { activeClubId } = useTenant();
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: UpdateTaskRequest }) => {
const res = await apiClient(`/api/tasks/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Failed to update task');
return res.json();
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['tasks', activeClubId] });
queryClient.invalidateQueries({ queryKey: ['tasks', activeClubId, variables.id] });
},
});
}