mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
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:
parent
62572d8e03
commit
1eaf9b7c84
11 changed files with 457 additions and 33 deletions
|
|
@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
SeaHaven.Services/Helpers/WorkOrderBoardSortFields.cs
Normal file
29
SeaHaven.Services/Helpers/WorkOrderBoardSortFields.cs
Normal 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"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue