From 028908bd5a1d53628230ddf9016baae400aa442e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 20:51:44 -0300 Subject: [PATCH 1/4] fix(board-search): make the Advanced Filters date range narrow results (SH-391) An explicit date range let every undated, non-terminal work order through, so Unassigned plus a range still returned all ~1,780 unassigned rows. The range now matches Schedule On, or the Target Week for week-only rows (overlap), the same date the weekly board groups by. Undated rows are added only when the caller sends includeDateless, which the client uses for searches with no range selected and for the Unassigned queue. --- .../Helpers/WorkOrderBoardQueryFilters.cs | 20 ++- .../WorkOrderAdvancedSearchDataService.cs | 3 +- .../WorkOrderAdvancedSearchModels.cs | 3 +- .../DTOs/WorkOrderBoardRequestDTOs.cs | 6 + .../WorkOrderAdvancedSearchService.cs | 3 +- .../WorkOrderAdvancedSearchDateRangeTests.cs | 162 ++++++++++++++++++ .../WorkOrderBoardSearchTests.cs | 14 +- 7 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index 71c4d94..b665f2c 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -220,13 +220,26 @@ namespace SeaHaven.DataServices.Helpers && w.LifecycleStatus != LifecycleStatus.Completed && w.LifecycleStatus != LifecycleStatus.Canceled); + /// + /// Narrows to rows whose Schedule On date falls in the range, or, for + /// week-only rows, whose Target Week overlaps it: the same date the + /// weekly board groups by (SH-391). Rows with no date never match a + /// range; they are added only when + /// is set, which callers send for searches with no date range selected. + /// Completed and canceled dateless rows stay out, matching + /// . + /// public static IQueryable ApplyDateRangeFilter( IQueryable query, DateOnly dateFrom, - DateOnly dateTo) + DateOnly dateTo, + bool includeDateless = false) { var fromDate = dateFrom.ToDateTime(TimeOnly.MinValue).Date; var toDate = dateTo.ToDateTime(TimeOnly.MinValue).Date; + // A target week (stored as its Monday) overlaps the range when it + // starts by the range end and its Sunday is on or after the range start. + var earliestWeekStart = dateFrom.DayNumber >= 6 ? dateFrom.AddDays(-6) : dateFrom; return query.Where(w => (w.ScheduledDate != null @@ -234,9 +247,10 @@ namespace SeaHaven.DataServices.Helpers && w.ScheduledDate.Value.Date <= toDate) || (w.ScheduleWeekOnly == true && w.TargetWeek != null - && w.TargetWeek >= dateFrom + && w.TargetWeek >= earliestWeekStart && w.TargetWeek <= dateTo) - || (w.ScheduledDate == null + || (includeDateless + && w.ScheduledDate == null && w.LifecycleStatus != LifecycleStatus.Completed && w.LifecycleStatus != LifecycleStatus.Canceled)); } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs index eeb28c5..74b5fb2 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs @@ -22,7 +22,8 @@ namespace SeaHaven.DataServices.Implementation if (query.UnscheduledOnly) baseQuery = WorkOrderBoardQueryFilters.ApplyUnscheduledOnlyFilter(baseQuery); else - baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo); + baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter( + baseQuery, query.DateFrom, query.DateTo, query.IncludeDateless); baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites); baseQuery = WorkOrderBoardQueryFilters.ApplyRegionFilter(baseQuery, query.Regions); baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue); diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs index 2c0a627..4ee175a 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs @@ -32,7 +32,8 @@ namespace SeaHaven.DataServices.Interfaces int PageSize, string SortBy, string SortDir, - int? AccountId = null); + int? AccountId = null, + bool IncludeDateless = false); public record WorkOrderAdvancedSearchResult( IReadOnlyList Rows, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 8f2c9df..e7c3dc6 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -87,6 +87,12 @@ namespace SeaHaven.Services.DTOs /// filter; every other facet still applies. /// public bool UnscheduledOnly { get; set; } + /// + /// When true, rows with no date (no Schedule On) are returned alongside + /// the in-range rows. Sent for searches with no date range selected; an + /// explicit range leaves it false so the range strictly narrows (SH-391). + /// + public bool IncludeDateless { get; set; } public List? Sites { get; set; } public List? Regions { get; set; } public List? Types { get; set; } diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs index fa97dc5..c428da7 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -72,7 +72,8 @@ namespace SeaHaven.Services.Implementation pageSize, sortBy, query.SortDir ?? "asc", - accountId); + accountId, + query.IncludeDateless); var result = await _searchDataService.SearchAsync(dataQuery); var utcNow = DateTime.UtcNow; diff --git a/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs b/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs new file mode 100644 index 0000000..4bacc93 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs @@ -0,0 +1,162 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +/// +/// SH-391: an Advanced Filters date range narrows GET /board/search to rows +/// whose Schedule On (or week-only Target Week) is in the range. Rows with no +/// date come back only when the caller asks for them (no range selected). +/// +public class WorkOrderAdvancedSearchDateRangeTests +{ + private static readonly DateOnly RangeFrom = new(2026, 9, 21); + private static readonly DateOnly RangeTo = new(2026, 9, 25); + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrderAdvancedSearchService CreateService(ApplicationDbContext context) + => new(new WorkOrderAdvancedSearchDataService(context), WorkOrderAccountTestHelpers.Resolver(context)); + + private static WorkOrder Wo( + int id, + DateTime? scheduled, + string? assignTo = null, + LifecycleStatus status = LifecycleStatus.Scheduled, + DateOnly? targetWeek = null, + int? accountId = null) + => new() + { + Id = id, + InternalWONumber = $"1000000000{id}", + AssignTo = assignTo, + ScheduledDate = scheduled, + ScheduleWeekOnly = targetWeek.HasValue, + TargetWeek = targetWeek, + LifecycleStatus = status, + AccountId = accountId, + istemplate = false + }; + + private static WorkOrderAdvancedSearchQueryDto UnassignedInRange(bool includeDateless = false) => new() + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = RangeFrom, + DateTo = RangeTo, + Dispatchers = new List { "__unassigned__" }, + IncludeDateless = includeDateless, + PageSize = 50 + }; + + [Fact] + public async Task SearchAsync_UnassignedWithRange_ReturnsOnlyUnassignedRowsScheduledInRange() + { + await using var context = CreateContext(); + context.workOrders.AddRange( + Wo(1, new DateTime(2026, 9, 23)), + Wo(2, new DateTime(2026, 8, 4)), + Wo(3, null, status: LifecycleStatus.Incomplete), + Wo(4, new DateTime(2026, 9, 24), assignTo: "user-a"), + Wo(5, new DateTime(2026, 10, 2))); + await context.SaveChangesAsync(); + + var result = await CreateService(context).SearchAsync( + UnassignedInRange(), WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(1, result.TotalCount); + Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray()); + } + + [Fact] + public async Task SearchAsync_RangeWithoutDispatcherFilter_ExcludesUndatedRows() + { + await using var context = CreateContext(); + context.workOrders.AddRange( + Wo(1, new DateTime(2026, 9, 22), assignTo: "user-a"), + Wo(2, null, assignTo: "user-a", status: LifecycleStatus.Incomplete), + Wo(3, new DateTime(2026, 9, 14), assignTo: "user-a", status: LifecycleStatus.Incomplete)); + await context.SaveChangesAsync(); + + var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = RangeFrom, + DateTo = RangeTo + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray()); + } + + [Fact] + public async Task SearchAsync_WeekOnlyRow_MatchesRangeThatOverlapsItsTargetWeek() + { + await using var context = CreateContext(); + context.workOrders.AddRange( + Wo(1, null, targetWeek: new DateOnly(2026, 9, 21)), + Wo(2, null, targetWeek: new DateOnly(2026, 9, 14)), + Wo(3, null, targetWeek: new DateOnly(2026, 9, 28))); + await context.SaveChangesAsync(); + + // Wed–Fri of the week of 9/21: the week-only row for that week overlaps it. + var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(2026, 9, 23), + DateTo = RangeTo, + Dispatchers = new List { "__unassigned__" } + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray()); + } + + [Fact] + public async Task SearchAsync_IncludeDateless_AddsOpenUndatedRowsToTheRange() + { + await using var context = CreateContext(); + context.workOrders.AddRange( + Wo(1, new DateTime(2026, 9, 23)), + Wo(2, new DateTime(2026, 8, 4)), + Wo(3, null, status: LifecycleStatus.Incomplete), + Wo(4, null, status: LifecycleStatus.Completed), + Wo(5, null, status: LifecycleStatus.Canceled)); + await context.SaveChangesAsync(); + + var result = await CreateService(context).SearchAsync( + UnassignedInRange(includeDateless: true), WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(new[] { 1, 3 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray()); + } + + [Fact] + public async Task SearchAsync_RangeForAccountUser_NeverReturnsAnotherAccountsRows() + { + await using var context = CreateContext(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A"); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B"); + context.workOrders.AddRange( + Wo(1, new DateTime(2026, 9, 23), accountId: 1), + Wo(2, new DateTime(2026, 9, 23), accountId: 2), + Wo(3, null, status: LifecycleStatus.Incomplete, accountId: 2), + Wo(4, null, targetWeek: new DateOnly(2026, 9, 21), accountId: 2)); + await context.SaveChangesAsync(); + + var user = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"); + var strict = await CreateService(context).SearchAsync(UnassignedInRange(), user, "disp-1"); + var withDateless = await CreateService(context).SearchAsync( + UnassignedInRange(includeDateless: true), user, "disp-1"); + + Assert.Equal(new[] { 1 }, strict.Items.Select(i => i.Id).ToArray()); + Assert.Equal(new[] { 1 }, withDateless.Items.Select(i => i.Id).ToArray()); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs index cd47e05..6a1f2e3 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -319,6 +319,7 @@ public class WorkOrderAdvancedSearchServiceTests DateFrom = new DateOnly(2000, 1, 1), DateTo = new DateOnly(2099, 12, 31), Dispatchers = new List { "__unassigned__" }, + IncludeDateless = true, Page = 0, PageSize = pageSize }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); @@ -380,6 +381,7 @@ public class WorkOrderAdvancedSearchServiceTests DateFrom = new DateOnly(2000, 1, 1), DateTo = new DateOnly(2099, 12, 31), Dispatchers = new List { "__unassigned__" }, + IncludeDateless = true, Page = 0, PageSize = 50 }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); @@ -436,6 +438,7 @@ public class WorkOrderAdvancedSearchServiceTests DateFrom = new DateOnly(2000, 1, 1), DateTo = new DateOnly(2099, 12, 31), Dispatchers = new List { "__unassigned__" }, + IncludeDateless = true, Page = 0, PageSize = 2 }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); @@ -446,6 +449,7 @@ public class WorkOrderAdvancedSearchServiceTests DateFrom = new DateOnly(2000, 1, 1), DateTo = new DateOnly(2099, 12, 31), Dispatchers = new List { "__unassigned__" }, + IncludeDateless = true, Page = 1, PageSize = 2 }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); @@ -773,7 +777,7 @@ public class WorkOrderAdvancedSearchServiceTests } [Fact] - public async Task SearchAsync_IncludesUnscheduledRows() + public async Task SearchAsync_IncludeDateless_IncludesUnscheduledRows() { await using var context = CreateContext(); var weekStart = new DateOnly(2026, 6, 22); @@ -803,7 +807,8 @@ public class WorkOrderAdvancedSearchServiceTests { DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, DateFrom = weekStart, - DateTo = weekStart.AddDays(4) + DateTo = weekStart.AddDays(4), + IncludeDateless = true }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); Assert.Equal(2, result.TotalCount); @@ -1193,9 +1198,10 @@ public class WorkOrderAdvancedSearchServiceTests Assert.Equal( withFlagAbsent.Items.Select(i => i.Id).ToList(), withFlagFalse.Items.Select(i => i.Id).ToList()); - Assert.Equal(2, withFlagFalse.TotalCount); + // SH-391: an explicit range narrows strictly, so the undated row 3 is out. + Assert.Equal(1, withFlagFalse.TotalCount); Assert.Contains(withFlagFalse.Items, i => i.Id == 1); - Assert.Contains(withFlagFalse.Items, i => i.Id == 3); + Assert.DoesNotContain(withFlagFalse.Items, i => i.Id == 3); Assert.DoesNotContain(withFlagFalse.Items, i => i.Id == 2); } From 9d5798437d09f867879466bdf8d4b0d4d02c41be Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:11:20 -0300 Subject: [PATCH 2/4] test(workorders): pin the week-only date range rule shared with the board page (SH-391) --- .../WorkOrderAdvancedSearchDateRangeTests.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs b/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs index 4bacc93..634c12f 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs @@ -49,6 +49,12 @@ public class WorkOrderAdvancedSearchDateRangeTests istemplate = false }; + private static WorkOrder NotWeekOnly(WorkOrder wo) + { + wo.ScheduleWeekOnly = false; + return wo; + } + private static WorkOrderAdvancedSearchQueryDto UnassignedInRange(bool includeDateless = false) => new() { DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, @@ -105,7 +111,12 @@ public class WorkOrderAdvancedSearchDateRangeTests context.workOrders.AddRange( Wo(1, null, targetWeek: new DateOnly(2026, 9, 21)), Wo(2, null, targetWeek: new DateOnly(2026, 9, 14)), - Wo(3, null, targetWeek: new DateOnly(2026, 9, 28))); + Wo(3, null, targetWeek: new DateOnly(2026, 9, 28)), + // Week-only with a leftover date outside the range: the week decides, + // as on the board page (SH-391). + Wo(4, new DateTime(2026, 10, 2), targetWeek: new DateOnly(2026, 9, 21)), + // Not week-only: its date decides, so a target week does not bring it in. + NotWeekOnly(Wo(5, new DateTime(2026, 10, 2), targetWeek: new DateOnly(2026, 9, 21)))); await context.SaveChangesAsync(); // Wed–Fri of the week of 9/21: the week-only row for that week overlaps it. @@ -117,7 +128,7 @@ public class WorkOrderAdvancedSearchDateRangeTests Dispatchers = new List { "__unassigned__" } }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); - Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray()); + Assert.Equal(new[] { 1, 4 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray()); } [Fact] From 4876fa27175dee7254b08ae164265999977bb34e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:02:28 -0300 Subject: [PATCH 3/4] feat(dashboard): count open unassigned work orders for the period (SH-392) GET /dashboard/stats now returns unassigned: open (not Completed or Canceled) work orders with no dispatcher in the selected period, within the caller's server-derived account scope. It uses the same filters as the Unassigned board search, so the Dashboard number equals the list it drills into. A dispatcher-scoped Dashboard has no unassigned work and returns 0. --- .../DashboardControllerTests.cs | 4 +- .../DashboardUnassignedTests.cs | 176 ++++++++++++++++++ .../Controllers/DashboardController.cs | 1 + .../Implementation/DashboardDataService.cs | 28 +++ .../Interfaces/IDashboardDataService.cs | 12 ++ SeaHaven.Services/DTOs/DashboardDTOs.cs | 2 + .../Implementation/DashboardService.cs | 7 + 7 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs diff --git a/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs b/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs index 4ef3b58..ddd6082 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs @@ -40,7 +40,8 @@ public class DashboardControllerTests { ScheduledTomorrow = 3, PendingUplifts = 5, - AvetaPending = 7 + AvetaPending = 7, + Unassigned = 11 }); var result = await NewController(service).GetStats( @@ -50,5 +51,6 @@ public class DashboardControllerTests ((int)Prop(body, "scheduledTomorrow")).Should().Be(3); ((int)Prop(body, "pendingUplifts")).Should().Be(5); ((int)Prop(body, "avetaPending")).Should().Be(7); + ((int)Prop(body, "unassigned")).Should().Be(11); } } diff --git a/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs b/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs new file mode 100644 index 0000000..dbdbc7e --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs @@ -0,0 +1,176 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// SH-392: the Dashboard "Unassigned" count is open work orders with no +/// dispatcher in the selected period, and it equals the Work Orders list the +/// tile drills into (GET /board/search, Dispatcher = Unassigned). +/// +public class DashboardUnassignedTests +{ + private static readonly DateOnly From = new(2026, 9, 21); + private static readonly DateOnly To = new(2026, 9, 25); + + private static readonly List OpenStatuses = Enum.GetValues() + .Where(s => s != LifecycleStatus.Completed && s != LifecycleStatus.Canceled) + .ToList(); + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrderAccountResolver Resolver(ApplicationDbContext ctx) + => new(new AccountDataService(ctx), new LocationDataService(ctx)); + + private static DashboardService NewDashboard(ApplicationDbContext ctx) + => new(new DashboardDataService(ctx), Resolver(ctx)); + + private static WorkOrderAdvancedSearchService NewSearch(ApplicationDbContext ctx) + => new(new WorkOrderAdvancedSearchDataService(ctx), Resolver(ctx)); + + private static ClaimsPrincipal AccountUser(int accountId, string role = "Admin", string userId = "admin-1") + => new(new ClaimsIdentity(new[] + { + new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Role, role) + }, "test")); + + private static WorkOrder Wo( + int id, + DateTime? scheduled, + string? assignTo = null, + LifecycleStatus status = LifecycleStatus.Scheduled, + DateOnly? targetWeek = null, + int accountId = 1) + => new() + { + Id = id, + AccountId = accountId, + InternalWONumber = $"2000000{id:0000}", + AssignTo = assignTo, + ScheduledDate = scheduled, + ScheduleWeekOnly = targetWeek.HasValue, + TargetWeek = targetWeek, + LifecycleStatus = status, + istemplate = false + }; + + private static void Seed(ApplicationDbContext ctx) + { + ctx.workOrders.AddRange( + Wo(1, new DateTime(2026, 9, 22)), // counted + Wo(2, new DateTime(2026, 9, 23), assignTo: ""), // counted: blank assignee + Wo(3, null, targetWeek: new DateOnly(2026, 9, 21)), // counted: week-only in range + Wo(4, new DateTime(2026, 9, 24), assignTo: "disp-1"), // assigned + Wo(5, new DateTime(2026, 9, 24), status: LifecycleStatus.Completed), + Wo(6, new DateTime(2026, 9, 24), status: LifecycleStatus.Canceled), + Wo(7, new DateTime(2026, 8, 4)), // out of range + Wo(8, null, status: LifecycleStatus.Incomplete), // undated + Wo(9, new DateTime(2026, 9, 22), accountId: 2), // other tenant + Wo(10, null, status: LifecycleStatus.Incomplete, accountId: 2)); // other tenant, undated + ctx.SaveChanges(); + } + + [Fact] + public async Task GetStatsAsync_Range_CountsOpenUnassignedScheduledInPeriod() + { + using var ctx = NewContext(); + Seed(ctx); + + var stats = await NewDashboard(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None); + + stats.Unassigned.Should().Be(3); + } + + [Fact] + public async Task GetStatsAsync_AllTime_CountsEveryOpenUnassignedIncludingUndated() + { + using var ctx = NewContext(); + Seed(ctx); + + var stats = await NewDashboard(ctx).GetStatsAsync( + AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None); + + // 1, 2, 3, 7 (out of any week but open) and 8 (undated). + stats.Unassigned.Should().Be(5); + } + + [Fact] + public async Task GetStatsAsync_Unassigned_NeverCountsAnotherTenantsWorkOrders() + { + using var ctx = NewContext(); + Seed(ctx); + + var accountTwo = await NewDashboard(ctx).GetStatsAsync( + AccountUser(2), new DashboardStatsQueryDTO(), CancellationToken.None); + var accountTwoInRange = await NewDashboard(ctx).GetStatsAsync( + AccountUser(2), new DashboardStatsQueryDTO { DateFrom = From, DateTo = To }, CancellationToken.None); + + accountTwo.Unassigned.Should().Be(2); + accountTwoInRange.Unassigned.Should().Be(1); + } + + [Fact] + public async Task GetStatsAsync_DispatcherScope_HasNoUnassignedWork() + { + using var ctx = NewContext(); + Seed(ctx); + + var dispatcher = await NewDashboard(ctx).GetStatsAsync( + AccountUser(1, role: "Dispatcher", userId: "disp-1"), + new DashboardStatsQueryDTO(), + CancellationToken.None); + var adminPickingDispatcher = await NewDashboard(ctx).GetStatsAsync( + AccountUser(1), + new DashboardStatsQueryDTO { DispatcherId = "disp-1" }, + CancellationToken.None); + + dispatcher.Unassigned.Should().Be(0); + adminPickingDispatcher.Unassigned.Should().Be(0); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task GetStatsAsync_UnassignedMatchesTheDrillDownList(bool withRange) + { + using var ctx = NewContext(); + Seed(ctx); + var user = AccountUser(1); + + var stats = await NewDashboard(ctx).GetStatsAsync( + user, + withRange ? new DashboardStatsQueryDTO { DateFrom = From, DateTo = To } : new DashboardStatsQueryDTO(), + CancellationToken.None); + + // What the Work Orders board sends for the tile's drill-down link. + var list = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = withRange ? From : new DateOnly(2000, 1, 1), + DateTo = withRange ? To : new DateOnly(2099, 12, 31), + IncludeDateless = !withRange, + Dispatchers = new List { "__unassigned__" }, + Statuses = OpenStatuses, + PageSize = 200 + }, user, "admin-1"); + + stats.Unassigned.Should().Be(list.TotalCount); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index b53c4bf..e40c9fd 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -33,6 +33,7 @@ namespace Api.SeaHavenIndustries.Controllers scheduledTomorrow = stats.ScheduledTomorrow, pendingUplifts = stats.PendingUplifts, avetaPending = stats.AvetaPending, + unassigned = stats.Unassigned, breakdown = stats.Breakdown, dueCount = stats.DueCount, completedDueCount = stats.CompletedDueCount, diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index a6c03f2..7f9767f 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -39,6 +39,34 @@ namespace SeaHaven.DataServices.Implementation }; } + private static readonly IReadOnlyList OpenLifecycleStatuses = Enum + .GetValues() + .Where(s => s != LifecycleStatus.Completed && s != LifecycleStatus.Canceled) + .ToList(); + + public async Task CountOpenUnassignedAsync( + int? accountId, + string? dispatcherId, + DateOnly? dateFrom, + DateOnly? dateTo, + CancellationToken cancellationToken) + { + // A dispatcher-scoped Dashboard has no unassigned work by definition. + if (dispatcherId != null) + return 0; + + var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); + if (accountId is int scopedAccountId) + query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, scopedAccountId); + query = WorkOrderBoardQueryFilters.ApplyDispatcherFilter( + query, new[] { "__unassigned__" }, myWorkOrders: false, currentUserId: null); + query = WorkOrderBoardQueryFilters.ApplyStatusFilter(query, OpenLifecycleStatuses); + if (dateFrom is DateOnly from && dateTo is DateOnly to) + query = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(query, from, to); + + return await query.CountAsync(cancellationToken); + } + public async Task> GetDashboardWorkOrdersAsync( int? accountId, string? dispatcherId, diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs index 51ef7b5..d55bdea 100644 --- a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -14,6 +14,18 @@ namespace SeaHaven.DataServices.Interfaces DateOnly today, CancellationToken cancellationToken); + /// + /// Open work orders with no dispatcher in the period, counted with the + /// same filters as GET /board/search for Dispatcher=Unassigned (SH-392), + /// so the Dashboard number matches the list it drills into. + /// + Task CountOpenUnassignedAsync( + int? accountId, + string? dispatcherId, + DateOnly? dateFrom, + DateOnly? dateTo, + CancellationToken cancellationToken); + Task> GetDashboardWorkOrdersAsync( int? accountId, string? dispatcherId, diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index 9cf76c0..146c491 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -106,6 +106,8 @@ namespace SeaHaven.Services.DTOs public int ScheduledTomorrow { get; set; } public int PendingUplifts { get; set; } public int AvetaPending { get; set; } + /// Open work orders with no dispatcher in the selected period (SH-392). + public int Unassigned { 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 02642dc..e528b97 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -43,6 +43,12 @@ namespace SeaHaven.Services.Implementation query.DateTo, cancellationToken); var metrics = DashboardMetrics.Calculate(workOrders, DashboardBusinessTime.Today()); + var unassigned = await _dataService.CountOpenUnassignedAsync( + accountId, + dispatcherId, + query.DateFrom, + query.DateTo, + cancellationToken); var useMetrics = query.DateFrom.HasValue && query.DateTo.HasValue || dispatcherId is not null; @@ -55,6 +61,7 @@ namespace SeaHaven.Services.Implementation ScheduledTomorrow = kpis.ScheduledTomorrow, PendingUplifts = kpis.PendingUplifts, AvetaPending = kpis.AvetaPending, + Unassigned = unassigned, Breakdown = metrics.Breakdown, DueCount = metrics.DueCount, CompletedDueCount = metrics.CompletedDueCount, From 7297e302129f8dbec0c29a74b9db91f6cca65c1d Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:45:06 -0300 Subject: [PATCH 4/4] fix(dashboard): count All time over the same window the board lists (SH-392) The Unassigned drill-down opens the board with no range, which searches 2000-01-01..2099-12-31 plus undated work. The All time count had no bounds, so a work order dated outside that window was counted but not listed. The count now uses the same window. --- .../DashboardUnassignedTests.cs | 3 +++ .../Helpers/WorkOrderBoardQueryFilters.cs | 8 ++++++++ .../Implementation/DashboardDataService.cs | 11 +++++++++-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs b/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs index dbdbc7e..b3cc03e 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardUnassignedTests.cs @@ -152,6 +152,9 @@ public class DashboardUnassignedTests { using var ctx = NewContext(); Seed(ctx); + // Dated outside the board's all-weeks window: neither side may count them. + ctx.workOrders.AddRange(Wo(11, new DateTime(1999, 12, 31)), Wo(12, new DateTime(2100, 1, 1))); + ctx.SaveChanges(); var user = AccountUser(1); var stats = await NewDashboard(ctx).GetStatsAsync( diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index b665f2c..d4810ca 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -5,6 +5,14 @@ namespace SeaHaven.DataServices.Helpers { public static class WorkOrderBoardQueryFilters { + /// + /// The window the Work Orders board searches when no date range is selected + /// (client ADVANCED_SEARCH_ALL_WEEKS_FROM/TO), used with includeDateless. + /// Counts that drill into that list use it too, so both see the same rows. + /// + public static readonly DateOnly AllWeeksFrom = new(2000, 1, 1); + public static readonly DateOnly AllWeeksTo = new(2099, 12, 31); + public static IQueryable ApplyBaseScope(IQueryable query) => query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs index 7f9767f..02362a2 100644 --- a/SeaHaven.DataServices/Implementation/DashboardDataService.cs +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -61,8 +61,15 @@ namespace SeaHaven.DataServices.Implementation query = WorkOrderBoardQueryFilters.ApplyDispatcherFilter( query, new[] { "__unassigned__" }, myWorkOrders: false, currentUserId: null); query = WorkOrderBoardQueryFilters.ApplyStatusFilter(query, OpenLifecycleStatuses); - if (dateFrom is DateOnly from && dateTo is DateOnly to) - query = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(query, from, to); + // All time counts what the board lists with no range: every week plus + // undated work, so the tile and its drill-down agree (SH-392). + query = dateFrom is DateOnly from && dateTo is DateOnly to + ? WorkOrderBoardQueryFilters.ApplyDateRangeFilter(query, from, to) + : WorkOrderBoardQueryFilters.ApplyDateRangeFilter( + query, + WorkOrderBoardQueryFilters.AllWeeksFrom, + WorkOrderBoardQueryFilters.AllWeeksTo, + includeDateless: true); return await query.CountAsync(cancellationToken); }