diff --git a/backend/WorkClub.Api/Endpoints/Shifts/ShiftEndpoints.cs b/backend/WorkClub.Api/Endpoints/Shifts/ShiftEndpoints.cs index 03ad142..0823308 100644 --- a/backend/WorkClub.Api/Endpoints/Shifts/ShiftEndpoints.cs +++ b/backend/WorkClub.Api/Endpoints/Shifts/ShiftEndpoints.cs @@ -42,20 +42,24 @@ public static class ShiftEndpoints private static async Task> GetShifts( ShiftService shiftService, + HttpContext httpContext, [FromQuery] DateTimeOffset? from = null, [FromQuery] DateTimeOffset? to = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 20) { - var result = await shiftService.GetShiftsAsync(from, to, page, pageSize); + var externalUserId = httpContext.User.FindFirst("sub")?.Value; + var result = await shiftService.GetShiftsAsync(from, to, page, pageSize, externalUserId); return TypedResults.Ok(result); } private static async Task, NotFound>> GetShift( Guid id, - ShiftService shiftService) + ShiftService shiftService, + HttpContext httpContext) { - var result = await shiftService.GetShiftByIdAsync(id); + var externalUserId = httpContext.User.FindFirst("sub")?.Value; + var result = await shiftService.GetShiftByIdAsync(id, externalUserId); if (result == null) return TypedResults.NotFound(); @@ -85,9 +89,11 @@ public static class ShiftEndpoints private static async Task, NotFound, Conflict>> UpdateShift( Guid id, UpdateShiftRequest request, - ShiftService shiftService) + ShiftService shiftService, + HttpContext httpContext) { - var (shift, error, isConflict) = await shiftService.UpdateShiftAsync(id, request); + var externalUserId = httpContext.User.FindFirst("sub")?.Value; + var (shift, error, isConflict) = await shiftService.UpdateShiftAsync(id, request, externalUserId); if (error != null) { diff --git a/backend/WorkClub.Api/Endpoints/Tasks/TaskEndpoints.cs b/backend/WorkClub.Api/Endpoints/Tasks/TaskEndpoints.cs index 199ebdf..aee359c 100644 --- a/backend/WorkClub.Api/Endpoints/Tasks/TaskEndpoints.cs +++ b/backend/WorkClub.Api/Endpoints/Tasks/TaskEndpoints.cs @@ -30,23 +30,35 @@ public static class TaskEndpoints group.MapDelete("{id:guid}", DeleteTask) .RequireAuthorization("RequireAdmin") .WithName("DeleteTask"); + + group.MapPost("{id:guid}/assign", AssignTaskToMe) + .RequireAuthorization("RequireMember") + .WithName("AssignTaskToMe"); + + group.MapDelete("{id:guid}/assign", UnassignTaskFromMe) + .RequireAuthorization("RequireMember") + .WithName("UnassignTaskFromMe"); } private static async Task> GetTasks( TaskService taskService, + HttpContext httpContext, [FromQuery] string? status = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 20) { - var result = await taskService.GetTasksAsync(status, page, pageSize); + var externalUserId = httpContext.User.FindFirst("sub")?.Value; + var result = await taskService.GetTasksAsync(status, page, pageSize, externalUserId); return TypedResults.Ok(result); } private static async Task, NotFound>> GetTask( Guid id, - TaskService taskService) + TaskService taskService, + HttpContext httpContext) { - var result = await taskService.GetTaskByIdAsync(id); + var externalUserId = httpContext.User.FindFirst("sub")?.Value; + var result = await taskService.GetTaskByIdAsync(id, externalUserId); if (result == null) return TypedResults.NotFound(); @@ -76,9 +88,11 @@ public static class TaskEndpoints private static async Task, NotFound, UnprocessableEntity, Conflict>> UpdateTask( Guid id, UpdateTaskRequest request, - TaskService taskService) + TaskService taskService, + HttpContext httpContext) { - var (task, error, isConflict) = await taskService.UpdateTaskAsync(id, request); + var externalUserId = httpContext.User.FindFirst("sub")?.Value; + var (task, error, isConflict) = await taskService.UpdateTaskAsync(id, request, externalUserId); if (error != null) { @@ -105,4 +119,42 @@ public static class TaskEndpoints return TypedResults.NoContent(); } + + private static async Task, NotFound>> AssignTaskToMe( + Guid id, + TaskService taskService, + HttpContext httpContext) + { + var externalUserId = httpContext.User.FindFirst("sub")?.Value; + if (externalUserId == null) return TypedResults.BadRequest("Invalid user"); + + var (success, error) = await taskService.AssignToMeAsync(id, externalUserId); + + if (!success) + { + if (error == "Task not found") return TypedResults.NotFound(); + return TypedResults.BadRequest(error ?? "Failed to assign task"); + } + + return TypedResults.Ok(); + } + + private static async Task, NotFound>> UnassignTaskFromMe( + Guid id, + TaskService taskService, + HttpContext httpContext) + { + var externalUserId = httpContext.User.FindFirst("sub")?.Value; + if (externalUserId == null) return TypedResults.BadRequest("Invalid user"); + + var (success, error) = await taskService.UnassignFromMeAsync(id, externalUserId); + + if (!success) + { + if (error == "Task not found") return TypedResults.NotFound(); + return TypedResults.BadRequest(error ?? "Failed to unassign task"); + } + + return TypedResults.Ok(); + } } diff --git a/backend/WorkClub.Api/Services/ShiftService.cs b/backend/WorkClub.Api/Services/ShiftService.cs index e40475e..6d5954a 100644 --- a/backend/WorkClub.Api/Services/ShiftService.cs +++ b/backend/WorkClub.Api/Services/ShiftService.cs @@ -17,7 +17,7 @@ public class ShiftService _tenantProvider = tenantProvider; } - public async Task GetShiftsAsync(DateTimeOffset? from, DateTimeOffset? to, int page, int pageSize) + public async Task GetShiftsAsync(DateTimeOffset? from, DateTimeOffset? to, int page, int pageSize, string? currentExternalUserId = null) { var query = _context.Shifts.AsQueryable(); @@ -42,19 +42,37 @@ public class ShiftService .Select(g => new { ShiftId = g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.ShiftId, x => x.Count); + var tenantId = _tenantProvider.GetTenantId(); + var memberId = currentExternalUserId != null + ? await _context.Members + .Where(m => m.ExternalUserId == currentExternalUserId && m.TenantId == tenantId) + .Select(m => (Guid?)m.Id) + .FirstOrDefaultAsync() + : null; + + var userSignups = memberId.HasValue + ? await _context.ShiftSignups + .Where(ss => shiftIds.Contains(ss.ShiftId) && ss.MemberId == memberId.Value) + .Select(ss => ss.ShiftId) + .ToListAsync() + : new List(); + + var userSignedUpShiftIds = userSignups.ToHashSet(); + var items = shifts.Select(s => new ShiftListItemDto( s.Id, s.Title, s.StartTime, s.EndTime, s.Capacity, - signupCounts.GetValueOrDefault(s.Id, 0) + signupCounts.GetValueOrDefault(s.Id, 0), + userSignedUpShiftIds.Contains(s.Id) )).ToList(); return new ShiftListDto(items, total, page, pageSize); } - public async Task GetShiftByIdAsync(Guid id) + public async Task GetShiftByIdAsync(Guid id, string? currentExternalUserId = null) { var shift = await _context.Shifts.FindAsync(id); @@ -75,6 +93,8 @@ public class ShiftService ss.SignedUpAt )).ToList(); + var isSignedUp = currentExternalUserId != null && signupDtos.Any(s => s.ExternalUserId == currentExternalUserId); + return new ShiftDetailDto( shift.Id, shift.Title, @@ -87,7 +107,8 @@ public class ShiftService shift.ClubId, shift.CreatedById, shift.CreatedAt, - shift.UpdatedAt + shift.UpdatedAt, + isSignedUp ); } @@ -126,13 +147,14 @@ public class ShiftService shift.ClubId, shift.CreatedById, shift.CreatedAt, - shift.UpdatedAt + shift.UpdatedAt, + false ); return (dto, null); } - public async Task<(ShiftDetailDto? shift, string? error, bool isConflict)> UpdateShiftAsync(Guid id, UpdateShiftRequest request) + public async Task<(ShiftDetailDto? shift, string? error, bool isConflict)> UpdateShiftAsync(Guid id, UpdateShiftRequest request, string? currentExternalUserId = null) { var shift = await _context.Shifts.FindAsync(id); @@ -182,6 +204,8 @@ public class ShiftService ss.SignedUpAt )).ToList(); + var isSignedUp = currentExternalUserId != null && signupDtos.Any(s => s.ExternalUserId == currentExternalUserId); + var dto = new ShiftDetailDto( shift.Id, shift.Title, @@ -194,7 +218,8 @@ public class ShiftService shift.ClubId, shift.CreatedById, shift.CreatedAt, - shift.UpdatedAt + shift.UpdatedAt, + isSignedUp ); return (dto, null, false); diff --git a/backend/WorkClub.Api/Services/TaskService.cs b/backend/WorkClub.Api/Services/TaskService.cs index 3a65f17..797ac7c 100644 --- a/backend/WorkClub.Api/Services/TaskService.cs +++ b/backend/WorkClub.Api/Services/TaskService.cs @@ -18,7 +18,7 @@ public class TaskService _tenantProvider = tenantProvider; } - public async Task GetTasksAsync(string? statusFilter, int page, int pageSize) + public async Task GetTasksAsync(string? statusFilter, int page, int pageSize, string? currentExternalUserId = null) { var query = _context.WorkItems.AsQueryable(); @@ -38,24 +38,45 @@ public class TaskService .Take(pageSize) .ToListAsync(); + Guid? memberId = null; + if (currentExternalUserId != null) + { + var tenantId = _tenantProvider.GetTenantId(); + memberId = await _context.Members + .Where(m => m.ExternalUserId == currentExternalUserId && m.TenantId == tenantId) + .Select(m => m.Id) + .FirstOrDefaultAsync(); + } + var itemDtos = items.Select(w => new TaskListItemDto( w.Id, w.Title, w.Status.ToString(), w.AssigneeId, - w.CreatedAt + w.CreatedAt, + memberId != null && w.AssigneeId == memberId )).ToList(); return new TaskListDto(itemDtos, total, page, pageSize); } - public async Task GetTaskByIdAsync(Guid id) + public async Task GetTaskByIdAsync(Guid id, string? currentExternalUserId = null) { var workItem = await _context.WorkItems.FindAsync(id); if (workItem == null) return null; + Guid? memberId = null; + if (currentExternalUserId != null) + { + var tenantId = _tenantProvider.GetTenantId(); + memberId = await _context.Members + .Where(m => m.ExternalUserId == currentExternalUserId && m.TenantId == tenantId) + .Select(m => m.Id) + .FirstOrDefaultAsync(); + } + return new TaskDetailDto( workItem.Id, workItem.Title, @@ -66,7 +87,8 @@ public class TaskService workItem.ClubId, workItem.DueDate, workItem.CreatedAt, - workItem.UpdatedAt + workItem.UpdatedAt, + memberId != null && workItem.AssigneeId == memberId ); } @@ -102,13 +124,14 @@ public class TaskService workItem.ClubId, workItem.DueDate, workItem.CreatedAt, - workItem.UpdatedAt + workItem.UpdatedAt, + false ); return (dto, null); } - public async Task<(TaskDetailDto? task, string? error, bool isConflict)> UpdateTaskAsync(Guid id, UpdateTaskRequest request) + public async Task<(TaskDetailDto? task, string? error, bool isConflict)> UpdateTaskAsync(Guid id, UpdateTaskRequest request, string? currentExternalUserId = null) { var workItem = await _context.WorkItems.FindAsync(id); @@ -153,6 +176,16 @@ public class TaskService return (null, "Task was modified by another user. Please refresh and try again.", true); } + Guid? memberId = null; + if (currentExternalUserId != null) + { + var tenantId = _tenantProvider.GetTenantId(); + memberId = await _context.Members + .Where(m => m.ExternalUserId == currentExternalUserId && m.TenantId == tenantId) + .Select(m => m.Id) + .FirstOrDefaultAsync(); + } + var dto = new TaskDetailDto( workItem.Id, workItem.Title, @@ -163,7 +196,8 @@ public class TaskService workItem.ClubId, workItem.DueDate, workItem.CreatedAt, - workItem.UpdatedAt + workItem.UpdatedAt, + memberId != null && workItem.AssigneeId == memberId ); return (dto, null, false); @@ -181,4 +215,81 @@ public class TaskService return true; } + + public async Task<(bool success, string? error)> AssignToMeAsync(Guid taskId, string externalUserId) + { + var tenantId = _tenantProvider.GetTenantId(); + var memberId = await _context.Members + .Where(m => m.ExternalUserId == externalUserId && m.TenantId == tenantId) + .Select(m => m.Id) + .FirstOrDefaultAsync(); + + if (memberId == Guid.Empty) + return (false, "User is not a member of this club"); + + var workItem = await _context.WorkItems.FindAsync(taskId); + if (workItem == null) + return (false, "Task not found"); + + if (workItem.AssigneeId.HasValue) + return (false, "Task is already assigned"); + + workItem.AssigneeId = memberId; + + if (workItem.CanTransitionTo(WorkItemStatus.Assigned)) + workItem.TransitionTo(WorkItemStatus.Assigned); + + workItem.UpdatedAt = DateTimeOffset.UtcNow; + + try + { + await _context.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + return (false, "Task was modified by another user"); + } + + return (true, null); + } + + public async Task<(bool success, string? error)> UnassignFromMeAsync(Guid taskId, string externalUserId) + { + var tenantId = _tenantProvider.GetTenantId(); + var memberId = await _context.Members + .Where(m => m.ExternalUserId == externalUserId && m.TenantId == tenantId) + .Select(m => m.Id) + .FirstOrDefaultAsync(); + + if (memberId == Guid.Empty) + return (false, "User is not a member of this club"); + + var workItem = await _context.WorkItems.FindAsync(taskId); + if (workItem == null) + return (false, "Task not found"); + + if (workItem.AssigneeId != memberId) + return (false, "Task is not assigned to you"); + + workItem.AssigneeId = null; + + if (workItem.Status == WorkItemStatus.Assigned || workItem.Status == WorkItemStatus.InProgress) + { + // Transition back to open if no longer assigned and not marked Review/Done + workItem.Status = WorkItemStatus.Open; + } + + workItem.UpdatedAt = DateTimeOffset.UtcNow; + + try + { + await _context.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + return (false, "Task was modified by another user"); + } + + return (true, null); + } } diff --git a/backend/WorkClub.Application/Shifts/DTOs/ShiftDetailDto.cs b/backend/WorkClub.Application/Shifts/DTOs/ShiftDetailDto.cs index 1e4250a..4e450e9 100644 --- a/backend/WorkClub.Application/Shifts/DTOs/ShiftDetailDto.cs +++ b/backend/WorkClub.Application/Shifts/DTOs/ShiftDetailDto.cs @@ -12,7 +12,8 @@ public record ShiftDetailDto( Guid ClubId, Guid CreatedById, DateTimeOffset CreatedAt, - DateTimeOffset UpdatedAt + DateTimeOffset UpdatedAt, + bool IsSignedUp ); public record ShiftSignupDto( diff --git a/backend/WorkClub.Application/Shifts/DTOs/ShiftListDto.cs b/backend/WorkClub.Application/Shifts/DTOs/ShiftListDto.cs index 7b36cbf..ff65486 100644 --- a/backend/WorkClub.Application/Shifts/DTOs/ShiftListDto.cs +++ b/backend/WorkClub.Application/Shifts/DTOs/ShiftListDto.cs @@ -13,5 +13,6 @@ public record ShiftListItemDto( DateTimeOffset StartTime, DateTimeOffset EndTime, int Capacity, - int CurrentSignups + int CurrentSignups, + bool IsSignedUp ); diff --git a/backend/WorkClub.Application/Tasks/DTOs/TaskDetailDto.cs b/backend/WorkClub.Application/Tasks/DTOs/TaskDetailDto.cs index a1b22d5..e05dc7f 100644 --- a/backend/WorkClub.Application/Tasks/DTOs/TaskDetailDto.cs +++ b/backend/WorkClub.Application/Tasks/DTOs/TaskDetailDto.cs @@ -10,5 +10,6 @@ public record TaskDetailDto( Guid ClubId, DateTimeOffset? DueDate, DateTimeOffset CreatedAt, - DateTimeOffset UpdatedAt + DateTimeOffset UpdatedAt, + bool IsAssignedToMe ); diff --git a/backend/WorkClub.Application/Tasks/DTOs/TaskListDto.cs b/backend/WorkClub.Application/Tasks/DTOs/TaskListDto.cs index f75b26c..0c75d7f 100644 --- a/backend/WorkClub.Application/Tasks/DTOs/TaskListDto.cs +++ b/backend/WorkClub.Application/Tasks/DTOs/TaskListDto.cs @@ -12,5 +12,6 @@ public record TaskListItemDto( string Title, string Status, Guid? AssigneeId, - DateTimeOffset CreatedAt + DateTimeOffset CreatedAt, + bool IsAssignedToMe ); diff --git a/backend/WorkClub.Tests.Integration/Shifts/ShiftCrudTests.cs b/backend/WorkClub.Tests.Integration/Shifts/ShiftCrudTests.cs index 0126418..486f4c1 100644 --- a/backend/WorkClub.Tests.Integration/Shifts/ShiftCrudTests.cs +++ b/backend/WorkClub.Tests.Integration/Shifts/ShiftCrudTests.cs @@ -198,9 +198,8 @@ public class ShiftCrudTests : IntegrationTestBase { // Arrange var shiftId = Guid.NewGuid(); - var clubId = Guid.NewGuid(); + var (clubId, memberId, externalUserId) = await SeedMemberAsync("tenant1", "member@test.com"); var createdBy = Guid.NewGuid(); - var memberId = Guid.NewGuid(); var now = DateTimeOffset.UtcNow; using (var scope = Factory.Services.CreateScope()) @@ -236,7 +235,7 @@ public class ShiftCrudTests : IntegrationTestBase } SetTenant("tenant1"); - AuthenticateAs("member@test.com", new Dictionary { ["tenant1"] = "Member" }); + AuthenticateAs("member@test.com", new Dictionary { ["tenant1"] = "Member" }, externalUserId); // Act var response = await Client.GetAsync($"/api/shifts/{shiftId}"); @@ -707,6 +706,6 @@ public class ShiftCrudTests : IntegrationTestBase // Response DTOs for test assertions public record ShiftListResponse(List Items, int Total, int Page, int PageSize); -public record ShiftListItemResponse(Guid Id, string Title, DateTimeOffset StartTime, DateTimeOffset EndTime, int Capacity, int CurrentSignups); +public record ShiftListItemResponse(Guid Id, string Title, DateTimeOffset StartTime, DateTimeOffset EndTime, int Capacity, int CurrentSignups, bool IsSignedUp); public record ShiftDetailResponse(Guid Id, string Title, string? Description, string? Location, DateTimeOffset StartTime, DateTimeOffset EndTime, int Capacity, List Signups, Guid ClubId, Guid CreatedById, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); public record ShiftSignupResponse(Guid Id, Guid MemberId, DateTimeOffset SignedUpAt); diff --git a/frontend/src/app/(protected)/shifts/[id]/page.tsx b/frontend/src/app/(protected)/shifts/[id]/page.tsx index be3409f..a1e4fb6 100644 --- a/frontend/src/app/(protected)/shifts/[id]/page.tsx +++ b/frontend/src/app/(protected)/shifts/[id]/page.tsx @@ -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); diff --git a/frontend/src/app/(protected)/tasks/[id]/page.tsx b/frontend/src/app/(protected)/tasks/[id]/page.tsx index c68e327..d28a279 100644 --- a/frontend/src/app/(protected)/tasks/[id]/page.tsx +++ b/frontend/src/app/(protected)/tasks/[id]/page.tsx @@ -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 = { 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
Loading task...
; if (error || !task) return
Failed to load task.
; @@ -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'} + + )} + {task.isAssignedToMe && ( + )} {validTransitions.map((nextStatus) => ( diff --git a/frontend/src/components/__tests__/shift-card.test.tsx b/frontend/src/components/__tests__/shift-card.test.tsx index 0092df6..58f23cf 100644 --- a/frontend/src/components/__tests__/shift-card.test.tsx +++ b/frontend/src/components/__tests__/shift-card.test.tsx @@ -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).mockReturnValue({ mutate: mockSignUp, isPending: false }); + (useCancelSignUp as ReturnType).mockReturnValue({ mutate: mockCancel, isPending: false }); + }); + it('shows capacity correctly (2/3 spots filled)', () => { render( { 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( + + ); + expect(screen.getByText('Cancel Sign-up')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/__tests__/shift-detail.test.tsx b/frontend/src/components/__tests__/shift-detail.test.tsx index 2221312..950af58 100644 --- a/frontend/src/components/__tests__/shift-detail.test.tsx +++ b/frontend/src/components/__tests__/shift-detail.test.tsx @@ -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, }); diff --git a/frontend/src/components/__tests__/task-detail.test.tsx b/frontend/src/components/__tests__/task-detail.test.tsx index 60d9f23..dd249e0 100644 --- a/frontend/src/components/__tests__/task-detail.test.tsx +++ b/frontend/src/components/__tests__/task-detail.test.tsx @@ -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).mockReturnValue({ - mutate: mockMutate, + mutate: mockUpdate, + isPending: false, + }); + (useAssignTask as ReturnType).mockReturnValue({ + mutate: mockAssign, + isPending: false, + }); + (useUnassignTask as ReturnType).mockReturnValue({ + mutate: mockUnassign, isPending: false, }); }); it('shows valid transitions for Open status', async () => { (useTask as ReturnType).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).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).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).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).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).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(); + }); + + const button = screen.getByText('Unassign'); + expect(button).toBeInTheDocument(); + + await act(async () => { + button.click(); + }); + + expect(mockUnassign).toHaveBeenCalledWith('1'); }); }); diff --git a/frontend/src/components/shifts/shift-card.tsx b/frontend/src/components/shifts/shift-card.tsx index ffd4677..abcf1ed 100644 --- a/frontend/src/components/shifts/shift-card.tsx +++ b/frontend/src/components/shifts/shift-card.tsx @@ -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) { - {!isPast && !isFull && ( - + {!isPast && !isFull && !shift.isSignedUp && ( + + )} + {!isPast && shift.isSignedUp && ( + )} diff --git a/frontend/src/hooks/useShifts.ts b/frontend/src/hooks/useShifts.ts index 26fdafe..eea43f2 100644 --- a/frontend/src/hooks/useShifts.ts +++ b/frontend/src/hooks/useShifts.ts @@ -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 { diff --git a/frontend/src/hooks/useTasks.ts b/frontend/src/hooks/useTasks.ts index 222452b..18bc86e 100644 --- a/frontend/src/hooks/useTasks.ts +++ b/frontend/src/hooks/useTasks.ts @@ -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] }); + }, + }); +}