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.
This commit is contained in:
Arthur Bassi 2026-07-17 10:37:24 -03:00
parent 62572d8e03
commit 1eaf9b7c84
11 changed files with 457 additions and 33 deletions

View file

@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Moq; using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers; using SeaHaven.Services.Helpers;
@ -59,7 +60,11 @@ public class WorkOrderControllerSearchTests
Mock.Of<IWorkOrderBoardUpdateService>(), Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(), Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(), Mock.Of<IWorkOrderBoardCancelService>(),
advancedSearch); advancedSearch,
Mock.Of<IWorkOrderDetailService>(),
Mock.Of<IWorkOrderCommentService>(),
Mock.Of<IWorkOrderCompletionService>(),
Mock.Of<IWorkOrderMediaService>());
controller.ControllerContext = new ControllerContext controller.ControllerContext = new ControllerContext
{ {
@ -98,4 +103,61 @@ public class WorkOrderControllerSearchTests
body.Status.Should().Be("Error"); body.Status.Should().Be("Error");
body.Message.Should().Contain("dateFrom"); 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<IWorkOrderAdvancedSearchService>());
// Replace board service - controller uses injected mock. Need different approach.
// Use real board service via manual controller setup
var advancedSearch = Mock.Of<IWorkOrderAdvancedSearchService>();
var controllerWithBoard = new WorkOrderController(
MockUserManager(),
Mock.Of<IWorkOrderService>(),
Mock.Of<IWorkOrderDataService>(),
Mock.Of<ICommentDataService>(),
Mock.Of<IQuotesDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<IDispatchDataService>(),
Mock.Of<IVendorDataService>(),
Mock.Of<ITaskListTemplateDataService>(),
ctx,
Mock.Of<IWebHostEnvironment>(),
Mock.Of<IHttpContextAccessor>(),
null!,
null!,
Mock.Of<IConfiguration>(),
boardService,
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
advancedSearch,
Mock.Of<IWorkOrderDetailService>(),
Mock.Of<IWorkOrderCommentService>(),
Mock.Of<IWorkOrderCompletionService>(),
Mock.Of<IWorkOrderMediaService>());
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<BadRequestObjectResult>().Subject;
var body = badRequest.Value.Should().BeOfType<Response>().Subject;
body.Status.Should().Be("Error");
body.Message.Should().Contain("weekEnd");
}
} }

View file

@ -114,19 +114,26 @@ namespace Api.SeaHavenIndustries.Controllers
[FromQuery] List<WorkOrderType>? types = null, [FromQuery] List<WorkOrderType>? types = null,
[FromQuery] string? search = null) [FromQuery] string? search = null)
{ {
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); try
var query = new WorkOrderBoardQueryDto
{ {
WeekStart = weekStart, var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
WeekEnd = weekEnd, var query = new WorkOrderBoardQueryDto
Dispatchers = dispatchers, {
MyWorkOrders = myWorkOrders, WeekStart = weekStart,
Types = types, WeekEnd = weekEnd,
Search = search Dispatchers = dispatchers,
}; MyWorkOrders = myWorkOrders,
Types = types,
Search = search
};
var result = await _workOrderBoardService.GetBoardAsync(query, userId); var result = await _workOrderBoardService.GetBoardAsync(query, userId);
return Ok(result); return Ok(result);
}
catch (ArgumentException ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
} }
[HttpGet("board/search")] [HttpGet("board/search")]
@ -278,6 +285,35 @@ namespace Api.SeaHavenIndustries.Controllers
} }
} }
[HttpPatch("{id:int}/comments/{commentId:int}")]
public async Task<IActionResult> 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")] [HttpGet("completion-templates")]
public async Task<IActionResult> GetCompletionTemplates( public async Task<IActionResult> GetCompletionTemplates(
[FromQuery] string? serviceKey = null, [FromQuery] string? serviceKey = null,

View file

@ -37,16 +37,49 @@ namespace SeaHaven.DataServices.Helpers
return query.Where(w => w.AssignTo != null && ids.Contains(w.AssignTo)); return query.Where(w => w.AssignTo != null && ids.Contains(w.AssignTo));
} }
public static IQueryable<WorkOrder> ApplyTypeFilter( /// <summary>
/// FE sends <see cref="WorkOrderType.Other"/> (99) as the overdue/isPastDue sentinel — not a real type filter.
/// </summary>
public static IQueryable<WorkOrder> ApplyTypeAndOverdueFilter(
IQueryable<WorkOrder> query, IQueryable<WorkOrder> query,
IReadOnlyList<WorkOrderType>? types) IReadOnlyList<WorkOrderType>? types,
DateTime? utcNow = null)
{ {
if (types == null || types.Count == 0) if (types == null || types.Count == 0)
return query; 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<WorkOrder> ApplyTypeFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<WorkOrderType>? types)
=> ApplyTypeAndOverdueFilter(query, types);
public static IQueryable<WorkOrder> ApplySiteFilter( public static IQueryable<WorkOrder> ApplySiteFilter(
IQueryable<WorkOrder> query, IQueryable<WorkOrder> query,
IReadOnlyList<string>? sites) IReadOnlyList<string>? sites)
@ -132,7 +165,10 @@ namespace SeaHaven.DataServices.Helpers
|| (w.ScheduleWeekOnly == true || (w.ScheduleWeekOnly == true
&& w.TargetWeek != null && w.TargetWeek != null
&& w.TargetWeek >= dateFrom && w.TargetWeek >= dateFrom
&& w.TargetWeek <= dateTo)); && w.TargetWeek <= dateTo)
|| (w.ScheduledDate == null
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled));
} }
} }
} }

