diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index 1bfe088..2cc73ee 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -270,11 +270,13 @@ public class DashboardServiceTests 1, CancellationToken.None); - result.TotalDispatchers.Should().Be(1); - result.Items.Should().ContainSingle(); - result.Items[0].DispatcherName.Should().Be("Ada One"); + result.TotalDispatchers.Should().Be(2); + result.Items.Select(item => item.DispatcherName) + .Should().ContainInOrder("Ada One", "Ben Two"); result.Items[0].TotalCount.Should().Be(2); result.Items[0].OpenCount.Should().Be(1); + result.Items[1].TotalCount.Should().Be(0); + result.Items[1].OpenCount.Should().Be(0); result.PageSize.Should().Be(10); var adminResult = await NewService(ctx).GetWorkloadAsync( @@ -444,6 +446,275 @@ public class DashboardServiceTests result.Items[0].RescheduleRate.Should().Be(50); } + [Fact] + public async Task GetStatsAsync_DispatcherQuerySelectionCannotBroadenScope() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }); + ctx.SaveChanges(); + + var query = new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" }; + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None); + + stats.Total.Should().Be(2); + stats.Open.Should().Be(2); + } + + [Fact] + public async Task GetStatsAsync_AdminCanSelectIndividualDispatcherOrMine() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Done", AssignTo = "admin-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }); + ctx.SaveChanges(); + + var service = NewService(ctx); + + var selected = await service.GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" }, + CancellationToken.None); + selected.Total.Should().Be(3); + selected.Open.Should().Be(3); + + var mine = await service.GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO { DispatcherId = "mine" }, + CancellationToken.None); + mine.Total.Should().Be(1); + mine.Completed.Should().Be(1); + + var all = await service.GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO(), + CancellationToken.None); + all.Total.Should().Be(4); + } + + [Fact] + public async Task GetStatsAsync_ManagerSelectionScopesStats() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "manager-1", "Manager"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" }, + CancellationToken.None); + + stats.Total.Should().Be(1); + } + + [Fact] + public async Task GetStatsAsync_SchedulerSelectionScopesStats() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" }, + new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "scheduler-1", "Scheduler"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" }, + CancellationToken.None); + + stats.Total.Should().Be(1); + } + + [Fact] + public async Task GetDashboardAsync_UnauthorizedRoleFailsClosed() + { + using var ctx = NewContext(); + var service = NewService(ctx); + var user = AccountUser(1, "tech-1", "Technician"); + + var statsAct = () => service.GetStatsAsync( + user, new DashboardStatsQueryDTO(), CancellationToken.None); + var statsException = await statsAct.Should().ThrowAsync(); + statsException.Which.Code.Should().Be("Forbidden"); + + var workloadAct = () => service.GetWorkloadAsync( + user, new DashboardStatsQueryDTO(), 1, CancellationToken.None); + var workloadException = await workloadAct.Should().ThrowAsync(); + workloadException.Which.Code.Should().Be("Forbidden"); + + var performanceAct = () => service.GetPerformanceAsync( + user, new DashboardStatsQueryDTO(), 1, CancellationToken.None); + var performanceException = await performanceAct.Should().ThrowAsync(); + performanceException.Which.Code.Should().Be("Forbidden"); + + var regionsAct = () => service.GetRegionsAsync( + user, new DashboardStatsQueryDTO(), CancellationToken.None); + var regionsException = await regionsAct.Should().ThrowAsync(); + regionsException.Which.Code.Should().Be("Forbidden"); + + var trendAct = () => service.GetTrendAsync( + user, new DashboardTrendQueryDTO(), CancellationToken.None); + var trendException = await trendAct.Should().ThrowAsync(); + trendException.Which.Code.Should().Be("Forbidden"); + } + + [Fact] + public async Task GetWorkloadAsync_IndividualSelectionPreservesRosterWithZeroRows() + { + using var ctx = NewContext(); + ctx.Roles.Add(new IdentityRole + { + Id = "dispatcher-role", + Name = "Dispatcher", + NormalizedName = "DISPATCHER" + }); + ctx.Users.AddRange( + new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" }, + new ApplicationUser { Id = "dispatcher-2", FirstName = "Ben", LastName = "Two" }); + ctx.UserRoles.AddRange( + new IdentityUserRole { UserId = "dispatcher-1", RoleId = "dispatcher-role" }, + new IdentityUserRole { UserId = "dispatcher-2", RoleId = "dispatcher-role" }); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" }, + new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" }); + ctx.SaveChanges(); + + var workload = await NewService(ctx).GetWorkloadAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" }, + 1, + CancellationToken.None); + + workload.TotalDispatchers.Should().Be(2); + workload.Items.Should().HaveCount(2); + workload.Items[0].DispatcherId.Should().Be("dispatcher-1"); + workload.Items[0].TotalCount.Should().Be(2); + workload.Items[0].OpenCount.Should().Be(2); + workload.Items[1].DispatcherId.Should().Be("dispatcher-2"); + workload.Items[1].TotalCount.Should().Be(0); + workload.Items[1].OpenCount.Should().Be(0); + workload.Page.Should().Be(1); + workload.PageSize.Should().Be(10); + + var performance = await NewService(ctx).GetPerformanceAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" }, + 1, + CancellationToken.None); + + performance.TotalDispatchers.Should().Be(2); + performance.Items.Should().HaveCount(2); + performance.Items[0].DispatcherId.Should().Be("dispatcher-1"); + performance.Items[0].AssignedCount.Should().Be(2); + performance.Items[1].DispatcherId.Should().Be("dispatcher-2"); + performance.Items[1].AssignedCount.Should().Be(0); + performance.Items[1].CompletedCount.Should().Be(0); + } + + [Fact] + public async Task GetPerformanceAsync_PopulatesAssignedAndCompletedCounts() + { + using var ctx = NewContext(); + ctx.Roles.Add(new IdentityRole + { + Id = "dispatcher-role", + Name = "Dispatcher", + NormalizedName = "DISPATCHER" + }); + ctx.Users.Add(new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" }); + ctx.UserRoles.Add(new IdentityUserRole + { + UserId = "dispatcher-1", + RoleId = "dispatcher-role" + }); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed + }, + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled + }, + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled + }); + ctx.SaveChanges(); + + var result = await NewService(ctx).GetPerformanceAsync( + AccountUser(1), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + result.Items.Should().ContainSingle(); + result.Items[0].AssignedCount.Should().Be(3); + result.Items[0].CompletedCount.Should().Be(1); + } + + [Fact] + public async Task GetStatsAsync_StatusDistributionBucketsResolvedStatuses() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled + }, + new WorkOrder { AccountId = 1, Status = "In Progress" }, + new WorkOrder { AccountId = 1, Status = "Done" }, + new WorkOrder { AccountId = 1, Status = "Done" }, + new WorkOrder { AccountId = 1, Status = "Unparseable" }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO(), + CancellationToken.None); + + stats.StatusDistribution.Should().HaveCount(4); + stats.StatusDistribution + .Select(bucket => (bucket.Status, bucket.Count)) + .Should().BeEquivalentTo(new[] + { + (Status: "Completed", Count: 2), + (Status: "In Progress", Count: 1), + (Status: "Scheduled", Count: 1), + (Status: "Unknown", Count: 1) + }); + stats.StatusDistribution.Sum(bucket => bucket.Count).Should().Be(5); + } + + [Fact] + public async Task GetStatsAsync_StatusDistributionIsEmptyWithoutWorkOrders() + { + using var ctx = NewContext(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1, "admin-1"), + new DashboardStatsQueryDTO(), + CancellationToken.None); + + stats.StatusDistribution.Should().BeEmpty(); + } + [Fact] public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate() { diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index 483f891..b53c4bf 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -37,7 +37,8 @@ namespace Api.SeaHavenIndustries.Controllers dueCount = stats.DueCount, completedDueCount = stats.CompletedDueCount, completionRate = stats.CompletionRate, - averageResolutionDays = stats.AverageResolutionDays + averageResolutionDays = stats.AverageResolutionDays, + statusDistribution = stats.StatusDistribution }); } diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index 91c545d..a6c03f2 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -166,7 +166,6 @@ namespace SeaHaven.DataServices.Implementation on userRole.RoleId equals role.Id where user.IsDeleted != true && (role.Name == "Dispatcher" || role.Name == "DISPATCHER") - && (dispatcherId == null || user.Id == dispatcherId) select new { user.Id, @@ -178,6 +177,9 @@ namespace SeaHaven.DataServices.Implementation if (accountId is int scopedAccountId) workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId); + if (dispatcherId != null) + workOrders = workOrders.Where(w => w.AssignTo == dispatcherId); + if (dateFrom is DateOnly from && dateTo is DateOnly to) { var fromDate = from.ToDateTime(TimeOnly.MinValue); diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index 3544ebb..9cf76c0 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -4,6 +4,7 @@ namespace SeaHaven.Services.DTOs { public DateOnly? DateFrom { get; set; } public DateOnly? DateTo { get; set; } + public string? DispatcherId { get; set; } } public sealed class DashboardTrendQueryDTO : DashboardStatsQueryDTO @@ -63,6 +64,8 @@ namespace SeaHaven.Services.DTOs public string? Color { get; init; } public decimal CompletionRate { get; init; } public decimal? AverageResolutionDays { get; init; } + public int AssignedCount { get; init; } + public int CompletedCount { get; init; } } public sealed class DashboardRegionResponseDTO @@ -108,6 +111,13 @@ namespace SeaHaven.Services.DTOs public int CompletedDueCount { get; set; } public decimal CompletionRate { get; set; } public decimal? AverageResolutionDays { get; set; } + public IReadOnlyList StatusDistribution { get; set; } = []; + } + + public sealed class DashboardStatusBucketDTO + { + public string Status { get; init; } = string.Empty; + public int Count { get; init; } } public sealed class DashboardBreakdownDTO diff --git a/SeaHaven.Services/Helpers/DashboardMetrics.cs b/SeaHaven.Services/Helpers/DashboardMetrics.cs index e49d921..885ac7d 100644 --- a/SeaHaven.Services/Helpers/DashboardMetrics.cs +++ b/SeaHaven.Services/Helpers/DashboardMetrics.cs @@ -61,9 +61,26 @@ public static class DashboardMetrics dueCount, completedDueCount, dueCount == 0 ? 0 : Math.Round((decimal)completedDueCount / dueCount * 100, 2), - resolutionDays.Count == 0 ? null : Math.Round(resolutionDays.Average(), 2)); + resolutionDays.Count == 0 ? null : Math.Round(resolutionDays.Average(), 2), + BuildStatusDistribution(workOrders)); } + private static IReadOnlyList BuildStatusDistribution( + IReadOnlyList workOrders) + => workOrders + .GroupBy(workOrder => workOrder.LifecycleStatus + ?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus)) + .Select(group => new DashboardStatusBucketDTO + { + Status = group.Key.HasValue + ? LifecycleStatusMapper.ToFeLabel(group.Key.Value) + : "Unknown", + Count = group.Count() + }) + .OrderByDescending(bucket => bucket.Count) + .ThenBy(bucket => bucket.Status, StringComparer.OrdinalIgnoreCase) + .ToList(); + private static bool IsPastDue( DashboardWorkOrder workOrder, LifecycleStatus? status, @@ -110,4 +127,5 @@ public sealed record DashboardMetricResult( int DueCount, int CompletedDueCount, decimal CompletionRate, - decimal? AverageResolutionDays); + decimal? AverageResolutionDays, + IReadOnlyList StatusDistribution); diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index f17e301..02642dc 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -28,11 +28,7 @@ namespace SeaHaven.Services.Implementation CancellationToken cancellationToken) { var accountId = _accountResolver.ResolveAccountFilter(user); - var dispatcherId = user.IsInRole("Dispatcher") - ? user.FindFirstValue(ClaimTypes.NameIdentifier) - : null; - if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId)) - throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + var dispatcherId = ResolveDispatcherScope(user, query); var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken); var kpis = await _dataService.GetKpiCountsAsync( @@ -63,7 +59,8 @@ namespace SeaHaven.Services.Implementation DueCount = metrics.DueCount, CompletedDueCount = metrics.CompletedDueCount, CompletionRate = metrics.CompletionRate, - AverageResolutionDays = metrics.AverageResolutionDays + AverageResolutionDays = metrics.AverageResolutionDays, + StatusDistribution = metrics.StatusDistribution }; } @@ -77,11 +74,7 @@ namespace SeaHaven.Services.Implementation throw new ArgumentOutOfRangeException(nameof(page)); var accountId = _accountResolver.ResolveAccountFilter(user); - var dispatcherId = user.IsInRole("Dispatcher") - ? user.FindFirstValue(ClaimTypes.NameIdentifier) - : null; - if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId)) - throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + var dispatcherId = ResolveDispatcherScope(user, query); var rows = await _dataService.GetDispatcherWorkloadAsync( accountId, @@ -123,11 +116,7 @@ namespace SeaHaven.Services.Implementation throw new ArgumentOutOfRangeException(nameof(page)); var accountId = _accountResolver.ResolveAccountFilter(user); - var dispatcherId = user.IsInRole("Dispatcher") - ? user.FindFirstValue(ClaimTypes.NameIdentifier) - : null; - if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId)) - throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + var dispatcherId = ResolveDispatcherScope(user, query); var directory = await _dataService.GetDispatcherWorkloadAsync( accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken); @@ -145,7 +134,9 @@ namespace SeaHaven.Services.Implementation DispatcherName = dispatcher.DispatcherName, Color = dispatcher.Color, CompletionRate = metrics.CompletionRate, - AverageResolutionDays = metrics.AverageResolutionDays + AverageResolutionDays = metrics.AverageResolutionDays, + AssignedCount = metrics.Total, + CompletedCount = metrics.Completed }; }) .OrderByDescending(row => row.CompletionRate) @@ -169,11 +160,7 @@ namespace SeaHaven.Services.Implementation CancellationToken cancellationToken) { var accountId = _accountResolver.ResolveAccountFilter(user); - var dispatcherId = user.IsInRole("Dispatcher") - ? user.FindFirstValue(ClaimTypes.NameIdentifier) - : null; - if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId)) - throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + var dispatcherId = ResolveDispatcherScope(user, query); var workOrders = await _dataService.GetDashboardWorkOrdersAsync( accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken); @@ -196,11 +183,7 @@ namespace SeaHaven.Services.Implementation CancellationToken cancellationToken) { var accountId = _accountResolver.ResolveAccountFilter(user); - var dispatcherId = user.IsInRole("Dispatcher") - ? user.FindFirstValue(ClaimTypes.NameIdentifier) - : null; - if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId)) - throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + var dispatcherId = ResolveDispatcherScope(user, query); var today = DashboardBusinessTime.Today(); var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today); @@ -404,5 +387,33 @@ namespace SeaHaven.Services.Implementation TotalVendors = rows.Count }; } + + private string? ResolveDispatcherScope(ClaimsPrincipal user, DashboardStatsQueryDTO query) + { + if (user.IsInRole("Dispatcher")) + { + var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(dispatcherId)) + throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + return dispatcherId; + } + + if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler")) + { + var selected = query.DispatcherId; + if (string.IsNullOrWhiteSpace(selected)) + return null; + if (selected.Equals("mine", StringComparison.OrdinalIgnoreCase)) + { + var identity = user.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(identity)) + throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + return identity; + } + return selected; + } + + throw new WorkOrderBoardValidationException("Forbidden", "Dashboard access requires an authorized role."); + } } }