feat(work-orders): add weekly board read API (Phase 1)

- GET /api/workorders/board with week window, Unscheduled section, and X of Y counts
- GET /api/workorders/lookups/dispatchers for SHOC filter avatars
- Board projection via WorkOrderBoardDataService with vendor/dispatch join and derived isPastDue
- Phase1_BoardIndexes migration (IX_workOrders_ScheduledDate)
- 5 board unit tests
This commit is contained in:
Arthur Bassi 2026-07-07 10:10:06 -03:00
parent e5902e1df3
commit ff53cb9fa6
21 changed files with 1255 additions and 56 deletions

View file

@ -42,6 +42,7 @@ namespace Api.SeaHavenIndustries.Controllers
private readonly Helper.SendMessage _sendMessage;
private readonly Helper.VendorPortalTokenService _vendorTokens;
private readonly IConfiguration _config;
private readonly IWorkOrderBoardService _workOrderBoardService;
public WorkOrderController(
UserManager<ApplicationUser> userManager,
@ -58,7 +59,8 @@ namespace Api.SeaHavenIndustries.Controllers
IHttpContextAccessor httpContext,
Helper.SendMessage sendMessage,
Helper.VendorPortalTokenService vendorTokens,
IConfiguration config)
IConfiguration config,
IWorkOrderBoardService workOrderBoardService)
{
_userManager = userManager;
_workOrderService = workOrderService;
@ -75,7 +77,40 @@ namespace Api.SeaHavenIndustries.Controllers
_sendMessage = sendMessage;
_vendorTokens = vendorTokens;
_config = config;
_workOrderBoardService = workOrderBoardService;
}
[HttpGet("board")]
public async Task<IActionResult> GetBoard(
[FromQuery] DateOnly weekStart,
[FromQuery] DateOnly? weekEnd = null,
[FromQuery] List<string>? dispatchers = null,
[FromQuery] bool myWorkOrders = false,
[FromQuery] List<WorkOrderType>? types = null,
[FromQuery] string? search = null)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var query = new WorkOrderBoardQueryDto
{
WeekStart = weekStart,
WeekEnd = weekEnd,
Dispatchers = dispatchers,
MyWorkOrders = myWorkOrders,
Types = types,
Search = search
};
var result = await _workOrderBoardService.GetBoardAsync(query, userId);
return Ok(result);
}
[HttpGet("lookups/dispatchers")]
public async Task<IActionResult> GetDispatcherLookups()
{
var result = await _workOrderBoardService.GetDispatcherLookupsAsync();
return Ok(result);
}
[HttpPost]
[Route("AddWorkorder")]
public async Task<IActionResult> Addworkorder([FromForm] Workorder_DTO model)

View file