View file

@ -1,3 +1,5 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
@ -5,6 +7,10 @@ namespace SeaHaven.DataServices.Helpers
{ {
public static class WorkOrderBoardSearchFilter 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) public static string? NormalizeSearch(string? search)
{ {
if (string.IsNullOrWhiteSpace(search)) if (string.IsNullOrWhiteSpace(search))
@ -21,10 +27,23 @@ namespace SeaHaven.DataServices.Helpers
return query; return query;
var s = normalized.ToLower(); 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 => return query.Where(w =>
(w.SiteCode ?? "").ToLower().Contains(s) (w.SiteCode ?? "").ToLower().Contains(s)
|| (w.InternalWONumber ?? "").ToLower().Contains(s) || (w.InternalWONumber ?? "").ToLower().Contains(s)
|| (w.WorkerOrderNumber ?? "").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.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s))
|| (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s)) || (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s))
|| (w.Trade ?? "").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.CompanyName ?? "").ToLower().Contains(s))
|| (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.ContactName ?? "").ToLower().Contains(s)) || (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.ContactName ?? "").ToLower().Contains(s))
|| (w.Status ?? "").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.Incomplete && "incomplete".Contains(s))
|| (w.LifecycleStatus == LifecycleStatus.Pending && ("pending".Contains(s) || "on hold".Contains(s) || "onhold".Contains(s))) || (w.LifecycleStatus == LifecycleStatus.Pending && ("pending".Contains(s) || "on hold".Contains(s) || "onhold".Contains(s)))
|| (w.LifecycleStatus == LifecycleStatus.Scheduled && "scheduled".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.Completed && ("completed".Contains(s) || "complete".Contains(s) || "done".Contains(s) || "closed".Contains(s)))
|| (w.LifecycleStatus == LifecycleStatus.Rescheduled && "rescheduled".Contains(s)) || (w.LifecycleStatus == LifecycleStatus.Rescheduled && "rescheduled".Contains(s))
|| (w.LifecycleStatus == LifecycleStatus.Canceled && ("canceled".Contains(s) || "cancelled".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);
} }
} }
} }

View file

@ -35,7 +35,7 @@ namespace SeaHaven.DataServices.Implementation
var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir); var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir);
var paged = sorted var paged = sorted
.Skip((query.Page - 1) * query.PageSize) .Skip(query.Page * query.PageSize)
.Take(query.PageSize); .Take(query.PageSize);
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(paged, isUnscheduled: false); var rows = await WorkOrderBoardProjection.ProjectRowsAsync(paged, isUnscheduled: false);

View file

