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 de6a9a6..074f36d 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -99,6 +99,55 @@ 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. 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 }); + 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, 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 56b4c8a..f17e301 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,