Fix task and shift self-assignment features
This commit is contained in:
@@ -42,20 +42,24 @@ public static class ShiftEndpoints
|
||||
|
||||
private static async Task<Ok<ShiftListDto>> 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<Results<Ok<ShiftDetailDto>, 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<Results<Ok<ShiftDetailDto>, NotFound, Conflict<string>>> 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)
|
||||
{
|
||||
|
||||
@@ -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<Ok<TaskListDto>> 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<Results<Ok<TaskDetailDto>, 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<Results<Ok<TaskDetailDto>, NotFound, UnprocessableEntity<string>, Conflict<string>>> 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<Results<Ok, BadRequest<string>, 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<Results<Ok, BadRequest<string>, 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public class ShiftService
|
||||
_tenantProvider = tenantProvider;
|
||||
}
|
||||
|
||||
public async Task<ShiftListDto> GetShiftsAsync(DateTimeOffset? from, DateTimeOffset? to, int page, int pageSize)
|
||||
public async Task<ShiftListDto> 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<Guid>();
|
||||
|
||||
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<ShiftDetailDto?> GetShiftByIdAsync(Guid id)
|
||||
public async Task<ShiftDetailDto?> 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);
|
||||
|
||||
@@ -18,7 +18,7 @@ public class TaskService
|
||||
_tenantProvider = tenantProvider;
|
||||
}
|
||||
|
||||
public async Task<TaskListDto> GetTasksAsync(string? statusFilter, int page, int pageSize)
|
||||
public async Task<TaskListDto> 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<TaskDetailDto?> GetTaskByIdAsync(Guid id)
|
||||
public async Task<TaskDetailDto?> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ public record ShiftDetailDto(
|
||||
Guid ClubId,
|
||||
Guid CreatedById,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt
|
||||
DateTimeOffset UpdatedAt,
|
||||
bool IsSignedUp
|
||||
);
|
||||
|
||||
public record ShiftSignupDto(
|
||||
|
||||
@@ -13,5 +13,6 @@ public record ShiftListItemDto(
|
||||
DateTimeOffset StartTime,
|
||||
DateTimeOffset EndTime,
|
||||
int Capacity,
|
||||
int CurrentSignups
|
||||
int CurrentSignups,
|
||||
bool IsSignedUp
|
||||
);
|
||||
|
||||
@@ -10,5 +10,6 @@ public record TaskDetailDto(
|
||||
Guid ClubId,
|
||||
DateTimeOffset? DueDate,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt
|
||||
DateTimeOffset UpdatedAt,
|
||||
bool IsAssignedToMe
|
||||
);
|
||||
|
||||
@@ -12,5 +12,6 @@ public record TaskListItemDto(
|
||||
string Title,
|
||||
string Status,
|
||||
Guid? AssigneeId,
|
||||
DateTimeOffset CreatedAt
|
||||
DateTimeOffset CreatedAt,
|
||||
bool IsAssignedToMe
|
||||
);
|
||||
|
||||
@@ -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<string, string> { ["tenant1"] = "Member" });
|
||||
AuthenticateAs("member@test.com", new Dictionary<string, string> { ["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<ShiftListItemResponse> 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<ShiftSignupResponse> Signups, Guid ClubId, Guid CreatedById, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);
|
||||
public record ShiftSignupResponse(Guid Id, Guid MemberId, DateTimeOffset SignedUpAt);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user