Add approval workflow for task section completion

- Updated status transitions to include PendingForCompletion before Completed
- Added API endpoint and command/handler for approving/rejecting section completion
- Fixed additional time calculation to include minutes
- Refactored project board query logic and improved user ordering
- Updated launch settings with new HTTPS endpoint
- Documented progress percentage feature for TaskSection
This commit is contained in:
2026-01-04 20:39:20 +03:30
parent 00b5066f6f
commit 8d93fa4fc6
6 changed files with 154 additions and 61 deletions

View File

@@ -0,0 +1,57 @@
using GozareshgirProgramManager.Application._Common.Interfaces;
using GozareshgirProgramManager.Application._Common.Models;
using GozareshgirProgramManager.Domain._Common;
using GozareshgirProgramManager.Domain._Common.Exceptions;
using GozareshgirProgramManager.Domain.ProjectAgg.Enums;
using GozareshgirProgramManager.Domain.ProjectAgg.Repositories;
namespace GozareshgirProgramManager.Application.Modules.Projects.Commands.ApproveTaskSectionCompletion;
public record ApproveTaskSectionCompletionCommand(Guid TaskSectionId, bool IsApproved) : IBaseCommand;
public class ApproveTaskSectionCompletionCommandHandler : IBaseCommandHandler<ApproveTaskSectionCompletionCommand>
{
private readonly ITaskSectionRepository _taskSectionRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly IAuthHelper _authHelper;
public ApproveTaskSectionCompletionCommandHandler(
ITaskSectionRepository taskSectionRepository,
IUnitOfWork unitOfWork,
IAuthHelper authHelper)
{
_taskSectionRepository = taskSectionRepository;
_unitOfWork = unitOfWork;
_authHelper = authHelper;
}
public async Task<OperationResult> Handle(ApproveTaskSectionCompletionCommand request, CancellationToken cancellationToken)
{
var currentUserId = _authHelper.GetCurrentUserId()
?? throw new UnAuthorizedException("˜ÇÑÈÑ ÇÍÑÇÒ åæ?Ê äÔÏå ÇÓÊ");
var section = await _taskSectionRepository.GetByIdAsync(request.TaskSectionId, cancellationToken);
if (section == null)
{
return OperationResult.NotFound("ÈÎÔ ãæÑÏ äÙÑ ?ÇÝÊ äÔÏ");
}
if (section.Status != TaskSectionStatus.PendingForCompletion)
{
return OperationResult.Failure("ÝÞØ ÈÎÔ<C38E>åÇ?? ˜å ÏÑ ÇäÊÙÇÑ Ê˜ã?á åÓÊäÏ ÞÇÈá ÊÇ??Ï ?Ç ÑÏ åÓÊäÏ");
}
if (request.IsApproved)
{
section.UpdateStatus(TaskSectionStatus.Completed);
}
else
{
section.UpdateStatus(TaskSectionStatus.Incomplete);
}
await _unitOfWork.SaveChangesAsync(cancellationToken);
return OperationResult.Success();
}
}

View File

@@ -0,0 +1,14 @@
using FluentValidation;
namespace GozareshgirProgramManager.Application.Modules.Projects.Commands.ApproveTaskSectionCompletion;
public class ApproveTaskSectionCompletionCommandValidator : AbstractValidator<ApproveTaskSectionCompletionCommand>
{
public ApproveTaskSectionCompletionCommandValidator()
{
RuleFor(c => c.TaskSectionId)
.NotEmpty()
.NotNull()
.WithMessage("ÔäÇÓå ÈÎÔ äã?<3F>ÊæÇäÏ ÎÇá? ÈÇÔÏ");
}
}

View File

@@ -89,9 +89,9 @@ public class ChangeStatusSectionCommandHandler : IBaseCommandHandler<ChangeStatu
var validTransitions = new Dictionary<TaskSectionStatus, List<TaskSectionStatus>>
{
{ TaskSectionStatus.ReadyToStart, [TaskSectionStatus.InProgress] },
{ TaskSectionStatus.InProgress, [TaskSectionStatus.Incomplete, TaskSectionStatus.Completed] },
{ TaskSectionStatus.Incomplete, [TaskSectionStatus.InProgress, TaskSectionStatus.Completed] },
{ TaskSectionStatus.Completed, [TaskSectionStatus.InProgress, TaskSectionStatus.Incomplete] }, // Can return to InProgress or Incomplete
{ TaskSectionStatus.InProgress, [TaskSectionStatus.Incomplete, TaskSectionStatus.PendingForCompletion] },
{ TaskSectionStatus.Incomplete, [TaskSectionStatus.InProgress, TaskSectionStatus.PendingForCompletion] },
{ TaskSectionStatus.PendingForCompletion, [TaskSectionStatus.InProgress, TaskSectionStatus.Incomplete] }, // Can return to InProgress or Incomplete
{ TaskSectionStatus.NotAssigned, [TaskSectionStatus.InProgress, TaskSectionStatus.ReadyToStart] }
};

View File

@@ -366,7 +366,7 @@ public class SetTimeProjectCommandHandler : IBaseCommandHandler<SetTimeProjectCo
// افزودن زمان‌های اضافی
foreach (var additionalTime in sectionItem.AdditionalTime)
{
var additionalTimeSpan = TimeSpan.FromHours(additionalTime.Hours);
var additionalTimeSpan = TimeSpan.FromHours(additionalTime.Hours).Add(TimeSpan.FromMinutes(additionalTime.Minutes));
section.AddAdditionalTime(additionalTimeSpan, additionalTime.Description, addedByUserId);
}
}

View File

