diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/EmailGroupUsers/IEmailGroupUsersAppService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/EmailGroupUsers/IEmailGroupUsersAppService.cs index fadfc0c1cb..0ec76ecc36 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/EmailGroupUsers/IEmailGroupUsersAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/EmailGroupUsers/IEmailGroupUsersAppService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; namespace Unity.Notifications.EmailGroups @@ -11,6 +12,7 @@ public interface IEmailGroupUsersAppService Task DeleteUserAsync (Guid id); Task DeleteUsersByGroupIdAsync (Guid id); Task DeleteUsersByUserIdAsync(Guid id); + [AllowAnonymous] Task> GetEmailGroupUsersByGroupIdAsync(Guid id); Task> GetListAsync(); } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/EmailGroups/IEmailGroupsAppService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/EmailGroups/IEmailGroupsAppService.cs index c9616322c5..58ca4fba1f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/EmailGroups/IEmailGroupsAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/EmailGroups/IEmailGroupsAppService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; namespace Unity.Notifications.EmailGroups @@ -10,6 +11,7 @@ public interface IEmailGroupsAppService Task CreateAsync (EmailGroupDto dto); Task UpdateAsync (EmailGroupDto dto); Task DeleteAsync (Guid id); + [AllowAnonymous] Task> GetListAsync(); Task GetEmailGroupByIdAsync(Guid id); } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissionDefinitionProvider.cs index 826ca5789b..ce368255c9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissionDefinitionProvider.cs @@ -41,10 +41,6 @@ public override void Define(IPermissionDefinitionContext context) NotificationsPermissions.Email.ScheduleCreate, L($"Permission:{NotificationsPermissions.Email.ScheduleCreate}")); - scheduleNotificationsPermissions.AddChild( - NotificationsPermissions.Email.ScheduleDelete, - L($"Permission:{NotificationsPermissions.Email.ScheduleDelete}")); - scheduleNotificationsPermissions.AddChild( NotificationsPermissions.Email.ScheduleCancel, L($"Permission:{NotificationsPermissions.Email.ScheduleCancel}")); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissions.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissions.cs index 65ec30f3d4..20bd2b4ec1 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissions.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissions.cs @@ -16,7 +16,6 @@ public static class Email public const string Schedule = "Notifications.Email.Schedule"; public const string NotificationsTab = "Notifications.Form.Tab"; public const string ScheduleCreate = "Notifications.Form.Email.Schedule.Create"; - public const string ScheduleDelete = "Notifications.Form.Email.Schedule.Delete"; public const string ScheduleCancel = "Notifications.Form.Email.Schedule.Cancel"; } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailGroupUsers/EmailGroupUsersAppService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailGroupUsers/EmailGroupUsersAppService.cs index d3a8969c5c..1f5e70ecb9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailGroupUsers/EmailGroupUsersAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailGroupUsers/EmailGroupUsersAppService.cs @@ -75,6 +75,7 @@ public async Task DeleteUsersByGroupIdAsync(Guid id) } } + [AllowAnonymous] public async Task> GetEmailGroupUsersByGroupIdAsync(Guid id) { var users = await _emailGroupUsersRepository.GetListAsync(u => u.GroupId == id); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailGroups/EmailGroupsAppService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailGroups/EmailGroupsAppService.cs index bec6f37481..42a0eb9beb 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailGroups/EmailGroupsAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailGroups/EmailGroupsAppService.cs @@ -21,7 +21,7 @@ public EmailGroupsAppService(IEmailGroupsRepository emailGroupsRepository) _emailGroupsRepository = emailGroupsRepository; } public async Task CreateAsync(EmailGroupDto dto) - { + { var newGroup = await _emailGroupsRepository.InsertAsync(new EmailGroup { Name = dto.Name, @@ -42,7 +42,7 @@ public async Task UpdateAsync(EmailGroupDto dto) var emailGroup = await _emailGroupsRepository.GetAsync(dto.Id, true); emailGroup.Name = dto.Name; emailGroup.Description = dto.Description; - emailGroup.Type = dto.Type; + emailGroup.Type = dto.Type; await _emailGroupsRepository.UpdateAsync(emailGroup, autoSave: true); return new EmailGroupDto { @@ -66,9 +66,10 @@ public async Task DeleteAsync(Guid id) } } + [AllowAnonymous] public async Task> GetListAsync() { - var groups = await _emailGroupsRepository.GetListAsync(); + var groups = await _emailGroupsRepository.GetListAsync(); return ObjectMapper.Map, List>(groups); } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.cshtml index bdc9957838..1fc31ed1c2 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.cshtml @@ -63,6 +63,11 @@ - - - - - - - - - \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/UnitySelector.Override.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/UnitySelector.Override.cs index 913fe23187..b61eba84f8 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/UnitySelector.Override.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/UnitySelector.Override.cs @@ -49,4 +49,15 @@ public static partial class Update } } } + + public static partial class Application + { + public static partial class Status + { + public const string Default = "Unity.GrantManager.ApplicationManagement.Application.Status"; + public const string Publish = "Unity.GrantManager.ApplicationManagement.Application.Status.Publish"; + public const string Unpublish = "Unity.GrantManager.ApplicationManagement.Application.Status.Unpublish"; + public const string BulkPublish = "Unity.GrantManager.ApplicationManagement.Application.Status.BulkPublish"; + } + } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantTenantDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantTenantDto.cs index e9e5b5c24a..efed16a462 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantTenantDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantTenantDto.cs @@ -7,6 +7,7 @@ public class ApplicantTenantDto { public Guid TenantId { get; set; } public string TenantName { get; set; } = string.Empty; - public Dictionary Metadata { get; set; } = []; + public string? DisplayName { get; set; } + public string? DefaultFromAddress { get; set; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applications/IApplicationBulkActionsAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applications/IApplicationBulkActionsAppService.cs index 20b6988185..b7f21dadab 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applications/IApplicationBulkActionsAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applications/IApplicationBulkActionsAppService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Unity.GrantManager.GrantApplications; using Volo.Abp.Application.Services; namespace Unity.GrantManager.Applications @@ -13,6 +14,8 @@ public interface IApplicationBulkActionsAppService : IApplicationService /// Request containing list of application IDs /// Cache key to retrieve the stored IDs Task StoreApplicationIdsAsync(StoreApplicationIdsRequestDto input); + Task> GetApplicationsForBulkPublish(Guid[] applicationGuids, bool excludePublished = true); + Task BulkPublishApplications(Guid[] applicationGuids); } public class StoreApplicationIdsRequestDto diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/BulkPublishDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/BulkPublishDto.cs new file mode 100644 index 0000000000..f0461e734b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/BulkPublishDto.cs @@ -0,0 +1,24 @@ +using System; + +namespace Unity.GrantManager.GrantApplications; + +public class BulkPublishDto +{ + public BulkPublishDto() + { + ReferenceNo = string.Empty; + ApplicantName = string.Empty; + FormName = string.Empty; + ApplicationStatus = string.Empty; + } + + public Guid ApplicationId { get; set; } + public string ReferenceNo { get; set; } + public string ApplicantName { get; set; } + public string FormName { get; set; } + public DateTime? FinalDecisionDate { get; set; } + public string ApplicationStatus { get; set; } + public bool ExternalStatusVisibility { get; set; } + public string ExternalStatus { get; set; } = string.Empty; + public string PublishedStatus { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs index b9ef4f56e3..07f89f02f8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs @@ -70,6 +70,12 @@ public override void Define(IPermissionDefinitionContext context) approvalPermissions.AddChild(GrantApplicationPermissions.Approvals.DeferAfterApproval, L("Permission:GrantApplicationManagement.Approvals.DeferAfterApproval")); approvalPermissions.AddChild(GrantApplicationPermissions.Approvals.BulkApplicationApproval, L("Permission:GrantApplicationManagement.Approvals.BulkApplicationApproval")); + // External Status Visibility + var statusVisibilityPermissions = grantApplicationPermissionsGroup.AddPermission(UnitySelector.Application.Status.Default, L(UnitySelector.Application.Status.Default)); + statusVisibilityPermissions.AddUnityChild(UnitySelector.Application.Status.Publish); + statusVisibilityPermissions.AddUnityChild(UnitySelector.Application.Status.Unpublish); + statusVisibilityPermissions.AddUnityChild(UnitySelector.Application.Status.BulkPublish); + // Comments var appCommentPermissions = grantApplicationPermissionsGroup.AddPermission(GrantApplicationPermissions.Comments.Default, L("Permission:GrantApplicationManagement.Comments.Default")); appCommentPermissions.AddChild(GrantApplicationPermissions.Comments.Add, L("Permission:GrantApplicationManagement.Comments.Add")); @@ -87,6 +93,7 @@ public override void Define(IPermissionDefinitionContext context) settingManagement.AddPermission(UnitySettingManagementPermissions.UserInterface, L("Permission:UnitySettingManagementPermissions.UserInterface")); settingManagement.AddPermission(UnitySettingManagementPermissions.BackgroundJobSettings, L("Permission:UnitySettingManagementPermissions.BackgroundJobs")); settingManagement.AddPermission(UnitySettingManagementPermissions.ConfigurePayments, L("Permission:UnitySettingManagementPermissions.ConfigurePayments")); + settingManagement.AddPermission(UnitySettingManagementPermissions.EditProgramDetails, L("Permission:UnitySettingManagementPermissions.EditProgramDetails")); var emailingPermission = context.GetPermissionOrNull(SettingManagementPermissions.Emailing); if (emailingPermission != null) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/SettingManagement/IProgramDetailsAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/SettingManagement/IProgramDetailsAppService.cs new file mode 100644 index 0000000000..589462dae0 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/SettingManagement/IProgramDetailsAppService.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using Volo.Abp.Application.Services; + +namespace Unity.GrantManager.SettingManagement; + +public interface IProgramDetailsAppService : IApplicationService +{ + Task GetProgramDetailsAsync(); + Task UpdateProgramDetailsAsync(UpdateProgramDetailsDto input); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/SettingManagement/ProgramDetailsDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/SettingManagement/ProgramDetailsDto.cs new file mode 100644 index 0000000000..7ca2ea34d4 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/SettingManagement/ProgramDetailsDto.cs @@ -0,0 +1,15 @@ +using System; + +namespace Unity.GrantManager.SettingManagement; + +[Serializable] +public class ProgramDetailsDto +{ + public string DisplayName { get; set; } = string.Empty; + + public string Division { get; set; } = string.Empty; + + public string Branch { get; set; } = string.Empty; + + public string Description { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/SettingManagement/UpdateProgramDetailsDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/SettingManagement/UpdateProgramDetailsDto.cs new file mode 100644 index 0000000000..25eb70a238 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/SettingManagement/UpdateProgramDetailsDto.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Unity.GrantManager.SettingManagement; + +public class UpdateProgramDetailsDto +{ + [StringLength(ProgramDetailsConsts.MaxDisplayNameLength)] + public string? DisplayName { get; set; } + + [StringLength(ProgramDetailsConsts.MaxDivisionLength)] + public string? Division { get; set; } + + [StringLength(ProgramDetailsConsts.MaxBranchLength)] + public string? Branch { get; set; } + + [StringLength(ProgramDetailsConsts.MaxDescriptionLength)] + public string? Description { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/Queries/ApplicantProfileQueryService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/Queries/ApplicantProfileQueryService.cs index 99bb33d239..f9571442f7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/Queries/ApplicantProfileQueryService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/Queries/ApplicantProfileQueryService.cs @@ -10,6 +10,7 @@ using Volo.Abp.Domain.Repositories; using Volo.Abp.MultiTenancy; using Volo.Abp.Settings; +using Volo.Abp.TenantManagement; namespace Unity.GrantManager.ApplicantProfile { @@ -22,6 +23,7 @@ namespace Unity.GrantManager.ApplicantProfile public class ApplicantProfileQueryService( ICurrentTenant currentTenant, IRepository applicantTenantMapRepository, + IRepository tenantRepository, IEnumerable dataProviders, ISettingProvider settingProvider, ILogger logger) @@ -63,7 +65,7 @@ public async Task GetApplicantProfileAsync(ApplicantProfile /// /// The method extracts the username portion from the subject identifier in the request /// to match tenant mappings. This operation is asynchronous and queries the host database for relevant tenant - /// associations. + /// associations. Tenant display names are batch-loaded for efficiency, falling back to the tenant name if not set. /// An object containing applicant profile information, including the subject identifier used to locate tenant /// mappings. /// A list of objects representing the tenants linked to the applicant. The @@ -75,7 +77,7 @@ public async Task> GetApplicantTenantsAsync(ApplicantPr if (subUsername is null) return []; List mappings = []; - // Query the ApplicantTenantMaps table in the host database + // Query 1: Get all tenant mappings for the applicant using (currentTenant.Change(null)) { var queryable = await applicantTenantMapRepository.GetQueryableAsync(); @@ -89,6 +91,33 @@ public async Task> GetApplicantTenantsAsync(ApplicantPr .ToListAsync(); } + if (mappings.Count == 0) + { + return mappings; + } + + // Query 2: Batch-load all tenants by their IDs to get display names + using (currentTenant.Change(null)) + { + var tenantIds = mappings.Select(m => m.TenantId).ToHashSet(); + var queryable = await tenantRepository.GetQueryableAsync(); + var tenants = await queryable.Where(t => tenantIds.Contains(t.Id)).ToListAsync(); + // Create a map of tenant ID to DisplayName (with fallback to Name) + var tenantDisplayNames = tenants.ToDictionary( + t => t.Id, + t => GetTenantDisplayName(t) + ); + + // Populate DisplayName for each mapping + foreach (var mapping in mappings) + { + if (tenantDisplayNames.TryGetValue(mapping.TenantId, out var displayName)) + { + mapping.DisplayName = displayName; + } + } + } + // Apply tenant specific metadata foreach (var map in mappings) { @@ -107,8 +136,24 @@ private async Task AddTenantMetadataAsync(ApplicantTenantDto tenantMap) using (currentTenant.Change(tenantMap.TenantId)) { var defaultEmailAddress = await settingProvider.GetOrNullAsync(NotificationsSettings.Mailing.DefaultFromAddress); - tenantMap.Metadata[ApplicantTenantMetadataKeys.DefaultFromAddress] = defaultEmailAddress ?? "NoReply@gov.bc.ca"; + tenantMap.DefaultFromAddress = defaultEmailAddress ?? "NoReply@gov.bc.ca"; + } + } + + /// + /// Extracts the DisplayName from the tenant's extra properties, falling back to the tenant's Name. + /// + private static string GetTenantDisplayName(Volo.Abp.TenantManagement.Tenant tenant) + { + if (tenant.ExtraProperties.TryGetValue("DisplayName", out var displayNameObj)) + { + var displayName = displayNameObj?.ToString()?.Trim(); + if (!string.IsNullOrEmpty(displayName)) + { + return displayName; + } } + return tenant.Name; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applications/ApplicationBulkActionsAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applications/ApplicationBulkActionsAppService.cs index f8290e94ad..669d03e32c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applications/ApplicationBulkActionsAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applications/ApplicationBulkActionsAppService.cs @@ -1,56 +1,113 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using Unity.GrantManager.GrantApplications; +using Unity.Modules.Shared; using Volo.Abp; using Volo.Abp.DependencyInjection; -namespace Unity.GrantManager.Applications +namespace Unity.GrantManager.Applications; + +[Dependency(ReplaceServices = true)] +[ExposeServices(typeof(ApplicationBulkActionsAppService), typeof(IApplicationBulkActionsAppService))] +[Authorize] +public class ApplicationBulkActionsAppService( + ApplicationIdsCacheService cacheService, + IApplicationRepository applicationRepository, + IApplicationStatusRepository applicationStatusRepository, + IApplicationFormRepository applicationFormRepository) : GrantManagerAppService, IApplicationBulkActionsAppService { - [Dependency(ReplaceServices = true)] - [ExposeServices(typeof(ApplicationBulkActionsAppService), typeof(IApplicationBulkActionsAppService))] - [Authorize] - public class ApplicationBulkActionsAppService : GrantManagerAppService, IApplicationBulkActionsAppService - { - private readonly ApplicationIdsCacheService _cacheService; + private readonly ApplicationIdsCacheService _cacheService = cacheService; - public ApplicationBulkActionsAppService(ApplicationIdsCacheService cacheService) + /// + /// Stores application IDs in distributed cache for bulk operations + /// + /// Request containing list of application IDs + /// Cache key to retrieve the stored IDs + public async Task StoreApplicationIdsAsync(StoreApplicationIdsRequestDto input) + { + if (input == null || input.ApplicationIds == null || input.ApplicationIds.Count == 0) { - _cacheService = cacheService; + throw new UserFriendlyException("No application IDs provided"); } - /// - /// Stores application IDs in distributed cache for bulk operations - /// - /// Request containing list of application IDs - /// Cache key to retrieve the stored IDs - public async Task StoreApplicationIdsAsync(StoreApplicationIdsRequestDto input) + try { - if (input == null || input.ApplicationIds == null || input.ApplicationIds.Count == 0) - { - throw new UserFriendlyException("No application IDs provided"); - } + var cacheKey = await _cacheService.StoreApplicationIdsAsync(input.ApplicationIds); + + Logger.LogInformation( + "User {UserId} stored {Count} application IDs for bulk operation with cache key: {CacheKey}", + CurrentUser?.Id, + input.ApplicationIds.Count, + cacheKey); - try + return new StoreApplicationIdsResultDto { - var cacheKey = await _cacheService.StoreApplicationIdsAsync(input.ApplicationIds); - - Logger.LogInformation( - "User {UserId} stored {Count} application IDs for bulk operation with cache key: {CacheKey}", - CurrentUser?.Id, - input.ApplicationIds.Count, - cacheKey); - - return new StoreApplicationIdsResultDto - { - CacheKey = cacheKey - }; - } - catch (Exception ex) + CacheKey = cacheKey + }; + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to store application IDs for bulk operation"); + throw new UserFriendlyException("Failed to prepare bulk operation. Please try again."); + } + } + + /// + /// Get applications for bulk publish with internal and external status information + /// + /// + /// + /// + [Authorize(UnitySelector.Application.Status.BulkPublish)] + public async Task> GetApplicationsForBulkPublish(Guid[] applicationGuids, bool excludePublished = true) + { + var applicationsQuery = await applicationRepository.GetQueryableAsync(); + var statusesQuery = await applicationStatusRepository.GetQueryableAsync(); + var formsQuery = await applicationFormRepository.GetQueryableAsync(); + + var results = await ( + from application in applicationsQuery + join form in formsQuery on application.ApplicationFormId equals form.Id + join status in statusesQuery on application.ApplicationStatusId equals status.Id + where applicationGuids.Contains(application.Id) && (!excludePublished || !application.ExternalStatusVisibility) + select new BulkPublishDto { - Logger.LogError(ex, "Failed to store application IDs for bulk operation"); - throw new UserFriendlyException("Failed to prepare bulk operation. Please try again."); - } + ApplicationId = application.Id, + ReferenceNo = application.ReferenceNo, + ApplicantName = application.Applicant.ApplicantName ?? string.Empty, + ApplicationStatus = status.InternalStatus, + FormName = form.ApplicationFormName ?? string.Empty, + FinalDecisionDate = application.FinalDecisionDate, + ExternalStatusVisibility = application.ExternalStatusVisibility, + ExternalStatus = application.ExternalStatusVisibility + ? status.NotifiedStatus ?? status.ExternalStatus + : status.ExternalStatus, + PublishedStatus = status.NotifiedStatus ?? status.ExternalStatus, + }).ToListAsync(); + + return results; + } + + /// + /// Bulk publish applications by setting the ExternalStatusVisibility to true for the specified application GUIDs. + /// + /// The GUIDs of the applications to be bulk published. + /// A task representing the asynchronous operation. + [Authorize(UnitySelector.Application.Status.BulkPublish)] + public async Task BulkPublishApplications(Guid[] applicationGuids) + { + var applications = await applicationRepository + .GetListAsync(x => applicationGuids.Contains(x.Id) && !x.ExternalStatusVisibility); + + foreach (var application in applications) + { + application.ExternalStatusVisibility = true; + await applicationRepository.UpdateAsync(application); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assignments/ApplicationAssignmentsAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assignments/ApplicationAssignmentsAppService.cs index 95fc01d378..2656e06db5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assignments/ApplicationAssignmentsAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assignments/ApplicationAssignmentsAppService.cs @@ -10,6 +10,7 @@ using Unity.GrantManager.Identity; using Volo.Abp.Application.Services; using Volo.Abp.DependencyInjection; +using Volo.Abp.EventBus.Local; namespace Unity.GrantManager.Assignments; @@ -18,13 +19,13 @@ namespace Unity.GrantManager.Assignments; [ExposeServices(typeof(ApplicationAssignmentsAppService), typeof(IApplicationAssignmentsService))] public class ApplicationAssignmentsAppService(IApplicationManager applicationManager, IPersonRepository personRepository, + ILocalEventBus localEventBus, IApplicationAssignmentRepository applicationAssignmentRepository) : ApplicationService, IApplicationAssignmentsService { public async Task> GetListWithApplicationIdsAsync(List ids) { var assignments = await applicationAssignmentRepository.GetListAsync(e => ids.Contains(e.ApplicationId)); - return ObjectMapper.Map, List>(assignments.OrderBy(t => t.Id).ToList()); } @@ -41,6 +42,7 @@ public async Task InsertAssigneeAsync(Guid applicationId, Guid assigneeId, strin { await applicationManager.UpdateAssigneeAsync(applicationId, assigneeId, duty); } + await localEventBus.PublishAsync(new Events.ApplicationChangedEvent { Action = GrantApplicationAction.Internal_Assign, ApplicationId = applicationId }); } catch (Exception ex) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationHelper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationHelper.cs index e03f81412d..11addcbd97 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationHelper.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Events/ScheduledNotificationHelper.cs @@ -68,7 +68,7 @@ public static Dictionary BuildTokenValues(Application applicatio ["contact_full_name"] = applicantAgent?.Name ?? string.Empty, ["contact_title"] = applicantAgent?.Title ?? string.Empty, ["category"] = applicationForm?.Category ?? string.Empty, - ["today_date"] = $"Today, {DateTime.Today.ToString("MMMM d, yyyy")}", + ["today_date"] = $"{DateTime.Today.ToString("MMMM d, yyyy")}", ["unity_application_id"] = application.UnityApplicationId ?? string.Empty }; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationStatusAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationStatusAppService.cs index 9fd421dae6..648088d64b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationStatusAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationStatusAppService.cs @@ -14,6 +14,7 @@ namespace Unity.GrantManager.GrantApplications; public class ApplicationStatusAppService : ApplicationService, IApplicationStatusService { private readonly IApplicationStatusRepository _applicationStatusRepository; + public ApplicationStatusAppService(IApplicationStatusRepository repository) { _applicationStatusRepository = repository; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/BulkApprovalsAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/BulkApprovalsAppService.cs index be2ee1672e..60d663f617 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/BulkApprovalsAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/BulkApprovalsAppService.cs @@ -14,7 +14,8 @@ namespace Unity.GrantManager.GrantApplications { [Authorize] - public class BulkApprovalsAppService(IApplicationRepository applicationRepository, + public class BulkApprovalsAppService( + IApplicationRepository applicationRepository, IApplicationManager applicationManager, ILocalEventBus localEventBus, IUnitOfWorkManager unitofWorkManager) : GrantManagerAppService, IBulkApprovalsAppService diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index 52adccaaf0..602de5054d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -161,6 +161,7 @@ public async Task> GetListAsync(GrantApplica ContractExecutionDate = rec.ContractExecutionDate, RiskRanking = rec.RiskRanking, UnityApplicationId = rec.UnityApplicationId, + ExternalStatusVisibility = rec.ExternalStatusVisibility, // From ApplicationStatus Status = rec.Status, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeFormSubmissionManager.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeFormSubmissionManager.cs index 822c169ec1..ed497b2a36 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeFormSubmissionManager.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeFormSubmissionManager.cs @@ -14,6 +14,7 @@ using Volo.Abp.Domain.Services; using Volo.Abp.EventBus.Local; using Volo.Abp.Uow; +using Unity.GrantManager.Events; namespace Unity.GrantManager.Intakes { @@ -93,9 +94,9 @@ await localEventBus.PublishAsync(new ApplicationProcessEvent ApplicationFormSubmission = newSubmission, RawSubmission = formSubmission }); - + + await localEventBus.PublishAsync(new ApplicationChangedEvent { Action = GrantApplicationAction.Submit, ApplicationId = application.Id }); await uow.SaveChangesAsync(); - return application.Id; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/SettingManagement/ProgramDetailsAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/SettingManagement/ProgramDetailsAppService.cs new file mode 100644 index 0000000000..632d7c7a00 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/SettingManagement/ProgramDetailsAppService.cs @@ -0,0 +1,66 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Unity.GrantManager.Permissions; +using Volo.Abp.Authorization; +using Volo.Abp.Data; +using Volo.Abp.Domain.Entities; +using Volo.Abp.TenantManagement; + +namespace Unity.GrantManager.SettingManagement; + +[Authorize(UnitySettingManagementPermissions.EditProgramDetails)] +public class ProgramDetailsAppService(ITenantRepository tenantRepository) + : GrantManagerAppService, IProgramDetailsAppService +{ + public virtual async Task GetProgramDetailsAsync() + { + var tenant = await GetCurrentTenantAsync(); + + return new ProgramDetailsDto + { + DisplayName = GetExtraPropertyValue(tenant, "DisplayName"), + Division = GetExtraPropertyValue(tenant, "Division"), + Branch = GetExtraPropertyValue(tenant, "Branch"), + Description = GetExtraPropertyValue(tenant, "Description") + }; + } + + public virtual async Task UpdateProgramDetailsAsync(UpdateProgramDetailsDto input) + { + var tenant = await GetCurrentTenantAsync(); + + tenant.SetProperty("DisplayName", NormalizeProgramDetail(input.DisplayName)); + tenant.SetProperty("Division", NormalizeProgramDetail(input.Division)); + tenant.SetProperty("Branch", NormalizeProgramDetail(input.Branch)); + tenant.SetProperty("Description", NormalizeProgramDetail(input.Description)); + + using (CurrentTenant.Change(null)) + { + await tenantRepository.UpdateAsync(tenant); + } + } + + protected virtual async Task GetCurrentTenantAsync() + { + if (!CurrentTenant.Id.HasValue) + { + throw new AbpAuthorizationException("Program details can only be managed within a tenant context."); + } + + var tenantId = CurrentTenant.Id.Value; + using (CurrentTenant.Change(null)) + { + return await tenantRepository.FindAsync(tenantId) ?? throw new EntityNotFoundException(typeof(Tenant), tenantId); + } + } + + protected virtual string GetExtraPropertyValue(Tenant tenant, string propertyName) + { + return tenant.GetProperty(propertyName)?.ToString() ?? string.Empty; + } + + protected virtual string NormalizeProgramDetail(string? value) + { + return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim(); + } +} 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 92c7fefd64..aa81531138 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 @@ -67,6 +67,7 @@ "Assignee": "Assignee", "Probability": "Probability", "GrantApplicationStatus": "Status", + "ExternalStatusVisibility": "Status Visibility", "ProposalDate": "Proposal Date", "SubmissionDate": "Submission Date", "GrantApplications": "Grant Applications", @@ -159,7 +160,7 @@ "Permission:GrantApplicationManagement.Approvals.DeferAfterApproval": "Defer After Approval", "Permission:GrantApplicationManagement.Comments.Default": "Comments", "Permission:GrantApplicationManagement.Comments.Add": "Add Application Comment", - "Permission:GrantApplicationManagement.Organizations.Default": "Organizations", + "Permission:GrantApplicationManagement.Organizations.Default": "Organizations", "Permission:GrantApplicationManagement.Organizations.ManageProfiles": "Manage Organization Profiles", "Permission:GrantManagerManagement.Intakes.Default": "Manage Intakes", "Permission:GrantManagerManagement.ApplicationForms.Default": "Manage Forms", @@ -245,6 +246,11 @@ "Unity.GrantManager.ApplicationManagement.Payment.Supplier.Update": "Edit Supplier Info", "Unity.GrantManager.ApplicationManagement.Payment.PaymentList": "Payment List", + "Unity.GrantManager.ApplicationManagement.Application.Status": "External Status Visibility", + "Unity.GrantManager.ApplicationManagement.Application.Status.Publish": "Publish Status", + "Unity.GrantManager.ApplicationManagement.Application.Status.Unpublish": "Unpublish Status", + "Unity.GrantManager.ApplicationManagement.Application.Status.BulkPublish": "Bulk Publish Status", + "Permission:GrantApplicationManagement.ProjectInfo": "Project Info", "Permission:GrantApplicationManagement.ProjectInfo.Update": "Project Info Update", "Permission:GrantApplicationManagement.ProjectInfo.Update.UpdateFinalStateFields": "Edit Project Info Fields After Approval", @@ -286,15 +292,16 @@ "AssessmentResultsView:AssessmentResultsForm.NotificationDate": "Notification Date", "AssessmentResultsView:AssessmentResultsForm.RiskRanking": "Risk Ranking", "AssessmentResultsView:AssessmentResultsForm.Published": "Published", - "DetailsActionBar:PublishButton": "Publish", - "DetailsActionBar:UnpublishButton": "Unpublish", + "DetailsActionBar:PublishButton": "Publish Status", + "DetailsActionBar:UnpublishButton": "Unpublish Status", "DetailsActionBar:ConfirmActionTitle": "Confirm Action", "DetailsActionBar:PublishConfirmationText": "Are you sure you want to publish this application status?", "DetailsActionBar:UnpublishConfirmationText": "Are you sure you want to unpublish this application status?", "DetailsActionBar:ConfirmButton": "Confirm", "DetailsActionBar:CancelButton": "Cancel", - "DetailsActionBar:PublishStatusUpdated": "Publish status has been updated.", - "DetailsActionBar:PublishStatusUpdateFailed": "Failed to update publish status.", + "DetailsActionBar:PublishStatusUpdatedToast": "Application status has been published.", + "DetailsActionBar:UnpublishStatusUpdatedToast": "Application status has been unpublished.", + "DetailsActionBar:PublishStatusUpdateFailed": "Failed to update status.", "GrantApplication:ActionButtonName": "Status Actions", "GrantApplication:ActionButton.RedStopWarning": "This application is from an applicant with a Red-Stop. Status actions are not permitted.", @@ -331,6 +338,7 @@ "Permission:UnitySettingManagementPermissions.BackgroundJobs": "Background Jobs", "Permission:UnitySettingManagementPermissions.UserInterface": "Application Tabs", "Permission:UnitySettingManagementPermissions.ConfigurePayments": "Configure Payments", + "Permission:UnitySettingManagementPermissions.EditProgramDetails": "Edit Program Details", "Setting:GrantManager.UI.PageTitle": "Application Tabs Configuration", "Setting:GrantManager.UI.Tabs.Applicant.Description": "Toggle visibility of the Applicant Info tab for the tenant", @@ -525,6 +533,10 @@ "ApplicationBatchApprovalRequest:InvalidApprovedAmount": "Invalid Approved Amount, it must be greater than 0.00", "ApplicationBatchApprovalRequest:InvalidRecommendedAmount": "Invalid Recommended Amount, it must be greater than 0.00", + "ApplicationBatchPublishRequest:MaxCountExceeded": "You have exceeded the maximum number of items for bulk status publishing. Please reduce the number to {0} or fewer", + "ApplicationBatchPublishRequest:MinCountExceeded": "You have no items selected for bulk status publishing. Please close this prompt to continue", + "ApplicationBatchPublishRequest:ConfirmationNote": "By confirming, the selected application statuses will be published and made visible to applicants in the portal.", + "ApplicationLinks:Category": "Category", "ApplicationLinks:ID": "ID", "ApplicationLinks:Status": "Status", @@ -532,6 +544,7 @@ "ApplicantPortalSettings:Title": "Applicant Portal Configuration", "ApplicantPortalSettings:ManageStatuses": "Manage Statuses", + "ApplicantPortalSettings:ProgramDetails": "Program Details", "ApplicantPortalSettings:PortalStatusHeading": "Applicant Portal Status", "ApplicantPortalSettings:InternalStatus": "Internal Status", "ApplicantPortalSettings:PortalStatusLabel": "External Status", @@ -544,6 +557,18 @@ "ApplicantPortalSettings:SaveError": "An error occurred while saving portal status labels.", "ApplicantPortalSettings:ValidationRequired": "Portal status label cannot be empty.", + "ProgramDetails:Heading": "Program Details", + "ProgramDetails:DisplayName": "Display Name", + "ProgramDetails:Division": "Division", + "ProgramDetails:Branch": "Branch", + "ProgramDetails:Description": "Description", + "ProgramDetails:SaveChanges": "Save Changes", + "ProgramDetails:ResetChanges": "Reset", + "ProgramDetails:NoChanges": "No changes to save.", + "ProgramDetails:ChangesReset": "Changes have been reset to original values.", + "ProgramDetails:SaveSuccess": "Program details updated successfully.", + "ProgramDetails:SaveError": "An error occurred while saving program details.", + "Common:Command:Cancel": "Cancel", "Common:Command:Save": "Save", "Common:Command:Filter": "Filter", diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantManagerPermissions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantManagerPermissions.cs index 31c447af0c..c38b9205fb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantManagerPermissions.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantManagerPermissions.cs @@ -23,7 +23,7 @@ public static class ApplicationForms { public const string Default = GroupName + ".ApplicationForms"; } - + public static class Endpoints { public const string Default = GroupName + ".Endpoints"; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/UnitySettingManagementPermissions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/UnitySettingManagementPermissions.cs index 84a8d195cd..18d1637c55 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/UnitySettingManagementPermissions.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/UnitySettingManagementPermissions.cs @@ -7,5 +7,7 @@ public static class UnitySettingManagementPermissions public const string BackgroundJobSettings = "SettingManagement.GrantManager"; - public const string ConfigurePayments = "SettingManagement.ConfigurePayments"; + public const string ConfigurePayments = GroupName + ".ConfigurePayments"; + + public const string EditProgramDetails = GroupName + ".EditProgramDetails"; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/SettingManagement/ProgramDetailsConsts.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/SettingManagement/ProgramDetailsConsts.cs new file mode 100644 index 0000000000..f8d16973f0 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/SettingManagement/ProgramDetailsConsts.cs @@ -0,0 +1,9 @@ +namespace Unity.GrantManager.SettingManagement; + +public static class ProgramDetailsConsts +{ + public const int MaxDisplayNameLength = 30; + public const int MaxDivisionLength = 50; + public const int MaxBranchLength = 50; + public const int MaxDescriptionLength = 500; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationListRecord.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationListRecord.cs index 999ad5fa13..7b4f4e11ca 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationListRecord.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationListRecord.cs @@ -57,6 +57,7 @@ public class ApplicationListRecord public DateTime? ContractExecutionDate { get; init; } public string? RiskRanking { get; init; } public string? UnityApplicationId { get; init; } + public bool ExternalStatusVisibility { get; init; } // ApplicationStatus (always joined) public string Status { get; init; } = string.Empty; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs index 833ac0b40c..2525fee37b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs @@ -76,7 +76,6 @@ public PermissionGrantsDataSeeder(IPermissionDataSeeder permissionDataSeeder) public readonly List NotificationsScheduling_CommonPermissions = [ NotificationsPermissions.Email.CancelScheduled, NotificationsPermissions.Email.ScheduleCreate, - NotificationsPermissions.Email.ScheduleDelete, NotificationsPermissions.Email.ScheduleCancel ]; @@ -105,6 +104,13 @@ public PermissionGrantsDataSeeder(IPermissionDataSeeder permissionDataSeeder) UnitySelector.Payment.Tags.Delete, ]; + public readonly List ExternalStatusVisibility_CommonPermissions = [ + UnitySelector.Application.Status.Default, + UnitySelector.Application.Status.Publish, + UnitySelector.Application.Status.Unpublish, + UnitySelector.Application.Status.BulkPublish + ]; + public async Task SeedAsync(DataSeedContext context) { // Default permission grants based on role @@ -132,6 +138,8 @@ await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, IdentitySeedPermissions.Roles.ManagePermissions, GrantManagerPermissions.Intakes.Default, GrantManagerPermissions.ApplicationForms.Default, + UnitySettingManagementPermissions.UserInterface, + UnitySettingManagementPermissions.EditProgramDetails, .. SettingManagement_Tags_CommonPermissions, .. ReviewAndAssessment_CommonPermissions, @@ -143,6 +151,7 @@ await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, .. NotificationsScheduling_CommonPermissions, .. Dashboard_CommonPermissions, .. Tags_CommonPermissions, + .. ExternalStatusVisibility_CommonPermissions, AIPermissions.Configuration.ConfigureAI, FlexPermissions.Worksheets.Default, FlexPermissions.Worksheets.Delete @@ -208,6 +217,7 @@ await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, .. NotificationsScheduling_CommonPermissions, .. Dashboard_CommonPermissions, .. Tags_CommonPermissions, + .. ExternalStatusVisibility_CommonPermissions, // Role Specific Permissions UnitySelector.Project.Summary.Update.UpdateFinalStateFields, @@ -236,6 +246,7 @@ await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, [ GrantManagerPermissions.Default, UnitySettingManagementPermissions.UserInterface, + UnitySettingManagementPermissions.EditProgramDetails, GrantManagerPermissions.Organizations.Default, GrantManagerPermissions.Organizations.ManageProfiles, GrantManagerPermissions.Intakes.Default, @@ -345,4 +356,3 @@ await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, } } } - 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 15ec1b58b8..80432f764a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicationRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicationRepository.cs @@ -322,6 +322,7 @@ public async Task> GetApplicationListRecordsAsync( a.RiskRanking, a.UnityApplicationId, Status = a.ApplicationStatus.InternalStatus, // ApplicationStatus (always joined) + a.ExternalStatusVisibility, Category = a.ApplicationForm.Category ?? string.Empty, // ApplicationForm (always joined) a.ApplicantId, ApplicantName = a.Applicant.ApplicantName, // Applicant (always joined) @@ -506,6 +507,7 @@ public async Task> GetApplicationListRecordsAsync( RiskRanking = a.RiskRanking, UnityApplicationId = a.UnityApplicationId, Status = a.Status, + ExternalStatusVisibility = a.ExternalStatusVisibility, Category = a.Category, ApplicantId = a.ApplicantId, ApplicantName = a.ApplicantName, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs index 777d5f6066..95f9393ef3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs @@ -266,7 +266,16 @@ internal static void Register(ServiceConfigurationContext context) authorizationBuilder.AddPolicy(ReportingPermissions.Configuration.Update, policy => policy.RequireClaim(PermissionConstant, ReportingPermissions.Configuration.Update)); authorizationBuilder.AddPolicy(ReportingPermissions.Configuration.Delete, - policy => policy.RequireClaim(PermissionConstant, ReportingPermissions.Configuration.Delete)); + policy => policy.RequireClaim(PermissionConstant, ReportingPermissions.Configuration.Delete)); + + // Application - External Status Visibility + authorizationBuilder.AddPolicy(UnitySelector.Application.Status.Default, + policy => policy.RequireClaim(PermissionConstant, UnitySelector.Application.Status.Default)); + authorizationBuilder.AddPolicy(UnitySelector.Application.Status.Publish, + policy => policy.RequireClaim(PermissionConstant, UnitySelector.Application.Status.Publish)); + authorizationBuilder.AddPolicy(UnitySelector.Application.Status.Unpublish, + policy => policy.RequireClaim(PermissionConstant, UnitySelector.Application.Status.Unpublish)); + authorizationBuilder.AddPolicy(UnitySelector.Application.Status.BulkPublish, + policy => policy.RequireClaim(PermissionConstant, UnitySelector.Application.Status.BulkPublish)); } } - diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Mapping/GrantManagerWebMapperlyProfile.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Mapping/GrantManagerWebMapperlyProfile.cs index ad9bd5b7ab..18a72ebc69 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Mapping/GrantManagerWebMapperlyProfile.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Mapping/GrantManagerWebMapperlyProfile.cs @@ -1,5 +1,5 @@ -using System; using Riok.Mapperly.Abstractions; +using System; using Unity.GrantManager.ApplicantProfile; using Unity.GrantManager.ApplicantProfile.ProfileData; using Unity.GrantManager.ApplicationForms; @@ -13,6 +13,7 @@ using Unity.GrantManager.Web.Pages.ApplicantContact; using Unity.GrantManager.Web.Pages.ApplicationContact; using Unity.GrantManager.Web.Pages.ApplicationForms.ViewModels; +using Unity.GrantManager.Web.Pages.BulkActions; using Unity.GrantManager.Web.Pages.Sites.ViewModels; using Unity.GrantManager.Web.Views.Shared.Components.ApplicantInfo; using Unity.GrantManager.Web.Views.Shared.Components.SummaryWidget; @@ -320,3 +321,13 @@ public partial class ApplicantContactModalViewModelToUpdateApplicantContactDtoMa [MapperIgnoreTarget(nameof(UpdateApplicantContactDto.WorkPhoneExtension))] public override partial void Map(ApplicantContactModalViewModel source, UpdateApplicantContactDto destination); } + +[Mapper] +public partial class BulkPublishDtoToViewModelMapper : MapperBase +{ + [MapperIgnoreTarget(nameof(BulkPublishApplicationViewModel.IsValid))] + public override partial BulkPublishApplicationViewModel Map(BulkPublishDto source); + + [MapperIgnoreTarget(nameof(BulkPublishApplicationViewModel.IsValid))] + public override partial void Map(BulkPublishDto source, BulkPublishApplicationViewModel destination); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.css index f4ff0510f2..f6d49826d5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.css @@ -6,6 +6,10 @@ background-color: white; } +.unt-tab-content .form-select { + width: auto; +} + .configuration-action-bar { align-items: center; border-bottom: 1px solid #dee2e6; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkActions/BulkPublishApplicationViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkActions/BulkPublishApplicationViewModel.cs new file mode 100644 index 0000000000..b7f6ab6e48 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkActions/BulkPublishApplicationViewModel.cs @@ -0,0 +1,24 @@ +using System; +using System.ComponentModel; + +namespace Unity.GrantManager.Web.Pages.BulkActions; + +public class BulkPublishApplicationViewModel +{ + public Guid ApplicationId { get; set; } + public string? ApplicantName { get; set; } = string.Empty; + public string ReferenceNo { get; set; } = string.Empty; + public bool ExternalStatusVisibility { get; set; } + public string ApplicationStatus { get; set; } = string.Empty; + public string FormName { get; set; } = string.Empty; + + [DisplayName("External Status (Current)")] + public string ExternalStatus { get; set; } = string.Empty; + + [DisplayName("Published Status (Future)")] + public string PublishedStatus { get; set; } = string.Empty; + + [DisplayName("Decision Date")] + public DateTime? FinalDecisionDate { get; set; } + public bool IsValid { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkActions/BulkPublishApplications.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkActions/BulkPublishApplications.cshtml new file mode 100644 index 0000000000..4167de2eb8 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkActions/BulkPublishApplications.cshtml @@ -0,0 +1,123 @@ +@page +@model Unity.GrantManager.Web.Pages.BulkActions.BulkPublishApplicationsModel +@using Microsoft.Extensions.Localization +@using Unity.GrantManager.Localization + +@inject IStringLocalizer L +@{ + Layout = null; +} + +
+ + + + @if (ViewData["Error"] != null) + { + + } + else + { + + + + + @for (var i = 0; i < Model.BulkApplications?.Count; i++) + { +
+ + +
+
@Model.BulkApplications[i].ReferenceNo
+
+ @(string.IsNullOrWhiteSpace(Model.BulkApplications[i].ApplicantName) ? "Applicant Name" : @Model.BulkApplications[i].ApplicantName) +
+
+ @(string.IsNullOrWhiteSpace(Model.BulkApplications[i].FormName) ? "(Form Name)" : string.Format("({0})", @Model.BulkApplications[i].FormName)) +
+
@Model.BulkApplications[i].ApplicationStatus
+
+
+ + + +
+ + + + + + + + + + + + + + + + + + @if (Model.BulkApplications[i].FinalDecisionDate.HasValue) + { + + } + else + { +
+ + +
+ } +
+
+
+ } +
+
+ + +

+ Error @Model.MaxBatchCountExceededError +

+

+ Error @L["ApplicationBatchPublishRequest:MinCountExceeded"] +

+
+
+ + +

+ Note: @L["ApplicationBatchPublishRequest:ConfirmationNote"] +

+
+
+ } +
+ + + + + + +
+
\ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkActions/BulkPublishApplications.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkActions/BulkPublishApplications.cshtml.cs new file mode 100644 index 0000000000..1525ffbac1 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkActions/BulkPublishApplications.cshtml.cs @@ -0,0 +1,121 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; +using Unity.Modules.Shared; +using Unity.Modules.Shared.Utils; +using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; + +namespace Unity.GrantManager.Web.Pages.BulkActions; + +[Authorize(UnitySelector.Application.Status.BulkPublish)] +public class BulkPublishApplicationsModel( + ApplicationIdsCacheService cacheService, + IApplicationBulkActionsAppService bulkActionsAppService) : AbpPageModel +{ + [BindProperty] + public List? BulkApplications { get; set; } + + [BindProperty] + public string SelectedApplicationIds { get; set; } = string.Empty; + + [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 Task OnGetAsync(string cacheKey) + { + if (!await AuthorizationService.IsGrantedAsync(UnitySelector.Application.Status.BulkPublish)) + { + return; + } + + MaxBatchCount = BatchApprovalConsts.MaxBatchCount; + BulkApplications = []; + MaxBatchCountExceededError = L["ApplicationBatchPublishRequest:MaxCountExceeded", BatchApprovalConsts.MaxBatchCount.ToString()].Value; + + try + { + // Retrieve application IDs from distributed cache + var selectedApplicationIds = await cacheService.GetApplicationIdsAsync(cacheKey); + + if (selectedApplicationIds == null || selectedApplicationIds.Count == 0) + { + Logger.LogWarning("Cache key expired or invalid: {CacheKey}", cacheKey.SanitizeField()); + ViewData["Error"] = "The session has expired. Please try selecting applications again."; + Invalid = true; + return; + } + + // Convert List to Guid[] for existing code compatibility + Guid[] applicationGuids = [.. selectedApplicationIds]; + + // Clean up cache after retrieval (one-time use) + await cacheService.RemoveAsync(cacheKey); + + if (applicationGuids.Length > MaxBatchCount) + { + MaxBatchCountExceeded = true; + } + + if (applicationGuids.Length == 0) + { + Invalid = true; + return; + } + + // Fetch application details for the selected IDs + var bulkApplications = await bulkActionsAppService.GetApplicationsForBulkPublish(applicationGuids); + + BulkApplications = ObjectMapper.Map, List>(bulkApplications); + ApplicationsCount = BulkApplications.Count; + Invalid = MaxBatchCountExceeded || ApplicationsCount == 0; + } + catch (Exception ex) + { + Logger.LogError(ex, "Error loading bulk publish applications modal"); + ViewData["Error"] = "An error occurred while loading the applications. Please try again."; + Invalid = true; + return; + } + } + + public async Task OnPostAsync() + { + try + { + if (BulkApplications == null) return NoContent(); + if (BulkApplications.Count == 0 || BulkApplications.Count > BatchApprovalConsts.MaxBatchCount) + { + return BadRequest(); + } + + var applicationsToPublish = BulkApplications.Select(y => y.ApplicationId).ToArray(); + await bulkActionsAppService.BulkPublishApplications(applicationsToPublish); + + return new OkResult(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error updating application status"); + } + + return NoContent(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkActions/BulkPublishApplications.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkActions/BulkPublishApplications.css new file mode 100644 index 0000000000..fe7b184776 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkActions/BulkPublishApplications.css @@ -0,0 +1,110 @@ +.batch-action-card { + max-height: 65vh; + overflow: auto; +} + +.batch-action-container { + border: var(--bs-border-width) solid; + border-color: var(--bs-border-color); +} + +.bulk-action-row-header { + padding-bottom: 0 !important; +} + +.bulk-action-remove-row { + min-width: 55px +} + + +/* This is for the summary modal*/ + +.table-container { + max-height: 65vh; + overflow-y: auto; +} + +.batch-action-container input.form-control:disabled { + color: var(--bs-body-color); +} + +.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%; +} + +.action-details-header { + display: flex; + align-items: center; + gap: 0.75em; +} + + .action-details-header + .header-data { + color: var(--bs-body-color); + -webkit-font-smoothing: antialiased; + } + + .action-details-header + .reference-no { + font-weight: 700; + } + + .action-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-top: 2px; + } + + +.action-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; +} + +.action-note-error { + color: var(--lpx-danger); + border-color: var(--lpx-danger); +} + +.batch-direct-action-indicator { + padding-left: 1rem; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkActions/BulkPublishApplications.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkActions/BulkPublishApplications.js new file mode 100644 index 0000000000..685d121930 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkActions/BulkPublishApplications.js @@ -0,0 +1,59 @@ +function removeApplicationRow(applicationId) { + $('#' + applicationId).remove(); + let applicationsCount = $('#ApplicationsCount').val(); + $('#ApplicationsCount').val(applicationsCount - 1); + runValidations(); +} + +function setMaxCountError(visible) { + const summary = $('#batch-action-summary'); + if (visible) { + summary.css('display', 'block'); + } else { + summary.css('display', 'none'); + } +} + +function closePublish() { + $('#bulkPublishApplicationsModal').modal('hide'); +} + +function runValidations() { + const isValid = validBatchCount(); + $('#batch-action-summary').toggleClass('d-none', isValid); + $("#bulkPublishApplicationsModal").find('#btnSubmitBatchPublish').prop("disabled", !isValid); +} + +function validBatchCount() { + const applicationsCount = Number.parseInt(String($('#ApplicationsCount').val() ?? ''), 10); + const maxBatchCount = Number.parseInt(String($('#MaxBatchCount').val() ?? ''), 10); + let validationMaxValid = true; + let validationMinValid = true; + + const hasInvalidNumber = Number.isNaN(applicationsCount) || Number.isNaN(maxBatchCount); + if (hasInvalidNumber) { + return false; + } + + if (applicationsCount > maxBatchCount) { + validationMaxValid = false; + } else if (applicationsCount === 0) { + validationMinValid = false; + } + + $('#maxCountWarning').toggleClass('d-none', validationMaxValid); + $('#minCountWarning').toggleClass('d-none', validationMinValid); + $('.batch-action-card').toggleClass('d-none', !validationMinValid); + + return validationMaxValid && validationMinValid; +} + +$(function () { + class BulkPublishModal { + initModal(publicApi, args) { + runValidations(); + } + } + + abp.modals.BulkPublishModal = BulkPublishModal; +}); \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml index bfc372b165..93cc895573 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml @@ -25,6 +25,10 @@ { } + @if (Model.ShowProgramDetails) + { + + } } @section scripts { @@ -46,6 +50,11 @@ { } + @if (Model.ShowProgramDetails) + { + + + } }
@@ -88,6 +97,12 @@ AI } + @if (Model.ShowProgramDetails) + { + + }
@@ -226,5 +241,13 @@ @await Component.InvokeAsync(typeof(Unity.AI.Web.Views.Settings.AISettingGroup.AISettingViewComponent)) } + + + @if (Model.ShowProgramDetails) + { +
+ @await Component.InvokeAsync(typeof(Unity.GrantManager.Web.Views.Settings.ProgramDetails.ProgramDetailsViewComponent)) +
+ } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml.cs index b60b82d183..52fc1d3282 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ConfigurationManagement/Index.cshtml.cs @@ -36,6 +36,7 @@ public class IndexModel( public bool ShowScoresheets { get; set; } public bool ShowTags { get; set; } public bool ShowAI { get; set; } + public bool ShowProgramDetails { get; set; } public async Task OnGetAsync() { @@ -62,6 +63,8 @@ public async Task OnGetAsync() ShowAI = await featureChecker.IsEnabledAsync("Unity.AI.Scoring") && await permissionChecker.IsGrantedAsync(AIPermissions.Configuration.ConfigureAI); + ShowProgramDetails = await permissionChecker.IsGrantedAsync(UnitySettingManagementPermissions.EditProgramDetails); + // Load payment data only if visible if (ShowPayments) { 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 1e24a3cf75..442ec9b777 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 @@ -569,6 +569,7 @@ $(function () { getTotalProjectBudgetColumn(columnIndex++), getAssigneesColumn(columnIndex++), getStatusColumn(columnIndex++), + getExternalStatusVisibilityColumn(columnIndex++), getRequestedAmountColumn(columnIndex++), getApprovedAmountColumn(columnIndex++), getEconomicRegionColumn(columnIndex++), @@ -789,6 +790,19 @@ $(function () { } } + function getExternalStatusVisibilityColumn(columnIndex) { + return { + title: l('ExternalStatusVisibility'), + data: 'externalStatusVisibility', + name: 'externalStatusVisibility', + className: 'data-table-header', + render: function (data) { + return data ? 'Published' : 'Unpublished'; + }, + index: columnIndex + } + } + function getRequestedAmountColumn(columnIndex) { return { title: l('RequestedAmount'), diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Settings/ProgramDetails/CharacterCounter.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Settings/ProgramDetails/CharacterCounter.js new file mode 100644 index 0000000000..573b8e6e0d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Settings/ProgramDetails/CharacterCounter.js @@ -0,0 +1,42 @@ +(function ($) { + window.UnityCharacterCounter = { + init: function (containerSelector) { + const $container = $(containerSelector || document); + + $container.find('[data-char-count]').each(function () { + const $input = $(this); + const maxLength = Number.parseInt($input.attr('data-char-count'), 10); + + if (Number.isNaN(maxLength)) { + return; + } + + // Create wrapper with relative positioning (idempotent) + if ($input.data('unityCharCounterInitialized')) { + return; + } + $input.data('unityCharCounterInitialized', true); + + if (!$input.parent().hasClass('char-counter-wrapper')) { + $input.wrap('
'); + } + + let $counter = $input.siblings('.char-counter'); + if (!$counter.length) { + $counter = $('
').insertAfter($input); + } + function updateCounter() { + const currentLength = $input.val().length; + $counter.text(currentLength + '/' + maxLength); + + const percentageUsed = (currentLength / maxLength) * 100; + $counter.toggleClass('char-counter-critical', percentageUsed >= 95); + $counter.toggleClass('char-counter-warning', percentageUsed >= 80 && percentageUsed < 95); + } + + $input.on('input', updateCounter); + updateCounter(); + }); + } + }; +})(jQuery); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Settings/ProgramDetails/ProgramDetails.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Settings/ProgramDetails/ProgramDetails.css new file mode 100644 index 0000000000..d6e9ad6991 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Settings/ProgramDetails/ProgramDetails.css @@ -0,0 +1,26 @@ +.char-counter-wrapper { + position: relative; + display: block; +} + +.char-counter { + position: absolute; + top: 0; + right: 8px; + font-size: 0.65rem; + font-style: italic; + color: #6c757d; + white-space: nowrap; + padding: 4px 0; + pointer-events: none; +} + +.char-counter-warning { + color: #ffc107; + font-weight: 500; +} + +.char-counter-critical { + color: #dc3545; + font-weight: 500; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Settings/ProgramDetails/ProgramDetails.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Settings/ProgramDetails/ProgramDetails.js new file mode 100644 index 0000000000..58335f06a6 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Settings/ProgramDetails/ProgramDetails.js @@ -0,0 +1,114 @@ +(function ($) { + const l = abp.localization.getResource('GrantManager'); + + function debounce(func, wait) { + let timeout; + return function () { + clearTimeout(timeout); + timeout = setTimeout(func, wait); + }; + } + + const $programDetailsForm = $('#ProgramDetailsForm'); + if (!$programDetailsForm.length) { + return; + } + + UnityCharacterCounter.init('#ProgramDetailsForm'); + + const $programDetailsSaveButton = $('#ProgramDetailsSaveButton'); + const $programDetailsResetButton = $('#ProgramDetailsResetButton'); + const $displayNameInput = $('#ProgramDetailsDisplayName'); + const $divisionInput = $('#ProgramDetailsDivision'); + const $branchInput = $('#ProgramDetailsBranch'); + const $descriptionInput = $('#ProgramDetailsDescription'); + + const originalProgramDetailsValues = { + displayName: $displayNameInput.val().trim(), + division: $divisionInput.val().trim(), + branch: $branchInput.val().trim(), + description: $descriptionInput.val().trim() + }; + + function getProgramDetailsValues() { + return { + displayName: $displayNameInput.val().trim(), + division: $divisionInput.val().trim(), + branch: $branchInput.val().trim(), + description: $descriptionInput.val().trim() + }; + } + + function hasProgramDetailsChanges() { + const currentValues = getProgramDetailsValues(); + return currentValues.displayName !== originalProgramDetailsValues.displayName + || currentValues.division !== originalProgramDetailsValues.division + || currentValues.branch !== originalProgramDetailsValues.branch + || currentValues.description !== originalProgramDetailsValues.description; + } + + function updateProgramDetailsButtonStates() { + const changed = hasProgramDetailsChanges(); + $programDetailsSaveButton.prop('disabled', !changed); + $programDetailsResetButton.prop('disabled', !changed); + } + + const debouncedProgramDetailsButtonUpdate = debounce(updateProgramDetailsButtonStates, 150); + + $programDetailsForm.on('input', 'input, textarea', function () { + debouncedProgramDetailsButtonUpdate(); + }); + + $programDetailsResetButton.on('click', function (e) { + e.preventDefault(); + $displayNameInput.val(originalProgramDetailsValues.displayName); + $divisionInput.val(originalProgramDetailsValues.division); + $branchInput.val(originalProgramDetailsValues.branch); + $descriptionInput.val(originalProgramDetailsValues.description); + + // Trigger input events to update character counters + $displayNameInput.trigger('input'); + $divisionInput.trigger('input'); + $branchInput.trigger('input'); + $descriptionInput.trigger('input'); + + updateProgramDetailsButtonStates(); + abp.notify.info(l('ProgramDetails:ChangesReset')); + }); + + updateProgramDetailsButtonStates(); + + $programDetailsForm.on('submit', function (e) { + e.preventDefault(); + + if (!hasProgramDetailsChanges()) { + abp.notify.info(l('ProgramDetails:NoChanges')); + return; + } + + const currentValues = getProgramDetailsValues(); + + abp.ui.setBusy($programDetailsForm); + unity.grantManager.settingManagement.programDetails + .updateProgramDetails({ + displayName: currentValues.displayName, + division: currentValues.division, + branch: currentValues.branch, + description: currentValues.description + }) + .then(function () { + originalProgramDetailsValues.displayName = currentValues.displayName; + originalProgramDetailsValues.division = currentValues.division; + originalProgramDetailsValues.branch = currentValues.branch; + originalProgramDetailsValues.description = currentValues.description; + updateProgramDetailsButtonStates(); + abp.notify.success(l('ProgramDetails:SaveSuccess')); + }) + .catch(function (error) { + abp.notify.error(error.message || l('ProgramDetails:SaveError')); + }) + .always(function () { + abp.ui.clearBusy($programDetailsForm); + }); + }); +})(jQuery); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Settings/ProgramDetails/ProgramDetailsViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Settings/ProgramDetails/ProgramDetailsViewComponent.cs new file mode 100644 index 0000000000..54f16500cf --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Settings/ProgramDetails/ProgramDetailsViewComponent.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using Unity.GrantManager.SettingManagement; +using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.AspNetCore.Mvc.UI.Widgets; + +namespace Unity.GrantManager.Web.Views.Settings.ProgramDetails; + +[Widget(AutoInitialize = true)] +public class ProgramDetailsViewComponent(IProgramDetailsAppService programDetailsAppService) : AbpViewComponent +{ + public virtual async Task InvokeAsync() + { + var model = await programDetailsAppService.GetProgramDetailsAsync(); + return View("~/Views/Settings/ProgramDetails/ProgramDetailsViewComponent.cshtml", model); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Settings/ProgramDetails/ProgramDetailsViewComponent.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Settings/ProgramDetails/ProgramDetailsViewComponent.cshtml new file mode 100644 index 0000000000..f87ad8c49f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Settings/ProgramDetails/ProgramDetailsViewComponent.cshtml @@ -0,0 +1,66 @@ +@using Microsoft.Extensions.Localization +@using Unity.GrantManager.SettingManagement +@using Unity.GrantManager.Localization +@model Unity.GrantManager.SettingManagement.ProgramDetailsDto + +@inject IStringLocalizer L + +
+
+

@L["ProgramDetails:Heading"]

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/ActionBar.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/ActionBar.cs index 97183d98f8..18a1e5edfc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/ActionBar.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/ActionBar.cs @@ -26,6 +26,8 @@ public override void ConfigureBundle(BundleConfigurationContext context) .AddIfNotContains("/Views/Shared/Components/ActionBar/Default.css"); context.Files .AddIfNotContains("/Pages/BulkApprovals/ApproveApplicationsModal.css"); + context.Files + .AddIfNotContains("/Pages/BulkActions/BulkPublishApplications.css"); } } @@ -48,6 +50,8 @@ public override void ConfigureBundle(BundleConfigurationContext context) .AddIfNotContains("/libs/jquery-maskmoney/dist/jquery.maskMoney.min.js"); context.Files .AddIfNotContains("/Pages/BulkApprovals/ApproveApplicationsModal.js"); + context.Files + .AddIfNotContains("/Pages/BulkActions/BulkPublishApplications.js"); } } } 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 7aaf21086f..274ae5d1a7 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 @@ -79,6 +79,15 @@ text="@L["ApplicationList:ApproveButton"].Value" /> } + @if (await PermissionChecker.IsGrantedAsync(UnitySelector.Application.Status.BulkPublish)) + { + + } + @if(await PermissionChecker.IsGrantedAsync(UnitySelector.Application.Tags.Create) || await PermissionChecker.IsGrantedAsync(UnitySelector.Application.Tags.Delete)) { { selectedApplicationIds.push(data.id); manageActionButtons(); @@ -302,7 +347,9 @@ $(function () { selectedApplicationIds = []; manageActionButtons(); }); + //#endregion Selection Events + //#region Action Button Click Events $('#assignApplication').on('click', function () { // Store application IDs in distributed cache to avoid URL length limits unity.grantManager.applications.applicationBulkActions @@ -344,7 +391,9 @@ $(function () { '/GrantApplications/Details?ApplicationId=' + selectedApplicationIds[0]; }); + //#endregion Action Button Click Events + //#region Action Bar State let summaryWidgetManager = new abp.WidgetManager({ wrapper: '#summaryWidgetArea', filterCallback: function () { @@ -379,7 +428,9 @@ $(function () { } } } + //#endregion Action Bar State + //#region Tags and Payments $('#tagApplication').on('click', function () { // Store application IDs in distributed cache to avoid URL length limits @@ -442,5 +493,6 @@ $(function () { abp.notify.success('The historical payment has been successfully recorded.', 'Historical Payment'); PubSub.publish("refresh_application_list"); }); + //#endregion Tags and Payments }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.css index a18a578ab2..f56ee09e68 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.css @@ -26,9 +26,18 @@ z-index: 999; } -.applicant-info-container .save-button-container .floating-save-btn { - position: static; -} + .applicant-info-container input[readonly] { + color: var(--bc-colors-grey-text-200); + pointer-events: none; + background-color: var(--bc-colors-grey-hover) !important; + opacity: var(--bs-btn-disabled-opacity); + background-blend-mode: difference; + border: var(--bs-border-width) solid var(--bs-border-color); + } + + .applicant-info-container .save-button-container .floating-save-btn { + position: static; + } .applicant-organization-info { border-radius: 8px; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.css index 1388466ab7..4a9ddf92ef 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.css @@ -1,4 +1,8 @@ -.configuration-warning { +#otherConfigForm .form-select { + width: 400px !important; +} + +.configuration-warning { color: var(--lpx-danger); border-color: var(--lpx-danger); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml index 03e84b9a4f..7cf334679f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml @@ -39,7 +39,7 @@ } -
+
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 b0f02c82ad..3df9968bc0 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 @@ -290,7 +290,15 @@ $(function () { ? l('DetailsActionBar:UnpublishButton') : l('DetailsActionBar:PublishButton')); - abp.notify.success(l('DetailsActionBar:PublishStatusUpdated')); + abp.notify.success(nextPublishedState + ? l('DetailsActionBar:PublishStatusUpdatedToast') + : l('DetailsActionBar:UnpublishStatusUpdatedToast')); + + let canPublishStatus = abp.auth.isGranted("Unity.GrantManager.ApplicationManagement.Application.Status.Publish"); + let canUnpublishStatus = abp.auth.isGranted("Unity.GrantManager.ApplicationManagement.Application.Status.Unpublish"); + let canUpdateExternalStatusVisibility = (!nextPublishedState && canPublishStatus) || (nextPublishedState && canUnpublishStatus); + $('#togglePublishStatusBtn').prop('disabled', !canUpdateExternalStatusVisibility); + PubSub.publish('application_status_changed', nextPublishedState ? 'Publish' : 'Unpublish'); PubSub.publish('refresh_detail_panel_summary'); }) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/DetailsActionBar/DetailsActionBar.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/DetailsActionBar/DetailsActionBar.cs index 34c4a29f37..dd99427fc3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/DetailsActionBar/DetailsActionBar.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/DetailsActionBar/DetailsActionBar.cs @@ -23,13 +23,14 @@ public async Task InvokeAsync(Guid applicationId) SelectedApplicationId = applicationId; var application = await grantApplicationAppService.GetBasicAsync(SelectedApplicationId); - var canUpdateExternalStatusVisibility = await authorizationService.IsGrantedAsync(UnitySelector.Review.AssessmentResults.Update.Default); + var canPublishStatus = await authorizationService.IsGrantedAnyAsync(UnitySelector.Application.Status.Publish); + var canUnpublishStatus = await authorizationService.IsGrantedAnyAsync(UnitySelector.Application.Status.Unpublish); return View(new DetailsActionBarViewModel { ApplicationId = SelectedApplicationId, ExternalStatusVisibility = application.ExternalStatusVisibility, - CanUpdateExternalStatusVisibility = canUpdateExternalStatusVisibility + CanUpdateExternalStatusVisibility = (!application.ExternalStatusVisibility && canPublishStatus) || (application.ExternalStatusVisibility && canUnpublishStatus), }); } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.cshtml index 3b42f4dabb..061e8cd0f7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.cshtml @@ -39,12 +39,6 @@ Cancel } - @if (await PermissionChecker.IsGrantedAsync(NotificationsPermissions.Email.ScheduleDelete)) - { - - }
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.js index 7aa69023e8..a81ab5d530 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/Notifications/Default.js @@ -224,9 +224,6 @@ $('#notifications-list').on('click', '.js-cancel-notification', function () { onCancelNotification($(this).data('id')); }); - $('#notifications-list').on('click', '.js-delete-notification', function () { - onDeleteNotification($(this).data('id')); - }); } function reloadTable() { @@ -265,34 +262,6 @@ }); } - function onDeleteNotification(id) { - if (!id) return; - Swal.fire({ - title: 'Delete Notification?', - text: 'Are you sure you want to delete this scheduled notification?', - showCancelButton: true, - confirmButtonText: 'Confirm', - customClass: { - confirmButton: 'btn btn-primary', - cancelButton: 'btn btn-secondary' - } - }).then((result) => { - if (!result.isConfirmed) return; - fetch('/api/form-notifications/' + encodeURIComponent(formId) + '/' + encodeURIComponent(id), { - method: 'DELETE', - headers: { 'RequestVerificationToken': abp.security.antiForgery.getToken() } - }) - .then(r => { - if (!r.ok) throw new Error('Failed to delete'); - abp.notify.success('Notification deleted'); - reloadTable(); - }) - .catch(err => { - console.error(err); - abp.notify.error('Failed to delete notification'); - }); - }); - } function onEditNotification(id) { if (!id) return; @@ -593,12 +562,6 @@ cancelBtn.remove(); } } - - const deleteBtn = container.querySelector('.js-delete-notification'); - if (deleteBtn) { - deleteBtn.dataset.id = row.id; - } - return container.innerHTML; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.cshtml index d3a0219eaf..a8653662a7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.cshtml @@ -75,7 +75,7 @@ The individual Approval Thresholds assigned to each l2_approver will still take precedence if they are lower than the threshold specified in this form.

