From 1eaf9b7c845fd1756cc2a6441adaa1afe7b655e6 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 10:37:24 -0300 Subject: [PATCH] feat(work-orders): enrich board search overdue filters and 0-based paging Align week presets to Mon-Fri, treat types=Other as past-due, expand text search, whitelist sortBy, and use 0-based paging for advanced search without breaking 1-based PagedResult consumers. --- .../WorkOrderControllerSearchTests.cs | 64 +++++++- .../Controllers/WorkOrderController.cs | 58 +++++-- .../Helpers/WorkOrderBoardQueryFilters.cs | 44 ++++- .../Helpers/WorkOrderBoardSearchFilter.cs | 88 +++++++++- .../WorkOrderAdvancedSearchDataService.cs | 2 +- SeaHaven.Services.Tests/PagedResultTests.cs | 23 ++- SeaHaven.Services/DTOs/ContactDTOs.cs | 15 +- .../Helpers/WorkOrderBoardSortFields.cs | 29 ++++ .../WorkOrderSearchDateRangeResolver.cs | 6 +- .../WorkOrderAdvancedSearchService.cs | 11 +- .../WorkOrderBoardSearchTests.cs | 150 +++++++++++++++++- 11 files changed, 457 insertions(+), 33 deletions(-) create mode 100644 SeaHaven.Services/Helpers/WorkOrderBoardSortFields.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs index 61d37c9..ef2ac67 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Moq; +using SeaHaven.DataServices.Implementation; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; @@ -59,7 +60,11 @@ public class WorkOrderControllerSearchTests Mock.Of(), Mock.Of(), Mock.Of(), - advancedSearch); + advancedSearch, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); controller.ControllerContext = new ControllerContext { @@ -98,4 +103,61 @@ public class WorkOrderControllerSearchTests body.Status.Should().Be("Error"); body.Message.Should().Contain("dateFrom"); } + + [Fact] + public async Task GetBoard_WhenWeekEndBeforeWeekStart_ReturnsBadRequest() + { + using var ctx = NewContext(); + var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(ctx)); + var controller = NewController(ctx, Mock.Of()); + // Replace board service - controller uses injected mock. Need different approach. + + // Use real board service via manual controller setup + var advancedSearch = Mock.Of(); + var controllerWithBoard = new WorkOrderController( + MockUserManager(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + ctx, + Mock.Of(), + Mock.Of(), + null!, + null!, + Mock.Of(), + boardService, + Mock.Of(), + Mock.Of(), + Mock.Of(), + advancedSearch, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); + + controllerWithBoard.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new System.Security.Claims.ClaimsPrincipal( + new System.Security.Claims.ClaimsIdentity( + new[] { new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, "test-user") }, + "Test")) + } + }; + + var result = await controllerWithBoard.GetBoard( + new DateOnly(2026, 6, 22), + new DateOnly(2026, 6, 15)); + + var badRequest = result.Should().BeOfType().Subject; + var body = badRequest.Value.Should().BeOfType().Subject; + body.Status.Should().Be("Error"); + body.Message.Should().Contain("weekEnd"); + } } diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 9bfdfdf..cedc42f 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -114,19 +114,26 @@ namespace Api.SeaHavenIndustries.Controllers [FromQuery] List? types = null, [FromQuery] string? search = null) { - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var query = new WorkOrderBoardQueryDto + try { - WeekStart = weekStart, - WeekEnd = weekEnd, - Dispatchers = dispatchers, - MyWorkOrders = myWorkOrders, - Types = types, - Search = search - }; + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var query = new WorkOrderBoardQueryDto + { + WeekStart = weekStart, + WeekEnd = weekEnd, + Dispatchers = dispatchers, + MyWorkOrders = myWorkOrders, + Types = types, + Search = search + }; - var result = await _workOrderBoardService.GetBoardAsync(query, userId); - return Ok(result); + var result = await _workOrderBoardService.GetBoardAsync(query, userId); + return Ok(result); + } + catch (ArgumentException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } } [HttpGet("board/search")] @@ -278,6 +285,35 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpPatch("{id:int}/comments/{commentId:int}")] + public async Task UpdateBoardComment( + int id, + int commentId, + [FromBody] WorkOrderCommentCreateDto request) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var isAdmin = User.IsInRole("Admin"); + var comment = await _workOrderCommentService.UpdateCommentAsync( + id, commentId, request, actorId, isAdmin); + return Ok(comment); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, + new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + [HttpGet("completion-templates")] public async Task GetCompletionTemplates( [FromQuery] string? serviceKey = null, diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index bb9919b..46069db 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -37,16 +37,49 @@ namespace SeaHaven.DataServices.Helpers return query.Where(w => w.AssignTo != null && ids.Contains(w.AssignTo)); } - public static IQueryable ApplyTypeFilter( + /// + /// FE sends (99) as the overdue/isPastDue sentinel — not a real type filter. + /// + public static IQueryable ApplyTypeAndOverdueFilter( IQueryable query, - IReadOnlyList? types) + IReadOnlyList? types, + DateTime? utcNow = null) { if (types == null || types.Count == 0) return query; - return query.Where(w => w.WorkOrderType != null && types.Contains(w.WorkOrderType.Value)); + var today = (utcNow ?? DateTime.UtcNow).Date; + var includeOverdue = types.Contains(WorkOrderType.Other); + var typeFilters = types.Where(t => t != WorkOrderType.Other).ToList(); + + // Keep past-due predicate inline — EF cannot translate local method calls. + if (includeOverdue && typeFilters.Count == 0) + { + return query.Where(w => + w.ScheduledDate != null + && w.ScheduledDate.Value.Date < today + && w.LifecycleStatus != LifecycleStatus.Completed + && w.LifecycleStatus != LifecycleStatus.Canceled); + } + + if (includeOverdue) + { + return query.Where(w => + (w.ScheduledDate != null + && w.ScheduledDate.Value.Date < today + && w.LifecycleStatus != LifecycleStatus.Completed + && w.LifecycleStatus != LifecycleStatus.Canceled) + || (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))); + } + + return query.Where(w => w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value)); } + public static IQueryable ApplyTypeFilter( + IQueryable query, + IReadOnlyList? types) + => ApplyTypeAndOverdueFilter(query, types); + public static IQueryable ApplySiteFilter( IQueryable query, IReadOnlyList? sites) @@ -132,7 +165,10 @@ namespace SeaHaven.DataServices.Helpers || (w.ScheduleWeekOnly == true && w.TargetWeek != null && w.TargetWeek >= dateFrom - && w.TargetWeek <= dateTo)); + && w.TargetWeek <= dateTo) + || (w.ScheduledDate == null + && w.LifecycleStatus != LifecycleStatus.Completed + && w.LifecycleStatus != LifecycleStatus.Canceled)); } } } diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs index eac12ba..d6cf28d 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs @@ -1,3 +1,5 @@ +using System.Globalization; +using System.Text.RegularExpressions; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; @@ -5,6 +7,10 @@ namespace SeaHaven.DataServices.Helpers { public static class WorkOrderBoardSearchFilter { + private static readonly Regex PartialDatePattern = new( + @"^\d{1,2}/\d{1,2}$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + public static string? NormalizeSearch(string? search) { if (string.IsNullOrWhiteSpace(search)) @@ -21,10 +27,23 @@ namespace SeaHaven.DataServices.Helpers return query; var s = normalized.ToLower(); + var today = DateTime.UtcNow.Date; + var exactDate = TryParseExactDate(normalized); + var hasExactDate = exactDate.HasValue; + var exactDateValue = exactDate?.Date ?? default; + + var partialMonthDay = TryParsePartialMonthDay(normalized); + var hasPartialMonthDay = partialMonthDay.HasValue; + var partialMonth = partialMonthDay?.Month ?? 0; + var partialDay = partialMonthDay?.Day ?? 0; + + // Keep predicates inline — EF Core cannot translate local method calls. return query.Where(w => (w.SiteCode ?? "").ToLower().Contains(s) || (w.InternalWONumber ?? "").ToLower().Contains(s) || (w.WorkerOrderNumber ?? "").ToLower().Contains(s) + || (w.WorkerOrderTitle ?? "").ToLower().Contains(s) + || (w.Description ?? "").ToLower().Contains(s) || (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s)) || (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s)) || (w.Trade ?? "").ToLower().Contains(s) @@ -32,6 +51,19 @@ namespace SeaHaven.DataServices.Helpers || (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s)) || (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.ContactName ?? "").ToLower().Contains(s)) || (w.Status ?? "").ToLower().Contains(s) + || (w.WorkOrderType == WorkOrderType.PO && "po".Contains(s)) + || (w.WorkOrderType == WorkOrderType.PM && "pm".Contains(s)) + || (w.WorkOrderType == WorkOrderType.Emergency && "emergency".Contains(s)) + || (w.WorkOrderType == WorkOrderType.Project && "project".Contains(s)) + || (w.WorkOrderType == WorkOrderType.Inspection && "inspection".Contains(s)) + || (w.WorkOrderType == WorkOrderType.Reactive && "reactive".Contains(s)) + || (w.WorkOrderType == WorkOrderType.AddOn && ("add-on".Contains(s) || "addon".Contains(s))) + || (w.WorkOrderType == WorkOrderType.Other && "other".Contains(s)) + || ("overdue".Contains(s) + && w.ScheduledDate != null + && w.ScheduledDate.Value.Date < today + && w.LifecycleStatus != LifecycleStatus.Completed + && w.LifecycleStatus != LifecycleStatus.Canceled) || (w.LifecycleStatus == LifecycleStatus.Incomplete && "incomplete".Contains(s)) || (w.LifecycleStatus == LifecycleStatus.Pending && ("pending".Contains(s) || "on hold".Contains(s) || "onhold".Contains(s))) || (w.LifecycleStatus == LifecycleStatus.Scheduled && "scheduled".Contains(s)) @@ -41,7 +73,61 @@ namespace SeaHaven.DataServices.Helpers || (w.LifecycleStatus == LifecycleStatus.Completed && ("completed".Contains(s) || "complete".Contains(s) || "done".Contains(s) || "closed".Contains(s))) || (w.LifecycleStatus == LifecycleStatus.Rescheduled && "rescheduled".Contains(s)) || (w.LifecycleStatus == LifecycleStatus.Canceled && ("canceled".Contains(s) || "cancelled".Contains(s))) - || (w.LifecycleStatus == LifecycleStatus.PendingQuote && ("pending quote".Contains(s) || "pendingquote".Contains(s) || "pending approval".Contains(s) || "pendingapproval".Contains(s)))); + || (w.LifecycleStatus == LifecycleStatus.PendingQuote && ("pending quote".Contains(s) || "pendingquote".Contains(s) || "pending approval".Contains(s) || "pendingapproval".Contains(s))) + || (hasExactDate + && ((w.ScheduledDate != null && w.ScheduledDate.Value.Date == exactDateValue) + || (w.DueDate != null && w.DueDate.Value.Date == exactDateValue))) + || (hasPartialMonthDay + && ((w.ScheduledDate != null + && w.ScheduledDate.Value.Month == partialMonth + && w.ScheduledDate.Value.Day == partialDay) + || (w.DueDate != null + && w.DueDate.Value.Month == partialMonth + && w.DueDate.Value.Day == partialDay)))); + } + + internal static DateTime? TryParseExactDate(string search) + { + if (PartialDatePattern.IsMatch(search)) + return null; + + var formats = new[] + { + "yyyy-MM-dd", + "MM/dd/yyyy", + "M/d/yyyy", + "MM/dd/yy", + "M/d/yy" + }; + + if (DateTime.TryParseExact(search, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) + return parsed.Date; + + if (DateTime.TryParse(search, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed)) + return parsed.Date; + + return null; + } + + internal static (int Month, int Day)? TryParsePartialMonthDay(string search) + { + if (!PartialDatePattern.IsMatch(search)) + return null; + + var parts = search.Split('/'); + if (parts.Length != 2) + return null; + + if (!int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var month)) + return null; + + if (!int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var day)) + return null; + + if (month is < 1 or > 12 || day is < 1 or > 31) + return null; + + return (month, day); } } } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs index 556fe4f..d30cb49 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs @@ -35,7 +35,7 @@ namespace SeaHaven.DataServices.Implementation var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir); var paged = sorted - .Skip((query.Page - 1) * query.PageSize) + .Skip(query.Page * query.PageSize) .Take(query.PageSize); var rows = await WorkOrderBoardProjection.ProjectRowsAsync(paged, isUnscheduled: false); diff --git a/SeaHaven.Services.Tests/PagedResultTests.cs b/SeaHaven.Services.Tests/PagedResultTests.cs index 911370e..6621180 100644 --- a/SeaHaven.Services.Tests/PagedResultTests.cs +++ b/SeaHaven.Services.Tests/PagedResultTests.cs @@ -8,8 +8,9 @@ public class PagedResultTests { [Theory] [InlineData(0, 10, 0)] + [InlineData(1, 10, 1)] [InlineData(10, 10, 1)] - [InlineData(25, 10, 3)] + [InlineData(11, 10, 2)] [InlineData(100, 25, 4)] public void TotalPages_IsCeilingOfCountOverPageSize(int totalCount, int pageSize, int expectedPages) { @@ -19,16 +20,32 @@ public class PagedResultTests } [Fact] - public void HasPrevious_IsFalseOnFirstPage_TrueAfterward() + public void HasPrevious_IsFalseOnFirstPage_TrueAfterward_OneBased() { new PagedResult { Page = 1, PageSize = 10, TotalCount = 30 }.HasPrevious.Should().BeFalse(); new PagedResult { Page = 2, PageSize = 10, TotalCount = 30 }.HasPrevious.Should().BeTrue(); } [Fact] - public void HasNext_IsTrueBeforeLastPage_FalseOnLastPage() + public void HasNext_IsTrueBeforeLastPage_FalseOnLastPage_OneBased() { new PagedResult { Page = 2, PageSize = 10, TotalCount = 30 }.HasNext.Should().BeTrue(); new PagedResult { Page = 3, PageSize = 10, TotalCount = 30 }.HasNext.Should().BeFalse(); } + + [Fact] + public void HasPrevious_And_HasNext_ZeroBased_WhenPageBaseIsZero() + { + var first = new PagedResult { Page = 0, PageSize = 10, TotalCount = 30, PageBase = 0 }; + first.HasPrevious.Should().BeFalse(); + first.HasNext.Should().BeTrue(); + + var middle = new PagedResult { Page = 1, PageSize = 10, TotalCount = 30, PageBase = 0 }; + middle.HasPrevious.Should().BeTrue(); + middle.HasNext.Should().BeTrue(); + + var last = new PagedResult { Page = 2, PageSize = 10, TotalCount = 30, PageBase = 0 }; + last.HasPrevious.Should().BeTrue(); + last.HasNext.Should().BeFalse(); + } } diff --git a/SeaHaven.Services/DTOs/ContactDTOs.cs b/SeaHaven.Services/DTOs/ContactDTOs.cs index 90cafc9..8710ecc 100644 --- a/SeaHaven.Services/DTOs/ContactDTOs.cs +++ b/SeaHaven.Services/DTOs/ContactDTOs.cs @@ -48,8 +48,17 @@ namespace SeaHaven.Services.DTOs public int TotalCount { get; set; } public int Page { get; set; } public int PageSize { get; set; } - public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); - public bool HasPrevious => Page > 1; - public bool HasNext => Page < TotalPages; + + /// + /// Page numbering base for / . + /// Default 1 (classic APIs). Work-order advanced search sets 0. + /// + public int PageBase { get; set; } = 1; + + public int TotalPages => PageSize <= 0 ? 0 : (int)Math.Ceiling(TotalCount / (double)PageSize); + + public bool HasPrevious => PageBase <= 0 ? Page > 0 : Page > 1; + + public bool HasNext => PageBase <= 0 ? Page + 1 < TotalPages : Page < TotalPages; } } diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardSortFields.cs b/SeaHaven.Services/Helpers/WorkOrderBoardSortFields.cs new file mode 100644 index 0000000..e2505a3 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderBoardSortFields.cs @@ -0,0 +1,29 @@ +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderBoardSortFields + { + public static readonly IReadOnlySet Allowed = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "scheduledDate", + "woNumber", + "dueDate" + }; + + public static string NormalizeOrThrow(string? sortBy) + { + var field = (sortBy ?? "scheduledDate").Trim(); + if (!Allowed.Contains(field)) + { + throw new ArgumentException( + "sortBy must be one of: scheduledDate, woNumber, dueDate."); + } + + return field.ToLowerInvariant() switch + { + "wonumber" => "woNumber", + "duedate" => "dueDate", + _ => "scheduledDate" + }; + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs b/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs index d41df2f..48a8f48 100644 --- a/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs +++ b/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs @@ -56,19 +56,19 @@ namespace SeaHaven.Services.Helpers private static WorkOrderSearchDateRange ThisIsoWeek(DateOnly today) { var start = GetIsoWeekStart(today); - return new WorkOrderSearchDateRange(start, start.AddDays(6)); + return new WorkOrderSearchDateRange(start, start.AddDays(4)); } private static WorkOrderSearchDateRange LastIsoWeek(DateOnly today) { var start = GetIsoWeekStart(today).AddDays(-7); - return new WorkOrderSearchDateRange(start, start.AddDays(6)); + return new WorkOrderSearchDateRange(start, start.AddDays(4)); } private static WorkOrderSearchDateRange NextIsoWeek(DateOnly today) { var start = GetIsoWeekStart(today).AddDays(7); - return new WorkOrderSearchDateRange(start, start.AddDays(6)); + return new WorkOrderSearchDateRange(start, start.AddDays(4)); } private static WorkOrderSearchDateRange ThisMonth(DateOnly today) diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs index 285ec9e..8b35a1a 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -8,7 +8,7 @@ namespace SeaHaven.Services.Implementation { public class WorkOrderAdvancedSearchService : IWorkOrderAdvancedSearchService { - private const int DefaultPageSize = 50; + private const int DefaultPageSize = 100; private const int MaxPageSize = 100; private readonly IWorkOrderAdvancedSearchDataService _searchDataService; @@ -22,7 +22,7 @@ namespace SeaHaven.Services.Implementation WorkOrderAdvancedSearchQueryDto query, string? currentUserId) { - var page = query.Page < 1 ? 1 : query.Page; + var page = query.Page < 0 ? 0 : query.Page; var pageSize = query.PageSize < 1 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize); var dateRange = WorkOrderSearchDateRangeResolver.Resolve( @@ -30,6 +30,8 @@ namespace SeaHaven.Services.Implementation query.DateFrom, query.DateTo); + var sortBy = WorkOrderBoardSortFields.NormalizeOrThrow(query.SortBy); + var dataQuery = new WorkOrderAdvancedSearchQuery( WorkOrderBoardSearchFilter.NormalizeSearch(query.Search), dateRange.From, @@ -45,7 +47,7 @@ namespace SeaHaven.Services.Implementation currentUserId, page, pageSize, - query.SortBy ?? "scheduledDate", + sortBy, query.SortDir ?? "asc"); var result = await _searchDataService.SearchAsync(dataQuery); @@ -56,7 +58,8 @@ namespace SeaHaven.Services.Implementation Items = result.Rows.Select(r => WorkOrderBoardService.MapRawRow(r, utcNow)).ToList(), TotalCount = result.TotalCount, Page = page, - PageSize = pageSize + PageSize = pageSize, + PageBase = 0 }; } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs index 290b63d..7580562 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -22,6 +22,9 @@ public class WorkOrderBoardSearchFilterTests [InlineData("HVAC Tech", 1)] [InlineData("Acme HVAC", 1)] [InlineData("sched", 1)] + [InlineData("Leak repair", 1)] + [InlineData("Reactive", 1)] + [InlineData("6/23", 1)] public async Task Apply_MatchesExpectedField(string search, int expectedId) { await using var context = CreateContext(); @@ -73,6 +76,9 @@ public class WorkOrderBoardSearchFilterTests Id = 1, SiteCode = "BK5", InternalWONumber = "10000000001", + WorkerOrderTitle = "Leak repair", + WorkOrderType = WorkOrderType.Reactive, + ScheduledDate = new DateTime(2026, 6, 23), LifecycleStatus = LifecycleStatus.Scheduled, PrimaryDispatchId = 10 }); @@ -152,7 +158,7 @@ public class WorkOrderSearchDateRangeResolverTests reference); Assert.Equal(new DateOnly(2026, 6, 22), range.From); - Assert.Equal(new DateOnly(2026, 6, 28), range.To); + Assert.Equal(new DateOnly(2026, 6, 26), range.To); } [Fact] @@ -213,7 +219,7 @@ public class WorkOrderAdvancedSearchServiceTests DateFrom = weekStart, DateTo = weekStart.AddDays(6), Sites = new List { "BK5" }, - Page = 1, + Page = 0, PageSize = 50 }, null); @@ -237,4 +243,144 @@ public class WorkOrderAdvancedSearchServiceTests Assert.Equal(100, result.PageSize); } + + [Fact] + public async Task SearchAsync_PageZero_ReturnsFirstPage() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + SiteCode = "BK5", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000001" + }, + new WorkOrder + { + Id = 2, + SiteCode = "BK6", + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000002" + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = weekStart, + DateTo = weekStart.AddDays(4), + Page = 0, + PageSize = 1 + }, null); + + Assert.Equal(2, result.TotalCount); + Assert.Single(result.Items); + Assert.Equal(0, result.Page); + } + + [Fact] + public async Task SearchAsync_OverdueTypeSentinel_FiltersPastDueOnly() + { + await using var context = CreateContext(); + var today = DateTime.UtcNow.Date; + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + WorkOrderType = WorkOrderType.PM, + ScheduledDate = today.AddDays(-3), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000001" + }, + new WorkOrder + { + Id = 2, + WorkOrderType = WorkOrderType.Other, + ScheduledDate = today.AddDays(3), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000002" + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)), + DateTo = DateOnly.FromDateTime(today.AddMonths(1)), + Types = new List { WorkOrderType.Other } + }, null); + + Assert.Equal(1, result.TotalCount); + Assert.Equal(1, result.Items.First().Id); + Assert.True(result.Items.First().IsPastDue); + } + + [Fact] + public async Task SearchAsync_IncludesUnscheduledRows() + { + 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" + }, + new WorkOrder + { + Id = 2, + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete, + InternalWONumber = "10000000002" + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + + var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = weekStart, + DateTo = weekStart.AddDays(4) + }, null); + + Assert.Equal(2, result.TotalCount); + Assert.Contains(result.Items, i => i.Id == 2); + } + + [Fact] + public async Task SearchAsync_InvalidSortBy_Throws() + { + await using var context = CreateContext(); + var dataService = new WorkOrderAdvancedSearchDataService(context); + var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); + + var ex = await Assert.ThrowsAsync(() => service.SearchAsync( + new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto + { + DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek, + SortBy = "invalid" + }, null)); + + Assert.Contains("sortBy", ex.Message); + } }