diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json index 0493edd6f..1173ab47f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json @@ -102,13 +102,28 @@ "ApplicationPaymentStatusRequest:L2ApproveOrDeclineTitle": "Approve/Decline L2 Payment Requests", "ApplicationPaymentStatusRequest:L3ApproveOrDeclineTitle": "Approve/Decline L3 Payment Requests", - "Permission:Payments": "Payments", "Permission:Payments.Default": "Payments", "Permission:Payments.L1ApproveOrDecline": "Approve/Decline L1 Payments", "Permission:Payments.L2ApproveOrDecline": "Approve/Decline L2 Payments", "Permission:Payments.L3ApproveOrDecline": "Approve/Decline L3 Payments", "Permission:Payments.RequestPayment": "Request Payment", - "Permission:Payments.EditSupplierInfo": "Update Supplier Info" + "Permission:Payments.EditSupplierInfo": "Update Supplier Info", + + "Enum:PaymentRequestStatus.L1Pending": "L1 Pending", + "Enum:PaymentRequestStatus.L1Approved": "L1 Approved", + "Enum:PaymentRequestStatus.L1Declined": "L1 Declined", + "Enum:PaymentRequestStatus.L2Pending": "L2 Pending", + "Enum:PaymentRequestStatus.L2Approved": "L2 Approved", + "Enum:PaymentRequestStatus.L2Declined": "L2 Declined", + "Enum:PaymentRequestStatus.L3Pending": "L3 Pending", + "Enum:PaymentRequestStatus.L3Approved": "L3 Approved", + "Enum:PaymentRequestStatus.L3Declined": "L3 Declined", + "Enum:PaymentRequestStatus.Submitted": "Submitted to CAS", + "Enum:PaymentRequestStatus.Validated": "Validated", + "Enum:PaymentRequestStatus.NotValidated": "Not Validated", + "Enum:PaymentRequestStatus.Paid": "Paid", + "Enum:PaymentRequestStatus.Failed": "Payment Failed", + "Enum:PaymentRequestStatus.PaymentFailed": "Payment Failed" } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js index fec3434e1..ac3452311 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js @@ -424,10 +424,8 @@ $(function () { className: 'data-table-header', index: columnIndex, render: function (data) { - - let statusText = getStatusText(data); let statusColor = getStatusTextColor(data); - return '' + statusText + ''; + return `` + l(`Enum:PaymentRequestStatus.${data}`) + ''; } }; } @@ -653,7 +651,7 @@ $(function () { case "Paid": return "#42814A"; - case "PaymentFailed": + case "Failed": return "#CE3E39"; default: @@ -661,52 +659,6 @@ $(function () { } } - function getStatusText(status) { - - switch (status) { - - case "L1Pending": - return "L1 Pending"; - - case "L1Approved": - return "L1 Approved"; - - case "L1Declined": - return "L1 Declined"; - - case "L2Pending": - return "L2 Pending"; - - case "L2Approved": - return "L2 Approved"; - - case "L2Declined": - return "L2 Declined"; - - case "L3Pending": - return "L3 Pending"; - - case "L3Approved": - return "L3 Approved"; - - case "L3Declined": - return "L3 Declined"; - - case "Submitted": - return "Submitted to CAS"; - - case "Paid": - return "Paid"; - - case "PaymentFailed": - return "Payment Failed"; - - - default: - return "Created"; - } - } - $('.select-all-payments').click(function () { if ($(this).is(':checked')) { dataTable.rows({ 'page': 'current' }).select(); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js index a8b4ff430..dcb1baa68 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js @@ -244,7 +244,11 @@ name: 'status', data: 'status', className: 'data-table-header', - index: 3 + index: 3, + render: function (data) { + let statusColor = getPaymentStatusTextColor(data); + return `` + l(`Enum:PaymentRequestStatus.${data}`) + ''; + } }; } @@ -431,3 +435,37 @@ function enablePaymentInfoSaveBtn() { function nullToEmpty(value) { return value == null ? '' : value; } + +function getPaymentStatusTextColor(status) { + switch (status) { + case "L1Pending": + return "#053662"; + + case "L1Declined": + return "#CE3E39"; + + case "L2Pending": + return "#053662"; + + case "L2Declined": + return "#CE3E39"; + + case "L3Pending": + return "#053662"; + + case "L3Declined": + return "#CE3E39"; + + case "Submitted": + return "#5595D9"; + + case "Paid": + return "#42814A"; + + case "Failed": + return "#CE3E39"; + + default: + return "#053662"; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/BulkApprovalDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/BulkApprovalDto.cs new file mode 100644 index 000000000..5e08208af --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/BulkApprovalDto.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; + +namespace Unity.GrantManager.GrantApplications +{ + public class BulkApprovalDto + { + public BulkApprovalDto() + { + ValidationMessages = []; + ReferenceNo = string.Empty; + ApplicantName = string.Empty; + FormName = string.Empty; + ApplicationStatus = string.Empty; + } + + public List ValidationMessages { get; set; } + public bool IsValid { get;set; } + + public Guid ApplicationId { get; set; } + public decimal ApprovedAmount { get; set; } + public decimal RequestedAmount { get; set; } + public DateTime? FinalDecisionDate { get; set; } + public string ReferenceNo { get; set; } + public string ApplicantName { get; set; } + public string FormName { get; set; } + public string ApplicationStatus { get; set; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/BulkApprovalResultDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/BulkApprovalResultDto.cs new file mode 100644 index 000000000..c3720fe7d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/BulkApprovalResultDto.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Unity.GrantManager.GrantApplications +{ + public class BulkApprovalResultDto + { + public List Successes { get; set; } = []; + + [JsonProperty("failures")] + public List> Failures { get; set; } = []; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IBulkApprovalsAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IBulkApprovalsAppService.cs new file mode 100644 index 000000000..1340a7c73 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IBulkApprovalsAppService.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Unity.GrantManager.GrantApplications +{ + public interface IBulkApprovalsAppService + { + Task BulkApproveApplications(List batchApplicationsToApprove); + Task> GetApplicationsForBulkApproval(Guid[] applicationGuids); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs index d38aac16c..bff3976b9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs @@ -18,5 +18,6 @@ public interface IGrantApplicationAppService : ICommentsService Task> GetApplicationDetailsListAsync(List applicationIds); Task GetAsync(Guid id); Task> GetListAsync(PagedAndSortedResultRequestDto input); + Task TriggerAction(Guid applicationId, GrantApplicationAction triggerAction); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/BulkApprovalsAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/BulkApprovalsAppService.cs new file mode 100644 index 000000000..95ad1310e --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/BulkApprovalsAppService.cs @@ -0,0 +1,199 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Infrastructure; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Events; +using Unity.GrantManager.Permissions; +using Volo.Abp.EventBus.Local; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantApplications +{ + [Authorize] + public class BulkApprovalsAppService(IApplicationRepository applicationRepository, + IApplicationManager applicationManager, + ILocalEventBus localEventBus, + IUnitOfWorkManager unitofWorkManager) : GrantManagerAppService, IBulkApprovalsAppService + { + /// + /// Bulk approve applications + /// + /// + /// + public async Task BulkApproveApplications(List batchApplicationsToApprove) + { + var bulkApprovalResult = new BulkApprovalResultDto(); + + // Need to Look at refactoring this into the single control flow for workflow approvals + var approvalAction = GrantApplicationAction.Approve; + + // We read and write individually here to make sure all applications trigger ther approval correctly as a best effort per application + foreach (var applicationToUpdateAndApprove in batchApplicationsToApprove) + { + Application? application = null; + + try + { + // Fields to update + using var uowFields = unitofWorkManager.Begin(requiresNew: true); + application = await applicationRepository.GetAsync(applicationToUpdateAndApprove.ApplicationId); + + if (application.ReferenceNo == "64CDF30C" || application.ReferenceNo == "64CDF30E") + { + throw new NotImplementedException(); + } + + application.ValidateAndChangeFinalDecisionDate(applicationToUpdateAndApprove.FinalDecisionDate); + application.ValidateMinAndChangeApprovedAmount(applicationToUpdateAndApprove.ApprovedAmount); + application.ApprovedAmount = applicationToUpdateAndApprove.ApprovedAmount; + + if (!await AuthorizationService.IsGrantedAsync(application, GetActionAuthorizationRequirement(GrantApplicationAction.Approve))) + { + throw new UnauthorizedAccessException(); + } + + _ = await applicationManager.TriggerAction(application.Id, GrantApplicationAction.Approve); + + await localEventBus.PublishAsync( + new ApplicationChangedEvent + { + Action = approvalAction, + ApplicationId = application.Id + } + ); + + await uowFields.CompleteAsync(); + + bulkApprovalResult.Successes.Add(application.ReferenceNo); + } + catch (Exception ex) + { + // Log the error and continue with the next application + Logger.LogError(ex, "Error approving application with ID: {ApplicationId} and ReferenceNo: {ReferenceNo}", + applicationToUpdateAndApprove.ApplicationId, + applicationToUpdateAndApprove.ReferenceNo); + + // Add to error list or handle as needed + bulkApprovalResult.Failures.Add(new KeyValuePair(application?.ReferenceNo ?? string.Empty, ex.Message)); + } + } + + return bulkApprovalResult; + } + + /// + /// Get applications for bulk approval with addeded on validation information + /// + /// + /// + public async Task> GetApplicationsForBulkApproval(Guid[] applicationGuids) + { + var applications = await applicationRepository.GetListByIdsAsync(applicationGuids); + return await ValidateBulkApplications([.. applications]); + } + + + /// + /// Add validations to the applications + /// + /// + /// + private async Task> ValidateBulkApplications(Application[] applications) + { + var applicationsForApproval = new List(); + + foreach (var application in applications) + { + List<(bool, string)> validationMessages = await RunValidations(application); + + applicationsForApproval.Add(new BulkApprovalDto() + { + ApplicationId = application.Id, + ApprovedAmount = application.ApprovedAmount, + RequestedAmount = application.RequestedAmount, + FinalDecisionDate = application.FinalDecisionDate, + ReferenceNo = application.ReferenceNo, + ValidationMessages = validationMessages.Select(s => s.Item2).ToList(), + ApplicantName = application.Applicant.ApplicantName ?? string.Empty, + ApplicationStatus = application.ApplicationStatus.InternalStatus, + FormName = application.ApplicationForm?.ApplicationFormName ?? string.Empty, + IsValid = !validationMessages.Exists(s => s.Item1) + }); + } + + return applicationsForApproval; + } + + /// + /// Run the validations for the application + /// + /// + /// A tuple with validation messages and if it should trigger a invalid state for the record + private async Task> RunValidations(Application application) + { + var validWorkflow = MeetsWorkflowRequirement(application, GrantApplicationAction.Approve); + var authorized = await MeetsAuthorizationRequirement(application, GrantApplicationAction.Approve); + var validationMessages = new List<(bool, string)>(); + + if (!validWorkflow) + validationMessages.Add(new(true, "INVALID_STATUS")); + if (!authorized) + validationMessages.Add(new(true, "INVALID_PERMISSIONS")); + + return validationMessages; + } + + /// + /// Inline explicit validation of status check for bulk application approval + /// + /// + /// + /// + private static bool MeetsWorkflowRequirement(Application application, GrantApplicationAction triggerAction) + { + if (triggerAction != GrantApplicationAction.Approve) + { + return false; + } + + if (application.ApplicationStatus.StatusCode != GrantApplicationState.ASSESSMENT_COMPLETED) + { + return false; + } + + return true; + } + + /// + /// Inline explicit validation of status check for bulk application approval + /// + /// + /// + /// + private async Task MeetsAuthorizationRequirement(Application application, GrantApplicationAction triggerAction) + { + if (!await AuthorizationService.IsGrantedAsync(application, GetActionAuthorizationRequirement(triggerAction))) + { + Logger.LogWarning("Approval requested for application with insufficient permissions: {ApplicationId}", application.Id); + return false; + } + + return true; + } + + /// + /// Check the authorization requirement + /// + /// + /// + private static OperationAuthorizationRequirement GetActionAuthorizationRequirement(GrantApplicationAction triggerAction) + { + return new OperationAuthorizationRequirement { Name = $"{GrantApplicationPermissions.Applications.Default}.{triggerAction}" }; + // this should allow anyone for now, but needs to change to a specific permission + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/BatchApprovalConsts.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/BatchApprovalConsts.cs new file mode 100644 index 000000000..4164261fd --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/BatchApprovalConsts.cs @@ -0,0 +1,7 @@ +namespace Unity.GrantManager.GrantApplications +{ + public static class BatchApprovalConsts + { + public static int MaxBatchCount => 50; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json index 52586aff3..c46f85f7b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json @@ -70,6 +70,7 @@ "ApplicationList:OpenButton": "Open", "ApplicationList:AssignButton": "Assign", + "ApplicationList:ApproveButton": "Approve", "ApplicationList:StatusButton": "Status", "ApplicationList:ApprovedButton": "Approved", "ApplicationList:NotApprovedButton": "Not approved", @@ -412,7 +413,16 @@ "ApplicationContact:Email": "Email", "ApplicationContact:MobilePhone": "Mobile Phone Number", "ApplicationContact:WorkPhone": "Work Phone Number", - "ApplicationContact:Delete": "Delete This Contact" + "ApplicationContact:Delete": "Delete This Contact", + + "ApplicationBatchApprovalRequest:Title": "Approve Applications", + "ApplicationBatchApprovalRequest:SubmitButtonText": "Approve", + "ApplicationBatchApprovalRequest:CancelButtonText": "Cancel", + "ApplicationBatchApprovalRequest:DecisionDateDefaulted": "Decision Date has been defaulted", + "ApplicationBatchApprovalRequest:ApprovedAmountDefaulted": "Approved Amount has been defaulted", + "ApplicationBatchApprovalRequest:InvalidStatus": "The assessment for the selected item is not in the Assessment Completed state", + "ApplicationBatchApprovalRequest:InvalidPermissions": "Invalid permissions", + "ApplicationBatchApprovalRequest:InvalidApprovedAmount": "Invalid Approved Amount, it must be greater than 0.00", + "ApplicationBatchApprovalRequest:MaxCountExceeded": "You have exceeded the maximum number of items for bulk approval. Please reduce the number to {0} or fewer" } } - diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/Application.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/Application.cs index 2ddeaac53..58bc8069c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/Application.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/Application.cs @@ -196,4 +196,12 @@ public void ValidateAndChangeFinalDecisionDate(DateTime? finalDecisionDate) FinalDecisionDate = finalDecisionDate; } } + + public void ValidateMinAndChangeApprovedAmount(decimal approvedAmount) + { + if ((ApprovedAmount != approvedAmount) && approvedAmount <= 0m) + { + throw new BusinessException("Approved amount cannot be 0."); + } + } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationRepository.cs index 58deb0040..653fe193b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/IApplicationRepository.cs @@ -10,4 +10,5 @@ public interface IApplicationRepository : IRepository { Task WithBasicDetailsAsync(Guid id); Task>> WithFullDetailsGroupedAsync(int skipCount, int maxResultCount, string? sorting = null); + Task> GetListByIdsAsync(Guid[] ids); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicationRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicationRepository.cs index ac26cbe80..9aa9ac181 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicationRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicationRepository.cs @@ -56,8 +56,19 @@ public async Task WithBasicDetailsAsync(Guid id) .Include(s => s.Applicant) .ThenInclude(s => s.ApplicantAddresses) .Include(s => s.ApplicantAgent) - .Include(s => s.ApplicationStatus) - .FirstAsync(s => s.Id == id); + .Include(s => s.ApplicationStatus) + .FirstAsync(s => s.Id == id); + } + + public async Task> GetListByIdsAsync(Guid[] ids) + { + return await (await GetQueryableAsync()) + .AsNoTracking() + .Include(s => s.ApplicationStatus) + .Include(s => s.Applicant) + .Include(s => s.ApplicationForm) + .Where(s => ids.Contains(s.Id)) + .ToListAsync(); } /// diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Approve/ApproveApplicationsModal.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Approve/ApproveApplicationsModal.cshtml deleted file mode 100644 index 93f891b0b..000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Approve/ApproveApplicationsModal.cshtml +++ /dev/null @@ -1,23 +0,0 @@ -@page -@model Unity.GrantManager.Web.Pages.Approve.ApproveApplicationsModalModel -@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal -@{ - Layout = null; -} - - -
- - - - @Model.PopupMessage - - - - - - - - - -
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Approve/ApproveApplicationsModal.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Approve/ApproveApplicationsModal.cshtml.cs deleted file mode 100644 index 9f2b89d38..000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Approve/ApproveApplicationsModal.cshtml.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Unity.GrantManager.GrantApplications; -using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; - -namespace Unity.GrantManager.Web.Pages.Approve; - -public class ApproveApplicationsModalModel : AbpPageModel -{ - - [BindProperty] - public string SelectedApplicationIds { get; set; } = ""; - [BindProperty] - public string OperationStatusCode { get; set; } = ""; - [TempData] - public string PopupMessage { get; set; } = ""; - [TempData] - public string PopupTitle { get; set; } = ""; - - public List StatusList { get; set; } = new(); - - private readonly IApplicationStatusService _statusService; - private readonly GrantApplicationAppService _applicationService; - - public ApproveApplicationsModalModel(IApplicationStatusService statusService, GrantApplicationAppService applicationService) - { - _statusService = statusService; - _applicationService = applicationService; - } - - public void OnGet(string applicationIds, string operation, string message, string title) - { - SelectedApplicationIds = applicationIds; - OperationStatusCode = operation; - PopupMessage = message; - PopupTitle = title; - } - - public async Task OnPostAsync() - { - try - { - Guid statusId; - var statuses = await _statusService.GetListAsync(); - var approvedStatus = statuses.FirstOrDefault(status => status.StatusCode == OperationStatusCode); - if (approvedStatus != null) - { - statusId = approvedStatus.Id; - } - else - { - throw new ArgumentException(OperationStatusCode + " status code is not found in the database!"); - } - - var applicationIds = JsonConvert.DeserializeObject>(SelectedApplicationIds); - if (null != applicationIds) - { - await _applicationService.UpdateApplicationStatus(applicationIds.ToArray(), statusId); - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Error updating application statuses"); - } - return NoContent(); - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Approvals/ApproveApplicationsModal.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Approvals/ApproveApplicationsModal.cshtml new file mode 100644 index 000000000..708a33620 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Approvals/ApproveApplicationsModal.cshtml @@ -0,0 +1,118 @@ +@page +@model Unity.GrantManager.Web.Pages.GrantApplications.Approvals.ApproveApplicationsModalModel +@using Microsoft.Extensions.Localization +@using Unity.GrantManager.Localization + +@inject IStringLocalizer L + +@{ + Layout = null; +} + +
+ + + + + + + + @for (var i = 0; i < Model.BulkApplicationApprovals?.Count; i++) + { +
+ + +
+
@Model.BulkApplicationApprovals[i].ReferenceNo
+
@Model.BulkApplicationApprovals[i].ApplicantName
+
@string.Format("({0})", @Model.BulkApplicationApprovals[i].FormName)
+
@Model.BulkApplicationApprovals[i].ApplicationStatus
+
+
+ + + +
+ + + + + + + + + + + + + + + + + + @for (var j = 0; j < Model.BulkApplicationApprovals[i].Notes?.Count; j++) + { + + + @if (Model.BulkApplicationApprovals[i].Notes[j].IsError) + { + Error + } + else + { + Note + } + @Model.BulkApplicationApprovals[i].Notes[j].Description + + + } + +
+ } +
+
+
+ Error @Model.MaxBatchCountExceededError +
+
+ + + + + + + +
+
+ + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Approvals/ApproveApplicationsModal.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Approvals/ApproveApplicationsModal.cshtml.cs new file mode 100644 index 000000000..3b6a696dd --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Approvals/ApproveApplicationsModal.cshtml.cs @@ -0,0 +1,202 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading.Tasks; +using Unity.GrantManager.GrantApplications; +using Unity.Modules.Shared.Utils; +using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; + +namespace Unity.GrantManager.Web.Pages.GrantApplications.Approvals; + +public class ApproveApplicationsModalModel(IBulkApprovalsAppService bulkApprovalsAppService, + BrowserUtils browserUtils) : AbpPageModel +{ + [BindProperty] + public List? BulkApplicationApprovals { get; set; } + + [TempData] + public int ApplicationsCount { get; set; } + + [TempData] + public bool Invalid { get; set; } + + [TempData] + public int MaxBatchCount { get; set; } + + [TempData] + public string? MaxBatchCountExceededError { get; set; } + + [TempData] + public bool MaxBatchCountExceeded { get; set; } + + public async void OnGet(string applicationIds) + { + MaxBatchCount = BatchApprovalConsts.MaxBatchCount; + BulkApplicationApprovals = []; + MaxBatchCountExceededError = L["ApplicationBatchApprovalRequest:MaxCountExceeded", BatchApprovalConsts.MaxBatchCount.ToString()].Value; + + Guid[] applicationGuids = ParseApplicationIds(applicationIds); + + if (!ValidCount(applicationGuids)) + { + MaxBatchCountExceeded = true; + } + + // Load the applications by Id + var applications = await bulkApprovalsAppService.GetApplicationsForBulkApproval(applicationGuids); + var offsetMinutes = browserUtils.GetBrowserOffset(); + + foreach (var application in applications) + { + var bulkApproval = new BulkApplicationApproval + { + ApplicationId = application.ApplicationId, + ReferenceNo = application.ReferenceNo, + ApplicantName = application.ApplicantName, + DecisionDate = application.FinalDecisionDate ?? DateTime.UtcNow.AddMinutes(-offsetMinutes), + RequestedAmount = application.RequestedAmount, + ApprovedAmount = application.ApprovedAmount == 0m ? application.RequestedAmount : application.ApprovedAmount, + ApplicationStatus = application.ApplicationStatus, + FormName = application.FormName, + IsValid = application.IsValid, + Notes = SetNotesForApplication(application) + }; + + BulkApplicationApprovals.Add(bulkApproval); + } + + Invalid = applications.Exists(s => !s.IsValid) || MaxBatchCountExceeded; + ApplicationsCount = applications.Count; + } + + private List SetNotesForApplication(BulkApprovalDto application) + { + var notes = new List + { + new("DECISION_DATE_DEFAULTED", false, L.GetString("ApplicationBatchApprovalRequest:DecisionDateDefaulted"), false), + new("APPROVED_AMOUNT_DEFAULTED", false, L.GetString("ApplicationBatchApprovalRequest:ApprovedAmountDefaulted"), false), + new("INVALID_STATUS", false, L.GetString("ApplicationBatchApprovalRequest:InvalidStatus"), true), + new("INVALID_PERMISSIONS", false, L.GetString("ApplicationBatchApprovalRequest:InvalidPermissions"), true), + new("INVALID_APPROVED_AMOUNT", false, L.GetString("ApplicationBatchApprovalRequest:InvalidApprovedAmount"), true) + }; + + if (application.FinalDecisionDate == null) + { + notes[0] = new ApprovalNote(notes[0].Key, true, notes[0].Description, notes[0].IsError); + } + + if (application.ApprovedAmount == 0m) + { + notes[0] = new ApprovalNote(notes[1].Key, true, notes[1].Description, notes[1].IsError); + } + + foreach (var validation in application.ValidationMessages) + { + var index = notes.FindIndex(note => note.Key == validation); + if (index != -1) + { + notes[index] = new ApprovalNote(validation, true, notes[index].Description, notes[index].IsError); + } + } + + return notes; + } + + public async Task OnPostAsync() + { + try + { + if (BulkApplicationApprovals == null) return NoContent(); + + var approvalRequests = MapBulkApprovalRequests(); + + // Fire off request to approve the applications + var result = await bulkApprovalsAppService.BulkApproveApplications(approvalRequests); + + // Return the result out + return new OkObjectResult(result); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error updating application statuses"); + } + + return NoContent(); + } + + private List MapBulkApprovalRequests() + { + var bulkApprovals = new List(); + foreach (var application in BulkApplicationApprovals ?? []) + { + bulkApprovals.Add(new BulkApprovalDto() + { + ApplicantName = application.ApplicantName, + ApplicationId = application.ApplicationId, + ApprovedAmount = application.ApprovedAmount, + FinalDecisionDate = application.DecisionDate, + ReferenceNo = application.ReferenceNo, + RequestedAmount = application.RequestedAmount, + ValidationMessages = [] + }); + } + + return bulkApprovals; + } + + private static Guid[] ParseApplicationIds(string applicationIds) + { + return JsonConvert.DeserializeObject(applicationIds ?? string.Empty) ?? []; + } + + private bool ValidCount(Guid[] applicationGuids) + { + // Soft check in the UI for max approvals in one batch, this is subject to be tweaked later after performance testing + return applicationGuids.Length <= MaxBatchCount; + } + + public class BulkApplicationApproval + { + public BulkApplicationApproval() + { + Notes = []; + } + + public Guid ApplicationId { get; set; } + public string ReferenceNo { get; set; } = string.Empty; + public string ApplicantName { get; set; } = string.Empty; + public string FormName { get; set; } = string.Empty; + public string ApplicationStatus { get; set; } = string.Empty; + + [DisplayName("Requested Amount")] + public decimal RequestedAmount { get; set; } = 0m; + + [DisplayName("Approved Amount")] + public decimal ApprovedAmount { get; set; } = 0m; + + [DisplayName("Decision Date")] + public DateTime DecisionDate { get; set; } + public bool IsValid { get; set; } + public List Notes { get; set; } + } + + public class ApprovalNote + { + public ApprovalNote(string key, bool active, string description, bool isError) + { + Key = key; + Active = active; + Description = description; + IsError = isError; + } + + public string Key { get; set; } + public bool Active { get; set; } + public string Description { get; set; } + public bool IsError { get; set; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Approvals/ApproveApplicationsModal.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Approvals/ApproveApplicationsModal.css new file mode 100644 index 000000000..0055174e3 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Approvals/ApproveApplicationsModal.css @@ -0,0 +1,116 @@ +.batch-approval-card { + max-height: 65vh; + overflow: auto; +} + +.batch-approval-container { + border: var(--bs-border-width) solid; + border-color: var(--bs-border-color); +} + +.bulk-approval-row-header { + padding-bottom: 0 !important; +} + +.bulk-approval-remove-row { + min-width: 55px +} + + + +/* This is for the summary modal*/ + +.table-container { + max-height: 65vh; + overflow-y: auto; +} + +.custom-table { + width: 100%; + border-collapse: collapse; +} + +.centered-icon { + text-align: center; + padding-bottom: 0.85rem; +} + +.custom-table thead th { + position: sticky; + top: 0; + background-color: white; + z-index: 1; + font-size: 1rem; + padding-bottom: 1rem; +} + +.custom-table .col-5 { + width: 5%; +} + +.custom-table .col-20 { + width: 20%; +} + +.custom-table .col-75 { + width: 75%; +} + +.approval-details-header { + display: flex; + align-items: center; +} + + .approval-details-header + .reference-no { + font-weight: 700; + } + + .approval-details-header + .applicant-name { + font-weight: 400; + color: var(--bc-type-color-secondary); + margin-left: 0.75rem; + padding-right: 0.75rem !important; + } + + .approval-details-header + .form-name { + font-weight: 400; + color: var(--bc-type-color-secondary); + font-size: 0.8rem; + } + + .approval-details-header + .application-status { + font-weight: 700; + color: var(--bc-colors-blue-text-links); + text-transform: uppercase; + border: 3px solid var(--bc-colors-blue-text-links); + border-radius: 1rem; + font-size: 0.8rem; + padding: 0.125rem 0.5rem; + margin: 0 0.5rem; + margin-top: 2px; + } + + +.approval-note-prefix { + font-weight: 700; + color: var(--bc-colors-blue-text-links); + text-transform: uppercase; + border: 3px solid var(--bc-colors-blue-text-links); + border-radius: 1rem; + font-size: 0.8rem; + padding: 0.025rem 0.5rem; + line-height: 1.75rem; +} + +.approval-note-error { + color: var(--lpx-danger); + border-color: var(--lpx-danger); +} + +.batch-approval-summary { + text-align: center; +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Approvals/ApproveApplicationsModal.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Approvals/ApproveApplicationsModal.js new file mode 100644 index 000000000..668f40e7c --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Approvals/ApproveApplicationsModal.js @@ -0,0 +1,121 @@ +let approveApplicationsSummaryModal = new abp.ModalManager({ + viewUrl: 'GrantApplications/Approvals/ApproveApplicationsSummaryModal' +}); +function removeApplicationApproval(applicationId) { + $('#' + applicationId).remove(); + let applicationsCount = $('#ApplicationsCount').val(); + $('#ApplicationsCount').val(applicationsCount - 1); + runValidations(); +} + +function approvedAmountUpdated(event) { + const input = event.target; + const value = parseFloat(input.value.replace(/,/g, '')); + + setNote(event.target, '_APPROVED_AMOUNT_DEFAULTED', false); + + if (isNaN(value) || value <= 0) { + setNote(event.target, '_INVALID_APPROVED_AMOUNT', true); + } else { + setNote(event.target, '_INVALID_APPROVED_AMOUNT', false); + } + runValidations(); +} + +function decisionDateUpdated(event) { + setNote(event.target, '_DECISION_DATE_DEFAULTED', false) + runValidations(); +} + +function setNote(target, note, visible) { + const input = target; + const containerId = input.closest('.batch-approval-container').id; + const noteField = $('#' + containerId + note); + + if (noteField.length) { + if (visible) { + noteField.css('display', 'block'); + } else { + noteField.css('display', 'none'); + } + } +} + +function runValidations() { + let isValid = true; + let itemCount = 0; + + $('#bulkApprovalForm input[name="BulkApplicationApprovals.Index"]').each(function () { + itemCount++; + let index = $(this).val(); + let approvedAmount = parseFloat($('#bulkApprovalForm input[name="BulkApplicationApprovals[' + index + '].ApprovedAmount"]').val().replace(/,/g, '')); + let decisionDate = new Date($('#bulkApprovalForm input[name="BulkApplicationApprovals[' + index + '].DecisionDate"]').val()); + let isValidField = $('#bulkApprovalForm input[name="BulkApplicationApprovals[' + index + '].IsValid"]').val(); + + if (isValidField.toLowerCase() !== 'true' || isNaN(approvedAmount) || approvedAmount <= 0 || isNaN(decisionDate.getTime()) || decisionDate > new Date()) { + isValid = false; + } + }); + + if (itemCount === 0) { + isValid = false; + } + + if (!validBatchCount()) { + isValid = false; + setMaxCountError(true); + } else { + setMaxCountError(false); + } + + if (isValid) { + enableBulkApprovalSubmit(); + } else { + disableBulkApprovalSubmit(); + } +} + +function setMaxCountError(visible) { + const summary = $('#batch-approval-summary'); + if (visible) { + summary.css('display', 'block'); + } else { + summary.css('display', 'none'); + } +} + +function validBatchCount() { + let applicationsCount = $('#ApplicationsCount').val(); + let maxBatchCount = $('#MaxBatchCount').val(); + return applicationsCount <= maxBatchCount; +} + +function enableBulkApprovalSubmit() { + $("#approveApplicationsModal") + .find('#btnSubmitBatchApproval').prop("disabled", false); +} + +function disableBulkApprovalSubmit() { + $("#approveApplicationsModal") + .find('#btnSubmitBatchApproval').prop("disabled", true); +} + +function closeApprovals() { + $('#approveApplicationsModal').modal('hide'); +} + +function handleBulkApplicationsApprovalResponse(response) { + let transformedFailures = response.responseText.failures.map(failure => { + return { + Key: failure.key, + Value: failure.value + }; + }); + + let summaryJson = JSON.stringify( + { + Successes: response.responseText.successes, + Failures: transformedFailures + }); + approveApplicationsSummaryModal.open({ summaryJson: summaryJson }); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Approvals/ApproveApplicationsSummaryModal.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Approvals/ApproveApplicationsSummaryModal.cshtml new file mode 100644 index 000000000..e01459415 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Approvals/ApproveApplicationsSummaryModal.cshtml @@ -0,0 +1,50 @@ +@page +@model Unity.GrantManager.Web.Pages.GrantApplications.Approvals.ApproveApplicationsSummaryModalModel +@using Microsoft.Extensions.Localization +@using Unity.GrantManager.Localization +@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal +@inject IStringLocalizer L + +@{ + Layout = null; +} + + + + Approval Summary + + +
+ + + + + + + + @for (var i = 0; i < Model.BulkApprovalResults?.Count; i++) + { + + + + + + } + +
Reference No:Status:
+ @if (Model.BulkApprovalResults[i].IsSuccess) + { + + } + else + { + + } + + + + +
+
+
+
\ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Approvals/ApproveApplicationsSummaryModal.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Approvals/ApproveApplicationsSummaryModal.cshtml.cs new file mode 100644 index 000000000..66142c569 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Approvals/ApproveApplicationsSummaryModal.cshtml.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Unity.GrantManager.GrantApplications; + +namespace Unity.GrantManager.Web.Pages.GrantApplications.Approvals +{ + public class ApproveApplicationsSummaryModalModel : PageModel + { + [BindProperty] + public List? BulkApprovalResults { get; set; } + + public void OnGet(string summaryJson) + { + var items = new List(); + + var result = JsonSerializer.Deserialize(summaryJson); + + foreach (var item in result?.Successes ?? []) + { + items.Add(new BulkApprovalItemResult + { + ReferenceNo = item, + Message = "Success", + IsSuccess = true + }); + } + + foreach (var item in result?.Failures ?? []) + { + items.Add(new BulkApprovalItemResult + { + ReferenceNo = item.Key, + Message = item.Value, + IsSuccess = false + }); + } + + BulkApprovalResults = [.. items.OrderBy(s => s.ReferenceNo)]; + } + + public class BulkApprovalItemResult + { + public string ReferenceNo { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public bool IsSuccess { get; set; } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.cshtml index 92d946308..1f308cda2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.cshtml @@ -25,6 +25,7 @@ + } else { @@ -34,6 +35,7 @@ @section styles { + }
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js index 8f5ea60df..89e283adb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js @@ -237,8 +237,7 @@ data: 'referenceNo', name: 'referenceNo', className: 'data-table-header text-nowrap', - render: function (data, type, row) { - console.log(row); + render: function (data, type, row) { return `${data}`; }, index: 2 diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml index 7cd4272f3..2c17a050d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml @@ -31,6 +31,13 @@ class="custom-table-btn flex-none btn btn-secondary action-bar-btn-unavailable" text="@L["ApplicationList:AssignButton"].Value" /> + @if (true) { // Validate against required permission + + } { selectedApplicationIds.push(data.id); @@ -190,22 +177,6 @@ $(function () { applicationIds: JSON.stringify(selectedApplicationIds), }); }); - $('#approveApplications').click(function () { - approveApplicationsModal.open({ - applicationIds: JSON.stringify(selectedApplicationIds), - operation: 'GRANT_APPROVED', - message: 'Are you sure you want to approve the selected application/s?', - title: 'Approve Applications', - }); - }); - $('#dontApproveApplications').click(function () { - dontApproveApplicationsModal.open({ - applicationIds: JSON.stringify(selectedApplicationIds), - operation: 'GRANT_NOT_APPROVED', - message: 'Are you sure you want to disapprove the selected application/s?', - title: 'Not Approve Applications', - }); - }); $('#applicationLink').click(function () { const summaryCanvas = document.getElementById('applicationAsssessmentSummary'); @@ -232,24 +203,24 @@ $(function () { if (selectedApplicationIds.length == 0) { $('*[data-selector="applications-table-actions"]').prop('disabled', true); $('*[data-selector="applications-table-actions"]').addClass('action-bar-btn-unavailable'); - $('.action-bar').removeClass('active'); + $('.action-bar').removeClass('active'); const summaryCanvas = document.getElementById('applicationAsssessmentSummary'); summaryCanvas.classList.remove('show'); - } - else { + } + else { $('*[data-selector="applications-table-actions"]').prop('disabled', false); $('*[data-selector="applications-table-actions"]').removeClass('action-bar-btn-unavailable'); $('.action-bar').addClass('active'); $('#externalLink').addClass('action-bar-btn-unavailable'); $('#applicationLink').addClass('action-bar-btn-unavailable'); - + if (selectedApplicationIds.length == 1) { $('#externalLink').removeClass('action-bar-btn-unavailable'); $('#applicationLink').removeClass('action-bar-btn-unavailable'); - summaryWidgetManager.refresh(); + summaryWidgetManager.refresh(); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/DetailsActionBar/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/DetailsActionBar/Default.js index 98452c688..b3427e040 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/DetailsActionBar/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/DetailsActionBar/Default.js @@ -27,7 +27,7 @@ $(function () { ); }); - $('#approveApplicationsDetails').click(function () { + $('#approveApplications').click(function () { approveApplicationsModal.open({ applicationIds: JSON.stringify(new Array(selectedApplicationIds)), operation: 'GRANT_APPROVED', @@ -35,14 +35,6 @@ $(function () { title: 'Approve Applications', }); }); - $('#disApproveApplicationsDetails').click(function () { - dontApproveApplicationsModal.open({ - applicationIds: JSON.stringify(new Array(selectedApplicationIds)), - operation: 'GRANT_NOT_APPROVED', - message: 'Are you sure you want to disapprove this application?', - title: 'Not Approve Applications', - }); - }); let startAssessmentModal = new abp.ModalManager({ viewUrl: '../Approve/ApproveApplicationsModal'