diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index 44f965b..3d877e0 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -173,6 +173,14 @@ namespace SeaHaven.DataServices.Helpers || (w.Problem ?? "").ToLower().Contains(pm))); } + /// + /// Restricts to unscheduled rows. A null ScheduledDate is the canonical + /// unscheduled representation; replaces the date-range filter when the + /// unscheduled-only search mode is active. + /// + public static IQueryable ApplyUnscheduledOnlyFilter(IQueryable query) + => query.Where(w => w.ScheduledDate == null); + public static IQueryable ApplyDateRangeFilter( IQueryable query, DateOnly dateFrom, diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs index 2eebc30..ceac856 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs @@ -19,7 +19,10 @@ namespace SeaHaven.DataServices.Implementation var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); if (query.AccountId.HasValue) baseQuery = WorkOrderBoardQueryFilters.ApplyAccountScope(baseQuery, query.AccountId.Value); - baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo); + if (query.UnscheduledOnly) + baseQuery = WorkOrderBoardQueryFilters.ApplyUnscheduledOnlyFilter(baseQuery); + else + baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo); baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites); baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue); baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter( @@ -61,14 +64,14 @@ namespace SeaHaven.DataServices.Implementation return field switch { "wonumber" or "woNumber" => descending - ? query.OrderByDescending(w => w.InternalWONumber) - : query.OrderBy(w => w.InternalWONumber), + ? query.OrderByDescending(w => w.InternalWONumber).ThenBy(w => w.Id) + : query.OrderBy(w => w.InternalWONumber).ThenBy(w => w.Id), "duedate" or "dueDate" => descending - ? query.OrderByDescending(w => w.DueDate).ThenBy(w => w.InternalWONumber) - : query.OrderBy(w => w.DueDate).ThenBy(w => w.InternalWONumber), + ? query.OrderByDescending(w => w.DueDate).ThenBy(w => w.InternalWONumber).ThenBy(w => w.Id) + : query.OrderBy(w => w.DueDate).ThenBy(w => w.InternalWONumber).ThenBy(w => w.Id), _ => descending - ? query.OrderByDescending(w => w.ScheduledDate).ThenByDescending(w => w.InternalWONumber) - : query.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber) + ? query.OrderByDescending(w => w.ScheduledDate).ThenByDescending(w => w.InternalWONumber).ThenBy(w => w.Id) + : query.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber).ThenBy(w => w.Id) }; } } diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs index 44889dc..576af7a 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs @@ -7,6 +7,7 @@ namespace SeaHaven.DataServices.Interfaces string? Search, DateOnly DateFrom, DateOnly DateTo, + bool UnscheduledOnly, IReadOnlyList? Sites, IReadOnlyList? Types, bool Overdue, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index e53adb1..8c04373 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -66,6 +66,12 @@ namespace SeaHaven.Services.DTOs public WorkOrderAdvancedSearchDatePreset? DatePreset { get; set; } public DateOnly? DateFrom { get; set; } public DateOnly? DateTo { get; set; } + /// + /// When true, only unscheduled rows (ScheduledDate is null) are returned. + /// Takes precedence over DatePreset/DateFrom/DateTo for the scheduled-date + /// filter; every other facet still applies. + /// + public bool UnscheduledOnly { get; set; } public List? Sites { get; set; } public List? Types { get; set; } /// When true, include past-due (isPastDue) rows. Combined with Types via OR. diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs index 071acf0..6c4e3da 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -32,10 +32,14 @@ namespace SeaHaven.Services.Implementation var page = query.Page < 0 ? 0 : query.Page; var pageSize = query.PageSize < 1 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize); - var dateRange = WorkOrderSearchDateRangeResolver.Resolve( - query.DatePreset, - query.DateFrom, - query.DateTo); + // Unscheduled-only mode ignores the scheduled-date range entirely; + // the inert wide range keeps the data contract satisfied. + var dateRange = query.UnscheduledOnly + ? new WorkOrderSearchDateRange(DateOnly.MinValue, DateOnly.MaxValue) + : WorkOrderSearchDateRangeResolver.Resolve( + query.DatePreset, + query.DateFrom, + query.DateTo); var sortBy = WorkOrderBoardSortFields.NormalizeOrThrow(query.SortBy); @@ -43,6 +47,7 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardSearchFilter.NormalizeSearch(query.Search), dateRange.From, dateRange.To, + query.UnscheduledOnly, query.Sites, query.Types, query.Overdue, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs index 9c8dd75..d1d4890 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -1048,4 +1048,381 @@ public class WorkOrderAdvancedSearchServiceTests Assert.Equal(1, result.Items.Single().Id); Assert.True(result.Items.Single().AvetaRequired); } + + [Fact] + public async Task SearchAsync_UnscheduledOnly_ReturnsOnlyUndatedRows() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000001", + istemplate = false + }, + new WorkOrder + { + Id = 2, + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete, + InternalWONumber = "10000000002", + istemplate = false + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService( + dataService, + WorkOrderAccountTestHelpers.Resolver(context)); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = weekStart, + DateTo = weekStart.AddDays(4), + UnscheduledOnly = true + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(1, result.TotalCount); + Assert.Single(result.Items); + Assert.Equal(2, result.Items.Single().Id); + Assert.Null(result.Items.Single().ScheduledDate); + } + + [Fact] + public async Task SearchAsync_UnscheduledOnlyFalse_MatchesDefaultRangeSearch() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000001", + istemplate = false + }, + new WorkOrder + { + Id = 2, + ScheduledDate = new DateTime(2026, 8, 4), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000002", + istemplate = false + }, + new WorkOrder + { + Id = 3, + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete, + InternalWONumber = "10000000003", + istemplate = false + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService( + dataService, + WorkOrderAccountTestHelpers.Resolver(context)); + + var withFlagFalse = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = weekStart, + DateTo = weekStart.AddDays(4), + UnscheduledOnly = false + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + var withFlagAbsent = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = weekStart, + DateTo = weekStart.AddDays(4) + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(withFlagAbsent.TotalCount, withFlagFalse.TotalCount); + Assert.Equal( + withFlagAbsent.Items.Select(i => i.Id).ToList(), + withFlagFalse.Items.Select(i => i.Id).ToList()); + Assert.Equal(2, 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 == 2); + } + + [Fact] + public async Task SearchAsync_UnscheduledOnly_IgnoresScheduledDateRange() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000001", + istemplate = false + }, + new WorkOrder + { + Id = 2, + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete, + InternalWONumber = "10000000002", + istemplate = false + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService( + dataService, + WorkOrderAccountTestHelpers.Resolver(context)); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = weekStart, + DateTo = weekStart.AddDays(4), + UnscheduledOnly = true + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(1, result.TotalCount); + Assert.Single(result.Items); + Assert.Equal(2, result.Items.Single().Id); + } + + [Fact] + public async Task SearchAsync_UnscheduledOnly_CustomPresetWithoutDates_DoesNotThrow() + { + await using var context = CreateContext(); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete, + InternalWONumber = "10000000001", + istemplate = false + }); + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService( + dataService, + WorkOrderAccountTestHelpers.Resolver(context)); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + UnscheduledOnly = true + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(1, result.TotalCount); + Assert.Equal(1, result.Items.Single().Id); + } + + [Fact] + public async Task SearchAsync_UnscheduledOnly_ComposesWithAccountScopeAndSiteFacet() + { + await using var context = CreateContext(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A"); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B"); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + AccountId = 1, + SiteCode = "BK5", + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete, + InternalWONumber = "10000000001", + istemplate = false + }, + new WorkOrder + { + Id = 2, + AccountId = 2, + SiteCode = "BK5", + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete, + InternalWONumber = "10000000002", + istemplate = false + }, + new WorkOrder + { + Id = 3, + AccountId = 1, + SiteCode = "BK6", + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete, + InternalWONumber = "10000000003", + istemplate = false + }, + new WorkOrder + { + Id = 4, + AccountId = 1, + SiteCode = "BK5", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000004", + istemplate = false + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService( + dataService, + WorkOrderAccountTestHelpers.Resolver(context)); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(2026, 6, 22), + DateTo = new DateOnly(2026, 6, 26), + UnscheduledOnly = true, + Sites = new List { "BK5" } + }, WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"), "disp-1"); + + Assert.Equal(1, result.TotalCount); + Assert.Single(result.Items); + Assert.Equal(1, result.Items.Single().Id); + } + + [Fact] + public async Task SearchAsync_UnscheduledOnly_NoUndatedRows_ReturnsEmptyPage() + { + await using var context = CreateContext(); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000001", + istemplate = false + }, + new WorkOrder + { + Id = 2, + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000002", + istemplate = false + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService( + dataService, + WorkOrderAccountTestHelpers.Resolver(context)); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(2000, 1, 1), + DateTo = new DateOnly(2099, 12, 31), + UnscheduledOnly = true, + Page = 0, + PageSize = 25 + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(0, result.TotalCount); + Assert.Empty(result.Items); + Assert.Equal(0, result.Page); + Assert.Equal(25, result.PageSize); + } + + [Fact] + public async Task SearchAsync_UnscheduledOnly_PaginatesStableOrderWithoutDuplicates() + { + await using var context = CreateContext(); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete, + InternalWONumber = "10000000003", + istemplate = false + }, + new WorkOrder + { + Id = 2, + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete, + InternalWONumber = "10000000001", + istemplate = false + }, + new WorkOrder + { + Id = 3, + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete, + InternalWONumber = "10000000002", + istemplate = false + }, + new WorkOrder + { + Id = 4, + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000000", + istemplate = false + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService( + dataService, + WorkOrderAccountTestHelpers.Resolver(context)); + + var page0 = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(2026, 6, 22), + DateTo = new DateOnly(2026, 6, 26), + UnscheduledOnly = true, + Page = 0, + PageSize = 2 + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + var page1 = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(2026, 6, 22), + DateTo = new DateOnly(2026, 6, 26), + UnscheduledOnly = true, + Page = 1, + PageSize = 2 + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(3, page0.TotalCount); + Assert.Equal(2, page0.Items.Count()); + Assert.Equal(new[] { 2, 3 }, page0.Items.Select(i => i.Id).ToArray()); + + Assert.Equal(3, page1.TotalCount); + Assert.Single(page1.Items); + Assert.Equal(1, page1.Items.Single().Id); + + Assert.Empty(page0.Items.Select(i => i.Id).Intersect(page1.Items.Select(i => i.Id))); + Assert.Equal(new[] { 1, 2, 3 }, page0.Items.Concat(page1.Items).Select(i => i.Id).OrderBy(id => id).ToArray()); + } }