From dca8d898cb7b69fd05b98c635ad26ce9817556bb Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 21:37:17 -0300 Subject: [PATCH 1/2] feat(dashboard): add scoped metrics contract (SH-347) --- .../DashboardServiceTests.cs | 255 +++++++++++++++++- .../Controllers/DashboardController.cs | 3 +- .../Implementation/DashboardDataService.cs | 4 +- SeaHaven.Services/DTOs/DashboardDTOs.cs | 10 + SeaHaven.Services/Helpers/DashboardMetrics.cs | 22 +- .../Implementation/DashboardService.cs | 59 ++-- 6 files changed, 324 insertions(+), 29 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index a383bd7..6bb5b38 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -221,11 +221,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( @@ -367,4 +369,251 @@ public class DashboardServiceTests result.Items[0].CompletionRate.Should().Be(100); 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 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"); + } + + [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(); + } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index 055b933..cc63f77 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -34,7 +34,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 a56f74f..f92d080 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -102,7 +102,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, @@ -114,6 +113,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 a767a03..9c9d3a7 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 DashboardWorkloadResponseDTO @@ -38,6 +39,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 @@ -80,6 +83,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 757dfb3..49c5c1f 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -26,11 +26,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 workOrders = await _dataService.GetDashboardWorkOrdersAsync( @@ -53,7 +49,8 @@ namespace SeaHaven.Services.Implementation DueCount = metrics.DueCount, CompletedDueCount = metrics.CompletedDueCount, CompletionRate = metrics.CompletionRate, - AverageResolutionDays = metrics.AverageResolutionDays + AverageResolutionDays = metrics.AverageResolutionDays, + StatusDistribution = metrics.StatusDistribution }; } @@ -67,11 +64,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, @@ -113,11 +106,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); @@ -135,7 +124,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) @@ -159,11 +150,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); @@ -228,5 +215,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")) + { + 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."); + } } } From 564bd70301d57b100291223c6157dadcb6c35118 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 17 Sep 2026 03:37:36 -0300 Subject: [PATCH 2/2] fix(dashboard): scope Trend by picker and admit Scheduler to dispatcher picker ResolveDispatcherScope now admits the Scheduler role, which owns the viewAllDispatchersOnDashboard permission, so it can load Stats, Workload, Performance, Regions and Trend instead of being denied. GetTrendAsync now resolves scope via the shared ResolveDispatcherScope so a picker DispatcherId selection scopes Trend consistently with the other endpoints and unauthorized roles fail closed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../DashboardServiceTests.cs | 22 +++++++++++++++++++ .../Implementation/DashboardService.cs | 8 ++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index b1bb099..2cc73ee 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -517,6 +517,23 @@ public class DashboardServiceTests 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() { @@ -543,6 +560,11 @@ public class DashboardServiceTests 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] diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index 68c4a5f..02642dc 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -183,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); @@ -402,7 +398,7 @@ namespace SeaHaven.Services.Implementation return dispatcherId; } - if (user.IsInRole("Admin") || user.IsInRole("Manager")) + if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler")) { var selected = query.DispatcherId; if (string.IsNullOrWhiteSpace(selected))