From 7947e2e77defb6807bcce82f4769cb9e29192c9a Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 19:19:10 -0300 Subject: [PATCH 1/3] feat(dashboard): expose KPI counts (SH-353) --- .../Implementation/DashboardDataService.cs | 64 +++++++++++++++++++ .../Interfaces/IDashboardDataService.cs | 11 ++++ SeaHaven.Services/DTOs/DashboardDTOs.cs | 3 + .../Implementation/DashboardService.cs | 8 +++ 4 files changed, 86 insertions(+) diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index a56f74f..91c545d 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -88,6 +88,70 @@ namespace SeaHaven.DataServices.Implementation .ToListAsync(cancellationToken); } + public async Task GetKpiCountsAsync( + int? accountId, + string? dispatcherId, + DateOnly today, + CancellationToken cancellationToken) + { + var tomorrow = today.AddDays(1); + var dayAfterTomorrow = tomorrow.AddDays(1); + var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + if (accountId is int scopedAccountId) + workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId); + if (dispatcherId != null) + workOrders = workOrders.Where(w => w.AssignTo == dispatcherId); + + var scheduledTomorrow = await workOrders + .Where(w => w.ScheduledDate >= tomorrow.ToDateTime(TimeOnly.MinValue) + && w.ScheduledDate < dayAfterTomorrow.ToDateTime(TimeOnly.MinValue)) + .CountAsync(cancellationToken); + + var avetaPending = await workOrders + .Where(w => w.ScheduledDate >= today.ToDateTime(TimeOnly.MinValue) + && w.ScheduledDate < dayAfterTomorrow.ToDateTime(TimeOnly.MinValue) + && w.AvetaRequired + && 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")) + && !w.workOrderAttachments!.Any(attachment => + (attachment.IsDeleted == null || attachment.IsDeleted == false) + && attachment.Category == WorkOrderMediaCategory.Aveta)) + .CountAsync(cancellationToken); + + var pendingUplifts = _context.DispatchUpliftRequests + .AsNoTracking() + .Where(request => request.Status == "Pending" + && (request.IsDeleted == null || request.IsDeleted == false) + && request.Dispatch != null + && (request.Dispatch.IsDeleted == null || request.Dispatch.IsDeleted == false)); + if (accountId is int account) + { + pendingUplifts = pendingUplifts.Where(request => + (request.Dispatch!.WorkOrder != null && request.Dispatch.WorkOrder.AccountId == account) + || request.Dispatch.DispatchWorkOrders!.Any(link => + link.WorkOrder != null && link.WorkOrder.AccountId == account)); + } + if (dispatcherId != null) + { + pendingUplifts = pendingUplifts.Where(request => + (request.Dispatch!.WorkOrder != null && request.Dispatch.WorkOrder.AssignTo == dispatcherId) + || request.Dispatch.DispatchWorkOrders!.Any(link => + link.WorkOrder != null && link.WorkOrder.AssignTo == dispatcherId)); + } + + return new DashboardKpiCounts( + scheduledTomorrow, + await pendingUplifts.CountAsync(cancellationToken), + avetaPending); + } + public async Task> GetDispatcherWorkloadAsync( int? accountId, string? dispatcherId, diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs index 5348703..51ef7b5 100644 --- a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -8,6 +8,12 @@ namespace SeaHaven.DataServices.Interfaces int? accountId, CancellationToken cancellationToken); + Task GetKpiCountsAsync( + int? accountId, + string? dispatcherId, + DateOnly today, + CancellationToken cancellationToken); + Task> GetDashboardWorkOrdersAsync( int? accountId, string? dispatcherId, @@ -53,4 +59,9 @@ namespace SeaHaven.DataServices.Interfaces public int NotDispatched { get; set; } public int Completed { get; set; } } + + public sealed record DashboardKpiCounts( + int ScheduledTomorrow, + int PendingUplifts, + int AvetaPending); } diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index ef5c70d..3544ebb 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -100,6 +100,9 @@ namespace SeaHaven.Services.DTOs public int Open { get; set; } public int NotDispatched { get; set; } public int Completed { get; set; } + public int ScheduledTomorrow { get; set; } + public int PendingUplifts { get; set; } + public int AvetaPending { get; set; } public DashboardBreakdownDTO Breakdown { get; set; } = new(); public int DueCount { get; set; } public int CompletedDueCount { get; set; } diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index a4bdfce..3193c08 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -35,6 +35,11 @@ namespace SeaHaven.Services.Implementation throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken); + var kpis = await _dataService.GetKpiCountsAsync( + accountId, + dispatcherId, + DashboardBusinessTime.Today(), + cancellationToken); var workOrders = await _dataService.GetDashboardWorkOrdersAsync( accountId, dispatcherId, @@ -51,6 +56,9 @@ namespace SeaHaven.Services.Implementation Open = useMetrics ? metrics.Open : counts.Open, NotDispatched = useMetrics ? metrics.NotDispatched : counts.NotDispatched, Completed = useMetrics ? metrics.Completed : counts.Completed, + ScheduledTomorrow = kpis.ScheduledTomorrow, + PendingUplifts = kpis.PendingUplifts, + AvetaPending = kpis.AvetaPending, Breakdown = metrics.Breakdown, DueCount = metrics.DueCount, CompletedDueCount = metrics.CompletedDueCount, From 104439c23984fd4c9737bd979607fc2183a21f72 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 20:07:31 -0300 Subject: [PATCH 2/3] fix(dashboard): serialise KPI counts in Stats response (SH-353) GetKpiCountsAsync computes ScheduledTomorrow, PendingUplifts and AvetaPending and DashboardStatsDTO carries them, but the Stats endpoint's anonymous wire object never serialised them, so the tiles had nothing to read. Add scheduledTomorrow, pendingUplifts and avetaPending to the response. Add a DashboardServiceTests case covering GetKpiCountsAsync end to end: it asserts all three counts and pins the pendingUplifts semantics to Status "Pending" only, excluding "ChangesRequested" (which is back with the vendor, not awaiting an approval decision). --- .../DashboardServiceTests.cs | 47 +++++++++++++++++++ .../Controllers/DashboardController.cs | 3 ++ 2 files changed, 50 insertions(+) diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index 54b8886..1b2afb4 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -99,6 +99,53 @@ public class DashboardServiceTests stats.Completed.Should().Be(1); } + [Fact] + public async Task GetStatsAsync_ExposesKpiCounts_PendingUpliftsCountsPendingStatusOnly() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + var tomorrow = today.AddDays(1); + + // Scheduled-tomorrow tile: one account-1 order scheduled for tomorrow. + ctx.workOrders.Add(new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = tomorrow.ToDateTime(TimeOnly.MinValue) + }); + + // Aveta-pending tile: Aveta-required order in the today/tomorrow window + // with no Aveta attachment. Its Dispatch anchors the uplift requests below. + var avetaOrder = new WorkOrder + { + AccountId = 1, + AvetaRequired = true, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.ToDateTime(TimeOnly.MinValue) + }; + ctx.workOrders.Add(avetaOrder); + ctx.SaveChanges(); + + var dispatch = new Dispatch { WorkOrderId = avetaOrder.Id, IsDeleted = false }; + ctx.Set().Add(dispatch); + ctx.SaveChanges(); + + // Pending-uplifts tile: only Status == "Pending" is an outstanding uplift + // awaiting an approval decision. A "ChangesRequested" request has been sent + // back to the vendor and must NOT be counted (SH-353 review decision). + ctx.Set().AddRange( + new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "Pending", IsDeleted = false }, + new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "ChangesRequested", IsDeleted = false }); + ctx.SaveChanges(); + + var stats = await NewService(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); + + stats.ScheduledTomorrow.Should().Be(1); + stats.AvetaPending.Should().Be(1); + stats.PendingUplifts.Should().Be(1); + } + [Fact] public async Task GetStatsAsync_MissingScopeFailsClosed() { diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index 5551a5d..483f891 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -30,6 +30,9 @@ namespace Api.SeaHavenIndustries.Controllers open = stats.Open, notDispatched = stats.NotDispatched, completed = stats.Completed, + scheduledTomorrow = stats.ScheduledTomorrow, + pendingUplifts = stats.PendingUplifts, + avetaPending = stats.AvetaPending, breakdown = stats.Breakdown, dueCount = stats.DueCount, completedDueCount = stats.CompletedDueCount, From 93ecbbd410a67c280c5c47a222f5360ef3b1e94f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 20:25:45 -0300 Subject: [PATCH 3/3] test(dashboard): guard Stats wire object for KPI counts (SH-353) The added service test asserts DashboardStatsDTO, which already carried ScheduledTomorrow, PendingUplifts and AvetaPending, so it does not guard the regression that was actually shipped: the DashboardController Stats anonymous response dropping those keys and leaving the tiles empty. Add DashboardControllerTests.GetStats_SerialisesKpiCountsOnWireObject, which drives the controller and asserts the three keys are present on the serialised wire object, so re-dropping any of them fails a test. Also reword the pending-uplifts comment in DashboardServiceTests: the Pending-only-vs-ChangesRequested exclusion is the behaviour the code ships today, an open product call, not a settled review decision. --- .../DashboardControllerTests.cs | 54 +++++++++++++++++++ .../DashboardServiceTests.cs | 6 ++- 2 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs diff --git a/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs b/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs new file mode 100644 index 0000000..4ef3b58 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs @@ -0,0 +1,54 @@ +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class DashboardControllerTests +{ + private static object Prop(object source, string name) => + source.GetType().GetProperty(name)!.GetValue(source)!; + + private static DashboardController NewController(Mock service) => + new(service.Object) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + + // Guards the Stats wire object, not the service DTO. GetKpiCountsAsync and + // DashboardStatsDTO already carried these three counts; the regression this + // pins is the controller's anonymous response silently dropping them, which + // is what left the tiles with nothing to read. + [Fact] + public async Task GetStats_SerialisesKpiCountsOnWireObject() + { + var service = new Mock(); + service.Setup(s => s.GetStatsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new DashboardStatsDTO + { + ScheduledTomorrow = 3, + PendingUplifts = 5, + AvetaPending = 7 + }); + + var result = await NewController(service).GetStats( + new DashboardStatsQueryDTO(), CancellationToken.None); + + var body = result.Should().BeOfType().Subject.Value!; + ((int)Prop(body, "scheduledTomorrow")).Should().Be(3); + ((int)Prop(body, "pendingUplifts")).Should().Be(5); + ((int)Prop(body, "avetaPending")).Should().Be(7); + } +} diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index 1b2afb4..1d4e8ec 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -131,8 +131,10 @@ public class DashboardServiceTests ctx.SaveChanges(); // Pending-uplifts tile: only Status == "Pending" is an outstanding uplift - // awaiting an approval decision. A "ChangesRequested" request has been sent - // back to the vendor and must NOT be counted (SH-353 review decision). + // awaiting an approval decision. This test pins the behaviour the code + // ships today: a "ChangesRequested" request has been sent back to the + // vendor, so it is NOT counted. Whether pendingUplifts should also include + // ChangesRequested is an open product call, not a settled review decision. ctx.Set().AddRange( new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "Pending", IsDeleted = false }, new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "ChangesRequested", IsDeleted = false });