From 0194748e3b8fc989b5c890e93c12df651636f44a Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 17:58:08 -0300 Subject: [PATCH] feat(dashboard): add dispatcher performance (SH-347) --- .../DashboardServiceTests.cs | 63 +++++++++++++++++++ .../Controllers/DashboardController.cs | 26 ++++++++ .../Implementation/DashboardDataService.cs | 12 ++-- .../Interfaces/IDashboardDataService.cs | 1 + SeaHaven.Services/DTOs/DashboardDTOs.cs | 18 ++++++ .../Implementation/DashboardService.cs | 51 +++++++++++++++ .../Interfaces/IDashboardService.cs | 6 ++ 7 files changed, 172 insertions(+), 5 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index 3d426c9..ff65c94 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -237,5 +237,68 @@ public class DashboardServiceTests adminResult.TotalDispatchers.Should().Be(2); adminResult.Items.Select(item => item.DispatcherName) .Should().ContainInOrder("Ada One", "Ben Two"); + + var performance = await NewService(ctx).GetPerformanceAsync( + AccountUser(1), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + performance.TotalDispatchers.Should().Be(2); + performance.Items[0].DispatcherName.Should().Be("Ada One"); + } + + [Fact] + public async Task GetPerformanceAsync_UsesDueOnlyRateAndOriginalDateResolution() + { + 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", + Color = "#123456" + }); + ctx.UserRoles.Add(new IdentityUserRole + { + UserId = "dispatcher-1", + RoleId = "dispatcher-role" + }); + var today = DashboardBusinessTime.Today(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue), + OriginalDate = today.AddDays(-5), + CompletedDate = today.AddDays(-2).ToDateTime(TimeOnly.MinValue) + }, + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.AddDays(3).ToDateTime(TimeOnly.MinValue) + }); + ctx.SaveChanges(); + + var result = await NewService(ctx).GetPerformanceAsync( + AccountUser(1), + new DashboardStatsQueryDTO(), + 1, + CancellationToken.None); + + result.Items.Should().ContainSingle(); + result.Items[0].CompletionRate.Should().Be(100m); + result.Items[0].AverageResolutionDays.Should().Be(3m); + result.Items[0].Color.Should().Be("#123456"); } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index 91fc35e..aff03f3 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -63,5 +63,31 @@ namespace Api.SeaHavenIndustries.Controllers return BadRequest(ex.Message); } } + + [HttpGet("Performance")] + public async Task GetPerformance( + [FromQuery] DashboardStatsQueryDTO query, + [FromQuery] int page = 1, + CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetPerformanceAsync( + 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 f571302..14b1ffa 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -92,7 +92,8 @@ namespace SeaHaven.DataServices.Implementation select new { user.Id, - Name = ((user.FirstName ?? "") + " " + (user.LastName ?? "")).Trim() + Name = ((user.FirstName ?? "") + " " + (user.LastName ?? "")).Trim(), + user.Color }; var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); @@ -140,10 +141,11 @@ namespace SeaHaven.DataServices.Implementation { var count = matchingCounts.SingleOrDefault(); return new DashboardDispatcherWorkload( - user.Id, - user.Name, - count?.TotalCount ?? 0, - count?.OpenCount ?? 0); + user.Id, + user.Name, + user.Color, + count?.TotalCount ?? 0, + count?.OpenCount ?? 0); }) .ToList(); } diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs index dcc77b1..bd3a394 100644 --- a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -36,6 +36,7 @@ namespace SeaHaven.DataServices.Interfaces public sealed record DashboardDispatcherWorkload( string DispatcherId, string DispatcherName, + string? Color, int TotalCount, int OpenCount); diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index 8af4d3f..6034544 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -18,10 +18,28 @@ namespace SeaHaven.Services.DTOs { public string DispatcherId { get; init; } = string.Empty; public string DispatcherName { get; init; } = string.Empty; + public string? Color { get; init; } public int TotalCount { get; init; } public int OpenCount { get; init; } } + public sealed class DashboardPerformanceResponseDTO + { + public IReadOnlyList Items { get; init; } = []; + public int Page { get; init; } + public int PageSize { get; init; } + public int TotalDispatchers { get; init; } + } + + public sealed class DashboardPerformanceRowDTO + { + public string DispatcherId { get; init; } = string.Empty; + public string DispatcherName { get; init; } = string.Empty; + public string? Color { get; init; } + public decimal CompletionRate { get; init; } + public decimal? AverageResolutionDays { 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 ef6364c..aaad3f1 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -93,6 +93,7 @@ namespace SeaHaven.Services.Implementation { DispatcherId = row.DispatcherId, DispatcherName = row.DispatcherName, + Color = row.Color, TotalCount = row.TotalCount, OpenCount = row.OpenCount }).ToList(), @@ -101,5 +102,55 @@ namespace SeaHaven.Services.Implementation TotalDispatchers = orderedRows.Count }; } + + public async Task GetPerformanceAsync( + 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 directory = await _dataService.GetDispatcherWorkloadAsync( + accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken); + var workOrders = await _dataService.GetDashboardWorkOrdersAsync( + accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken); + var today = DashboardBusinessTime.Today(); + var rows = directory.Select(dispatcher => + { + var metrics = DashboardMetrics.Calculate( + workOrders.Where(workOrder => workOrder.AssignTo == dispatcher.DispatcherId).ToList(), + today); + return new DashboardPerformanceRowDTO + { + DispatcherId = dispatcher.DispatcherId, + DispatcherName = dispatcher.DispatcherName, + Color = dispatcher.Color, + CompletionRate = metrics.CompletionRate, + AverageResolutionDays = metrics.AverageResolutionDays + }; + }) + .OrderByDescending(row => row.CompletionRate) + .ThenBy(row => row.DispatcherName, StringComparer.OrdinalIgnoreCase) + .ThenBy(row => row.DispatcherId, StringComparer.Ordinal) + .ToList(); + const int pageSize = 10; + + return new DashboardPerformanceResponseDTO + { + Items = rows.Skip((page - 1) * pageSize).Take(pageSize).ToList(), + Page = page, + PageSize = pageSize, + TotalDispatchers = rows.Count + }; + } } } diff --git a/SeaHaven.Services/Interfaces/IDashboardService.cs b/SeaHaven.Services/Interfaces/IDashboardService.cs index cdb572c..8b74dd2 100644 --- a/SeaHaven.Services/Interfaces/IDashboardService.cs +++ b/SeaHaven.Services/Interfaces/IDashboardService.cs @@ -15,5 +15,11 @@ namespace SeaHaven.Services.Interfaces DashboardStatsQueryDTO query, int page, CancellationToken cancellationToken); + + Task GetPerformanceAsync( + ClaimsPrincipal user, + DashboardStatsQueryDTO query, + int page, + CancellationToken cancellationToken); } }