From 9b39d0e5ecab63fd3a297825dbd5192118d2ae27 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 17:43:49 -0300 Subject: [PATCH] feat(dashboard): add dispatcher workload (SH-346) --- .../DashboardServiceTests.cs | 47 ++++++++++++ .../Controllers/DashboardController.cs | 26 +++++++ .../Implementation/DashboardDataService.cs | 74 +++++++++++++++++++ .../Interfaces/IDashboardDataService.cs | 13 ++++ SeaHaven.Services/DTOs/DashboardDTOs.cs | 16 ++++ .../Implementation/DashboardService.cs | 45 +++++++++++ .../Interfaces/IDashboardService.cs | 6 ++ 7 files changed, 227 insertions(+) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index a5dadfb..3d426c9 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Data.SeaHavenIndustries; using FluentAssertions; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; using SeaHaven.Services.Exceptions; @@ -191,4 +192,50 @@ public class DashboardServiceTests stats.Total.Should().Be(1); stats.Open.Should().Be(1); } + + [Fact] + public async Task GetWorkloadAsync_UsesRoleScopeAndExcludesTerminalOrdersFromOpenCount() + { + 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", LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed }, + new WorkOrder { AccountId = 1, AssignTo = "dispatcher-2", Status = "Open" }); + ctx.SaveChanges(); + + var result = await NewService(ctx).GetWorkloadAsync( + AccountUser(1, "dispatcher-1", "Dispatcher"), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + result.TotalDispatchers.Should().Be(1); + result.Items.Should().ContainSingle(); + result.Items[0].DispatcherName.Should().Be("Ada One"); + result.Items[0].TotalCount.Should().Be(2); + result.Items[0].OpenCount.Should().Be(1); + result.PageSize.Should().Be(10); + + var adminResult = await NewService(ctx).GetWorkloadAsync( + AccountUser(1), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + adminResult.TotalDispatchers.Should().Be(2); + adminResult.Items.Select(item => item.DispatcherName) + .Should().ContainInOrder("Ada One", "Ben Two"); + } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index cb7e27c..91fc35e 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -37,5 +37,31 @@ namespace Api.SeaHavenIndustries.Controllers averageResolutionDays = stats.AverageResolutionDays }); } + + [HttpGet("Workload")] + public async Task GetWorkload( + [FromQuery] DashboardStatsQueryDTO query, + [FromQuery] int page = 1, + CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetWorkloadAsync( + User, query, page, cancellationToken)); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + catch (ArgumentOutOfRangeException ex) + { + return BadRequest(ex.Message); + } + } } } diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index 73c073e..f571302 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -73,5 +73,79 @@ namespace SeaHaven.DataServices.Implementation w.CompletedDate)) .ToListAsync(cancellationToken); } + + public async Task> GetDispatcherWorkloadAsync( + int? accountId, + string? dispatcherId, + DateOnly? dateFrom, + DateOnly? dateTo, + CancellationToken cancellationToken) + { + var dispatcherUsers = from user in _context.Users.AsNoTracking() + join userRole in _context.UserRoles + on user.Id equals userRole.UserId + join role in _context.Roles + 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, + Name = ((user.FirstName ?? "") + " " + (user.LastName ?? "")).Trim() + }; + + var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + if (accountId is int scopedAccountId) + workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId); + + if (dateFrom is DateOnly from && dateTo is DateOnly to) + { + var fromDate = from.ToDateTime(TimeOnly.MinValue); + var toDateExclusive = to.AddDays(1).ToDateTime(TimeOnly.MinValue); + workOrders = workOrders.Where(w => + w.ScheduledDate >= fromDate && w.ScheduledDate < toDateExclusive); + } + + var counts = await workOrders + .Where(w => w.AssignTo != null) + .GroupBy(w => w.AssignTo!) + .Select(group => new + { + DispatcherId = group.Key, + TotalCount = group.Count(), + OpenCount = group.Count(w => + w.LifecycleStatus != LifecycleStatus.Completed + && w.LifecycleStatus != LifecycleStatus.Canceled + && (w.LifecycleStatus != null + || (w.LegacyStatus ?? w.Status) != "Done" + && (w.LegacyStatus ?? w.Status) != "Completed" + && (w.LegacyStatus ?? w.Status) != "Complete" + && (w.LegacyStatus ?? w.Status) != "Closed" + && (w.LegacyStatus ?? w.Status) != "Canceled" + && (w.LegacyStatus ?? w.Status) != "Cancelled")) + }) + .ToListAsync(cancellationToken); + + var dispatcherList = await dispatcherUsers + .Distinct() + .ToListAsync(cancellationToken); + + return dispatcherList + .GroupJoin( + counts, + user => user.Id, + count => count.DispatcherId, + (user, matchingCounts) => + { + var count = matchingCounts.SingleOrDefault(); + return new DashboardDispatcherWorkload( + user.Id, + user.Name, + count?.TotalCount ?? 0, + count?.OpenCount ?? 0); + }) + .ToList(); + } } } diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs index 470e70e..dcc77b1 100644 --- a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -14,6 +14,13 @@ namespace SeaHaven.DataServices.Interfaces DateOnly? dateFrom, DateOnly? dateTo, CancellationToken cancellationToken); + + Task> GetDispatcherWorkloadAsync( + int? accountId, + string? dispatcherId, + DateOnly? dateFrom, + DateOnly? dateTo, + CancellationToken cancellationToken); } public sealed record DashboardWorkOrder( @@ -26,6 +33,12 @@ namespace SeaHaven.DataServices.Interfaces DateOnly? OriginalDate, DateTime? CompletedDate); + public sealed record DashboardDispatcherWorkload( + string DispatcherId, + string DispatcherName, + int TotalCount, + int OpenCount); + public class DashboardCounts { public int Total { get; set; } diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index 25ecafc..8af4d3f 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -6,6 +6,22 @@ namespace SeaHaven.Services.DTOs public DateOnly? DateTo { get; set; } } + public sealed class DashboardWorkloadResponseDTO + { + public IReadOnlyList Items { get; init; } = []; + public int Page { get; init; } + public int PageSize { get; init; } + public int TotalDispatchers { get; init; } + } + + public sealed class DashboardWorkloadRowDTO + { + public string DispatcherId { get; init; } = string.Empty; + public string DispatcherName { get; init; } = string.Empty; + public int TotalCount { get; init; } + public int OpenCount { get; init; } + } + public class DashboardStatsDTO { public int Total { get; set; } diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index afe4cd0..ef6364c 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -56,5 +56,50 @@ namespace SeaHaven.Services.Implementation AverageResolutionDays = metrics.AverageResolutionDays }; } + + public async Task GetWorkloadAsync( + ClaimsPrincipal user, + DashboardStatsQueryDTO query, + int page, + CancellationToken cancellationToken) + { + if (page < 1) + 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 rows = await _dataService.GetDispatcherWorkloadAsync( + accountId, + dispatcherId, + query.DateFrom, + query.DateTo, + cancellationToken); + var orderedRows = rows + .OrderByDescending(row => row.OpenCount) + .ThenBy(row => row.DispatcherName, StringComparer.OrdinalIgnoreCase) + .ThenBy(row => row.DispatcherId, StringComparer.Ordinal) + .ToList(); + const int pageSize = 10; + + return new DashboardWorkloadResponseDTO + { + Items = orderedRows.Skip((page - 1) * pageSize).Take(pageSize) + .Select(row => new DashboardWorkloadRowDTO + { + DispatcherId = row.DispatcherId, + DispatcherName = row.DispatcherName, + TotalCount = row.TotalCount, + OpenCount = row.OpenCount + }).ToList(), + Page = page, + PageSize = pageSize, + TotalDispatchers = orderedRows.Count + }; + } } } diff --git a/SeaHaven.Services/Interfaces/IDashboardService.cs b/SeaHaven.Services/Interfaces/IDashboardService.cs index a760d47..cdb572c 100644 --- a/SeaHaven.Services/Interfaces/IDashboardService.cs +++ b/SeaHaven.Services/Interfaces/IDashboardService.cs @@ -9,5 +9,11 @@ namespace SeaHaven.Services.Interfaces ClaimsPrincipal user, DashboardStatsQueryDTO query, CancellationToken cancellationToken); + + Task GetWorkloadAsync( + ClaimsPrincipal user, + DashboardStatsQueryDTO query, + int page, + CancellationToken cancellationToken); } }