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,77 @@
import { render, screen, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import TaskDetailPage from '@/app/(protected)/tasks/[id]/page';
import { useTask, useUpdateTask } from '@/hooks/useTasks';
vi.mock('next/navigation', () => ({
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
})),
}));
vi.mock('@/hooks/useTasks', () => ({
useTask: vi.fn(),
useUpdateTask: vi.fn(),
}));
describe('TaskDetailPage', () => {
const mockMutate = vi.fn();
beforeEach(() => {
vi.mocked(useUpdateTask).mockReturnValue({
mutate: mockMutate,
isPending: false,
} as any);
});
it('shows valid transitions for Open status', async () => {
vi.mocked(useTask).mockReturnValue({
data: { id: '1', title: 'Task 1', status: 'Open', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
isLoading: false,
error: null,
} as any);
const params = Promise.resolve({ id: '1' });
await act(async () => {
render(<TaskDetailPage params={params} />);
});
expect(screen.getByText('Move to Assigned')).toBeInTheDocument();
expect(screen.queryByText('Move to InProgress')).not.toBeInTheDocument();
expect(screen.queryByText('Mark as Done')).not.toBeInTheDocument();
});
it('shows valid transitions for InProgress status', async () => {
vi.mocked(useTask).mockReturnValue({
data: { id: '1', title: 'Task 1', status: 'InProgress', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
isLoading: false,
error: null,
} as any);
const params = Promise.resolve({ id: '1' });
await act(async () => {
render(<TaskDetailPage params={params} />);
});
expect(screen.getByText('Move to Review')).toBeInTheDocument();
expect(screen.queryByText('Mark as Done')).not.toBeInTheDocument();
});
it('shows valid transitions for Review status (including back transition)', async () => {
vi.mocked(useTask).mockReturnValue({
data: { id: '1', title: 'Task 1', status: 'Review', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
isLoading: false,
error: null,
} as any);
const params = Promise.resolve({ id: '1' });
await act(async () => {
render(<TaskDetailPage params={params} />);
});
expect(screen.getByText('Mark as Done')).toBeInTheDocument();
expect(screen.getByText('Back to InProgress')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,65 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import TaskListPage from '@/app/(protected)/tasks/page';
import { useTasks } from '@/hooks/useTasks';
vi.mock('next/navigation', () => ({
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
})),
usePathname: vi.fn(() => '/tasks'),
}));
vi.mock('@/contexts/tenant-context', () => ({
useTenant: vi.fn(() => ({
activeClubId: 'club-123',
clubs: [{ id: 'club-123', name: 'Tennis Club' }],
})),
}));
vi.mock('@/hooks/useTasks', () => ({
useTasks: vi.fn(),
}));
describe('TaskListPage', () => {
beforeEach(() => {
vi.mocked(useTasks).mockReturnValue({
data: {
items: [
{ id: '1', title: 'Test Task 1', status: 'Open', assigneeId: null, createdAt: '2024-01-01' },
{ id: '2', title: 'Test Task 2', status: 'InProgress', assigneeId: 'user-1', createdAt: '2024-01-02' },
{ id: '3', title: 'Test Task 3', status: 'Done', assigneeId: 'user-2', createdAt: '2024-01-03' },
],
total: 3,
page: 1,
pageSize: 20,
},
isLoading: false,
error: null,
} as any);
});
it('renders task list with 3 data rows', () => {
render(<TaskListPage />);
expect(screen.getAllByRole('row')).toHaveLength(4);
expect(screen.getByText('Test Task 1')).toBeInTheDocument();
expect(screen.getByText('Test Task 2')).toBeInTheDocument();
});
it('shows status badge with correct color classes', () => {
render(<TaskListPage />);
const openBadge = screen.getByText('Open', { selector: 'span' });
const inProgressBadge = screen.getByText('InProgress', { selector: 'span' });
const doneBadge = screen.getByText('Done', { selector: 'span' });
expect(openBadge).toBeInTheDocument();
expect(inProgressBadge).toBeInTheDocument();
expect(doneBadge).toBeInTheDocument();
});
it('renders "New Task" button', () => {
render(<TaskListPage />);
expect(screen.getByRole('link', { name: /new task/i })).toBeInTheDocument();
});
});