@ -8,8 +8,9 @@ public class PagedResultTests
{ {
[Theory] [Theory]
[InlineData(0, 10, 0)] [InlineData(0, 10, 0)]
[InlineData(1, 10, 1)]
[InlineData(10, 10, 1)] [InlineData(10, 10, 1)]
[InlineData(25, 10, 3)] [InlineData(11, 10, 2)]
[InlineData(100, 25, 4)] [InlineData(100, 25, 4)]
public void TotalPages_IsCeilingOfCountOverPageSize(int totalCount, int pageSize, int expectedPages) public void TotalPages_IsCeilingOfCountOverPageSize(int totalCount, int pageSize, int expectedPages)
{ {
@ -19,16 +20,32 @@ public class PagedResultTests
} }
[Fact] [Fact]
public void HasPrevious_IsFalseOnFirstPage_TrueAfterward() public void HasPrevious_IsFalseOnFirstPage_TrueAfterward_OneBased()
{ {
new PagedResult<string> { Page = 1, PageSize = 10, TotalCount = 30 }.HasPrevious.Should().BeFalse(); new PagedResult<string> { Page = 1, PageSize = 10, TotalCount = 30 }.HasPrevious.Should().BeFalse();
new PagedResult<string> { Page = 2, PageSize = 10, TotalCount = 30 }.HasPrevious.Should().BeTrue(); new PagedResult<string> { Page = 2, PageSize = 10, TotalCount = 30 }.HasPrevious.Should().BeTrue();
} }
[Fact] [Fact]
public void HasNext_IsTrueBeforeLastPage_FalseOnLastPage() public void HasNext_IsTrueBeforeLastPage_FalseOnLastPage_OneBased()
{ {
new PagedResult<string> { Page = 2, PageSize = 10, TotalCount = 30 }.HasNext.Should().BeTrue(); new PagedResult<string> { Page = 2, PageSize = 10, TotalCount = 30 }.HasNext.Should().BeTrue();
new PagedResult<string> { Page = 3, PageSize = 10, TotalCount = 30 }.HasNext.Should().BeFalse(); new PagedResult<string> { Page = 3, PageSize = 10, TotalCount = 30 }.HasNext.Should().BeFalse();
} }
[Fact]
public void HasPrevious_And_HasNext_ZeroBased_WhenPageBaseIsZero()
{
var first = new PagedResult<string> { Page = 0, PageSize = 10, TotalCount = 30, PageBase = 0 };
first.HasPrevious.Should().BeFalse();
first.HasNext.Should().BeTrue();
var middle = new PagedResult<string> { Page = 1, PageSize = 10, TotalCount = 30, PageBase = 0 };
middle.HasPrevious.Should().BeTrue();
middle.HasNext.Should().BeTrue();
var last = new PagedResult<string> { Page = 2, PageSize = 10, TotalCount = 30, PageBase = 0 };
last.HasPrevious.Should().BeTrue();
last.HasNext.Should().BeFalse();
}
} }

View file

@ -48,8 +48,17 @@ namespace SeaHaven.Services.DTOs
public int TotalCount { get; set; } public int TotalCount { get; set; }
public int Page { get; set; } public int Page { get; set; }
public int PageSize { get; set; } public int PageSize { get; set; }
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
public bool HasPrevious => Page > 1; /// <summary>
public bool HasNext => Page < TotalPages; /// Page numbering base for <see cref="HasPrevious"/> / <see cref="HasNext"/>.
/// Default <c>1</c> (classic APIs). Work-order advanced search sets <c>0</c>.
/// </summary>
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;
} }
} }

View file

@ -0,0 +1,29 @@
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderBoardSortFields
{
public static readonly IReadOnlySet<string> Allowed = new HashSet<string>(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"
};
}
}
}

View file

