From d4afcced87623005bea79adf6c31331e01e2d6ac Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 18:01:42 -0300 Subject: [PATCH] feat(dashboard): add regional work order metrics (SH-348) --- .../DashboardServiceTests.cs | 23 +++++ .../Controllers/DashboardController.cs | 20 +++++ .../Implementation/DashboardDataService.cs | 3 +- .../Interfaces/IDashboardDataService.cs | 3 +- SeaHaven.Services/DTOs/DashboardDTOs.cs | 11 +++ SeaHaven.Services/Helpers/DashboardRegions.cs | 84 +++++++++++++++++++ .../Implementation/DashboardService.cs | 27 ++++++ .../Interfaces/IDashboardService.cs | 5 ++ 8 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 SeaHaven.Services/Helpers/DashboardRegions.cs diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index ff65c94..c7c6410 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -301,4 +301,27 @@ public class DashboardServiceTests result.Items[0].AverageResolutionDays.Should().Be(3m); result.Items[0].Color.Should().Be("#123456"); } + + [Fact] + public async Task GetRegionsAsync_UsesCanonicalBucketsAndUnmappedOther() + { + await using var ctx = NewContext(); + ctx.Locations.AddRange( + new Locations { Id = 1, State = "NY" }, + new Locations { Id = 2, State = "ca" }, + new Locations { Id = 3, State = "XX" }); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, LocationId = 1 }, + new WorkOrder { AccountId = 1, LocationId = 2 }, + new WorkOrder { AccountId = 1, LocationId = 3 }, + new WorkOrder { AccountId = 1, LocationId = null }); + await ctx.SaveChangesAsync(); + + var result = await NewService(ctx).GetRegionsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); + + result.Items.Select(row => row.Region).Should().Equal( + "East", "Central", "West", "California", "Unmapped/Other"); + result.Items.Select(row => row.WorkOrderCount).Should().Equal(1, 0, 0, 1, 2); + } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index aff03f3..bdce2e2 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -89,5 +89,25 @@ namespace Api.SeaHavenIndustries.Controllers return BadRequest(ex.Message); } } + + [HttpGet("Regions")] + public async Task GetRegions( + [FromQuery] DashboardStatsQueryDTO query, + CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetRegionsAsync(User, query, cancellationToken)); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + } } } diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index 14b1ffa..a58aed3 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -70,7 +70,8 @@ namespace SeaHaven.DataServices.Implementation w.LegacyStatus ?? w.Status, w.ScheduledDate, w.OriginalDate, - w.CompletedDate)) + w.CompletedDate, + w.Locations == null ? null : w.Locations.State)) .ToListAsync(cancellationToken); } diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs index bd3a394..fa794b1 100644 --- a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -31,7 +31,8 @@ namespace SeaHaven.DataServices.Interfaces string? LegacyStatus, DateTime? ScheduledDate, DateOnly? OriginalDate, - DateTime? CompletedDate); + DateTime? CompletedDate, + string? LocationState = null); public sealed record DashboardDispatcherWorkload( string DispatcherId, diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index 6034544..e19f55c 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -40,6 +40,17 @@ namespace SeaHaven.Services.DTOs public decimal? AverageResolutionDays { get; init; } } + public sealed class DashboardRegionResponseDTO + { + public IReadOnlyList Items { get; init; } = []; + } + + public sealed class DashboardRegionRowDTO + { + public string Region { get; init; } = string.Empty; + public int WorkOrderCount { get; init; } + } + public class DashboardStatsDTO { public int Total { get; set; } diff --git a/SeaHaven.Services/Helpers/DashboardRegions.cs b/SeaHaven.Services/Helpers/DashboardRegions.cs new file mode 100644 index 0000000..f3b5fae --- /dev/null +++ b/SeaHaven.Services/Helpers/DashboardRegions.cs @@ -0,0 +1,84 @@ +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.Services.Helpers; + +public static class DashboardRegions +{ + public static readonly IReadOnlyList Names = + ["East", "Central", "West", "California", "Unmapped/Other"]; + + public static string Resolve(string? state) + { + if (string.IsNullOrWhiteSpace(state)) + return "Unmapped/Other"; + + return StateToRegion.TryGetValue(state.Trim().ToUpperInvariant(), out var region) + ? region + : "Unmapped/Other"; + } + + public static IReadOnlyDictionary Count( + IReadOnlyList workOrders) + { + var counts = Names.ToDictionary(name => name, _ => 0, StringComparer.Ordinal); + foreach (var workOrder in workOrders) + counts[Resolve(workOrder.LocationState)]++; + + return counts; + } + + private static readonly IReadOnlyDictionary StateToRegion = + new Dictionary(StringComparer.Ordinal) + { + ["CT"] = "East", + ["DE"] = "East", + ["GA"] = "East", + ["ME"] = "East", + ["MD"] = "East", + ["MA"] = "East", + ["NH"] = "East", + ["NJ"] = "East", + ["NY"] = "East", + ["NC"] = "East", + ["PA"] = "East", + ["RI"] = "East", + ["SC"] = "East", + ["VT"] = "East", + ["VA"] = "East", + ["WV"] = "East", + ["AL"] = "Central", + ["AR"] = "Central", + ["FL"] = "Central", + ["IL"] = "Central", + ["IN"] = "Central", + ["IA"] = "Central", + ["KS"] = "Central", + ["KY"] = "Central", + ["LA"] = "Central", + ["MI"] = "Central", + ["MN"] = "Central", + ["MS"] = "Central", + ["MO"] = "Central", + ["NE"] = "Central", + ["ND"] = "Central", + ["OH"] = "Central", + ["OK"] = "Central", + ["SD"] = "Central", + ["TN"] = "Central", + ["WI"] = "Central", + ["AK"] = "West", + ["AZ"] = "West", + ["CO"] = "West", + ["HI"] = "West", + ["ID"] = "West", + ["MT"] = "West", + ["NV"] = "West", + ["NM"] = "West", + ["OR"] = "West", + ["TX"] = "West", + ["UT"] = "West", + ["WA"] = "West", + ["WY"] = "West", + ["CA"] = "California" + }; +} diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index aaad3f1..2bbe7ca 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -152,5 +152,32 @@ namespace SeaHaven.Services.Implementation TotalDispatchers = rows.Count }; } + + public async Task GetRegionsAsync( + ClaimsPrincipal user, + DashboardStatsQueryDTO query, + 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 workOrders = await _dataService.GetDashboardWorkOrdersAsync( + accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken); + var counts = DashboardRegions.Count(workOrders); + return new DashboardRegionResponseDTO + { + Items = DashboardRegions.Names + .Select(region => new DashboardRegionRowDTO + { + Region = region, + WorkOrderCount = counts[region] + }) + .ToList() + }; + } } } diff --git a/SeaHaven.Services/Interfaces/IDashboardService.cs b/SeaHaven.Services/Interfaces/IDashboardService.cs index 8b74dd2..9b10f73 100644 --- a/SeaHaven.Services/Interfaces/IDashboardService.cs +++ b/SeaHaven.Services/Interfaces/IDashboardService.cs @@ -21,5 +21,10 @@ namespace SeaHaven.Services.Interfaces DashboardStatsQueryDTO query, int page, CancellationToken cancellationToken); + + Task GetRegionsAsync( + ClaimsPrincipal user, + DashboardStatsQueryDTO query, + CancellationToken cancellationToken); } }