mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
feat(work-orders): enrich board search overdue filters and 0-based paging (#23)
* feat(work-orders): enrich board search overdue filters and 0-based paging * fix(work-orders): align stacked services with CI build * fix(tests): pass userDataService in comment service unit test * fix(work-orders): use dedicated overdue query flag Stop treating WorkOrderType.Other as an overdue sentinel. Board and advanced search now accept overdue=true while types=Other filters real Other rows; combining both uses OR. * test(work-orders): cover overdue date/status boundary and Other type-filter Lock the PR #23 overdue regression boundary through the public advanced search service. Prove overdue filtering is driven by past-due date plus non-terminal status, not by the WorkOrderType.Other sentinel: - Other + future/not-completed excluded from overdue - past-due + Scheduled included; past-due + Completed/Canceled excluded - types=[PM, Other] keeps real Other rows and does not pull past-due rows - assert 0-based paging (Page=0) is preserved alongside overdue/type filters --------- Co-authored-by: Arthur Bassi <arthur.winiarski.ranger@outlook.com> Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
This commit is contained in:
parent
833fb816ee
commit
620a36af54
22 changed files with 675 additions and 34 deletions
|
|
@ -4,6 +4,7 @@ using FluentAssertions;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
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;
|
||||||
|
|
@ -60,4 +61,17 @@ 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()
|
||||||
|
{
|
||||||
|
var controller = NewController(Mock.Of<IWorkOrderAdvancedSearchService>());
|
||||||
|
|
||||||
|
var result = await controller.GetBoard(
|
||||||
|
new DateOnly(2026, 6, 22),
|
||||||
|
new DateOnly(2026, 6, 15));
|
||||||
|
|
||||||
|
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
badRequest.Value.Should().BeOfType<string>().Which.Should().Contain("weekEnd");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[FromQuery] List<string>? dispatchers = null,
|
[FromQuery] List<string>? dispatchers = null,
|
||||||
[FromQuery] bool myWorkOrders = false,
|
[FromQuery] bool myWorkOrders = false,
|
||||||
[FromQuery] List<WorkOrderType>? types = null,
|
[FromQuery] List<WorkOrderType>? types = null,
|
||||||
|
[FromQuery] bool overdue = false,
|
||||||
[FromQuery] string? search = null)
|
[FromQuery] string? search = null)
|
||||||
{
|
{
|
||||||
var resolvedWeekEnd = weekEnd ?? weekStart.AddDays(4);
|
var resolvedWeekEnd = weekEnd ?? weekStart.AddDays(4);
|
||||||
|
|
@ -70,6 +71,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
Dispatchers = dispatchers,
|
Dispatchers = dispatchers,
|
||||||
MyWorkOrders = myWorkOrders,
|
MyWorkOrders = myWorkOrders,
|
||||||
Types = types,
|
Types = types,
|
||||||
|
Overdue = overdue,
|
||||||
Search = search
|
Search = search
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -222,8 +222,8 @@ namespace Data.SeaHavenIndustries
|
||||||
public string? Initials { get; set; }
|
public string? Initials { get; set; }
|
||||||
public string? Color { get; set; }
|
public string? Color { get; set; }
|
||||||
public int? Type { get; set; } // 1 for users 0 for admin
|
public int? Type { get; set; } // 1 for users 0 for admin
|
||||||
public ICollection<Template>? Templates { get; set; }
|
public ICollection<Template>? Templates { get; set; }
|
||||||
public ICollection<WorkOrder>? WorkOrders { get; set; }
|
public ICollection<WorkOrder>? WorkOrders { get; set; }
|
||||||
}
|
}
|
||||||
public class FullAuditEntity
|
public class FullAuditEntity
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -42,16 +42,53 @@ 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>
|
||||||
|
/// Filters by real <paramref name="types"/> (including <see cref="WorkOrderType.Other"/>)
|
||||||
|
/// and/or past-due rows when <paramref name="overdue"/> is true.
|
||||||
|
/// Types and overdue are combined with OR when both are present.
|
||||||
|
/// </summary>
|
||||||
|
public static IQueryable<WorkOrder> ApplyTypeAndOverdueFilter(
|
||||||
IQueryable<WorkOrder> query,
|
IQueryable<WorkOrder> query,
|
||||||
IReadOnlyList<WorkOrderType>? types)
|
IReadOnlyList<WorkOrderType>? types,
|
||||||
|
bool overdue = false,
|
||||||
|
DateTime? utcNow = null)
|
||||||
{
|
{
|
||||||
if (types == null || types.Count == 0)
|
var hasTypes = types != null && types.Count > 0;
|
||||||
|
if (!hasTypes && !overdue)
|
||||||
return query;
|
return query;
|
||||||
|
|
||||||
return query.Where(w => w.WorkOrderType != null && types.Contains(w.WorkOrderType.Value));
|
var today = (utcNow ?? DateTime.UtcNow).Date;
|
||||||
|
var typeFilters = hasTypes ? types!.ToList() : new List<WorkOrderType>();
|
||||||
|
|
||||||
|
// Keep past-due predicate inline — EF cannot translate local method calls.
|
||||||
|
if (overdue && !hasTypes)
|
||||||
|
{
|
||||||
|
return query.Where(w =>
|
||||||
|
w.ScheduledDate != null
|
||||||
|
&& w.ScheduledDate.Value.Date < today
|
||||||
|
&& w.LifecycleStatus != LifecycleStatus.Completed
|
||||||
|
&& w.LifecycleStatus != LifecycleStatus.Canceled);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overdue)
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
bool overdue = false)
|
||||||
|
=> ApplyTypeAndOverdueFilter(query, types, overdue);
|
||||||
|
|
||||||
#region Phase 2 filter helpers (not wired in Phase 1 board API)
|
#region Phase 2 filter helpers (not wired in Phase 1 board API)
|
||||||
|
|
||||||
public static IQueryable<WorkOrder> ApplySiteFilter(
|
public static IQueryable<WorkOrder> ApplySiteFilter(
|
||||||
|
|
@ -139,7 +176,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));
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||||
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo);
|
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo);
|
||||||
baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites);
|
baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites);
|
||||||
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types);
|
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue);
|
||||||
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
|
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
|
||||||
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
|
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
|
||||||
baseQuery = WorkOrderBoardQueryFilters.ApplyStatusFilter(baseQuery, query.Statuses);
|
baseQuery = WorkOrderBoardQueryFilters.ApplyStatusFilter(baseQuery, query.Statuses);
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||||
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
|
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
|
||||||
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
|
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
|
||||||
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types);
|
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue);
|
||||||
|
|
||||||
var scheduledBase = baseQuery.Where(w =>
|
var scheduledBase = baseQuery.Where(w =>
|
||||||
(w.ScheduledDate != null
|
(w.ScheduledDate != null
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
DateOnly DateTo,
|
DateOnly DateTo,
|
||||||
IReadOnlyList<string>? Sites,
|
IReadOnlyList<string>? Sites,
|
||||||
IReadOnlyList<WorkOrderType>? Types,
|
IReadOnlyList<WorkOrderType>? Types,
|
||||||
|
bool Overdue,
|
||||||
IReadOnlyList<string>? Dispatchers,
|
IReadOnlyList<string>? Dispatchers,
|
||||||
IReadOnlyList<LifecycleStatus>? Statuses,
|
IReadOnlyList<LifecycleStatus>? Statuses,
|
||||||
IReadOnlyList<string>? PmTypes,
|
IReadOnlyList<string>? PmTypes,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
IReadOnlyList<string>? Dispatchers,
|
IReadOnlyList<string>? Dispatchers,
|
||||||
bool MyWorkOrders,
|
bool MyWorkOrders,
|
||||||
IReadOnlyList<WorkOrderType>? Types,
|
IReadOnlyList<WorkOrderType>? Types,
|
||||||
|
bool Overdue,
|
||||||
string? Search,
|
string? Search,
|
||||||
string? CurrentUserId);
|
string? CurrentUserId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ namespace SeaHaven.Services.DTOs
|
||||||
public List<string>? Dispatchers { get; set; }
|
public List<string>? Dispatchers { get; set; }
|
||||||
public bool MyWorkOrders { get; set; }
|
public bool MyWorkOrders { get; set; }
|
||||||
public List<WorkOrderType>? Types { get; set; }
|
public List<WorkOrderType>? Types { get; set; }
|
||||||
|
/// <summary>When true, include past-due (isPastDue) rows. Combined with Types via OR.</summary>
|
||||||
|
public bool Overdue { get; set; }
|
||||||
public string? Search { get; set; }
|
public string? Search { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ namespace SeaHaven.Services.DTOs
|
||||||
/// <summary>Advanced search / filter query for the board.</summary>
|
/// <summary>Advanced search / filter query for the board.</summary>
|
||||||
public class WorkOrderAdvancedSearchQueryDto
|
public class WorkOrderAdvancedSearchQueryDto
|
||||||
{
|
{
|
||||||
public int Page { get; set; } = 1;
|
public int Page { get; set; } = 0;
|
||||||
public int PageSize { get; set; } = 50;
|
public int PageSize { get; set; } = 50;
|
||||||
public string? Search { get; set; }
|
public string? Search { get; set; }
|
||||||
public WorkOrderAdvancedSearchDatePreset? DatePreset { get; set; }
|
public WorkOrderAdvancedSearchDatePreset? DatePreset { get; set; }
|
||||||
|
|
@ -51,6 +51,8 @@ namespace SeaHaven.Services.DTOs
|
||||||
public DateOnly? DateTo { get; set; }
|
public DateOnly? DateTo { get; set; }
|
||||||
public List<string>? Sites { get; set; }
|
public List<string>? Sites { get; set; }
|
||||||
public List<WorkOrderType>? Types { get; set; }
|
public List<WorkOrderType>? Types { get; set; }
|
||||||
|
/// <summary>When true, include past-due (isPastDue) rows. Combined with Types via OR.</summary>
|
||||||
|
public bool Overdue { get; set; }
|
||||||
public List<string>? Dispatchers { get; set; }
|
public List<string>? Dispatchers { get; set; }
|
||||||
public List<LifecycleStatus>? Statuses { get; set; }
|
public List<LifecycleStatus>? Statuses { get; set; }
|
||||||
public List<string>? PmTypes { get; set; }
|
public List<string>? PmTypes { get; set; }
|
||||||
|
|
|
||||||
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,12 +30,15 @@ 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,
|
||||||
dateRange.To,
|
dateRange.To,
|
||||||
query.Sites,
|
query.Sites,
|
||||||
query.Types,
|
query.Types,
|
||||||
|
query.Overdue,
|
||||||
query.Dispatchers,
|
query.Dispatchers,
|
||||||
query.Statuses,
|
query.Statuses,
|
||||||
query.PmTypes,
|
query.PmTypes,
|
||||||
|
|
@ -45,7 +48,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 +59,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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
query.Dispatchers,
|
query.Dispatchers,
|
||||||
query.MyWorkOrders,
|
query.MyWorkOrders,
|
||||||
query.Types,
|
query.Types,
|
||||||
|
query.Overdue,
|
||||||
query.Search,
|
query.Search,
|
||||||
currentUserId);
|
currentUserId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Exceptions;
|
using SeaHaven.Services.Exceptions;
|
||||||
|
|
@ -11,13 +11,16 @@ namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
private readonly IWorkOrderDetailDataService _detailData;
|
private readonly IWorkOrderDetailDataService _detailData;
|
||||||
private readonly ICommentDataService _commentData;
|
private readonly ICommentDataService _commentData;
|
||||||
|
private readonly IUserDataService _userDataService;
|
||||||
|
|
||||||
public WorkOrderCommentService(
|
public WorkOrderCommentService(
|
||||||
IWorkOrderDetailDataService detailData,
|
IWorkOrderDetailDataService detailData,
|
||||||
ICommentDataService commentData)
|
ICommentDataService commentData,
|
||||||
|
IUserDataService userDataService)
|
||||||
{
|
{
|
||||||
_detailData = detailData;
|
_detailData = detailData;
|
||||||
_commentData = commentData;
|
_commentData = commentData;
|
||||||
|
_userDataService = userDataService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId)
|
public async Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId)
|
||||||
|
|
@ -26,7 +29,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var comments = await _detailData.GetCommentsAsync(workOrderId);
|
var comments = await _detailData.GetCommentsAsync(workOrderId);
|
||||||
return WorkOrderCommentProjection.ToDtos(comments);
|
var authorNames = await ResolveAuthorNamesAsync(comments);
|
||||||
|
return WorkOrderCommentProjection.ToDtos(comments, authorNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WorkOrderCommentDto> AddCommentAsync(
|
public async Task<WorkOrderCommentDto> AddCommentAsync(
|
||||||
|
|
@ -53,7 +57,58 @@ namespace SeaHaven.Services.Implementation
|
||||||
};
|
};
|
||||||
|
|
||||||
var saved = await _commentData.AddAsync(comment);
|
var saved = await _commentData.AddAsync(comment);
|
||||||
return WorkOrderCommentProjection.ToDto(saved);
|
var authorNames = await ResolveAuthorNamesAsync(new[] { saved });
|
||||||
|
return WorkOrderCommentProjection.ToDto(saved, authorNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderCommentDto> UpdateCommentAsync(
|
||||||
|
int workOrderId,
|
||||||
|
int commentId,
|
||||||
|
WorkOrderCommentCreateDto request,
|
||||||
|
string? actorId,
|
||||||
|
bool isAdmin)
|
||||||
|
{
|
||||||
|
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId);
|
||||||
|
if (workOrder == null)
|
||||||
|
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
|
||||||
|
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
|
||||||
|
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Text))
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidValue", "Comment text is required.");
|
||||||
|
|
||||||
|
var comment = await _commentData.GetByIdAsync(commentId);
|
||||||
|
if (comment == null || comment.WorkerOrderId != workOrderId || comment.IsDeleted == true)
|
||||||
|
throw new WorkOrderBoardValidationException("NotFound", "Comment not found.");
|
||||||
|
|
||||||
|
if (!IsBoardEditableComment(comment))
|
||||||
|
throw new WorkOrderBoardValidationException(
|
||||||
|
"NotEditable",
|
||||||
|
"Only board comments (CommentType General) can be edited via this endpoint.");
|
||||||
|
|
||||||
|
var isAuthor = !string.IsNullOrWhiteSpace(actorId)
|
||||||
|
&& string.Equals(comment.UserId, actorId, StringComparison.Ordinal);
|
||||||
|
if (!isAuthor && !isAdmin)
|
||||||
|
throw new WorkOrderBoardValidationException("Forbidden", "You are not allowed to edit this comment.");
|
||||||
|
|
||||||
|
comment.Commenttext = request.Text.Trim();
|
||||||
|
comment.LastModificationTime = DateTime.UtcNow;
|
||||||
|
await _commentData.UpdateAsync(comment);
|
||||||
|
|
||||||
|
var authorNames = await ResolveAuthorNamesAsync(new[] { comment });
|
||||||
|
return WorkOrderCommentProjection.ToDto(comment, authorNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsBoardEditableComment(Comments comment)
|
||||||
|
=> string.Equals(comment.CommentType, "General", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(comment.RecordType, "WorkOrder", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private async Task<IReadOnlyDictionary<string, string>> ResolveAuthorNamesAsync(
|
||||||
|
IEnumerable<Comments> comments)
|
||||||
|
{
|
||||||
|
var authorIds = WorkOrderCommentProjection.CollectAuthorUserIds(comments);
|
||||||
|
return await _userDataService.GetDisplayNamesByIdsAsync(authorIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
namespace SeaHaven.Services.Interfaces
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
|
@ -7,5 +7,11 @@ namespace SeaHaven.Services.Interfaces
|
||||||
{
|
{
|
||||||
Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId);
|
Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId);
|
||||||
Task<WorkOrderCommentDto> AddCommentAsync(int workOrderId, WorkOrderCommentCreateDto request, string? actorId, string? documentUrl = null);
|
Task<WorkOrderCommentDto> AddCommentAsync(int workOrderId, WorkOrderCommentCreateDto request, string? actorId, string? documentUrl = null);
|
||||||
|
Task<WorkOrderCommentDto> UpdateCommentAsync(
|
||||||
|
int workOrderId,
|
||||||
|
int commentId,
|
||||||
|
WorkOrderCommentCreateDto request,
|
||||||
|
string? actorId,
|
||||||
|
bool isAdmin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
@ -128,6 +134,7 @@ public class WorkOrderBoardSearchCountsTests
|
||||||
null,
|
null,
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
|
false,
|
||||||
"BK5",
|
"BK5",
|
||||||
null));
|
null));
|
||||||
|
|
||||||
|
|
@ -152,7 +159,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 +220,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 +244,365 @@ 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_TypesOther_ReturnsOtherRowsNotPastDueSentinel()
|
||||||
|
{
|
||||||
|
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(2, result.Items.First().Id);
|
||||||
|
Assert.Equal(WorkOrderType.Other, result.Items.First().WorkOrderType);
|
||||||
|
Assert.False(result.Items.First().IsPastDue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchAsync_OverdueTrue_ReturnsPastDueRowsOnly()
|
||||||
|
{
|
||||||
|
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"
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 3,
|
||||||
|
WorkOrderType = WorkOrderType.Reactive,
|
||||||
|
ScheduledDate = today.AddDays(1),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
InternalWONumber = "10000000003"
|
||||||
|
});
|
||||||
|
|
||||||
|
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)),
|
||||||
|
Overdue = true
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.TotalCount);
|
||||||
|
Assert.Equal(1, result.Items.First().Id);
|
||||||
|
Assert.True(result.Items.First().IsPastDue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchAsync_OtherPlusOverdue_ReturnsUnion()
|
||||||
|
{
|
||||||
|
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"
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 3,
|
||||||
|
WorkOrderType = WorkOrderType.Reactive,
|
||||||
|
ScheduledDate = today.AddDays(1),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
InternalWONumber = "10000000003"
|
||||||
|
});
|
||||||
|
|
||||||
|
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 },
|
||||||
|
Overdue = true
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.TotalCount);
|
||||||
|
Assert.Contains(result.Items, i => i.Id == 1 && i.IsPastDue);
|
||||||
|
Assert.Contains(result.Items, i => i.Id == 2 && i.WorkOrderType == WorkOrderType.Other);
|
||||||
|
Assert.DoesNotContain(result.Items, i => i.Id == 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchAsync_OverdueDerivesFromDateAndStatus_NotWorkOrderType()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var today = DateTime.UtcNow.Date;
|
||||||
|
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
WorkOrderType = WorkOrderType.Other,
|
||||||
|
ScheduledDate = today.AddDays(5),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
InternalWONumber = "10000000001"
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
ScheduledDate = today.AddDays(-3),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
InternalWONumber = "10000000002"
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 3,
|
||||||
|
WorkOrderType = WorkOrderType.Reactive,
|
||||||
|
ScheduledDate = today.AddDays(-2),
|
||||||
|
LifecycleStatus = LifecycleStatus.Completed,
|
||||||
|
InternalWONumber = "10000000003"
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 4,
|
||||||
|
WorkOrderType = WorkOrderType.Reactive,
|
||||||
|
ScheduledDate = today.AddDays(-1),
|
||||||
|
LifecycleStatus = LifecycleStatus.Canceled,
|
||||||
|
InternalWONumber = "10000000004"
|
||||||
|
});
|
||||||
|
|
||||||
|
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)),
|
||||||
|
Overdue = true,
|
||||||
|
Page = 0,
|
||||||
|
PageSize = 50
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
var overdue = Assert.Single(result.Items);
|
||||||
|
Assert.Equal(0, result.Page);
|
||||||
|
Assert.Equal(2, overdue.Id);
|
||||||
|
Assert.True(overdue.IsPastDue);
|
||||||
|
Assert.DoesNotContain(result.Items, i => i.Id == 1);
|
||||||
|
Assert.DoesNotContain(result.Items, i => i.Id == 3);
|
||||||
|
Assert.DoesNotContain(result.Items, i => i.Id == 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchAsync_TreatsOtherAsRealTypeFilter_NotOverdueSentinel()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var today = DateTime.UtcNow.Date;
|
||||||
|
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
ScheduledDate = today.AddDays(2),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
InternalWONumber = "10000000001"
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
WorkOrderType = WorkOrderType.Other,
|
||||||
|
ScheduledDate = today.AddDays(2),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
InternalWONumber = "10000000002"
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 3,
|
||||||
|
WorkOrderType = WorkOrderType.Reactive,
|
||||||
|
ScheduledDate = today.AddDays(-3),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
InternalWONumber = "10000000003"
|
||||||
|
});
|
||||||
|
|
||||||
|
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.PM, WorkOrderType.Other },
|
||||||
|
Page = 0,
|
||||||
|
PageSize = 50
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.TotalCount);
|
||||||
|
Assert.Equal(0, result.Page);
|
||||||
|
Assert.Contains(result.Items, i => i.Id == 1 && i.WorkOrderType == WorkOrderType.PM);
|
||||||
|
Assert.Contains(result.Items, i => i.Id == 2 && i.WorkOrderType == WorkOrderType.Other);
|
||||||
|
Assert.DoesNotContain(result.Items, i => i.Id == 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ public class WorkOrderBoardDataServiceTests
|
||||||
null,
|
null,
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
|
false,
|
||||||
null,
|
null,
|
||||||
null));
|
null));
|
||||||
|
|
||||||
|
|
@ -109,6 +110,7 @@ public class WorkOrderBoardDataServiceTests
|
||||||
new[] { "user-a" },
|
new[] { "user-a" },
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
|
false,
|
||||||
null,
|
null,
|
||||||
null));
|
null));
|
||||||
|
|
||||||
|
|
@ -147,6 +149,7 @@ public class WorkOrderBoardDataServiceTests
|
||||||
null,
|
null,
|
||||||
true,
|
true,
|
||||||
null,
|
null,
|
||||||
|
false,
|
||||||
null,
|
null,
|
||||||
"me"));
|
"me"));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -313,7 +313,8 @@ public class WorkOrderCommentServiceTests
|
||||||
|
|
||||||
var detailData = new WorkOrderDetailDataService(context);
|
var detailData = new WorkOrderDetailDataService(context);
|
||||||
var commentData = new CommentDataService(context);
|
var commentData = new CommentDataService(context);
|
||||||
var service = new WorkOrderCommentService(detailData, commentData);
|
var userData = new UserDataService(context);
|
||||||
|
var service = new WorkOrderCommentService(detailData, commentData, userData);
|
||||||
|
|
||||||
var result = await service.AddCommentAsync(1, new WorkOrderCommentCreateDto { Text = "Note" }, "user-abc");
|
var result = await service.AddCommentAsync(1, new WorkOrderCommentCreateDto { Text = "Note" }, "user-abc");
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue