From efd13b6f60bfc7834371c8880c481df77e87aad6 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 17:12:03 -0300 Subject: [PATCH 1/6] fix(dashboard): scope stats to authenticated accounts (SH-336) --- .../DashboardServiceTests.cs | 86 ++++++++++++++++--- .../Controllers/DashboardController.cs | 2 +- .../Implementation/DashboardDataService.cs | 8 +- .../Interfaces/IDashboardDataService.cs | 4 +- .../Implementation/DashboardService.cs | 14 ++- .../Interfaces/IDashboardService.cs | 5 +- 6 files changed, 99 insertions(+), 20 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index 0af8f00..c45ab1a 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -1,7 +1,10 @@ +using System.Security.Claims; using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using Xunit; @@ -17,33 +20,90 @@ public class DashboardServiceTests return new ApplicationDbContext(options); } - private static DashboardService NewService(ApplicationDbContext ctx) => - new(new DashboardDataService(ctx)); + private static DashboardService NewService(ApplicationDbContext ctx) + { + var resolver = new WorkOrderAccountResolver( + new AccountDataService(ctx), + new LocationDataService(ctx)); + return new DashboardService(new DashboardDataService(ctx), resolver); + } - private static WorkOrder Wo(string? status, bool? isTemplate = false, string? assignTo = null) => - new() { Status = status, istemplate = isTemplate, AssignTo = assignTo }; + private static ClaimsPrincipal AccountUser(int accountId) + { + var claims = new[] + { + new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), + new Claim(ClaimTypes.Role, "Dispatcher") + }; + return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + } + + private static ClaimsPrincipal OrgWideUser() + { + var claims = new[] + { + new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll), + new Claim(ClaimTypes.Role, "Admin") + }; + return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + } [Fact] public async Task GetStatsAsync_CountsByStatusExcludingTemplates() { using var ctx = NewContext(); ctx.workOrders.AddRange( - Wo("Open", isTemplate: false), - Wo("Open", isTemplate: false, assignTo: "u1"), - Wo("In Progress"), - Wo("On Hold"), - Wo("Done"), - Wo("Done"), - Wo("Open", isTemplate: true), - Wo("Cancelled", isTemplate: false) + new WorkOrder { AccountId = 1, Status = "Open", istemplate = false }, + new WorkOrder { AccountId = 1, Status = "Open", istemplate = false, AssignTo = "u1" }, + new WorkOrder { AccountId = 1, Status = "In Progress" }, + new WorkOrder { AccountId = 1, Status = "On Hold" }, + new WorkOrder { AccountId = 1, Status = "Done" }, + new WorkOrder { AccountId = 1, Status = "Done" }, + new WorkOrder { AccountId = 1, Status = "Open", istemplate = true }, + new WorkOrder { AccountId = 1, Status = "Cancelled" }, + new WorkOrder { AccountId = 2, Status = "Open" }, + new WorkOrder { Status = "Open" } ); ctx.SaveChanges(); - var stats = await NewService(ctx).GetStatsAsync(CancellationToken.None); + var stats = await NewService(ctx).GetStatsAsync(AccountUser(1), CancellationToken.None); stats.Total.Should().Be(7); stats.Open.Should().Be(4); stats.NotDispatched.Should().Be(1); stats.Completed.Should().Be(2); } + + [Fact] + public async Task GetStatsAsync_OrgWideUserSeesAllNonTemplateOrders() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder { AccountId = 1, Status = "Open" }, + new WorkOrder { AccountId = 2, Status = "Done" }, + new WorkOrder { Status = "Open", istemplate = true }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync(OrgWideUser(), CancellationToken.None); + + stats.Total.Should().Be(2); + stats.Open.Should().Be(1); + stats.Completed.Should().Be(1); + } + + [Fact] + public async Task GetStatsAsync_MissingScopeFailsClosed() + { + using var ctx = NewContext(); + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.Role, "Dispatcher") + }, "test")); + + var act = () => NewService(ctx).GetStatsAsync(user, CancellationToken.None); + + var exception = await act.Should().ThrowAsync(); + + exception.Which.Code.Should().Be("Forbidden"); + } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index 6955cc2..4841c82 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -19,7 +19,7 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("Stats")] public async Task GetStats(CancellationToken cancellationToken) { - var stats = await _dashboardService.GetStatsAsync(cancellationToken); + var stats = await _dashboardService.GetStatsAsync(User, cancellationToken); return Ok(new { diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index d1e29c3..8bf21c9 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -13,9 +13,15 @@ namespace SeaHaven.DataServices.Implementation _context = context; } - public async Task GetWorkOrderCountsAsync(CancellationToken cancellationToken) + public async Task GetWorkOrderCountsAsync( + int? accountId, + CancellationToken cancellationToken) { var query = _context.workOrders.Where(w => w.istemplate != true); + if (accountId is int scopedAccountId) + { + query = query.Where(w => w.AccountId == scopedAccountId); + } var total = await query.CountAsync(cancellationToken); var open = await query.CountAsync(w => w.Status == "Open" || w.Status == "In Progress" || w.Status == "On Hold", cancellationToken); diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs index 7acb41f..5993609 100644 --- a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -2,7 +2,9 @@ namespace SeaHaven.DataServices.Interfaces { public interface IDashboardDataService { - Task GetWorkOrderCountsAsync(CancellationToken cancellationToken); + Task GetWorkOrderCountsAsync( + int? accountId, + CancellationToken cancellationToken); } public class DashboardCounts diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index 0bec513..30695b9 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; @@ -7,15 +8,22 @@ namespace SeaHaven.Services.Implementation public class DashboardService : IDashboardService { private readonly IDashboardDataService _dataService; + private readonly IWorkOrderAccountResolver _accountResolver; - public DashboardService(IDashboardDataService dataService) + public DashboardService( + IDashboardDataService dataService, + IWorkOrderAccountResolver accountResolver) { _dataService = dataService; + _accountResolver = accountResolver; } - public async Task GetStatsAsync(CancellationToken cancellationToken) + public async Task GetStatsAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken) { - var counts = await _dataService.GetWorkOrderCountsAsync(cancellationToken); + var accountId = _accountResolver.ResolveAccountFilter(user); + var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken); return new DashboardStatsDTO { diff --git a/SeaHaven.Services/Interfaces/IDashboardService.cs b/SeaHaven.Services/Interfaces/IDashboardService.cs index bfc2e2d..63379e1 100644 --- a/SeaHaven.Services/Interfaces/IDashboardService.cs +++ b/SeaHaven.Services/Interfaces/IDashboardService.cs @@ -1,9 +1,12 @@ +using System.Security.Claims; using SeaHaven.Services.DTOs; namespace SeaHaven.Services.Interfaces { public interface IDashboardService { - Task GetStatsAsync(CancellationToken cancellationToken); + Task GetStatsAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken); } } From a2e749c72db356b4c424ae96b3166517f0204a4f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 17:28:21 -0300 Subject: [PATCH 2/6] feat(dashboard): add core dashboard metrics (SH-343) --- .../DashboardServiceTests.cs | 95 ++++++++++++++- .../Controllers/DashboardController.cs | 14 ++- .../Implementation/DashboardDataService.cs | 37 ++++++ .../Interfaces/IDashboardDataService.cs | 19 +++ SeaHaven.Services/DTOs/DashboardDTOs.cs | 20 ++++ .../Helpers/DashboardBusinessTime.cs | 13 ++ SeaHaven.Services/Helpers/DashboardMetrics.cs | 113 ++++++++++++++++++ .../Implementation/DashboardService.cs | 31 ++++- .../Interfaces/IDashboardService.cs | 1 + 9 files changed, 331 insertions(+), 12 deletions(-) create mode 100644 SeaHaven.Services/Helpers/DashboardBusinessTime.cs create mode 100644 SeaHaven.Services/Helpers/DashboardMetrics.cs diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index c45ab1a..a5dadfb 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; using SeaHaven.Services.Exceptions; +using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using Xunit; @@ -28,12 +29,16 @@ public class DashboardServiceTests return new DashboardService(new DashboardDataService(ctx), resolver); } - private static ClaimsPrincipal AccountUser(int accountId) + private static ClaimsPrincipal AccountUser( + int accountId, + string userId = "dispatcher-1", + string role = "Admin") { var claims = new[] { new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), - new Claim(ClaimTypes.Role, "Dispatcher") + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Role, role) }; return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); } @@ -66,7 +71,8 @@ public class DashboardServiceTests ); ctx.SaveChanges(); - var stats = await NewService(ctx).GetStatsAsync(AccountUser(1), CancellationToken.None); + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); stats.Total.Should().Be(7); stats.Open.Should().Be(4); @@ -84,7 +90,8 @@ public class DashboardServiceTests new WorkOrder { Status = "Open", istemplate = true }); ctx.SaveChanges(); - var stats = await NewService(ctx).GetStatsAsync(OrgWideUser(), CancellationToken.None); + var stats = await NewService(ctx).GetStatsAsync( + OrgWideUser(), new DashboardStatsQueryDTO(), CancellationToken.None); stats.Total.Should().Be(2); stats.Open.Should().Be(1); @@ -100,10 +107,88 @@ public class DashboardServiceTests new Claim(ClaimTypes.Role, "Dispatcher") }, "test")); - var act = () => NewService(ctx).GetStatsAsync(user, CancellationToken.None); + var act = () => NewService(ctx).GetStatsAsync( + user, new DashboardStatsQueryDTO(), CancellationToken.None); var exception = await act.Should().ThrowAsync(); exception.Which.Code.Should().Be("Forbidden"); } + + [Fact] + public async Task GetStatsAsync_UsesDashboardMetricRulesForDateRange() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.PM, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue), + OriginalDate = today.AddDays(-6) + }, + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.Emergency, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.InProgress, + ScheduledDate = today.AddDays(-3).ToDateTime(TimeOnly.MinValue) + }, + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.Reactive, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue), + OriginalDate = today.AddDays(-15), + CompletedDate = today.AddDays(-12).ToDateTime(TimeOnly.MinValue) + }, + new WorkOrder + { + AccountId = 1, + WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.PM, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Incomplete, + ScheduledDate = today.AddDays(4).ToDateTime(TimeOnly.MinValue) + }); + ctx.SaveChanges(); + + var query = new DashboardStatsQueryDTO + { + DateFrom = today.AddDays(-10), + DateTo = today.AddDays(10) + }; + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1), query, CancellationToken.None); + + stats.Total.Should().Be(4); + stats.Breakdown.Overdue.Should().Be(1); + stats.Breakdown.Other.Should().Be(0); + stats.Breakdown.PM.Should().Be(2); + stats.Breakdown.Emergency.Should().Be(0); + stats.Breakdown.Reactive.Should().Be(1); + (stats.Breakdown.PM + stats.Breakdown.Emergency + stats.Breakdown.Reactive + + stats.Breakdown.Overdue + stats.Breakdown.Other).Should().Be(stats.Total); + stats.DueCount.Should().Be(3); + stats.CompletedDueCount.Should().Be(1); + stats.CompletionRate.Should().Be(33.33m); + stats.AverageResolutionDays.Should().Be(3m); + } + + [Fact] + public async Task GetStatsAsync_DispatcherOnlySeesAssignedOrders() + { + 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, role: "Dispatcher"), new DashboardStatsQueryDTO(), CancellationToken.None); + + stats.Total.Should().Be(1); + stats.Open.Should().Be(1); + } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index 4841c82..cb7e27c 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers @@ -17,16 +18,23 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("Stats")] - public async Task GetStats(CancellationToken cancellationToken) + public async Task GetStats( + [FromQuery] DashboardStatsQueryDTO query, + CancellationToken cancellationToken) { - var stats = await _dashboardService.GetStatsAsync(User, cancellationToken); + var stats = await _dashboardService.GetStatsAsync(User, query, cancellationToken); return Ok(new { total = stats.Total, open = stats.Open, notDispatched = stats.NotDispatched, - completed = stats.Completed + completed = stats.Completed, + breakdown = stats.Breakdown, + dueCount = stats.DueCount, + completedDueCount = stats.CompletedDueCount, + completionRate = stats.CompletionRate, + averageResolutionDays = stats.AverageResolutionDays }); } } diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index 8bf21c9..73c073e 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -1,5 +1,7 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -36,5 +38,40 @@ namespace SeaHaven.DataServices.Implementation Completed = completed }; } + + public async Task> GetDashboardWorkOrdersAsync( + int? accountId, + string? dispatcherId, + DateOnly? dateFrom, + DateOnly? dateTo, + CancellationToken cancellationToken) + { + var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + if (accountId is int scopedAccountId) + query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, scopedAccountId); + + if (dispatcherId != null) + query = query.Where(w => w.AssignTo == dispatcherId); + + if (dateFrom is DateOnly from && dateTo is DateOnly to) + { + var fromDate = from.ToDateTime(TimeOnly.MinValue); + var toDateExclusive = to.AddDays(1).ToDateTime(TimeOnly.MinValue); + query = query.Where(w => + w.ScheduledDate >= fromDate && w.ScheduledDate < toDateExclusive); + } + + return await query + .Select(w => new DashboardWorkOrder( + w.Id, + w.AssignTo, + w.WorkOrderType, + w.LifecycleStatus, + w.LegacyStatus ?? w.Status, + w.ScheduledDate, + w.OriginalDate, + w.CompletedDate)) + .ToListAsync(cancellationToken); + } } } diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs index 5993609..470e70e 100644 --- a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -1,12 +1,31 @@ namespace SeaHaven.DataServices.Interfaces { + using Data.SeaHavenIndustries.Enums; + public interface IDashboardDataService { Task GetWorkOrderCountsAsync( int? accountId, CancellationToken cancellationToken); + + Task> GetDashboardWorkOrdersAsync( + int? accountId, + string? dispatcherId, + DateOnly? dateFrom, + DateOnly? dateTo, + CancellationToken cancellationToken); } + public sealed record DashboardWorkOrder( + int Id, + string? AssignTo, + WorkOrderType? WorkOrderType, + LifecycleStatus? LifecycleStatus, + string? LegacyStatus, + DateTime? ScheduledDate, + DateOnly? OriginalDate, + DateTime? CompletedDate); + public class DashboardCounts { public int Total { get; set; } diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index 6bac68c..25ecafc 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -1,10 +1,30 @@ namespace SeaHaven.Services.DTOs { + public sealed class DashboardStatsQueryDTO + { + public DateOnly? DateFrom { get; set; } + public DateOnly? DateTo { get; set; } + } + public class DashboardStatsDTO { public int Total { get; set; } public int Open { get; set; } public int NotDispatched { get; set; } public int Completed { get; set; } + public DashboardBreakdownDTO Breakdown { get; set; } = new(); + public int DueCount { get; set; } + public int CompletedDueCount { get; set; } + public decimal CompletionRate { get; set; } + public decimal? AverageResolutionDays { get; set; } + } + + public sealed class DashboardBreakdownDTO + { + public int PM { get; set; } + public int Emergency { get; set; } + public int Reactive { get; set; } + public int Overdue { get; set; } + public int Other { get; set; } } } diff --git a/SeaHaven.Services/Helpers/DashboardBusinessTime.cs b/SeaHaven.Services/Helpers/DashboardBusinessTime.cs new file mode 100644 index 0000000..2ad280d --- /dev/null +++ b/SeaHaven.Services/Helpers/DashboardBusinessTime.cs @@ -0,0 +1,13 @@ +namespace SeaHaven.Services.Helpers; + +public static class DashboardBusinessTime +{ + public const string TimeZoneId = "America/New_York"; + + public static DateOnly Today() + { + var timezone = TimeZoneInfo.FindSystemTimeZoneById(TimeZoneId); + var businessNow = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, timezone); + return DateOnly.FromDateTime(businessNow); + } +} diff --git a/SeaHaven.Services/Helpers/DashboardMetrics.cs b/SeaHaven.Services/Helpers/DashboardMetrics.cs new file mode 100644 index 0000000..e49d921 --- /dev/null +++ b/SeaHaven.Services/Helpers/DashboardMetrics.cs @@ -0,0 +1,113 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Helpers; + +public static class DashboardMetrics +{ + public static DashboardMetricResult Calculate( + IReadOnlyList workOrders, + DateOnly today) + { + var breakdown = new DashboardBreakdownDTO(); + var dueCount = 0; + var completedDueCount = 0; + var resolutionDays = new List(); + + foreach (var workOrder in workOrders) + { + var status = workOrder.LifecycleStatus + ?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus); + var completed = status == LifecycleStatus.Completed; + var canceled = status == LifecycleStatus.Canceled; + var pastDue = IsPastDue(workOrder, status, today); + + AddBreakdownCount(breakdown, workOrder.WorkOrderType, pastDue); + + var due = completed || (workOrder.ScheduledDate is DateTime scheduled + && DateOnly.FromDateTime(scheduled.Date) <= today); + if (due) + { + dueCount++; + if (completed) + completedDueCount++; + } + + if (completed && workOrder.CompletedDate is DateTime completedDate) + { + var anchor = workOrder.OriginalDate + ?? (workOrder.ScheduledDate is DateTime scheduledDate + ? DateOnly.FromDateTime(scheduledDate.Date) + : null); + if (anchor is DateOnly originalDate) + resolutionDays.Add((decimal)(completedDate.Date - originalDate.ToDateTime(TimeOnly.MinValue)).TotalDays); + } + } + + return new DashboardMetricResult( + workOrders.Count, + workOrders.Count(workOrder => + { + var status = workOrder.LifecycleStatus + ?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus); + return status is not LifecycleStatus.Completed and not LifecycleStatus.Canceled; + }), + workOrders.Count(workOrder => string.IsNullOrWhiteSpace(workOrder.AssignTo)), + workOrders.Count(workOrder => + (workOrder.LifecycleStatus ?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus)) + == LifecycleStatus.Completed), + breakdown, + dueCount, + completedDueCount, + dueCount == 0 ? 0 : Math.Round((decimal)completedDueCount / dueCount * 100, 2), + resolutionDays.Count == 0 ? null : Math.Round(resolutionDays.Average(), 2)); + } + + private static bool IsPastDue( + DashboardWorkOrder workOrder, + LifecycleStatus? status, + DateOnly today) + => workOrder.ScheduledDate is DateTime scheduled + && DateOnly.FromDateTime(scheduled.Date) < today + && status is not LifecycleStatus.Completed and not LifecycleStatus.Canceled; + + private static void AddBreakdownCount( + DashboardBreakdownDTO breakdown, + WorkOrderType? type, + bool pastDue) + { + if (pastDue) + { + breakdown.Overdue++; + return; + } + + switch (type) + { + case WorkOrderType.PM: + breakdown.PM++; + break; + case WorkOrderType.Emergency: + breakdown.Emergency++; + break; + case WorkOrderType.Reactive: + breakdown.Reactive++; + break; + default: + breakdown.Other++; + break; + } + } +} + +public sealed record DashboardMetricResult( + int Total, + int Open, + int NotDispatched, + int Completed, + DashboardBreakdownDTO Breakdown, + int DueCount, + int CompletedDueCount, + decimal CompletionRate, + decimal? AverageResolutionDays); diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index 30695b9..afe4cd0 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -1,6 +1,8 @@ using System.Security.Claims; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation @@ -20,17 +22,38 @@ namespace SeaHaven.Services.Implementation public async Task GetStatsAsync( 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 counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken); + var workOrders = await _dataService.GetDashboardWorkOrdersAsync( + accountId, + dispatcherId, + query.DateFrom, + query.DateTo, + cancellationToken); + var metrics = DashboardMetrics.Calculate(workOrders, DashboardBusinessTime.Today()); + + var useMetrics = query.DateFrom.HasValue && query.DateTo.HasValue || dispatcherId is not null; return new DashboardStatsDTO { - Total = counts.Total, - Open = counts.Open, - NotDispatched = counts.NotDispatched, - Completed = counts.Completed + Total = useMetrics ? metrics.Total : counts.Total, + Open = useMetrics ? metrics.Open : counts.Open, + NotDispatched = useMetrics ? metrics.NotDispatched : counts.NotDispatched, + Completed = useMetrics ? metrics.Completed : counts.Completed, + Breakdown = metrics.Breakdown, + DueCount = metrics.DueCount, + CompletedDueCount = metrics.CompletedDueCount, + CompletionRate = metrics.CompletionRate, + AverageResolutionDays = metrics.AverageResolutionDays }; } } diff --git a/SeaHaven.Services/Interfaces/IDashboardService.cs b/SeaHaven.Services/Interfaces/IDashboardService.cs index 63379e1..a760d47 100644 --- a/SeaHaven.Services/Interfaces/IDashboardService.cs +++ b/SeaHaven.Services/Interfaces/IDashboardService.cs @@ -7,6 +7,7 @@ namespace SeaHaven.Services.Interfaces { Task GetStatsAsync( ClaimsPrincipal user, + DashboardStatsQueryDTO query, CancellationToken cancellationToken); } } From 9b39d0e5ecab63fd3a297825dbd5192118d2ae27 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 17:43:49 -0300 Subject: [PATCH 3/6] 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); } } From 0194748e3b8fc989b5c890e93c12df651636f44a Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 17:58:08 -0300 Subject: [PATCH 4/6] 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); } } From d4afcced87623005bea79adf6c31331e01e2d6ac Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 18:01:42 -0300 Subject: [PATCH 5/6] 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); } } From 22308f21f6a4c590a97606595daad36d3c5bfab3 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 20:15:20 -0300 Subject: [PATCH 6/6] fix(dashboard): resolve region zones from full state names (SH-348) Locations store State as either a two-letter postal code or a full state name (the location list filter expands codes to both forms via UsStateCodes.ExpandStorageValues, and dev fixtures carry State = "indiana"). DashboardRegions.Resolve only matched the two-letter key, so every location stored as a full name fell into Unmapped/Other and the East/Central/West/ California bars undercounted. Normalise the stored value through a new UsStateCodes.ToCode, which accepts a code or a full name and returns the canonical postal code, before the zone lookup. Extend the region test with full-name storage forms and add an invariant asserting every US state and its full-name form resolves to a canonical bucket. --- .../DashboardServiceTests.cs | 31 +++++++++++++++++-- SeaHaven.Services/Helpers/DashboardRegions.cs | 5 +-- SeaHaven.Services/Helpers/UsStateCodes.cs | 21 +++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index c7c6410..4b6b2c5 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -309,11 +309,17 @@ public class DashboardServiceTests ctx.Locations.AddRange( new Locations { Id = 1, State = "NY" }, new Locations { Id = 2, State = "ca" }, - new Locations { Id = 3, State = "XX" }); + new Locations { Id = 3, State = "XX" }, + new Locations { Id = 4, State = "indiana" }, + new Locations { Id = 5, State = "NEW YORK" }, + new Locations { Id = 6, State = "California" }); 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 = 4 }, + new WorkOrder { AccountId = 1, LocationId = 5 }, + new WorkOrder { AccountId = 1, LocationId = 6 }, new WorkOrder { AccountId = 1, LocationId = null }); await ctx.SaveChangesAsync(); @@ -322,6 +328,27 @@ public class DashboardServiceTests 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); + // NY + "NEW YORK" -> East; "indiana" -> Central; "ca" + "California" -> California; + // "XX" + unassigned location -> Unmapped/Other. Full-name storage forms must not + // fall through to Unmapped/Other (SH-348 review). + result.Items.Select(row => row.WorkOrderCount).Should().Equal(2, 1, 0, 2, 2); + } + + [Fact] + public void Resolve_MapsEveryUsStateAndItsFullNameToACanonicalBucket() + { + foreach (var code in UsStateCodes.All) + { + var byCode = DashboardRegions.Resolve(code); + byCode.Should().NotBe( + "Unmapped/Other", + $"state code {code} must belong to a canonical region"); + + var fullName = UsStateCodes.ExpandStorageValues(new[] { code }) + .First(value => value != code); + DashboardRegions.Resolve(fullName).Should().Be( + byCode, + $"the full-name storage form of {code} must resolve to the same region as the code"); + } } } diff --git a/SeaHaven.Services/Helpers/DashboardRegions.cs b/SeaHaven.Services/Helpers/DashboardRegions.cs index f3b5fae..d41ae83 100644 --- a/SeaHaven.Services/Helpers/DashboardRegions.cs +++ b/SeaHaven.Services/Helpers/DashboardRegions.cs @@ -9,10 +9,11 @@ public static class DashboardRegions public static string Resolve(string? state) { - if (string.IsNullOrWhiteSpace(state)) + var code = UsStateCodes.ToCode(state); + if (code is null) return "Unmapped/Other"; - return StateToRegion.TryGetValue(state.Trim().ToUpperInvariant(), out var region) + return StateToRegion.TryGetValue(code, out var region) ? region : "Unmapped/Other"; } diff --git a/SeaHaven.Services/Helpers/UsStateCodes.cs b/SeaHaven.Services/Helpers/UsStateCodes.cs index 9d64a1a..54c32ea 100644 --- a/SeaHaven.Services/Helpers/UsStateCodes.cs +++ b/SeaHaven.Services/Helpers/UsStateCodes.cs @@ -56,8 +56,29 @@ namespace SeaHaven.Services.Helpers ["WY"] = "WYOMING" }; + private static readonly IReadOnlyDictionary CodesByName = + Names.ToDictionary(pair => pair.Value, pair => pair.Key, StringComparer.Ordinal); + public static IReadOnlySet All { get; } = new HashSet(Names.Keys, StringComparer.Ordinal); + /// + /// Normalises a stored state value to its two-letter postal code. Accepts either a + /// postal code ("IN") or a full state name ("Indiana"), case-insensitively, matching + /// the storage forms produced by . Returns null when + /// the value is blank or is not a recognised US state. + /// + public static string? ToCode(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + var normalized = value.Trim().ToUpperInvariant(); + if (All.Contains(normalized)) + return normalized; + + return CodesByName.TryGetValue(normalized, out var code) ? code : null; + } + public static IReadOnlyCollection ExpandStorageValues(IEnumerable codes) { return codes