Fix task and shift self-assignment features
Some checks failed
CI Pipeline / Backend Build & Test (pull_request) Successful in 48s
CI Pipeline / Frontend Lint, Test & Build (pull_request) Failing after 28s
CI Pipeline / Infrastructure Validation (pull_request) Successful in 4s

This commit is contained in:
WorkClub Automation
2026-03-09 15:47:57 +01:00
parent 271b3c189c
commit 672dec5f21
17 changed files with 400 additions and 62 deletions

View File

@@ -23,7 +23,7 @@ export default function ShiftDetailPage({ params }: { params: Promise<{ id: stri
const capacityPercentage = (shift.signups.length / shift.capacity) * 100;
const isFull = shift.signups.length >= shift.capacity;
const isPast = new Date(shift.startTime) < new Date();
const isSignedUp = shift.signups.some((s) => s.memberId === session?.user?.id || s.externalUserId === session?.user?.id);
const isSignedUp = shift.isSignedUp;
const handleSignUp = async () => {
await signUpMutation.mutateAsync(shift.id);

View File

@@ -2,7 +2,7 @@
import { use } from 'react';
import Link from 'next/link';
import { useTask, useUpdateTask } from '@/hooks/useTasks';
import { useTask, useUpdateTask, useAssignTask, useUnassignTask } from '@/hooks/useTasks';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -26,9 +26,13 @@ const statusColors: Record<string, string> = {
export default function TaskDetailPage({ params }: { params: Promise<{ id: string }> }) {
const resolvedParams = use(params);
const { data: task, isLoading, error } = useTask(resolvedParams.id);
const { mutate: updateTask, isPending } = useUpdateTask();
const { mutate: updateTask, isPending: isUpdating } = useUpdateTask();
const { mutate: assignTask, isPending: isAssigning } = useAssignTask();
const { mutate: unassignTask, isPending: isUnassigning } = useUnassignTask();
const { data: session } = useSession();
const isPending = isUpdating || isAssigning || isUnassigning;
if (isLoading) return <div className="p-8">Loading task...</div>;
if (error || !task) return <div className="p-8 text-red-500">Failed to load task.</div>;
@@ -39,9 +43,11 @@ export default function TaskDetailPage({ params }: { params: Promise<{ id: strin
};
const handleAssignToMe = () => {
if (session?.user?.id) {
updateTask({ id: task.id, data: { assigneeId: session.user.id } });
}
assignTask(task.id);
};
const handleUnassign = () => {
unassignTask(task.id);
};
const getTransitionLabel = (status: string, newStatus: string) => {
@@ -107,7 +113,16 @@ export default function TaskDetailPage({ params }: { params: Promise<{ id: strin
disabled={isPending}
variant="outline"
>
{isPending ? 'Assigning...' : 'Assign to Me'}
{isAssigning ? 'Assigning...' : 'Assign to Me'}
</Button>
)}
{task.isAssignedToMe && (
<Button
onClick={handleUnassign}
disabled={isPending}
variant="outline"
>
{isUnassigning ? 'Unassigning...' : 'Unassign'}
</Button>
)}
{validTransitions.map((nextStatus) => (

View File

@@ -1,8 +1,23 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ShiftCard } from '../shifts/shift-card';
import { useSignUpShift, useCancelSignUp } from '@/hooks/useShifts';
vi.mock('@/hooks/useShifts', () => ({
useSignUpShift: vi.fn(),
useCancelSignUp: vi.fn(),
}));
describe('ShiftCard', () => {
const mockSignUp = vi.fn();
const mockCancel = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
(useSignUpShift as ReturnType<typeof vi.fn>).mockReturnValue({ mutate: mockSignUp, isPending: false });
(useCancelSignUp as ReturnType<typeof vi.fn>).mockReturnValue({ mutate: mockCancel, isPending: false });
});
it('shows capacity correctly (2/3 spots filled)', () => {
render(
<ShiftCard
@@ -13,6 +28,7 @@ describe('ShiftCard', () => {
endTime: new Date(Date.now() + 200000).toISOString(),
capacity: 3,
currentSignups: 2,
isSignedUp: false,
}}
/>
);
@@ -29,6 +45,7 @@ describe('ShiftCard', () => {
endTime: new Date(Date.now() + 200000).toISOString(),
capacity: 3,
currentSignups: 3,
isSignedUp: false,
}}
/>
);
@@ -46,10 +63,28 @@ describe('ShiftCard', () => {
endTime: new Date(Date.now() - 100000).toISOString(),
capacity: 3,
currentSignups: 1,
isSignedUp: false,
}}
/>
);
expect(screen.getByText('Past')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Sign Up' })).not.toBeInTheDocument();
});
it('shows cancel sign-up button when signed up', () => {
render(
<ShiftCard
shift={{
id: '1',
title: 'Signed Up Shift',
startTime: new Date(Date.now() + 100000).toISOString(),
endTime: new Date(Date.now() + 200000).toISOString(),
capacity: 3,
currentSignups: 1,
isSignedUp: true,
}}
/>
);
expect(screen.getByText('Cancel Sign-up')).toBeInTheDocument();
});
});

View File

@@ -51,6 +51,7 @@ describe('ShiftDetailPage', () => {
endTime: new Date(Date.now() + 200000).toISOString(),
capacity: 3,
signups: [{ id: 's1', memberId: 'other-user' }],
isSignedUp: false,
},
isLoading: false,
});
@@ -77,6 +78,7 @@ describe('ShiftDetailPage', () => {
endTime: new Date(Date.now() + 200000).toISOString(),
capacity: 3,
signups: [{ id: 's1', memberId: 'user-123' }],
isSignedUp: true,
},
isLoading: false,
});
@@ -103,6 +105,7 @@ describe('ShiftDetailPage', () => {
endTime: new Date(Date.now() + 200000).toISOString(),
capacity: 3,
signups: [],
isSignedUp: false,
},
isLoading: false,
});

View File

@@ -1,7 +1,7 @@
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';
import { useTask, useUpdateTask, useAssignTask, useUnassignTask } from '@/hooks/useTasks';
vi.mock('next/navigation', () => ({
useRouter: vi.fn(() => ({
@@ -21,21 +21,34 @@ vi.mock('next-auth/react', () => ({
vi.mock('@/hooks/useTasks', () => ({
useTask: vi.fn(),
useUpdateTask: vi.fn(),
useAssignTask: vi.fn(),
useUnassignTask: vi.fn(),
}));
describe('TaskDetailPage', () => {
const mockMutate = vi.fn();
const mockUpdate = vi.fn();
const mockAssign = vi.fn();
const mockUnassign = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
(useUpdateTask as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: mockMutate,
mutate: mockUpdate,
isPending: false,
});
(useAssignTask as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: mockAssign,
isPending: false,
});
(useUnassignTask as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: mockUnassign,
isPending: false,
});
});
it('shows valid transitions for Open status', async () => {
(useTask as ReturnType<typeof vi.fn>).mockReturnValue({
data: { id: '1', title: 'Task 1', status: 'Open', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
data: { id: '1', title: 'Task 1', status: 'Open', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01', isAssignedToMe: false },
isLoading: false,
error: null,
});
@@ -52,7 +65,7 @@ describe('TaskDetailPage', () => {
it('shows valid transitions for InProgress status', async () => {
(useTask as ReturnType<typeof vi.fn>).mockReturnValue({
data: { id: '1', title: 'Task 1', status: 'InProgress', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
data: { id: '1', title: 'Task 1', status: 'InProgress', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01', isAssignedToMe: false },
isLoading: false,
error: null,
});
@@ -68,7 +81,7 @@ describe('TaskDetailPage', () => {
it('shows valid transitions for Review status (including back transition)', async () => {
(useTask as ReturnType<typeof vi.fn>).mockReturnValue({
data: { id: '1', title: 'Task 1', status: 'Review', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
data: { id: '1', title: 'Task 1', status: 'Review', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01', isAssignedToMe: false },
isLoading: false,
error: null,
});
@@ -91,7 +104,8 @@ describe('TaskDetailPage', () => {
assigneeId: null,
description: 'Desc',
createdAt: '2024-01-01',
updatedAt: '2024-01-01'
updatedAt: '2024-01-01',
isAssignedToMe: false
},
isLoading: false,
error: null,
@@ -105,8 +119,7 @@ describe('TaskDetailPage', () => {
expect(screen.getByText('Assign to Me')).toBeInTheDocument();
});
it('calls updateTask with assigneeId when Assign to Me clicked', async () => {
const mockMutate = vi.fn();
it('calls assignTask with task id when Assign to Me clicked', async () => {
(useTask as ReturnType<typeof vi.fn>).mockReturnValue({
data: {
id: '1',
@@ -115,15 +128,12 @@ describe('TaskDetailPage', () => {
assigneeId: null,
description: 'Desc',
createdAt: '2024-01-01',
updatedAt: '2024-01-01'
updatedAt: '2024-01-01',
isAssignedToMe: false
},
isLoading: false,
error: null,
});
(useUpdateTask as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: mockMutate,
isPending: false,
});
const params = Promise.resolve({ id: '1' });
await act(async () => {
@@ -135,9 +145,37 @@ describe('TaskDetailPage', () => {
button.click();
});
expect(mockMutate).toHaveBeenCalledWith({
id: '1',
data: { assigneeId: 'user-123' },
expect(mockAssign).toHaveBeenCalledWith('1');
});
it('renders Unassign button and calls unassignTask when clicked', async () => {
(useTask as ReturnType<typeof vi.fn>).mockReturnValue({
data: {
id: '1',
title: 'Task 1',
status: 'Assigned',
assigneeId: 'some-member-id',
description: 'Desc',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
isAssignedToMe: true
},
isLoading: false,
error: null,
});
const params = Promise.resolve({ id: '1' });
await act(async () => {
render(<TaskDetailPage params={params} />);
});
const button = screen.getByText('Unassign');
expect(button).toBeInTheDocument();
await act(async () => {
button.click();
});
expect(mockUnassign).toHaveBeenCalledWith('1');
});
});

View File

@@ -3,13 +3,16 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/com
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { ShiftListItemDto } from '@/hooks/useShifts';
import { ShiftListItemDto, useSignUpShift, useCancelSignUp } from '@/hooks/useShifts';
interface ShiftCardProps {
shift: ShiftListItemDto;
}
export function ShiftCard({ shift }: ShiftCardProps) {
const signUpMutation = useSignUpShift();
const cancelMutation = useCancelSignUp();
const capacityPercentage = (shift.currentSignups / shift.capacity) * 100;
const isFull = shift.currentSignups >= shift.capacity;
const isPast = new Date(shift.startTime) < new Date();
@@ -39,8 +42,15 @@ export function ShiftCard({ shift }: ShiftCardProps) {
<Link href={`/shifts/${shift.id}`}>
<Button variant="outline" size="sm">View Details</Button>
</Link>
{!isPast && !isFull && (
<Button size="sm">Sign Up</Button>
{!isPast && !isFull && !shift.isSignedUp && (
<Button size="sm" onClick={() => signUpMutation.mutate(shift.id)} disabled={signUpMutation.isPending}>
{signUpMutation.isPending ? 'Signing up...' : 'Sign Up'}
</Button>
)}
{!isPast && shift.isSignedUp && (
<Button variant="outline" size="sm" onClick={() => cancelMutation.mutate(shift.id)} disabled={cancelMutation.isPending}>
{cancelMutation.isPending ? 'Canceling...' : 'Cancel Sign-up'}
</Button>
)}
</div>
</div>

View File

@@ -16,6 +16,7 @@ export interface ShiftListItemDto {
endTime: string;
capacity: number;
currentSignups: number;
isSignedUp: boolean;
}
export interface ShiftDetailDto {
@@ -31,6 +32,7 @@ export interface ShiftDetailDto {
createdById: string;
createdAt: string;
updatedAt: string;
isSignedUp: boolean;
}
export interface ShiftSignupDto {

View File

@@ -11,10 +11,9 @@ export interface TaskListDto {
export interface TaskListItemDto {
id: string;
title: string;
status: string;
assigneeId: string | null;
createdAt: string;
isAssignedToMe: boolean;
}
export interface TaskDetailDto {
@@ -28,6 +27,7 @@ export interface TaskDetailDto {
dueDate: string | null;
createdAt: string;
updatedAt: string;
isAssignedToMe: boolean;
}
export interface CreateTaskRequest {
@@ -120,3 +120,41 @@ export function useUpdateTask() {
},
});
}
export function useAssignTask() {
const queryClient = useQueryClient();
const { activeClubId } = useTenant();
return useMutation({
mutationFn: async (id: string) => {
const res = await apiClient(`/api/tasks/${id}/assign`, {
method: 'POST',
});
if (!res.ok) throw new Error('Failed to assign task');
return res;
},
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: ['tasks', activeClubId] });
queryClient.invalidateQueries({ queryKey: ['tasks', activeClubId, id] });
},
});
}
export function useUnassignTask() {
const queryClient = useQueryClient();
const { activeClubId } = useTenant();
return useMutation({
mutationFn: async (id: string) => {
const res = await apiClient(`/api/tasks/${id}/assign`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to unassign task');
return res;
},
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: ['tasks', activeClubId] });
queryClient.invalidateQueries({ queryKey: ['tasks', activeClubId, id] });
},
});
}