@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public class Phase1_BoardIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = 'IX_workOrders_ScheduledDate'
AND object_id = OBJECT_ID('workOrders'))
BEGIN
CREATE INDEX IX_workOrders_ScheduledDate ON workOrders (ScheduledDate);
END
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
IF EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = 'IX_workOrders_ScheduledDate'
AND object_id = OBJECT_ID('workOrders'))
DROP INDEX IX_workOrders_ScheduledDate ON workOrders;
""");
}
}
}

View file

@ -2035,6 +2035,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("PrimaryDispatchId");
b.HasIndex("ScheduledDate");
b.ToTable("workOrders");
});

View file

@ -0,0 +1,7 @@
namespace SeaHaven.DataServices.Board
{
public static class WorkOrderBoardConstants
{
public const int ClientSideThreshold = 300;
}
}

View file

@ -0,0 +1,10 @@
namespace SeaHaven.DataServices.DTOs
{
public record WorkOrderBoardQuery(
DateOnly ScheduledFrom,
DateOnly ScheduledTo,
string? Search,
IReadOnlyList<string>? AssigneeIds,
IReadOnlyList<string>? DbStatuses,
int? LocationId);
}

View file

@ -0,0 +1,20 @@
namespace SeaHaven.DataServices.DTOs
{
public record WorkOrderBoardRowData(
int Id,
string? WoNumber,
string? SiteCode,
string? LocationLabel,
DateTime? ScheduledDate,
DateTime? ScheduledStart,
DateTime? DueDate,
string? WorkOrderType,
string? ServiceType,
string? AssigneeId,
string? AssigneeName,
string? VendorCompany,
string? VendorTechnician,
string? Status,
string? PocName,
string? PocPhone);
}

View file

@ -0,0 +1,9 @@
namespace SeaHaven.DataServices.Exceptions
{
public class BoardQueryException : Exception
{
public BoardQueryException(string message) : base(message)
{
}
}
}

View file

@ -0,0 +1,102 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Helpers
{
public static class WorkOrderBoardProjection
{
public static async Task<IReadOnlyList<WorkOrderBoardRawRow>> ProjectRowsAsync(
IQueryable<WorkOrder> query,
bool isUnscheduled)
{
var rows = await query
.Select(w => new
{
w.Id,
w.InternalWONumber,
w.RescheduleCount,
w.CarriedOver,
w.WorkOrderType,
w.SiteCode,
LocationName = w.Locations != null ? w.Locations.Name : null,
Poc = w.WorkOrderContacts!
.OrderBy(c => c.Id)
.Select(c => new
{
Name = c.POC != null
? ((c.POC.FirstName ?? "") + " " + (c.POC.LastName ?? "")).Trim()
: null,
c.POC!.PhoneNumber,
c.Notes
})
.FirstOrDefault(),
w.LifecycleStatus,
w.LegacyStatus,
w.Status,
w.AssignTo,
w.AssignToUser!.FirstName,
w.AssignToUser!.LastName,
w.AssignToUser!.Initials,
w.AssignToUser!.Color,
w.DueDate,
w.ScheduledDate,
w.ScheduledStart,
w.ScheduledEnd,
w.TargetWeek,
w.ScheduleWeekOnly,
VendorId = w.PrimaryDispatch != null ? (int?)w.PrimaryDispatch.VendorId : null,
VendorName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
? w.PrimaryDispatch.Vendor.CompanyName
: null,
TechName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
? w.PrimaryDispatch.Vendor.ContactName
: null,
TechPhone = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
? w.PrimaryDispatch.Vendor.Phone
: null,
DispatchApptDate = w.PrimaryDispatch != null ? w.PrimaryDispatch.ScheduledDate : null,
w.Trade,
w.Problem,
w.DocStatus,
w.RowVersion
})
.ToListAsync();
return rows.Select(w => new WorkOrderBoardRawRow(
w.Id,
w.InternalWONumber,
w.RescheduleCount,
w.CarriedOver,
w.WorkOrderType,
w.SiteCode,
w.LocationName,
w.Poc?.Name,
w.Poc?.PhoneNumber,
w.Poc?.Notes,
w.LifecycleStatus,
w.LegacyStatus ?? w.Status,
w.AssignTo,
w.FirstName,
w.LastName,
w.Initials,
w.Color,
w.DueDate,
w.ScheduledDate,
w.ScheduledStart,
w.ScheduledEnd,
w.TargetWeek,
w.ScheduleWeekOnly,
w.VendorId,
w.VendorName,
w.TechName,
w.TechPhone,
w.DispatchApptDate,
w.Trade ?? w.Problem,
w.Problem,
w.DocStatus,
w.RowVersion,
isUnscheduled)).ToList();
}
}
}

View file

@ -0,0 +1,138 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Helpers
{
public static class WorkOrderBoardQueryFilters
{
public static IQueryable<WorkOrder> ApplyBaseScope(IQueryable<WorkOrder> query)
=> query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
public static IQueryable<WorkOrder> ApplyDispatcherFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<string>? dispatchers,
bool myWorkOrders,
string? currentUserId)
{
if (myWorkOrders && !string.IsNullOrEmpty(currentUserId))
return query.Where(w => w.AssignTo == currentUserId);
if (dispatchers == null || dispatchers.Count == 0)
return query;
var ids = dispatchers
.Where(d => d != "__unassigned__")
.ToList();
var includeUnassigned = dispatchers.Contains("__unassigned__");
if (includeUnassigned && ids.Count == 0)
return query.Where(w => w.AssignTo == null || w.AssignTo == "");
if (includeUnassigned && ids.Count > 0)
{
return query.Where(w =>
w.AssignTo == null || w.AssignTo == "" || ids.Contains(w.AssignTo));
}
return query.Where(w => w.AssignTo != null && ids.Contains(w.AssignTo));
}
public static IQueryable<WorkOrder> ApplyTypeFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<WorkOrderType>? types)
{
if (types == null || types.Count == 0)
return query;
return query.Where(w => w.WorkOrderType != null && types.Contains(w.WorkOrderType.Value));
}
public static IQueryable<WorkOrder> ApplySiteFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<string>? sites)
{
if (sites == null || sites.Count == 0)
return query;
var normalized = sites
.Where(s => !string.IsNullOrWhiteSpace(s))
.Select(s => s.Trim())
.ToList();
if (normalized.Count == 0)
return query;
return query.Where(w => w.SiteCode != null && normalized.Contains(w.SiteCode));
}
public static IQueryable<WorkOrder> ApplyStatusFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<LifecycleStatus>? statuses)
{
if (statuses == null || statuses.Count == 0)
return query;
return query.Where(w => w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value));
}
public static IQueryable<WorkOrder> ApplyDocStatusFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<DocStatus>? docStatuses)
{
if (docStatuses == null || docStatuses.Count == 0)
return query;
return query.Where(w => w.DocStatus != null && docStatuses.Contains(w.DocStatus.Value));
}
public static IQueryable<WorkOrder> ApplyVendorFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<int>? vendorIds)
{
if (vendorIds == null || vendorIds.Count == 0)
return query;
return query.Where(w =>
w.PrimaryDispatch != null && vendorIds.Contains(w.PrimaryDispatch.VendorId));
}
public static IQueryable<WorkOrder> ApplyPmTypeFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<string>? pmTypes)
{
if (pmTypes == null || pmTypes.Count == 0)
return query;
var normalized = pmTypes
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p.Trim().ToLower())
.ToList();
if (normalized.Count == 0)
return query;
return query.Where(w =>
normalized.Any(pm =>
(w.Trade ?? "").ToLower().Contains(pm)
|| (w.Problem ?? "").ToLower().Contains(pm)));
}
public static IQueryable<WorkOrder> ApplyDateRangeFilter(
IQueryable<WorkOrder> query,
DateOnly dateFrom,
DateOnly dateTo)
{
var fromDate = dateFrom.ToDateTime(TimeOnly.MinValue).Date;
var toDate = dateTo.ToDateTime(TimeOnly.MinValue).Date;
return query.Where(w =>
(w.ScheduledDate != null
&& w.ScheduledDate.Value.Date >= fromDate
&& w.ScheduledDate.Value.Date <= toDate)
|| (w.ScheduleWeekOnly == true
&& w.TargetWeek != null
&& w.TargetWeek >= dateFrom
&& w.TargetWeek <= dateTo));
}
}
}

View file

@ -0,0 +1,219 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class WorkOrderBoardDataService : IWorkOrderBoardDataService
{
private readonly ApplicationDbContext _context;
public WorkOrderBoardDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query)
{
var weekStartDate = query.WeekStart.ToDateTime(TimeOnly.MinValue);
var weekEndDate = query.WeekEnd.ToDateTime(TimeOnly.MinValue);
var baseQuery = _context.workOrders
.AsNoTracking()
.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
baseQuery = ApplyDispatcherFilter(baseQuery, query);
baseQuery = ApplyTypeFilter(baseQuery, query);
var scheduledBase = baseQuery.Where(w =>
(w.ScheduledDate != null
&& w.ScheduledDate.Value.Date >= weekStartDate.Date
&& w.ScheduledDate.Value.Date <= weekEndDate.Date)
|| (w.ScheduleWeekOnly == true && w.TargetWeek == query.WeekStart));
var unscheduledBase = baseQuery.Where(w =>
w.ScheduledDate == null
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled);
var scheduledTotal = await scheduledBase.CountAsync();
if (!string.IsNullOrWhiteSpace(query.Search))
{
scheduledBase = ApplySearchFilter(scheduledBase, query.Search);
unscheduledBase = ApplySearchFilter(unscheduledBase, query.Search);
}
var scheduledRows = await ProjectRows(scheduledBase, isUnscheduled: false);
var unscheduledRows = await ProjectRows(unscheduledBase, isUnscheduled: true);
return new WorkOrderBoardQueryResult(scheduledRows, unscheduledRows, scheduledTotal);
}
public async Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync()
{
return await _context.Users
.AsNoTracking()
.Where(u => u.IsDeleted != true)
.OrderBy(u => u.FirstName)
.ThenBy(u => u.LastName)
.Select(u => new DispatcherLookupRow(
u.Id,
u.FirstName ?? "",
u.LastName,
u.Initials,
u.Color))
.ToListAsync();
}
private static IQueryable<WorkOrder> ApplyDispatcherFilter(IQueryable<WorkOrder> query, WorkOrderBoardQuery boardQuery)
{
if (boardQuery.MyWorkOrders && !string.IsNullOrEmpty(boardQuery.CurrentUserId))
return query.Where(w => w.AssignTo == boardQuery.CurrentUserId);
if (boardQuery.Dispatchers == null || boardQuery.Dispatchers.Count == 0)
return query;
var ids = boardQuery.Dispatchers
.Where(d => d != "__unassigned__")
.ToList();
var includeUnassigned = boardQuery.Dispatchers.Contains("__unassigned__");
if (includeUnassigned && ids.Count == 0)
return query.Where(w => w.AssignTo == null || w.AssignTo == "");
if (includeUnassigned && ids.Count > 0)
{
return query.Where(w =>
w.AssignTo == null || w.AssignTo == "" || ids.Contains(w.AssignTo));
}
return query.Where(w => w.AssignTo != null && ids.Contains(w.AssignTo));
}
private static IQueryable<WorkOrder> ApplyTypeFilter(IQueryable<WorkOrder> query, WorkOrderBoardQuery boardQuery)
{
if (boardQuery.Types == null || boardQuery.Types.Count == 0)
return query;
return query.Where(w => w.WorkOrderType != null && boardQuery.Types.Contains(w.WorkOrderType.Value));
}
private static IQueryable<WorkOrder> ApplySearchFilter(IQueryable<WorkOrder> query, string search)
{
var s = search.Trim().ToLower();
return query.Where(w =>
(w.SiteCode ?? "").ToLower().Contains(s)
|| (w.InternalWONumber ?? "").ToLower().Contains(s)
|| (w.WorkerOrderNumber ?? "").ToLower().Contains(s)
|| (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s))
|| (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s))
|| (w.Trade ?? "").ToLower().Contains(s)
|| (w.Problem ?? "").ToLower().Contains(s)
|| (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s))
|| (w.Status ?? "").ToLower().Contains(s));
}
private async Task<IReadOnlyList<WorkOrderBoardRawRow>> ProjectRows(
IQueryable<WorkOrder> query,
bool isUnscheduled)
{
var rows = await query
.Include(w => w.AssignToUser)
.Include(w => w.Locations)
.Include(w => w.PrimaryDispatch!)
.ThenInclude(d => d!.Vendor)
.Include(w => w.WorkOrderContacts!)
.ThenInclude(c => c.POC)
.OrderBy(w => w.ScheduledDate)
.ThenBy(w => w.InternalWONumber)
.Select(w => new
{
w.Id,
w.InternalWONumber,
w.RescheduleCount,
w.CarriedOver,
w.WorkOrderType,
w.SiteCode,
LocationName = w.Locations != null ? w.Locations.Name : null,
Poc = w.WorkOrderContacts!
.OrderBy(c => c.Id)
.Select(c => new
{
Name = c.POC != null
? ((c.POC.FirstName ?? "") + " " + (c.POC.LastName ?? "")).Trim()
: null,
c.POC!.PhoneNumber,
c.Notes
})
.FirstOrDefault(),
w.LifecycleStatus,
w.LegacyStatus,
w.Status,
w.AssignTo,
w.AssignToUser!.FirstName,
w.AssignToUser!.LastName,
w.AssignToUser!.Initials,
w.AssignToUser!.Color,
w.DueDate,
w.ScheduledDate,
w.ScheduledStart,
w.ScheduledEnd,
w.TargetWeek,
w.ScheduleWeekOnly,
VendorId = w.PrimaryDispatch != null ? (int?)w.PrimaryDispatch.VendorId : null,
VendorName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
? w.PrimaryDispatch.Vendor.CompanyName
: null,
TechName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
? w.PrimaryDispatch.Vendor.ContactName
: null,
TechPhone = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
? w.PrimaryDispatch.Vendor.Phone
: null,
DispatchApptDate = w.PrimaryDispatch != null ? w.PrimaryDispatch.ScheduledDate : null,
w.Trade,
w.Problem,
w.DocStatus,
w.RowVersion
})
.ToListAsync();
return rows.Select(w => new WorkOrderBoardRawRow(
w.Id,
w.InternalWONumber,
w.RescheduleCount,
w.CarriedOver,
w.WorkOrderType,
w.SiteCode,
w.LocationName,
w.Poc?.Name,
w.Poc?.PhoneNumber,
w.Poc?.Notes,
w.LifecycleStatus,
w.LegacyStatus ?? w.Status,
w.AssignTo,
w.FirstName,
w.LastName,
w.Initials,
w.Color,
w.DueDate,
w.ScheduledDate,
w.ScheduledStart,
w.ScheduledEnd,
w.TargetWeek,
w.ScheduleWeekOnly,
w.VendorId,
w.VendorName,
w.TechName,
w.TechPhone,
w.DispatchApptDate,
w.Trade ?? w.Problem,
w.Problem,
w.DocStatus,
w.RowVersion,
isUnscheduled)).ToList();
}
}
}

View file

@ -0,0 +1,10 @@
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderBoardDataService
{
Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query);
Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync();
}
}

View file

@ -0,0 +1,9 @@
namespace SeaHaven.DataServices.Interfaces
{
public record PrimaryDispatchProjection(
int WorkOrderId,
int? DispatchId,
string? VendorName,
DateTime? ApptDate,
string? DispatchStatus);
}

View file

@ -0,0 +1,60 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Interfaces
{
public record WorkOrderBoardQuery(
DateOnly WeekStart,
DateOnly WeekEnd,
IReadOnlyList<string>? Dispatchers,
bool MyWorkOrders,
IReadOnlyList<WorkOrderType>? Types,
string? Search,
string? CurrentUserId);
public record WorkOrderBoardRawRow(
int Id,
string? InternalWONumber,
int RescheduleCount,
int CarriedOver,
WorkOrderType? WorkOrderType,
string? SiteCode,
string? LocationName,
string? PocName,
string? PocPhone,
string? PocNotes,
LifecycleStatus? LifecycleStatus,
string? LegacyStatus,
string? AssignTo,
string? DispatcherFirstName,
string? DispatcherLastName,
string? Initials,
string? Color,
DateTime? DueDate,
DateTime? ScheduledDate,
DateTime? ScheduledStart,
DateTime? ScheduledEnd,
DateOnly? TargetWeek,
bool? ScheduleWeekOnly,
int? VendorId,
string? VendorName,
string? TechName,
string? TechPhone,
DateTime? DispatchApptDate,
string? Trade,
string? Problem,
DocStatus? DocStatus,
byte[]? RowVersion,
bool IsUnscheduled);
public record WorkOrderBoardQueryResult(
IReadOnlyList<WorkOrderBoardRawRow> ScheduledRows,
IReadOnlyList<WorkOrderBoardRawRow> UnscheduledRows,
int ScheduledTotalBeforeSearch);
public record DispatcherLookupRow(
string Id,
string FirstName,
string? LastName,
string? Initials,
string? Color);
}

View file

@ -0,0 +1,8 @@
namespace SeaHaven.Services.Board
{
public static class WorkOrderBoardConstants
{
public const int MaxWindowDays = 90;
public const int ClientSideThreshold = 300;
}
}

View file

@ -0,0 +1,73 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.DTOs
{
public class WorkOrderBoardQueryDto
{
public DateOnly WeekStart { get; set; }
public DateOnly? WeekEnd { get; set; }
public List<string>? Dispatchers { get; set; }
public bool MyWorkOrders { get; set; }
public List<WorkOrderType>? Types { get; set; }
public string? Search { get; set; }
}
public class WorkOrderBoardCountsDto
{
public int Returned { get; set; }
public int Total { get; set; }
}
public class WorkOrderBoardRowDto
{
public int Id { get; set; }
public string? WoNumber { get; set; }
public int RescheduleCount { get; set; }
public int CarriedOver { get; set; }
public WorkOrderType? WorkOrderType { get; set; }
public bool IsPastDue { get; set; }
public string? SiteCode { get; set; }
public string? LocationName { get; set; }
public string? PocName { get; set; }
public string? PocPhone { get; set; }
public string? PocNotes { get; set; }
public LifecycleStatus? LifecycleStatus { get; set; }
public string? LifecycleStatusLabel { get; set; }
public string? LegacyStatus { get; set; }
public string? DispatcherId { get; set; }
public string? DispatcherName { get; set; }
public string? Initials { get; set; }
public string? Color { get; set; }
public DateTime? DueDate { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateOnly? TargetWeek { get; set; }
public bool? ScheduleWeekOnly { get; set; }
public string? DayGroup { get; set; }
public int? VendorId { get; set; }
public string? VendorName { get; set; }
public string? TechName { get; set; }
public string? TechPhone { get; set; }
public DateTime? ApptDate { get; set; }
public string? ApptTime { get; set; }
public string? Pm { get; set; }
public DocStatus? DocStatus { get; set; }
public byte[]? RowVersion { get; set; }
}
public class WorkOrderBoardResponseDto
{
public DateOnly WeekStart { get; set; }
public DateOnly WeekEnd { get; set; }
public WorkOrderBoardCountsDto Counts { get; set; } = new();
public List<WorkOrderBoardRowDto> Unscheduled { get; set; } = new();
public List<WorkOrderBoardRowDto> Scheduled { get; set; } = new();
}
public class DispatcherLookupDto
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string? Initials { get; set; }
public string? Color { get; set; }
}
}

View file

@ -0,0 +1,57 @@
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderBoardApptTimeParser
{
public static bool TryParse(string? value, out TimeSpan? start, out TimeSpan? end, out string? error)
{
start = null;
end = null;
error = null;
if (string.IsNullOrWhiteSpace(value))
return true;
var trimmed = value.Trim();
var parts = trimmed.Split(new[] { '–', '—', '-' })
.Select(p => p.Trim())
.Where(p => !string.IsNullOrEmpty(p))
.ToArray();
if (parts.Length == 1)
{
if (!TimeSpan.TryParse(parts[0], out var single))
{
error = "Invalid appointment time format.";
return false;
}
start = single;
return true;
}
if (parts.Length == 2)
{
if (!TimeSpan.TryParse(parts[0], out var startTime) || !TimeSpan.TryParse(parts[1], out var endTime))
{
error = "Invalid appointment time range format.";
return false;
}
start = startTime;
end = endTime;
return true;
}
error = "Invalid appointment time format.";
return false;
}
public static DateTime? CombineDateAndTime(DateTime? date, TimeSpan? time)
{
if (!date.HasValue || !time.HasValue)
return date;
return date.Value.Date.Add(time.Value);
}
}
}

View file

@ -1,55 +1,99 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderDerivedFields
{
/// <summary>
/// Past due overlay — never a WorkOrderType. Matches SHOC board design.
/// </summary>
public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null)
{
var today = asOf ?? DateOnly.FromDateTime(DateTime.UtcNow);
if (workOrder.ScheduledDate == null)
return false;
if (LifecycleStatusMapper.IsTerminal(workOrder.LifecycleStatus))
return false;
return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today;
}
/// <summary>
/// Sets initial lifecycle on create. New WOs start Incomplete unless auto-scheduled.
/// </summary>
public static void SetInitialLifecycleStatus(WorkOrder workOrder)
{
workOrder.LifecycleStatus ??= LifecycleStatus.Incomplete;
ApplyAutoScheduleIfEligible(workOrder);
}
/// <summary>
/// SHOC rule: specific scheduled date + assignee + Incomplete → Scheduled.
/// Week-only targets do not auto-schedule.
/// </summary>
public static bool ApplyAutoScheduleIfEligible(WorkOrder workOrder)
{
if (workOrder.LifecycleStatus != LifecycleStatus.Incomplete)
return false;
if (workOrder.ScheduleWeekOnly == true)
return false;
if (workOrder.ScheduledDate == null)
return false;
if (string.IsNullOrWhiteSpace(workOrder.AssignTo))
return false;
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
return true;
}
}
}
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderDerivedFields
{
/// <summary>
/// Past due overlay — never a WorkOrderType. Matches SHOC board design.
/// </summary>
public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null)
{
var today = asOf ?? DateOnly.FromDateTime(DateTime.UtcNow);
if (workOrder.ScheduledDate == null)
return false;
if (LifecycleStatusMapper.IsTerminal(workOrder.LifecycleStatus))
return false;
return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today;
}
public static bool IsPastDue(
DateTime? scheduledDate,
LifecycleStatus? lifecycleStatus,
DateTime? utcNow = null)
{
if (!scheduledDate.HasValue)
return false;
if (LifecycleStatusMapper.IsTerminal(lifecycleStatus))
return false;
var today = (utcNow ?? DateTime.UtcNow).Date;
return scheduledDate.Value.Date < today;
}
/// <summary>
/// Sets initial lifecycle on create. New WOs start Incomplete unless auto-scheduled.
/// </summary>
public static void SetInitialLifecycleStatus(WorkOrder workOrder)
{
workOrder.LifecycleStatus ??= LifecycleStatus.Incomplete;
ApplyAutoScheduleIfEligible(workOrder);
}
/// <summary>
/// SHOC rule: specific scheduled date + assignee + Incomplete → Scheduled.
/// Week-only targets do not auto-schedule.
/// </summary>
public static bool ApplyAutoScheduleIfEligible(WorkOrder workOrder)
{
if (workOrder.LifecycleStatus != LifecycleStatus.Incomplete)
return false;
if (workOrder.ScheduleWeekOnly == true)
return false;
if (workOrder.ScheduledDate == null)
return false;
if (string.IsNullOrWhiteSpace(workOrder.AssignTo))
return false;
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
return true;
}
public static string? GetDayGroup(DateTime? scheduledDate)
{
if (!scheduledDate.HasValue)
return null;
return scheduledDate.Value.DayOfWeek switch
{
DayOfWeek.Monday => "monday",
DayOfWeek.Tuesday => "tuesday",
DayOfWeek.Wednesday => "wednesday",
DayOfWeek.Thursday => "thursday",
DayOfWeek.Friday => "friday",
_ => null
};
}
public static string? FormatApptTime(DateTime? start, DateTime? end)
{
if (!start.HasValue)
return null;
if (end.HasValue)
return $"{start.Value:HH:mm} – {end.Value:HH:mm}";
return start.Value.ToString("HH:mm");
}
public static string GetLifecycleStatusLabel(LifecycleStatus? status)
=> status.HasValue ? LifecycleStatusMapper.ToFeLabel(status.Value) : "";
}
}

View file

@ -0,0 +1,20 @@
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderOperationalWeek
{
public static DateOnly GetMondayOfWeek(DateOnly date)
{
var daysFromMonday = ((int)date.DayOfWeek + 6) % 7;
return date.AddDays(-daysFromMonday);
}
public static DateOnly GetPreviousOperationalWeekStart(DateTime utcNow)
=> GetMondayOfWeek(DateOnly.FromDateTime(utcNow)).AddDays(-7);
public static DateOnly GetOperationalWeekEnd(DateOnly weekStartMonday)
=> weekStartMonday.AddDays(4);
public static string BuildWeekCorrelationId(DateOnly sourceWeekStart)
=> $"week:{sourceWeekStart:yyyy-MM-dd}";
}
}

View file

@ -0,0 +1,120 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderBoardService : IWorkOrderBoardService
{
private readonly IWorkOrderBoardDataService _boardDataService;
public WorkOrderBoardService(IWorkOrderBoardDataService boardDataService)
{
_boardDataService = boardDataService;
}
public async Task<WorkOrderBoardResponseDto> GetBoardAsync(
WorkOrderBoardQueryDto query,
string? currentUserId)
{
var weekStart = query.WeekStart;
var weekEnd = query.WeekEnd ?? weekStart.AddDays(4);
ValidateWeekWindow(weekStart, weekEnd);
var dataQuery = new WorkOrderBoardQuery(
weekStart,
weekEnd,
query.Dispatchers,
query.MyWorkOrders,
query.Types,
query.Search,
currentUserId);
var result = await _boardDataService.GetBoardRowsAsync(dataQuery);
var utcNow = DateTime.UtcNow;
return new WorkOrderBoardResponseDto
{
WeekStart = weekStart,
WeekEnd = weekEnd,
Counts = new WorkOrderBoardCountsDto
{
Total = result.ScheduledTotalBeforeSearch,
Returned = result.ScheduledRows.Count
},
Scheduled = result.ScheduledRows.Select(r => MapRow(r, utcNow)).ToList(),
Unscheduled = result.UnscheduledRows.Select(r => MapRow(r, utcNow)).ToList()
};
}
public async Task<IReadOnlyList<DispatcherLookupDto>> GetDispatcherLookupsAsync()
{
var rows = await _boardDataService.GetDispatcherLookupsAsync();
return rows.Select(r => new DispatcherLookupDto
{
Id = r.Id,
Name = FormatName(r.FirstName, r.LastName),
Initials = r.Initials,
Color = r.Color
}).ToList();
}
private static void ValidateWeekWindow(DateOnly weekStart, DateOnly weekEnd)
{
if (weekEnd < weekStart)
throw new ArgumentException("weekEnd must be on or after weekStart.");
var spanDays = weekEnd.DayNumber - weekStart.DayNumber;
if (spanDays > 6)
throw new ArgumentException("Week window cannot exceed 7 days.");
}
private static WorkOrderBoardRowDto MapRow(WorkOrderBoardRawRow row, DateTime utcNow)
{
var apptStart = row.ScheduledStart ?? row.DispatchApptDate;
var apptEnd = row.ScheduledEnd;
return new WorkOrderBoardRowDto
{
Id = row.Id,
WoNumber = row.InternalWONumber,
RescheduleCount = row.RescheduleCount,
CarriedOver = row.CarriedOver,
WorkOrderType = row.WorkOrderType,
IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow),
SiteCode = row.SiteCode,
LocationName = row.LocationName,
PocName = row.PocName,
PocPhone = row.PocPhone,
PocNotes = row.PocNotes,
LifecycleStatus = row.LifecycleStatus,
LifecycleStatusLabel = WorkOrderDerivedFields.GetLifecycleStatusLabel(row.LifecycleStatus),
LegacyStatus = row.LegacyStatus,
DispatcherId = row.AssignTo,
DispatcherName = FormatName(row.DispatcherFirstName, row.DispatcherLastName),
Initials = row.Initials,
Color = row.Color,
DueDate = row.DueDate,
ScheduledDate = row.ScheduledDate,
TargetWeek = row.TargetWeek,
ScheduleWeekOnly = row.ScheduleWeekOnly,
DayGroup = WorkOrderDerivedFields.GetDayGroup(row.ScheduledDate),
VendorId = row.VendorId,
VendorName = row.VendorName,
TechName = row.TechName,
TechPhone = row.TechPhone,
ApptDate = row.DispatchApptDate ?? row.ScheduledDate,
ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd),
Pm = row.Trade ?? row.Problem,
DocStatus = row.DocStatus,
RowVersion = row.RowVersion
};
}
private static string FormatName(string? firstName, string? lastName)
=> $"{firstName ?? ""} {lastName ?? ""}".Trim();
}
}

View file

@ -0,0 +1,10 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderBoardService
{
Task<WorkOrderBoardResponseDto> GetBoardAsync(WorkOrderBoardQueryDto query, string? currentUserId);
Task<IReadOnlyList<DispatcherLookupDto>> GetDispatcherLookupsAsync();
}
}

View file

@ -0,0 +1,210 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
namespace SeaHavenIndustries.Tests;
public class WorkOrderBoardDataServiceTests
{
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
[Fact]
public async Task GetBoardRows_ReturnsScheduledInWeekAndUnscheduled()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC", ContactName = "Tech", Phone = "555-0100" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0)
});
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
SiteCode = "BK5"
},
new WorkOrder
{
Id = 2,
InternalWONumber = "10000000002",
ScheduledDate = null,
LifecycleStatus = LifecycleStatus.Incomplete,
SiteCode = "BK6"
},
new WorkOrder
{
Id = 3,
InternalWONumber = "10000000003",
ScheduledDate = new DateTime(2026, 7, 1),
LifecycleStatus = LifecycleStatus.Scheduled
});
await context.SaveChangesAsync();
var service = new WorkOrderBoardDataService(context);
var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery(
weekStart,
weekStart.AddDays(4),
null,
false,
null,
null,
null));
Assert.Single(result.ScheduledRows);
Assert.Equal(1, result.ScheduledRows[0].Id);
Assert.Equal("Acme HVAC", result.ScheduledRows[0].VendorName);
Assert.Single(result.UnscheduledRows);
Assert.Equal(2, result.UnscheduledRows[0].Id);
Assert.Equal(1, result.ScheduledTotalBeforeSearch);
}
[Fact]
public async Task GetBoardRows_FiltersByDispatcher()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
AssignTo = "user-a",
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled
},
new WorkOrder
{
Id = 2,
AssignTo = "user-b",
ScheduledDate = new DateTime(2026, 6, 24),
LifecycleStatus = LifecycleStatus.Scheduled
});
await context.SaveChangesAsync();
var service = new WorkOrderBoardDataService(context);
var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery(
weekStart,
weekStart.AddDays(4),
new[] { "user-a" },
false,
null,
null,
null));
Assert.Single(result.ScheduledRows);
Assert.Equal("user-a", result.ScheduledRows[0].AssignTo);
}
[Fact]
public async Task GetBoardRows_MyWorkOrders_FiltersCurrentUser()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
AssignTo = "me",
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled
},
new WorkOrder
{
Id = 2,
AssignTo = "other",
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled
});
await context.SaveChangesAsync();
var service = new WorkOrderBoardDataService(context);
var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery(
weekStart,
weekStart.AddDays(4),
null,
true,
null,
null,
"me"));
Assert.Single(result.ScheduledRows);
Assert.Equal(1, result.ScheduledRows[0].Id);
}
}
public class WorkOrderBoardServiceTests
{
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
[Fact]
public async Task GetBoardAsync_ComputesCountsAndPastDue()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc),
LifecycleStatus = LifecycleStatus.Scheduled
});
await context.SaveChangesAsync();
var dataService = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(dataService);
var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto
{
WeekStart = weekStart
}, null);
Assert.Equal(1, response.Counts.Total);
Assert.Single(response.Scheduled);
Assert.True(response.Scheduled[0].IsPastDue);
Assert.Equal("wednesday", response.Scheduled[0].DayGroup);
}
[Fact]
public async Task GetBoardAsync_InvalidWeekWindow_Throws()
{
await using var context = CreateContext();
var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(context));
await Assert.ThrowsAsync<ArgumentException>(() => boardService.GetBoardAsync(new WorkOrderBoardQueryDto
{
WeekStart = new DateOnly(2026, 6, 22),
WeekEnd = new DateOnly(2026, 6, 15)
}, null));
}
}