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(
|
private static async Task<Ok<ShiftListDto>> GetShifts(
|
||||||
ShiftService shiftService,
|
ShiftService shiftService,
|
||||||
|
HttpContext httpContext,
|
||||||
[FromQuery] DateTimeOffset? from = null,
|
[FromQuery] DateTimeOffset? from = null,
|
||||||
[FromQuery] DateTimeOffset? to = null,
|
[FromQuery] DateTimeOffset? to = null,
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
[FromQuery] int pageSize = 20)
|
[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);
|
return TypedResults.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Results<Ok<ShiftDetailDto>, NotFound>> GetShift(
|
private static async Task<Results<Ok<ShiftDetailDto>, NotFound>> GetShift(
|
||||||
Guid id,
|
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)
|
if (result == null)
|
||||||
return TypedResults.NotFound();
|
return TypedResults.NotFound();
|
||||||
@@ -85,9 +89,11 @@ public static class ShiftEndpoints
|
|||||||
private static async Task<Results<Ok<ShiftDetailDto>, NotFound, Conflict<string>>> UpdateShift(
|
private static async Task<Results<Ok<ShiftDetailDto>, NotFound, Conflict<string>>> UpdateShift(
|
||||||
Guid id,
|
Guid id,
|
||||||
UpdateShiftRequest request,
|
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)
|
if (error != null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,23 +30,35 @@ public static class TaskEndpoints
|
|||||||
group.MapDelete("{id:guid}", DeleteTask)
|
group.MapDelete("{id:guid}", DeleteTask)
|
||||||
.RequireAuthorization("RequireAdmin")
|
.RequireAuthorization("RequireAdmin")
|
||||||
.WithName("DeleteTask");
|
.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(
|
private static async Task<Ok<TaskListDto>> GetTasks(
|
||||||
TaskService taskService,
|
TaskService taskService,
|
||||||
|
HttpContext httpContext,
|
||||||
[FromQuery] string? status = null,
|
[FromQuery] string? status = null,
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
[FromQuery] int pageSize = 20)
|
[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);
|
return TypedResults.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Results<Ok<TaskDetailDto>, NotFound>> GetTask(
|
private static async Task<Results<Ok<TaskDetailDto>, NotFound>> GetTask(
|
||||||
Guid id,
|
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)
|
if (result == null)
|
||||||
return TypedResults.NotFound();
|
return TypedResults.NotFound();
|
||||||
@@ -76,9 +88,11 @@ public static class TaskEndpoints
|
|||||||
private static async Task<Results<Ok<TaskDetailDto>, NotFound, UnprocessableEntity<string>, Conflict<string>>> UpdateTask(
|
private static async Task<Results<Ok<TaskDetailDto>, NotFound, UnprocessableEntity<string>, Conflict<string>>> UpdateTask(
|
||||||
Guid id,
|
Guid id,
|
||||||
UpdateTaskRequest request,
|
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)
|
if (error != null)
|
||||||
{
|
{
|
||||||
@@ -105,4 +119,42 @@ public static class TaskEndpoints
|
|||||||
|
|
||||||
return TypedResults.NoContent();
|
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;
|
_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();
|
var query = _context.Shifts.AsQueryable();
|
||||||
|
|
||||||
@@ -42,19 +42,37 @@ public class ShiftService
|
|||||||
.Select(g => new { ShiftId = g.Key, Count = g.Count() })
|
.Select(g => new { ShiftId = g.Key, Count = g.Count() })
|
||||||
.ToDictionaryAsync(x => x.ShiftId, x => x.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(
|
var items = shifts.Select(s => new ShiftListItemDto(
|
||||||
s.Id,
|
s.Id,
|
||||||
s.Title,
|
s.Title,
|
||||||
s.StartTime,
|
s.StartTime,
|
||||||
s.EndTime,
|
s.EndTime,
|
||||||
s.Capacity,
|
s.Capacity,
|
||||||
signupCounts.GetValueOrDefault(s.Id, 0)
|
signupCounts.GetValueOrDefault(s.Id, 0),
|
||||||
|
userSignedUpShiftIds.Contains(s.Id)
|
||||||
)).ToList();
|
)).ToList();
|
||||||
|
|
||||||
return new ShiftListDto(items, total, page, pageSize);
|
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);
|
var shift = await _context.Shifts.FindAsync(id);
|
||||||
|
|
||||||
@@ -75,6 +93,8 @@ public class ShiftService
|
|||||||
ss.SignedUpAt
|
ss.SignedUpAt
|
||||||
)).ToList();
|
)).ToList();
|
||||||
|
|
||||||
|
var isSignedUp = currentExternalUserId != null && signupDtos.Any(s => s.ExternalUserId == currentExternalUserId);
|
||||||
|
|
||||||
return new ShiftDetailDto(
|
return new ShiftDetailDto(
|
||||||
shift.Id,
|
shift.Id,
|
||||||
shift.Title,
|
shift.Title,
|
||||||
@@ -87,7 +107,8 @@ public class ShiftService
|
|||||||
shift.ClubId,
|
shift.ClubId,
|
||||||
shift.CreatedById,
|
shift.CreatedById,
|
||||||
shift.CreatedAt,
|
shift.CreatedAt,
|
||||||
shift.UpdatedAt
|
shift.UpdatedAt,
|
||||||
|
isSignedUp
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,13 +147,14 @@ public class ShiftService
|
|||||||
shift.ClubId,
|
shift.ClubId,
|
||||||
shift.CreatedById,
|
shift.CreatedById,
|
||||||
shift.CreatedAt,
|
shift.CreatedAt,
|
||||||
shift.UpdatedAt
|
shift.UpdatedAt,
|
||||||
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
return (dto, null);
|
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);
|
var shift = await _context.Shifts.FindAsync(id);
|
||||||
|
|
||||||
@@ -182,6 +204,8 @@ public class ShiftService
|
|||||||
ss.SignedUpAt
|
ss.SignedUpAt
|
||||||
)).ToList();
|
)).ToList();
|
||||||
|
|
||||||
|
var isSignedUp = currentExternalUserId != null && signupDtos.Any(s => s.ExternalUserId == currentExternalUserId);
|
||||||
|
|
||||||
var dto = new ShiftDetailDto(
|
var dto = new ShiftDetailDto(
|
||||||
shift.Id,
|
shift.Id,
|
||||||
shift.Title,
|
shift.Title,
|
||||||
@@ -194,7 +218,8 @@ public class ShiftService
|
|||||||
shift.ClubId,
|
shift.ClubId,
|
||||||
shift.CreatedById,
|
shift.CreatedById,
|
||||||
shift.CreatedAt,
|
shift.CreatedAt,
|
||||||
shift.UpdatedAt
|
shift.UpdatedAt,
|
||||||
|
isSignedUp
|
||||||
);
|
);
|
||||||
|
|
||||||
return (dto, null, false);
|
return (dto, null, false);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public class TaskService
|
|||||||
_tenantProvider = tenantProvider;
|
_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();
|
var query = _context.WorkItems.AsQueryable();
|
||||||
|
|
||||||
@@ -38,24 +38,45 @@ public class TaskService
|
|||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.ToListAsync();
|
.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(
|
var itemDtos = items.Select(w => new TaskListItemDto(
|
||||||
w.Id,
|
w.Id,
|
||||||
w.Title,
|
w.Title,
|
||||||
w.Status.ToString(),
|
w.Status.ToString(),
|
||||||
w.AssigneeId,
|
w.AssigneeId,
|
||||||
w.CreatedAt
|
w.CreatedAt,
|
||||||
|
memberId != null && w.AssigneeId == memberId
|
||||||
)).ToList();
|
)).ToList();
|
||||||
|
|
||||||
return new TaskListDto(itemDtos, total, page, pageSize);
|
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);
|
var workItem = await _context.WorkItems.FindAsync(id);
|
||||||
|
|
||||||
if (workItem == null)
|
if (workItem == null)
|
||||||
return 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(
|
return new TaskDetailDto(
|
||||||
workItem.Id,
|
workItem.Id,
|
||||||
workItem.Title,
|
workItem.Title,
|
||||||
@@ -66,7 +87,8 @@ public class TaskService
|
|||||||
workItem.ClubId,
|
workItem.ClubId,
|
||||||
workItem.DueDate,
|
workItem.DueDate,
|
||||||
workItem.CreatedAt,
|
workItem.CreatedAt,
|
||||||
workItem.UpdatedAt
|
workItem.UpdatedAt,
|
||||||
|
memberId != null && workItem.AssigneeId == memberId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,13 +124,14 @@ public class TaskService
|
|||||||
workItem.ClubId,
|
workItem.ClubId,
|
||||||
workItem.DueDate,
|
workItem.DueDate,
|
||||||
workItem.CreatedAt,
|
workItem.CreatedAt,
|
||||||
workItem.UpdatedAt
|
workItem.UpdatedAt,
|
||||||
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
return (dto, null);
|
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);
|
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);
|
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(
|
var dto = new TaskDetailDto(
|
||||||
workItem.Id,
|
workItem.Id,
|
||||||
workItem.Title,
|
workItem.Title,
|
||||||
@@ -163,7 +196,8 @@ public class TaskService
|
|||||||
workItem.ClubId,
|
workItem.ClubId,
|
||||||
workItem.DueDate,
|
workItem.DueDate,
|
||||||
workItem.CreatedAt,
|
workItem.CreatedAt,
|
||||||
workItem.UpdatedAt
|
workItem.UpdatedAt,
|
||||||
|
memberId != null && workItem.AssigneeId == memberId
|
||||||
);
|
);
|
||||||
|
|
||||||
return (dto, null, false);
|
return (dto, null, false);
|
||||||
@@ -181,4 +215,81 @@ public class TaskService
|
|||||||
|
|
||||||
return true;
|
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 ClubId,
|
||||||
Guid CreatedById,
|
Guid CreatedById,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
DateTimeOffset UpdatedAt
|
DateTimeOffset UpdatedAt,
|
||||||
|
bool IsSignedUp
|
||||||
);
|
);
|
||||||
|
|
||||||
public record ShiftSignupDto(
|
public record ShiftSignupDto(
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ public record ShiftListItemDto(
|
|||||||
DateTimeOffset StartTime,
|
DateTimeOffset StartTime,
|
||||||
DateTimeOffset EndTime,
|
DateTimeOffset EndTime,
|
||||||
int Capacity,
|
int Capacity,
|
||||||
int CurrentSignups
|
int CurrentSignups,
|
||||||
|
bool IsSignedUp
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ public record TaskDetailDto(
|
|||||||
Guid ClubId,
|
Guid ClubId,
|
||||||
DateTimeOffset? DueDate,
|
DateTimeOffset? DueDate,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
DateTimeOffset UpdatedAt
|
DateTimeOffset UpdatedAt,
|
||||||
|
bool IsAssignedToMe
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,5 +12,6 @@ public record TaskListItemDto(
|
|||||||
string Title,
|
string Title,
|
||||||
string Status,
|
string Status,
|
||||||
Guid? AssigneeId,
|
Guid? AssigneeId,
|
||||||
DateTimeOffset CreatedAt
|
DateTimeOffset CreatedAt,
|
||||||
|
bool IsAssignedToMe
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -198,9 +198,8 @@ public class ShiftCrudTests : IntegrationTestBase
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var shiftId = Guid.NewGuid();
|
var shiftId = Guid.NewGuid();
|
||||||
var clubId = Guid.NewGuid();
|
var (clubId, memberId, externalUserId) = await SeedMemberAsync("tenant1", "member@test.com");
|
||||||
var createdBy = Guid.NewGuid();
|
var createdBy = Guid.NewGuid();
|
||||||
var memberId = Guid.NewGuid();
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
using (var scope = Factory.Services.CreateScope())
|
using (var scope = Factory.Services.CreateScope())
|
||||||
@@ -236,7 +235,7 @@ public class ShiftCrudTests : IntegrationTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
SetTenant("tenant1");
|
SetTenant("tenant1");
|
||||||
AuthenticateAs("member@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" });
|
AuthenticateAs("member@test.com", new Dictionary<string, string> { ["tenant1"] = "Member" }, externalUserId);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await Client.GetAsync($"/api/shifts/{shiftId}");
|
var response = await Client.GetAsync($"/api/shifts/{shiftId}");
|
||||||
@@ -707,6 +706,6 @@ public class ShiftCrudTests : IntegrationTestBase
|
|||||||
|
|
||||||
// Response DTOs for test assertions
|
// Response DTOs for test assertions
|
||||||
public record ShiftListResponse(List<ShiftListItemResponse> Items, int Total, int Page, int PageSize);
|
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 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);
|
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 capacityPercentage = (shift.signups.length / shift.capacity) * 100;
|
||||||
const isFull = shift.signups.length >= shift.capacity;
|
const isFull = shift.signups.length >= shift.capacity;
|
||||||
const isPast = new Date(shift.startTime) < new Date();
|
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 () => {
|
const handleSignUp = async () => {
|
||||||
await signUpMutation.mutateAsync(shift.id);
|
await signUpMutation.mutateAsync(shift.id);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { use } from 'react';
|
import { use } from 'react';
|
||||||
import Link from 'next/link';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
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 }> }) {
|
export default function TaskDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const resolvedParams = use(params);
|
const resolvedParams = use(params);
|
||||||
const { data: task, isLoading, error } = useTask(resolvedParams.id);
|
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 { data: session } = useSession();
|
||||||
|
|
||||||
|
const isPending = isUpdating || isAssigning || isUnassigning;
|
||||||
|
|
||||||
if (isLoading) return <div className="p-8">Loading task...</div>;
|
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>;
|
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 = () => {
|
const handleAssignToMe = () => {
|
||||||
if (session?.user?.id) {
|
assignTask(task.id);
|
||||||
updateTask({ id: task.id, data: { assigneeId: session.user.id } });
|
};
|
||||||
}
|
|
||||||
|
const handleUnassign = () => {
|
||||||
|
unassignTask(task.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTransitionLabel = (status: string, newStatus: string) => {
|
const getTransitionLabel = (status: string, newStatus: string) => {
|
||||||
@@ -107,7 +113,16 @@ export default function TaskDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
variant="outline"
|
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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{validTransitions.map((nextStatus) => (
|
{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 { render, screen } from '@testing-library/react';
|
||||||
import { ShiftCard } from '../shifts/shift-card';
|
import { ShiftCard } from '../shifts/shift-card';
|
||||||
|
import { useSignUpShift, useCancelSignUp } from '@/hooks/useShifts';
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useShifts', () => ({
|
||||||
|
useSignUpShift: vi.fn(),
|
||||||
|
useCancelSignUp: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('ShiftCard', () => {
|
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)', () => {
|
it('shows capacity correctly (2/3 spots filled)', () => {
|
||||||
render(
|
render(
|
||||||
<ShiftCard
|
<ShiftCard
|
||||||
@@ -13,6 +28,7 @@ describe('ShiftCard', () => {
|
|||||||
endTime: new Date(Date.now() + 200000).toISOString(),
|
endTime: new Date(Date.now() + 200000).toISOString(),
|
||||||
capacity: 3,
|
capacity: 3,
|
||||||
currentSignups: 2,
|
currentSignups: 2,
|
||||||
|
isSignedUp: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -29,6 +45,7 @@ describe('ShiftCard', () => {
|
|||||||
endTime: new Date(Date.now() + 200000).toISOString(),
|
endTime: new Date(Date.now() + 200000).toISOString(),
|
||||||
capacity: 3,
|
capacity: 3,
|
||||||
currentSignups: 3,
|
currentSignups: 3,
|
||||||
|
isSignedUp: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -46,10 +63,28 @@ describe('ShiftCard', () => {
|
|||||||
endTime: new Date(Date.now() - 100000).toISOString(),
|
endTime: new Date(Date.now() - 100000).toISOString(),
|
||||||
capacity: 3,
|
capacity: 3,
|
||||||
currentSignups: 1,
|
currentSignups: 1,
|
||||||
|
isSignedUp: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
expect(screen.getByText('Past')).toBeInTheDocument();
|
expect(screen.getByText('Past')).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('button', { name: 'Sign Up' })).not.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(),
|
endTime: new Date(Date.now() + 200000).toISOString(),
|
||||||
capacity: 3,
|
capacity: 3,
|
||||||
signups: [{ id: 's1', memberId: 'other-user' }],
|
signups: [{ id: 's1', memberId: 'other-user' }],
|
||||||
|
isSignedUp: false,
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
@@ -77,6 +78,7 @@ describe('ShiftDetailPage', () => {
|
|||||||
endTime: new Date(Date.now() + 200000).toISOString(),
|
endTime: new Date(Date.now() + 200000).toISOString(),
|
||||||
capacity: 3,
|
capacity: 3,
|
||||||
signups: [{ id: 's1', memberId: 'user-123' }],
|
signups: [{ id: 's1', memberId: 'user-123' }],
|
||||||
|
isSignedUp: true,
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
@@ -103,6 +105,7 @@ describe('ShiftDetailPage', () => {
|
|||||||
endTime: new Date(Date.now() + 200000).toISOString(),
|
endTime: new Date(Date.now() + 200000).toISOString(),
|
||||||
capacity: 3,
|
capacity: 3,
|
||||||
signups: [],
|
signups: [],
|
||||||
|
isSignedUp: false,
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen, act } from '@testing-library/react';
|
import { render, screen, act } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import TaskDetailPage from '@/app/(protected)/tasks/[id]/page';
|
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', () => ({
|
vi.mock('next/navigation', () => ({
|
||||||
useRouter: vi.fn(() => ({
|
useRouter: vi.fn(() => ({
|
||||||
@@ -21,21 +21,34 @@ vi.mock('next-auth/react', () => ({
|
|||||||
vi.mock('@/hooks/useTasks', () => ({
|
vi.mock('@/hooks/useTasks', () => ({
|
||||||
useTask: vi.fn(),
|
useTask: vi.fn(),
|
||||||
useUpdateTask: vi.fn(),
|
useUpdateTask: vi.fn(),
|
||||||
|
useAssignTask: vi.fn(),
|
||||||
|
useUnassignTask: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('TaskDetailPage', () => {
|
describe('TaskDetailPage', () => {
|
||||||
const mockMutate = vi.fn();
|
const mockUpdate = vi.fn();
|
||||||
|
const mockAssign = vi.fn();
|
||||||
|
const mockUnassign = vi.fn();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
(useUpdateTask as ReturnType<typeof vi.fn>).mockReturnValue({
|
(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,
|
isPending: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows valid transitions for Open status', async () => {
|
it('shows valid transitions for Open status', async () => {
|
||||||
(useTask as ReturnType<typeof vi.fn>).mockReturnValue({
|
(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,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -52,7 +65,7 @@ describe('TaskDetailPage', () => {
|
|||||||
|
|
||||||
it('shows valid transitions for InProgress status', async () => {
|
it('shows valid transitions for InProgress status', async () => {
|
||||||
(useTask as ReturnType<typeof vi.fn>).mockReturnValue({
|
(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,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -68,7 +81,7 @@ describe('TaskDetailPage', () => {
|
|||||||
|
|
||||||
it('shows valid transitions for Review status (including back transition)', async () => {
|
it('shows valid transitions for Review status (including back transition)', async () => {
|
||||||
(useTask as ReturnType<typeof vi.fn>).mockReturnValue({
|
(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,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -91,7 +104,8 @@ describe('TaskDetailPage', () => {
|
|||||||
assigneeId: null,
|
assigneeId: null,
|
||||||
description: 'Desc',
|
description: 'Desc',
|
||||||
createdAt: '2024-01-01',
|
createdAt: '2024-01-01',
|
||||||
updatedAt: '2024-01-01'
|
updatedAt: '2024-01-01',
|
||||||
|
isAssignedToMe: false
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -105,8 +119,7 @@ describe('TaskDetailPage', () => {
|
|||||||
expect(screen.getByText('Assign to Me')).toBeInTheDocument();
|
expect(screen.getByText('Assign to Me')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls updateTask with assigneeId when Assign to Me clicked', async () => {
|
it('calls assignTask with task id when Assign to Me clicked', async () => {
|
||||||
const mockMutate = vi.fn();
|
|
||||||
(useTask as ReturnType<typeof vi.fn>).mockReturnValue({
|
(useTask as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
data: {
|
data: {
|
||||||
id: '1',
|
id: '1',
|
||||||
@@ -115,15 +128,12 @@ describe('TaskDetailPage', () => {
|
|||||||
assigneeId: null,
|
assigneeId: null,
|
||||||
description: 'Desc',
|
description: 'Desc',
|
||||||
createdAt: '2024-01-01',
|
createdAt: '2024-01-01',
|
||||||
updatedAt: '2024-01-01'
|
updatedAt: '2024-01-01',
|
||||||
|
isAssignedToMe: false
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
(useUpdateTask as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
||||||
mutate: mockMutate,
|
|
||||||
isPending: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const params = Promise.resolve({ id: '1' });
|
const params = Promise.resolve({ id: '1' });
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -135,9 +145,37 @@ describe('TaskDetailPage', () => {
|
|||||||
button.click();
|
button.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockMutate).toHaveBeenCalledWith({
|
expect(mockAssign).toHaveBeenCalledWith('1');
|
||||||
id: '1',
|
|
||||||
data: { assigneeId: 'user-123' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ShiftListItemDto } from '@/hooks/useShifts';
|
import { ShiftListItemDto, useSignUpShift, useCancelSignUp } from '@/hooks/useShifts';
|
||||||
|
|
||||||
interface ShiftCardProps {
|
interface ShiftCardProps {
|
||||||
shift: ShiftListItemDto;
|
shift: ShiftListItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShiftCard({ shift }: ShiftCardProps) {
|
export function ShiftCard({ shift }: ShiftCardProps) {
|
||||||
|
const signUpMutation = useSignUpShift();
|
||||||
|
const cancelMutation = useCancelSignUp();
|
||||||
|
|
||||||
const capacityPercentage = (shift.currentSignups / shift.capacity) * 100;
|
const capacityPercentage = (shift.currentSignups / shift.capacity) * 100;
|
||||||
const isFull = shift.currentSignups >= shift.capacity;
|
const isFull = shift.currentSignups >= shift.capacity;
|
||||||
const isPast = new Date(shift.startTime) < new Date();
|
const isPast = new Date(shift.startTime) < new Date();
|
||||||
@@ -39,8 +42,15 @@ export function ShiftCard({ shift }: ShiftCardProps) {
|
|||||||
<Link href={`/shifts/${shift.id}`}>
|
<Link href={`/shifts/${shift.id}`}>
|
||||||
<Button variant="outline" size="sm">View Details</Button>
|
<Button variant="outline" size="sm">View Details</Button>
|
||||||
</Link>
|
</Link>
|
||||||
{!isPast && !isFull && (
|
{!isPast && !isFull && !shift.isSignedUp && (
|
||||||
<Button size="sm">Sign Up</Button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface ShiftListItemDto {
|
|||||||
endTime: string;
|
endTime: string;
|
||||||
capacity: number;
|
capacity: number;
|
||||||
currentSignups: number;
|
currentSignups: number;
|
||||||
|
isSignedUp: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShiftDetailDto {
|
export interface ShiftDetailDto {
|
||||||
@@ -31,6 +32,7 @@ export interface ShiftDetailDto {
|
|||||||
createdById: string;
|
createdById: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
isSignedUp: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShiftSignupDto {
|
export interface ShiftSignupDto {
|
||||||
|
|||||||
@@ -11,10 +11,9 @@ export interface TaskListDto {
|
|||||||
|
|
||||||
export interface TaskListItemDto {
|
export interface TaskListItemDto {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
assigneeId: string | null;
|
assigneeId: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
isAssignedToMe: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskDetailDto {
|
export interface TaskDetailDto {
|
||||||
@@ -28,6 +27,7 @@ export interface TaskDetailDto {
|
|||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
isAssignedToMe: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateTaskRequest {
|
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