- +
diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/SettingManagement/ProgramDetailsAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/SettingManagement/ProgramDetailsAppServiceTests.cs new file mode 100644 index 0000000000..d498190cda --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/SettingManagement/ProgramDetailsAppServiceTests.cs @@ -0,0 +1,161 @@ +using System; +using System.Threading.Tasks; +using NSubstitute; +using Shouldly; +using Volo.Abp.Authorization; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Entities; +using Volo.Abp.MultiTenancy; +using Volo.Abp.TenantManagement; +using Xunit; +using Xunit.Abstractions; + +namespace Unity.GrantManager.SettingManagement; + +public class ProgramDetailsAppServiceTests(ITestOutputHelper outputHelper) : GrantManagerApplicationTestBase(outputHelper) +{ + [Fact] + public async Task GetProgramDetailsAsync_WithinTenantContext_ShouldReturnMappedExtraProperties() + { + var tenantId = Guid.NewGuid(); + var tenant = CreateTenant(tenantId); + tenant.SetProperty("DisplayName", "My Program"); + tenant.SetProperty("Division", "Housing Division"); + tenant.SetProperty("Branch", "Grants Branch"); + tenant.SetProperty("Description", "Sample description"); + + var tenantRepository = Substitute.For(); + tenantRepository.FindAsync(tenantId).Returns(tenant); + + var appService = CreateAppService(tenantRepository); + var currentTenant = GetRequiredService(); + + ProgramDetailsDto result; + using (currentTenant.Change(tenantId)) + { + result = await appService.GetProgramDetailsAsync(); + } + + result.DisplayName.ShouldBe("My Program"); + result.Division.ShouldBe("Housing Division"); + result.Branch.ShouldBe("Grants Branch"); + result.Description.ShouldBe("Sample description"); + } + + [Fact] + public async Task GetProgramDetailsAsync_WithMissingExtraProperties_ShouldReturnEmptyStrings() + { + var tenantId = Guid.NewGuid(); + var tenant = CreateTenant(tenantId); + + var tenantRepository = Substitute.For(); + tenantRepository.FindAsync(tenantId).Returns(tenant); + + var appService = CreateAppService(tenantRepository); + var currentTenant = GetRequiredService(); + + ProgramDetailsDto result; + using (currentTenant.Change(tenantId)) + { + result = await appService.GetProgramDetailsAsync(); + } + + result.DisplayName.ShouldBe(string.Empty); + result.Division.ShouldBe(string.Empty); + result.Branch.ShouldBe(string.Empty); + result.Description.ShouldBe(string.Empty); + } + + [Fact] + public async Task GetProgramDetailsAsync_WithoutTenantContext_ShouldThrowAbpAuthorizationException() + { + var tenantRepository = Substitute.For(); + var appService = CreateAppService(tenantRepository); + var currentTenant = GetRequiredService(); + + using (currentTenant.Change(null)) + { + await Should.ThrowAsync(() => appService.GetProgramDetailsAsync()); + } + } + + [Fact] + public async Task GetProgramDetailsAsync_WithUnknownTenant_ShouldThrowEntityNotFoundException() + { + var tenantId = Guid.NewGuid(); + var tenantRepository = Substitute.For(); + tenantRepository.FindAsync(tenantId).Returns((Tenant?)null); + + var appService = CreateAppService(tenantRepository); + var currentTenant = GetRequiredService(); + + using (currentTenant.Change(tenantId)) + { + await Should.ThrowAsync(() => appService.GetProgramDetailsAsync()); + } + } + + [Fact] + public async Task UpdateProgramDetailsAsync_ShouldNormalizeAndPersistTrimmedValues() + { + var tenantId = Guid.NewGuid(); + var tenant = CreateTenant(tenantId); + + var tenantRepository = Substitute.For(); + tenantRepository.FindAsync(tenantId).Returns(tenant); + + var appService = CreateAppService(tenantRepository); + var currentTenant = GetRequiredService(); + + var input = new UpdateProgramDetailsDto + { + DisplayName = " My Program ", + Division = " Housing Division ", + Branch = null, + Description = " " + }; + + using (currentTenant.Change(tenantId)) + { + await appService.UpdateProgramDetailsAsync(input); + } + + tenant.GetProperty("DisplayName")?.ToString().ShouldBe("My Program"); + tenant.GetProperty("Division")?.ToString().ShouldBe("Housing Division"); + tenant.GetProperty("Branch")?.ToString().ShouldBe(string.Empty); + tenant.GetProperty("Description")?.ToString().ShouldBe(string.Empty); + await tenantRepository.Received(1).UpdateAsync(tenant); + } + + [Fact] + public async Task UpdateProgramDetailsAsync_WithoutTenantContext_ShouldThrowAbpAuthorizationException() + { + var tenantRepository = Substitute.For(); + var appService = CreateAppService(tenantRepository); + var currentTenant = GetRequiredService(); + + var input = new UpdateProgramDetailsDto { DisplayName = "Test" }; + + using (currentTenant.Change(null)) + { + await Should.ThrowAsync(() => appService.UpdateProgramDetailsAsync(input)); + } + } + + private ProgramDetailsAppService CreateAppService(ITenantRepository tenantRepository) + { + var appService = new ProgramDetailsAppService(tenantRepository) + { + LazyServiceProvider = GetRequiredService() + }; + return appService; + } + + private static Tenant CreateTenant(Guid id) + { + var tenant = (Tenant)Activator.CreateInstance(typeof(Tenant), nonPublic: true)!; + EntityHelper.TrySetId(tenant, () => id); + return tenant; + } +}