@@ -53,67 +53,81 @@ public class ProjectBoardListQueryHandler : IBaseQueryHandler<ProjectBoardListQu
.ToDictionaryAsync(x => x.Id, x => x.FullName, cancellationToken);
var result = data.Select(x =>
{
// محاسبه یکبار برای هر Activity و Cache کردن نتیجه
var activityTimeData = x.Activities.Select(a =>
var result = data
.Select(x =>
{
var timeSpent = a.GetTimeSpent();
return new
// محاسبه یکبار برای هر Activity و Cache کردن نتیجه
var activityTimeData = x.Activities.Select(a =>
{
Activity = a,
TimeSpent = timeSpent,
TotalSeconds = timeSpent.TotalSeconds,
FormattedTime = timeSpent.ToString(@"hh\:mm")
};
}).ToList();
// ادغام پشت سر هم فعالیت‌های یک کاربر
var mergedHistories = new List<ProjectProgressHistoryDto>();
foreach (var activityData in activityTimeData)
{
var lastHistory = mergedHistories.LastOrDefault();
// اگر آخرین history برای همین کاربر باشد، زمان‌ها را جمع می‌کنیم
if (lastHistory != null && lastHistory.UserId == activityData.Activity.UserId)
{
var totalTimeSpan = lastHistory.WorkedTimeSpan + activityData.TimeSpent;
lastHistory.WorkedTimeSpan = totalTimeSpan;
lastHistory.WorkedTime = totalTimeSpan.ToString(@"hh\:mm");
}
else
{
// در غیر این صورت، یک history جدید اضافه می‌کنیم
mergedHistories.Add(new ProjectProgressHistoryDto()
var timeSpent = a.GetTimeSpent();
return new
{
UserId = activityData.Activity.UserId,
IsCurrentUser = activityData.Activity.UserId == currentUserId,
Name = users.GetValueOrDefault(activityData.Activity.UserId, "ناشناس"),
WorkedTime = activityData.FormattedTime,
WorkedTimeSpan = activityData.TimeSpent,
});
}
}
Activity = a,
TimeSpent = timeSpent,
TotalSeconds = timeSpent.TotalSeconds,
FormattedTime = timeSpent.ToString(@"hh\:mm")
};
}).ToList();
return new ProjectBoardListResponse()
{
Id = x.Id,
PhaseName = x.Task.Phase.Name,
ProjectName = x.Task.Phase.Project.Name,
TaskName = x.Task.Name,
SectionStatus = x.Status,
Progress = new ProjectProgressDto()
// ادغام پشت سر هم فعالیت‌های یک کاربر
var mergedHistories = new List<ProjectProgressHistoryDto>();
foreach (var activityData in activityTimeData)
{
CompleteSecond = x.FinalEstimatedHours.TotalSeconds,
CurrentSecond = activityTimeData.Sum(a => a.TotalSeconds),
Histories = mergedHistories
},
OriginalUser = users.GetValueOrDefault(x.OriginalAssignedUserId, "ناشناس"),
AssignedUser = x.CurrentAssignedUserId == x.OriginalAssignedUserId ? null
: users.GetValueOrDefault(x.CurrentAssignedUserId, "ناشناس"),
SkillName = x.Skill?.Name??"-",
};
}).ToList();
var lastHistory = mergedHistories.LastOrDefault();
// اگر آخرین history برای همین کاربر باشد، زمان‌ها را جمع می‌کنیم
if (lastHistory != null && lastHistory.UserId == activityData.Activity.UserId)
{
var totalTimeSpan = lastHistory.WorkedTimeSpan + activityData.TimeSpent;
lastHistory.WorkedTimeSpan = totalTimeSpan;
lastHistory.WorkedTime = totalTimeSpan.ToString(@"hh\:mm");
}
else
{
// در غیر این صورت، یک history جدید اضافه می‌کنیم
mergedHistories.Add(new ProjectProgressHistoryDto()
{
UserId = activityData.Activity.UserId,
IsCurrentUser = activityData.Activity.UserId == currentUserId,
Name = users.GetValueOrDefault(activityData.Activity.UserId, "ناشناس"),
WorkedTime = activityData.FormattedTime,
WorkedTimeSpan = activityData.TimeSpent,
});
}
}
mergedHistories = mergedHistories.OrderByDescending(h => h.IsCurrentUser).ToList();
return new ProjectBoardListResponse()
{
Id = x.Id,
PhaseName = x.Task.Phase.Name,
ProjectName = x.Task.Phase.Project.Name,
TaskName = x.Task.Name,
SectionStatus = x.Status,
Progress = new ProjectProgressDto()
{
CompleteSecond = x.FinalEstimatedHours.TotalSeconds,
CurrentSecond = activityTimeData.Sum(a => a.TotalSeconds),
Histories = mergedHistories
},
OriginalUser = users.GetValueOrDefault(x.OriginalAssignedUserId, "ناشناس"),
AssignedUser = x.CurrentAssignedUserId == x.OriginalAssignedUserId ? null
: users.GetValueOrDefault(x.CurrentAssignedUserId, "ناشناس"),
SkillName = x.Skill?.Name??"-",
};
})
.OrderByDescending(r =>
{
// اگر AssignedUser null نباشد، بررسی کن که برابر current user هست یا نه
if (r.AssignedUser != null)
{
return users.FirstOrDefault(u => u.Value == r.AssignedUser).Key == currentUserId;
}
// اگر AssignedUser null بود، از OriginalUser بررسی کن
return users.FirstOrDefault(u => u.Value == r.OriginalUser).Key == currentUserId;
})
.ToList();
return OperationResult<List<ProjectBoardListResponse>>.Success(result);
}