From efd13b6f60bfc7834371c8880c481df77e87aad6 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 17:12:03 -0300 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 3358ea5058707d08386e97bfe6dcc04877d42316 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 18:10:42 -0300 Subject: [PATCH 06/10] feat(dashboard): add vendor insights (SH-349) --- .../DashboardServiceTests.cs | 43 ++++++++++++++++ .../Controllers/DashboardController.cs | 18 +++++++ .../Implementation/DashboardDataService.cs | 15 +++++- .../Interfaces/IDashboardDataService.cs | 7 ++- SeaHaven.Services/DTOs/DashboardDTOs.cs | 18 +++++++ .../Implementation/DashboardService.cs | 49 +++++++++++++++++++ .../Interfaces/IDashboardService.cs | 4 ++ 7 files changed, 152 insertions(+), 2 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index c7c6410..a383bd7 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -324,4 +324,47 @@ public class DashboardServiceTests "East", "Central", "West", "California", "Unmapped/Other"); result.Items.Select(row => row.WorkOrderCount).Should().Equal(1, 0, 0, 1, 2); } + + [Fact] + public async Task GetVendorInsightsAsync_IsCompanyWideAndUsesVendorMetrics() + { + await using var ctx = NewContext(); + ctx.VendorCompanies.Add(new VendorCompany { Id = 10, Name = "Acme Services", IsDeleted = false }); + ctx.Vendors.Add(new Vendor + { + Id = 20, + CompanyId = 10, + IsActive = true, + CompanyName = "Acme Services" + }); + ctx.Dispatches.Add(new Dispatch { Id = 30, VendorId = 20 }); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + PrimaryDispatchId = 30, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = DateTime.UtcNow.Date.AddDays(-3), + CompletedDate = DateTime.UtcNow.Date.AddDays(-1), + RescheduleCount = 1 + }, + new WorkOrder + { + AccountId = 1, + PrimaryDispatchId = 30, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = DateTime.UtcNow.Date.AddDays(-2), + CompletedDate = DateTime.UtcNow.Date.AddDays(-1) + }); + await ctx.SaveChangesAsync(); + + var result = await NewService(ctx).GetVendorInsightsAsync( + AccountUser(1), CancellationToken.None); + + result.TotalVendors.Should().Be(1); + result.Items.Should().ContainSingle(); + result.Items[0].TotalJobs.Should().Be(2); + result.Items[0].CompletionRate.Should().Be(100); + result.Items[0].RescheduleRate.Should().Be(50); + } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index bdce2e2..055b933 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -109,5 +109,23 @@ namespace Api.SeaHavenIndustries.Controllers }); } } + + [HttpGet("VendorInsights")] + public async Task GetVendorInsights(CancellationToken cancellationToken = default) + { + try + { + return Ok(await _dashboardService.GetVendorInsightsAsync(User, 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 a58aed3..a56f74f 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -71,7 +71,20 @@ namespace SeaHaven.DataServices.Implementation w.ScheduledDate, w.OriginalDate, w.CompletedDate, - w.Locations == null ? null : w.Locations.State)) + w.Locations == null ? null : w.Locations.State, + w.RescheduleCount, + w.PrimaryDispatch == null || w.PrimaryDispatch.Vendor == null + ? null + : w.PrimaryDispatch.Vendor.CompanyId, + w.PrimaryDispatch == null || w.PrimaryDispatch.Vendor == null + || w.PrimaryDispatch.Vendor.Company == null + ? null + : w.PrimaryDispatch.Vendor.Company.Name, + w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null + && w.PrimaryDispatch.Vendor.IsActive, + w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null + && w.PrimaryDispatch.Vendor.Company != null + && w.PrimaryDispatch.Vendor.Company.IsDeleted == true)) .ToListAsync(cancellationToken); } diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs index fa794b1..5348703 100644 --- a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -32,7 +32,12 @@ namespace SeaHaven.DataServices.Interfaces DateTime? ScheduledDate, DateOnly? OriginalDate, DateTime? CompletedDate, - string? LocationState = null); + string? LocationState = null, + int RescheduleCount = 0, + int? VendorCompanyId = null, + string? VendorCompanyName = null, + bool VendorIsActive = false, + bool VendorCompanyIsDeleted = false); public sealed record DashboardDispatcherWorkload( string DispatcherId, diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index e19f55c..a767a03 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -51,6 +51,24 @@ namespace SeaHaven.Services.DTOs public int WorkOrderCount { get; init; } } + public sealed class DashboardVendorInsightsResponseDTO + { + public IReadOnlyList Items { get; init; } = []; + public int Page { get; init; } + public int PageSize { get; init; } + public int TotalVendors { get; init; } + } + + public sealed class DashboardVendorInsightsRowDTO + { + public int VendorCompanyId { get; init; } + public string VendorCompanyName { get; init; } = string.Empty; + public decimal CompletionRate { get; init; } + public decimal RescheduleRate { get; init; } + public decimal? AverageResolutionDays { get; init; } + public int TotalJobs { 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 2bbe7ca..757dfb3 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -179,5 +179,54 @@ namespace SeaHaven.Services.Implementation .ToList() }; } + + public async Task GetVendorInsightsAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + var accountId = _accountResolver.ResolveAccountFilter(user); + var workOrders = await _dataService.GetDashboardWorkOrdersAsync( + accountId, null, null, null, cancellationToken); + var activeVendorOrders = workOrders + .Where(workOrder => workOrder.VendorCompanyId.HasValue + && workOrder.VendorIsActive + && !workOrder.VendorCompanyIsDeleted) + .ToList(); + var today = DashboardBusinessTime.Today(); + var rows = activeVendorOrders + .GroupBy(workOrder => new + { + Id = workOrder.VendorCompanyId!.Value, + Name = workOrder.VendorCompanyName ?? string.Empty + }) + .Select(group => + { + var orders = group.ToList(); + var metrics = DashboardMetrics.Calculate(orders, today); + var rescheduled = orders.Count(order => order.RescheduleCount >= 1); + return new DashboardVendorInsightsRowDTO + { + VendorCompanyId = group.Key.Id, + VendorCompanyName = group.Key.Name, + CompletionRate = metrics.CompletionRate, + RescheduleRate = Math.Round((decimal)rescheduled / orders.Count * 100, 2), + AverageResolutionDays = metrics.AverageResolutionDays, + TotalJobs = orders.Count + }; + }) + .OrderByDescending(row => row.CompletionRate) + .ThenBy(row => row.VendorCompanyName, StringComparer.OrdinalIgnoreCase) + .ThenBy(row => row.VendorCompanyId) + .ToList(); + const int pageSize = 10; + + return new DashboardVendorInsightsResponseDTO + { + Items = rows.Take(pageSize).ToList(), + Page = 1, + PageSize = pageSize, + TotalVendors = rows.Count + }; + } } } diff --git a/SeaHaven.Services/Interfaces/IDashboardService.cs b/SeaHaven.Services/Interfaces/IDashboardService.cs index 9b10f73..2f744eb 100644 --- a/SeaHaven.Services/Interfaces/IDashboardService.cs +++ b/SeaHaven.Services/Interfaces/IDashboardService.cs @@ -26,5 +26,9 @@ namespace SeaHaven.Services.Interfaces ClaimsPrincipal user, DashboardStatsQueryDTO query, CancellationToken cancellationToken); + + Task GetVendorInsightsAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken); } } From 9d7efa34a6241227b852f47329264b08576c36ac Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 18:42:26 -0300 Subject: [PATCH 07/10] feat(dashboard): add trend metrics (SH-350) --- .../DashboardServiceTests.cs | 255 ++++++++++++++++++ .../Controllers/DashboardController.cs | 41 +++ SeaHaven.Services/DTOs/DashboardDTOs.cs | 27 +- .../Implementation/DashboardService.cs | 171 ++++++++++++ .../Interfaces/IDashboardService.cs | 5 + 5 files changed, 498 insertions(+), 1 deletion(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index a383bd7..54b8886 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -367,4 +367,259 @@ public class DashboardServiceTests result.Items[0].CompletionRate.Should().Be(100); result.Items[0].RescheduleRate.Should().Be(50); } + + [Fact] + public async Task GetTrendAsync_DailyBucketsClassifyByNewYorkScheduledDate() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 15, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled, + ScheduledDate = new DateTime(2026, 1, 14, 15, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = new DateTime(2026, 1, 15, 17, 0, 0, DateTimeKind.Utc), + CompletedDate = new DateTime(2026, 1, 16, 15, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 16, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 17, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 13, 20, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = new DateOnly(2026, 1, 14), + DateTo = new DateOnly(2026, 1, 16) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + trend.Granularity.Should().Be("day"); + trend.Buckets.Select(bucket => bucket.Date).Should().Equal( + new DateOnly(2026, 1, 14), new DateOnly(2026, 1, 15), new DateOnly(2026, 1, 16)); + trend.Buckets.Select(bucket => bucket.Label).Should().Equal( + "2026-01-14", "2026-01-15", "2026-01-16"); + + trend.Buckets[0].Total.Should().Be(2); + trend.Buckets[0].Open.Should().Be(1); + trend.Buckets[0].Canceled.Should().Be(1); + trend.Buckets[0].Completed.Should().Be(0); + trend.Buckets[0].Overdue.Should().Be(1); + + trend.Buckets[1].Total.Should().Be(2); + trend.Buckets[1].Open.Should().Be(1); + trend.Buckets[1].Completed.Should().Be(1); + trend.Buckets[1].Overdue.Should().Be(1); + + trend.Buckets[2].Total.Should().Be(1); + trend.Buckets[2].Open.Should().Be(1); + trend.Buckets[2].Overdue.Should().Be(1); + + trend.Buckets.Should().OnlyContain(bucket => !bucket.IsCurrent); + } + + [Fact] + public async Task GetTrendAsync_ThreeMonthRangeUsesWeeklyMondayBuckets() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 17, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 16, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = new DateTime(2026, 9, 16, 12, 0, 0, DateTimeKind.Utc), + CompletedDate = new DateTime(2026, 9, 16, 18, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + Range = "3m", + DateFrom = new DateOnly(2026, 6, 17), + DateTo = new DateOnly(2026, 9, 16) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + trend.Granularity.Should().Be("week"); + trend.Buckets.Should().HaveCount(14); + trend.Buckets.Select(bucket => bucket.Date.DayOfWeek) + .Should().OnlyContain(day => day == DayOfWeek.Monday); + trend.Buckets[0].Date.Should().Be(new DateOnly(2026, 6, 15)); + trend.Buckets[0].Total.Should().Be(2); + trend.Buckets[0].Open.Should().Be(2); + trend.Buckets[^1].Date.Should().Be(new DateOnly(2026, 9, 14)); + trend.Buckets[^1].Total.Should().Be(1); + trend.Buckets[^1].Completed.Should().Be(1); + } + + [Fact] + public async Task GetTrendAsync_YearFilterUsesMonthlyBuckets() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = new DateTime(2026, 3, 20, 4, 0, 0, DateTimeKind.Utc), + CompletedDate = new DateTime(2026, 3, 21, 4, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2025, 12, 31, 20, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2027, 1, 1, 3, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), new DashboardTrendQueryDTO { Year = 2026 }, CancellationToken.None); + + trend.Granularity.Should().Be("month"); + trend.Buckets.Should().HaveCount(12); + trend.Buckets[0].Label.Should().Be("2026-01-01"); + trend.Buckets[0].Total.Should().Be(1); + trend.Buckets[0].Open.Should().Be(1); + trend.Buckets[0].Overdue.Should().Be(1); + trend.Buckets[2].Label.Should().Be("2026-03-01"); + trend.Buckets[2].Total.Should().Be(1); + trend.Buckets[2].Completed.Should().Be(1); + trend.Buckets[11].Label.Should().Be("2026-12-01"); + trend.Buckets[11].Total.Should().Be(1); + trend.Buckets[11].Open.Should().Be(1); + trend.Buckets[11].Overdue.Should().Be(0); + trend.Today.Should().Be(DashboardBusinessTime.Today()); + } + + [Fact] + public async Task GetTrendAsync_MarksTodayBucketAsCurrent() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + ctx.workOrders.Add(new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.AddDays(-2).ToDateTime(new TimeOnly(12, 0)) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = today.AddDays(-2), + DateTo = today.AddDays(2) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + trend.Today.Should().Be(today); + trend.Buckets.Should().HaveCount(5); + trend.Buckets.Count(bucket => bucket.IsCurrent).Should().Be(1); + trend.Buckets.Single(bucket => bucket.IsCurrent).Date.Should().Be(today); + trend.Buckets[0].Total.Should().Be(1); + trend.Buckets.Single(bucket => bucket.Date == today).Total.Should().Be(0); + } + + [Fact] + public async Task GetTrendAsync_DispatcherRoleAndTenantScopeMatchStats() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-2", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 2, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = new DateOnly(2026, 2, 2), + DateTo = new DateOnly(2026, 2, 3) + }; + + var dispatcherTrend = await NewService(ctx).GetTrendAsync( + AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None); + + dispatcherTrend.Buckets.Should().HaveCount(2); + dispatcherTrend.Buckets[0].Total.Should().Be(1); + dispatcherTrend.Buckets[1].Total.Should().Be(0); + + var adminTrend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + adminTrend.Buckets[0].Total.Should().Be(2); + + var otherAccountTrend = await NewService(ctx).GetTrendAsync( + AccountUser(2), query, CancellationToken.None); + + otherAccountTrend.Buckets[0].Total.Should().Be(1); + } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index 055b933..5551a5d 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -110,6 +110,47 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpGet("Trend")] + public async Task GetTrend( + [FromQuery] DashboardTrendQueryDTO query, + CancellationToken cancellationToken = default) + { + try + { + var trend = await _dashboardService.GetTrendAsync(User, query, cancellationToken); + + return Ok(new + { + granularity = trend.Granularity, + today = trend.Today, + buckets = trend.Buckets.Select(bucket => new + { + date = bucket.Date, + label = bucket.Label, + total = bucket.Total, + open = bucket.Open, + completed = bucket.Completed, + canceled = bucket.Canceled, + overdue = bucket.Overdue, + isCurrent = bucket.IsCurrent + }) + }); + } + 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); + } + } + [HttpGet("VendorInsights")] public async Task GetVendorInsights(CancellationToken cancellationToken = default) { diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index a767a03..ef5c70d 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -1,11 +1,36 @@ namespace SeaHaven.Services.DTOs { - public sealed class DashboardStatsQueryDTO + public class DashboardStatsQueryDTO { public DateOnly? DateFrom { get; set; } public DateOnly? DateTo { get; set; } } + public sealed class DashboardTrendQueryDTO : DashboardStatsQueryDTO + { + public string? Range { get; set; } + public int? Year { get; set; } + } + + public sealed class DashboardTrendResponseDTO + { + public string Granularity { get; init; } = "day"; + public DateOnly Today { get; init; } + public IReadOnlyList Buckets { get; init; } = []; + } + + public sealed class DashboardTrendBucketDTO + { + public DateOnly Date { get; init; } + public string Label { get; init; } = string.Empty; + public int Total { get; init; } + public int Open { get; init; } + public int Completed { get; init; } + public int Canceled { get; init; } + public int Overdue { get; init; } + public bool IsCurrent { get; init; } + } + public sealed class DashboardWorkloadResponseDTO { public IReadOnlyList Items { get; init; } = []; diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index 757dfb3..a4bdfce 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -1,4 +1,6 @@ +using System.Globalization; using System.Security.Claims; +using Data.SeaHavenIndustries.Enums; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; @@ -180,6 +182,175 @@ namespace SeaHaven.Services.Implementation }; } + public async Task GetTrendAsync( + ClaimsPrincipal user, + DashboardTrendQueryDTO 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 today = DashboardBusinessTime.Today(); + var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today); + var timezone = TimeZoneInfo.FindSystemTimeZoneById(DashboardBusinessTime.TimeZoneId); + var firstBucketStart = BucketStart(windowFrom, granularity); + var lastBucketStart = BucketStart(windowTo, granularity); + var lastBucketEnd = BucketEnd(lastBucketStart, granularity); + + var workOrders = await _dataService.GetDashboardWorkOrdersAsync( + accountId, + dispatcherId, + firstBucketStart.AddDays(-1), + lastBucketEnd.AddDays(1), + cancellationToken); + + var buckets = BuildBucketStarts(firstBucketStart, windowTo, granularity) + .Select(start => new TrendBucket(start)) + .ToList(); + var bucketStarts = buckets.Select(bucket => bucket.Start).ToList(); + + foreach (var workOrder in workOrders) + { + if (workOrder.ScheduledDate is not DateTime scheduled) + continue; + var businessDate = DateOnly.FromDateTime( + TimeZoneInfo.ConvertTimeFromUtc( + DateTime.SpecifyKind(scheduled, DateTimeKind.Utc), timezone)); + if (businessDate < firstBucketStart || businessDate > lastBucketEnd) + continue; + var bucket = buckets[LocateBucket(bucketStarts, businessDate)]; + var status = workOrder.LifecycleStatus + ?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus); + bucket.Total++; + if (status == LifecycleStatus.Completed) + bucket.Completed++; + else if (status == LifecycleStatus.Canceled) + bucket.Canceled++; + else + { + bucket.Open++; + if (businessDate < today) + bucket.Overdue++; + } + } + + DateOnly? currentStart = today < firstBucketStart || today > lastBucketEnd + ? null + : buckets[LocateBucket(bucketStarts, today)].Start; + + return new DashboardTrendResponseDTO + { + Granularity = granularity, + Today = today, + Buckets = buckets.Select(bucket => new DashboardTrendBucketDTO + { + Date = bucket.Start, + Label = bucket.Start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + Total = bucket.Total, + Open = bucket.Open, + Completed = bucket.Completed, + Canceled = bucket.Canceled, + Overdue = bucket.Overdue, + IsCurrent = bucket.Start == currentStart + }).ToList() + }; + } + + private static (DateOnly From, DateOnly To, string Granularity) ResolveTrendWindow( + DashboardTrendQueryDTO query, + DateOnly today) + { + if (query.Year is int year) + { + if (year < DateOnly.MinValue.Year || year > DateOnly.MaxValue.Year) + throw new ArgumentOutOfRangeException(nameof(query)); + return (new DateOnly(year, 1, 1), new DateOnly(year, 12, 31), "month"); + } + + if (string.Equals(query.Range, ThreeMonthRange, StringComparison.OrdinalIgnoreCase)) + { + var weeklyTo = query.DateTo ?? today; + var weeklyFrom = query.DateFrom ?? weeklyTo.AddMonths(-3).AddDays(1); + if (weeklyFrom > weeklyTo) + throw new ArgumentOutOfRangeException(nameof(query)); + return (weeklyFrom, weeklyTo, "week"); + } + + if (query.DateFrom is DateOnly dailyFrom && query.DateTo is DateOnly dailyTo) + { + if (dailyFrom > dailyTo) + throw new ArgumentOutOfRangeException(nameof(query)); + return (dailyFrom, dailyTo, "day"); + } + + if (query.DateFrom.HasValue != query.DateTo.HasValue) + throw new ArgumentOutOfRangeException(nameof(query)); + + return (today, today, "day"); + } + + private static List BuildBucketStarts(DateOnly from, DateOnly to, string granularity) + { + var starts = new List(); + var current = BucketStart(from, granularity); + while (current <= to) + { + starts.Add(current); + current = granularity switch + { + "week" => current.AddDays(7), + "month" => current.AddMonths(1), + _ => current.AddDays(1) + }; + } + return starts; + } + + private static DateOnly BucketStart(DateOnly date, string granularity) => granularity switch + { + "week" => date.AddDays(-(((int)date.DayOfWeek + 6) % 7)), + "month" => new DateOnly(date.Year, date.Month, 1), + _ => date + }; + + private static DateOnly BucketEnd(DateOnly start, string granularity) => granularity switch + { + "week" => start.AddDays(6), + "month" => start.AddMonths(1).AddDays(-1), + _ => start + }; + + private static int LocateBucket(List starts, DateOnly date) + { + var low = 0; + var high = starts.Count - 1; + while (low < high) + { + var middle = (low + high + 1) / 2; + if (starts[middle] <= date) + low = middle; + else + high = middle - 1; + } + return low; + } + + private const string ThreeMonthRange = "3m"; + + private sealed class TrendBucket(DateOnly start) + { + public DateOnly Start { get; } = start; + public int Total { get; set; } + public int Open { get; set; } + public int Completed { get; set; } + public int Canceled { get; set; } + public int Overdue { get; set; } + } + public async Task GetVendorInsightsAsync( ClaimsPrincipal user, CancellationToken cancellationToken) diff --git a/SeaHaven.Services/Interfaces/IDashboardService.cs b/SeaHaven.Services/Interfaces/IDashboardService.cs index 2f744eb..8464bad 100644 --- a/SeaHaven.Services/Interfaces/IDashboardService.cs +++ b/SeaHaven.Services/Interfaces/IDashboardService.cs @@ -27,6 +27,11 @@ namespace SeaHaven.Services.Interfaces DashboardStatsQueryDTO query, CancellationToken cancellationToken); + Task GetTrendAsync( + ClaimsPrincipal user, + DashboardTrendQueryDTO query, + CancellationToken cancellationToken); + Task GetVendorInsightsAsync( ClaimsPrincipal user, CancellationToken cancellationToken); From 5877f7817f12ca86df1cc556f97979f777ce81f8 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 19:57:09 -0300 Subject: [PATCH 08/10] fix(dashboard): bucket trend by calendar ScheduledDate (SH-350) ScheduledDate is persisted as a midnight calendar value (board create writes request.ScheduledDate.Value.Date; board patch writes the parsed .Date into a datetime2 column with no offset). GetTrendAsync treated it as a UTC instant and converted to America/New_York, so midnight became 19:00/20:00 the previous day and every board-scheduled work order fell into the prior bucket: a job scheduled today read as yesterday and overdue, and the Today bucket showed zero. Bucket by DateOnly.FromDateTime(scheduled.Date) with no conversion, matching DashboardMetrics and WorkOrderDerivedFields, so Trend and Stats agree on the same rows. Rewrite the daily and yearly trend tests to assert calendar-date classification (the removed conversion had encoded the shift into their expectations) and add a regression test that a midnight-today work order stays in the Today bucket and is not overdue. --- .../DashboardServiceTests.cs | 57 ++++++++++++++++--- .../Implementation/DashboardService.cs | 5 +- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index 54b8886..de6a9a6 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -369,9 +369,14 @@ public class DashboardServiceTests } [Fact] - public async Task GetTrendAsync_DailyBucketsClassifyByNewYorkScheduledDate() + public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate() { using var ctx = NewContext(); + // ScheduledDate is a stored calendar value (board writes persist `.Date`), so a work + // order buckets on its own calendar day regardless of the time-of-day component. The + // UTC hours below were previously shifted a full day by an America/New_York conversion + // (03:00 UTC -> prior evening), so they double as a regression guard: each row must now + // stay on the calendar date it was scheduled for. ctx.workOrders.AddRange( new WorkOrder { @@ -426,17 +431,21 @@ public class DashboardServiceTests trend.Buckets.Select(bucket => bucket.Label).Should().Equal( "2026-01-14", "2026-01-15", "2026-01-16"); - trend.Buckets[0].Total.Should().Be(2); - trend.Buckets[0].Open.Should().Be(1); + // 01-14: only the canceled row scheduled that calendar day. + trend.Buckets[0].Total.Should().Be(1); + trend.Buckets[0].Open.Should().Be(0); trend.Buckets[0].Canceled.Should().Be(1); trend.Buckets[0].Completed.Should().Be(0); - trend.Buckets[0].Overdue.Should().Be(1); + trend.Buckets[0].Overdue.Should().Be(0); + // 01-15: the 03:00 UTC scheduled row (no longer shifted to 01-14) plus the completed row. trend.Buckets[1].Total.Should().Be(2); trend.Buckets[1].Open.Should().Be(1); trend.Buckets[1].Completed.Should().Be(1); + trend.Buckets[1].Canceled.Should().Be(0); trend.Buckets[1].Overdue.Should().Be(1); + // 01-16: the 03:00 UTC scheduled row that stays on its own day. trend.Buckets[2].Total.Should().Be(1); trend.Buckets[2].Open.Should().Be(1); trend.Buckets[2].Overdue.Should().Be(1); @@ -444,6 +453,38 @@ public class DashboardServiceTests trend.Buckets.Should().OnlyContain(bucket => !bucket.IsCurrent); } + [Fact] + public async Task GetTrendAsync_MidnightScheduledDateStaysInTodayBucketAndIsNotOverdue() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + // A board-scheduled work order for today is stored as midnight (`.Date`). It must land + // in the Today bucket and count as open, not roll back to yesterday and read as overdue. + ctx.workOrders.Add(new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = today.AddDays(-1), + DateTo = today.AddDays(1) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + var todayBucket = trend.Buckets.Single(bucket => bucket.Date == today); + todayBucket.Total.Should().Be(1); + todayBucket.Open.Should().Be(1); + todayBucket.Overdue.Should().Be(0); + + var yesterdayBucket = trend.Buckets.Single(bucket => bucket.Date == today.AddDays(-1)); + yesterdayBucket.Total.Should().Be(0); + } + [Fact] public async Task GetTrendAsync_ThreeMonthRangeUsesWeeklyMondayBuckets() { @@ -515,6 +556,8 @@ public class DashboardServiceTests LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, ScheduledDate = new DateTime(2025, 12, 31, 20, 0, 0, DateTimeKind.Utc) }, + // 2027-01-01 by calendar: outside the 2026 window. Previously an + // America/New_York conversion pulled it back to 2026-12-31 and into December. new WorkOrder { AccountId = 1, @@ -535,10 +578,10 @@ public class DashboardServiceTests trend.Buckets[2].Label.Should().Be("2026-03-01"); trend.Buckets[2].Total.Should().Be(1); trend.Buckets[2].Completed.Should().Be(1); + // December stays empty: the 2027-01-01 row is no longer pulled inside the window. trend.Buckets[11].Label.Should().Be("2026-12-01"); - trend.Buckets[11].Total.Should().Be(1); - trend.Buckets[11].Open.Should().Be(1); - trend.Buckets[11].Overdue.Should().Be(0); + trend.Buckets[11].Total.Should().Be(0); + trend.Buckets[11].Open.Should().Be(0); trend.Today.Should().Be(DashboardBusinessTime.Today()); } diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index a4bdfce..56b4c8a 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -196,7 +196,6 @@ namespace SeaHaven.Services.Implementation var today = DashboardBusinessTime.Today(); var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today); - var timezone = TimeZoneInfo.FindSystemTimeZoneById(DashboardBusinessTime.TimeZoneId); var firstBucketStart = BucketStart(windowFrom, granularity); var lastBucketStart = BucketStart(windowTo, granularity); var lastBucketEnd = BucketEnd(lastBucketStart, granularity); @@ -217,9 +216,7 @@ namespace SeaHaven.Services.Implementation { if (workOrder.ScheduledDate is not DateTime scheduled) continue; - var businessDate = DateOnly.FromDateTime( - TimeZoneInfo.ConvertTimeFromUtc( - DateTime.SpecifyKind(scheduled, DateTimeKind.Utc), timezone)); + var businessDate = DateOnly.FromDateTime(scheduled.Date); if (businessDate < firstBucketStart || businessDate > lastBucketEnd) continue; var bucket = buckets[LocateBucket(bucketStarts, businessDate)]; From 7bdcc61651c8977ac3e4c26ee93aa2ed0615367f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 20:15:30 -0300 Subject: [PATCH 09/10] fix(work-orders): bind GetWorkorderById id from the query string (SH-374) --- .../WorkOrderGetByIdBindingTests.cs | 106 ++++++++++++++++++ .../WorkOrderRouteContractTests.cs | 6 +- .../Controllers/WorkOrderController.cs | 10 +- 3 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs new file mode 100644 index 0000000..2d88138 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs @@ -0,0 +1,106 @@ +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// SH-374: GET api/WorkOrder/GetWorkorderById?id= must read the id from the query string, while +/// GET api/WorkOrder/{id} keeps reading it from the route. Both must return the same work order, +/// and an unknown id keeps the existing "ID not found!" response. +/// +public class WorkOrderGetByIdBindingTests +{ + private const int ExistingId = 374; + private const int UnknownId = 999_999; + + private static ControllerActionDescriptor ActionForTemplate(string relativeTemplate) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMvcCore().AddApplicationPart(typeof(WorkOrderController).Assembly); + + using var provider = services.BuildServiceProvider(); + return provider.GetRequiredService().ActionDescriptors.Items + .OfType() + .Where(cad => cad.ControllerTypeInfo == typeof(WorkOrderController)) + .Where(cad => cad.ActionConstraints! + .OfType() + .Any(c => c.HttpMethods.Contains("GET"))) + .Single(cad => string.Equals( + cad.AttributeRouteInfo?.Template?.Trim('/'), + $"api/WorkOrder/{relativeTemplate}", + StringComparison.Ordinal)); + } + + [Theory] + [InlineData("GetWorkorderById", "Query")] + [InlineData("{id:int}", "Path")] + public void Id_binds_from_the_source_its_route_provides(string relativeTemplate, string expectedSource) + { + var action = ActionForTemplate(relativeTemplate); + + var id = action.Parameters.Single(p => p.Name == "id"); + id.BindingInfo.Should().NotBeNull(); + id.BindingInfo!.BindingSource!.Id.Should().Be(expectedSource); + } + + private static (WorkOrderController Controller, WorkOrderDetailReadModel Existing) NewController() + { + var existing = new WorkOrderDetailReadModel { Id = ExistingId, Title = "Leaking roof" }; + var service = new Mock(); + service.Setup(s => s.GetWorkOrderDetailAsync(ExistingId, It.IsAny())).ReturnsAsync(existing); + service.Setup(s => s.GetWorkOrderDetailAsync(UnknownId, It.IsAny())) + .ReturnsAsync((WorkOrderDetailReadModel?)null); + + var controller = new WorkOrderController( + service.Object, + Mock.Of(), + Mock.Of>()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + return (controller, existing); + } + + [Fact] + public async Task Existing_id_returns_the_same_work_order_through_both_routes() + { + var (controller, existing) = NewController(); + + var byQuery = await controller.GetWorkorderById(ExistingId); + var byRoute = await controller.GetWorkorderByRouteId(ExistingId); + + byQuery.Should().BeOfType().Which.Value.Should().BeSameAs(existing); + byRoute.Should().BeOfType().Which.Value.Should().BeSameAs(existing); + } + + [Fact] + public async Task Unknown_id_keeps_the_not_found_response_through_both_routes() + { + var (controller, _) = NewController(); + + foreach (var result in new[] + { + await controller.GetWorkorderById(UnknownId), + await controller.GetWorkorderByRouteId(UnknownId), + }) + { + var body = result.Should().BeOfType().Which.Value + .Should().BeOfType().Subject; + body.Status.Should().Be("Error"); + body.Message.Should().Be("ID not found!"); + } + } +} diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs index 6411337..1bb17e5 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs @@ -17,8 +17,8 @@ namespace Api.SeaHavenIndustries.Tests; /// combination is registered more than once by different actions. /// /// Routes are read from the ASP.NET Core action descriptor provider so the EXACT templates the -/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder, -/// GetWorkorderById) and the two shared controller-level base routes. +/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder) +/// and the two shared controller-level base routes. /// public class WorkOrderRouteContractTests { @@ -39,7 +39,7 @@ public class WorkOrderRouteContractTests /// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122). /// Every action is reachable under both api/WorkOrder and api/workorders; that base-route /// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes - /// come from 50 actions (Editworkorder and GetWorkorderById each bind two routes). + /// come from 51 actions (Editworkorder binds two routes). /// private static readonly HashSet ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal) { diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index c7928de..9c73034 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -208,9 +208,15 @@ namespace Api.SeaHavenIndustries.Controllers } } - [HttpGet("{id:int}")] + // SH-374: two actions, one per route, so each binds id from the source its route provides. + // A single action carrying both routes inferred id as [FromRoute] and the query route got 0. [HttpGet("GetWorkorderById")] - public async Task GetWorkorderById(int id) + public Task GetWorkorderById([FromQuery] int id) => GetWorkorderDetail(id); + + [HttpGet("{id:int}")] + public Task GetWorkorderByRouteId([FromRoute] int id) => GetWorkorderDetail(id); + + private async Task GetWorkorderDetail(int id) { try { From af441894d7777409c5be701ed0cb49bc8155aa70 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 20:27:26 -0300 Subject: [PATCH 10/10] fix(permissions): clean up overrides on user delete and converge concurrent override writes (SH-328) DeleteUserWithCascadeAsync never removed UserPermissionOverrides rows, and the FK on UserId is Restrict. Once an override was saved for a member, the admin hard-delete (DeleteUserAsync -> DeleteUserWithCascadeAsync) failed the foreign key inside the transaction and the controller surfaced it as a 400, so the user was never deleted. Add an explicit ExecuteDelete on UserPermissionOverrides before the user is removed, matching how UserRoles is already cleared in the same method (no schema change). SetOverrideAsync was check-then-insert on the composite key with no DbUpdateException handling, so two concurrent PUTs for the same (UserId, PermissionKey) let the loser violate PK_UserPermissionOverrides and return 500. Catch the conflict, detach the pending insert, and converge by updating the persisted row to the caller's requested state. --- .../TeamPermissionOverrideDataService.cs | 43 +++++++++++++------ .../Implementation/UserDataService.cs | 4 ++ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs b/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs index 954e8a4..b968a8b 100644 --- a/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs +++ b/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs @@ -63,22 +63,39 @@ public sealed class TeamPermissionOverrideDataService : ITeamPermissionOverrideD && permission.PermissionKey == permissionKey, cancellationToken); - if (existing is null) - { - await _context.UserPermissionOverrides.AddAsync( - new UserPermissionOverride - { - UserId = userId, - PermissionKey = permissionKey, - State = state - }, - cancellationToken); - } - else + if (existing is not null) { existing.State = state; + await _context.SaveChangesAsync(cancellationToken); + return; } - await _context.SaveChangesAsync(cancellationToken); + var addition = new UserPermissionOverride + { + UserId = userId, + PermissionKey = permissionKey, + State = state + }; + await _context.UserPermissionOverrides.AddAsync(addition, cancellationToken); + + try + { + await _context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + // A concurrent PUT inserted the same (UserId, PermissionKey) between our + // existence check and this save, violating PK_UserPermissionOverrides. + // Converge on the caller's intent by updating the persisted row. + _context.Entry(addition).State = EntityState.Detached; + + var winner = await _context.UserPermissionOverrides + .SingleAsync( + permission => permission.UserId == userId + && permission.PermissionKey == permissionKey, + cancellationToken); + winner.State = state; + await _context.SaveChangesAsync(cancellationToken); + } } } diff --git a/SeaHaven.DataServices/Implementation/UserDataService.cs b/SeaHaven.DataServices/Implementation/UserDataService.cs index 055f870..f5dbb70 100644 --- a/SeaHaven.DataServices/Implementation/UserDataService.cs +++ b/SeaHaven.DataServices/Implementation/UserDataService.cs @@ -135,6 +135,10 @@ namespace SeaHaven.DataServices.Implementation .Where(role => role.UserId == id) .ExecuteDeleteAsync(cancellationToken); + await _context.UserPermissionOverrides + .Where(permissionOverride => permissionOverride.UserId == id) + .ExecuteDeleteAsync(cancellationToken); + await _context.Users .Where(existingUser => existingUser.Id == id) .ExecuteDeleteAsync(cancellationToken);