@ -56,19 +56,19 @@ namespace SeaHaven.Services.Helpers
private static WorkOrderSearchDateRange ThisIsoWeek(DateOnly today) private static WorkOrderSearchDateRange ThisIsoWeek(DateOnly today)
{ {
var start = GetIsoWeekStart(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) private static WorkOrderSearchDateRange LastIsoWeek(DateOnly today)
{ {
var start = GetIsoWeekStart(today).AddDays(-7); 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) private static WorkOrderSearchDateRange NextIsoWeek(DateOnly today)
{ {
var start = GetIsoWeekStart(today).AddDays(7); 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) private static WorkOrderSearchDateRange ThisMonth(DateOnly today)

View file

@ -8,7 +8,7 @@ namespace SeaHaven.Services.Implementation
{ {
public class WorkOrderAdvancedSearchService : IWorkOrderAdvancedSearchService public class WorkOrderAdvancedSearchService : IWorkOrderAdvancedSearchService
{ {
private const int DefaultPageSize = 50; private const int DefaultPageSize = 100;
private const int MaxPageSize = 100; private const int MaxPageSize = 100;
private readonly IWorkOrderAdvancedSearchDataService _searchDataService; private readonly IWorkOrderAdvancedSearchDataService _searchDataService;
@ -22,7 +22,7 @@ namespace SeaHaven.Services.Implementation
WorkOrderAdvancedSearchQueryDto query, WorkOrderAdvancedSearchQueryDto query,
string? currentUserId) 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 pageSize = query.PageSize < 1 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize);
var dateRange = WorkOrderSearchDateRangeResolver.Resolve( var dateRange = WorkOrderSearchDateRangeResolver.Resolve(
@ -30,6 +30,8 @@ namespace SeaHaven.Services.Implementation
query.DateFrom, query.DateFrom,
query.DateTo); query.DateTo);
var sortBy = WorkOrderBoardSortFields.NormalizeOrThrow(query.SortBy);
var dataQuery = new WorkOrderAdvancedSearchQuery( var dataQuery = new WorkOrderAdvancedSearchQuery(
WorkOrderBoardSearchFilter.NormalizeSearch(query.Search), WorkOrderBoardSearchFilter.NormalizeSearch(query.Search),
dateRange.From, dateRange.From,
@ -45,7 +47,7 @@ namespace SeaHaven.Services.Implementation
currentUserId, currentUserId,
page, page,
pageSize, pageSize,
query.SortBy ?? "scheduledDate", sortBy,
query.SortDir ?? "asc"); query.SortDir ?? "asc");
var result = await _searchDataService.SearchAsync(dataQuery); var result = await _searchDataService.SearchAsync(dataQuery);
@ -56,7 +58,8 @@ namespace SeaHaven.Services.Implementation
Items = result.Rows.Select(r => WorkOrderBoardService.MapRawRow(r, utcNow)).ToList(), Items = result.Rows.Select(r => WorkOrderBoardService.MapRawRow(r, utcNow)).ToList(),
TotalCount = result.TotalCount, TotalCount = result.TotalCount,
Page = page, Page = page,
PageSize = pageSize PageSize = pageSize,
PageBase = 0
}; };
} }
} }

View file

@ -22,6 +22,9 @@ public class WorkOrderBoardSearchFilterTests
[InlineData("HVAC Tech", 1)] [InlineData("HVAC Tech", 1)]
[InlineData("Acme HVAC", 1)] [InlineData("Acme HVAC", 1)]
[InlineData("sched", 1)] [InlineData("sched", 1)]
[InlineData("Leak repair", 1)]
[InlineData("Reactive", 1)]
[InlineData("6/23", 1)]
public async Task Apply_MatchesExpectedField(string search, int expectedId) public async Task Apply_MatchesExpectedField(string search, int expectedId)
{ {
await using var context = CreateContext(); await using var context = CreateContext();
@ -73,6 +76,9 @@ public class WorkOrderBoardSearchFilterTests
Id = 1, Id = 1,
SiteCode = "BK5", SiteCode = "BK5",
InternalWONumber = "10000000001", InternalWONumber = "10000000001",
WorkerOrderTitle = "Leak repair",
WorkOrderType = WorkOrderType.Reactive,
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled, LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10 PrimaryDispatchId = 10
}); });
@ -152,7 +158,7 @@ public class WorkOrderSearchDateRangeResolverTests
reference); reference);
Assert.Equal(new DateOnly(2026, 6, 22), range.From); 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] [Fact]
@ -213,7 +219,7 @@ public class WorkOrderAdvancedSearchServiceTests
DateFrom = weekStart, DateFrom = weekStart,
DateTo = weekStart.AddDays(6), DateTo = weekStart.AddDays(6),
Sites = new List<string> { "BK5" }, Sites = new List<string> { "BK5" },
Page = 1, Page = 0,
PageSize = 50 PageSize = 50
}, null); }, null);
@ -237,4 +243,144 @@ public class WorkOrderAdvancedSearchServiceTests
Assert.Equal(100, result.PageSize); 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> { 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<ArgumentException>(() => service.SearchAsync(
new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek,
SortBy = "invalid"
}, null));
Assert.Contains("sortBy", ex.Message);
}
} }