mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-04 16:02:11 +00:00
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:
parent
e5902e1df3
commit
ff53cb9fa6
21 changed files with 1255 additions and 56 deletions
|
|
@ -42,6 +42,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
private readonly Helper.SendMessage _sendMessage;
|
private readonly Helper.SendMessage _sendMessage;
|
||||||
private readonly Helper.VendorPortalTokenService _vendorTokens;
|
private readonly Helper.VendorPortalTokenService _vendorTokens;
|
||||||
private readonly IConfiguration _config;
|
private readonly IConfiguration _config;
|
||||||
|
private readonly IWorkOrderBoardService _workOrderBoardService;
|
||||||
|
|
||||||
public WorkOrderController(
|
public WorkOrderController(
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
|
|
@ -58,7 +59,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
IHttpContextAccessor httpContext,
|
IHttpContextAccessor httpContext,
|
||||||
Helper.SendMessage sendMessage,
|
Helper.SendMessage sendMessage,
|
||||||
Helper.VendorPortalTokenService vendorTokens,
|
Helper.VendorPortalTokenService vendorTokens,
|
||||||
IConfiguration config)
|
IConfiguration config,
|
||||||
|
IWorkOrderBoardService workOrderBoardService)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_workOrderService = workOrderService;
|
_workOrderService = workOrderService;
|
||||||
|
|
@ -75,7 +77,40 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
_sendMessage = sendMessage;
|
_sendMessage = sendMessage;
|
||||||
_vendorTokens = vendorTokens;
|
_vendorTokens = vendorTokens;
|
||||||
_config = config;
|
_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]
|
[HttpPost]
|
||||||
[Route("AddWorkorder")]
|
[Route("AddWorkorder")]
|
||||||
public async Task<IActionResult> Addworkorder([FromForm] Workorder_DTO model)
|
public async Task<IActionResult> Addworkorder([FromForm] Workorder_DTO model)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2035,6 +2035,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
|
||||||
b.HasIndex("PrimaryDispatchId");
|
b.HasIndex("PrimaryDispatchId");
|
||||||
|
|
||||||
|
b.HasIndex("ScheduledDate");
|
||||||
|
|
||||||
b.ToTable("workOrders");
|
b.ToTable("workOrders");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
7
SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs
Normal file
7
SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace SeaHaven.DataServices.Board
|
||||||
|
{
|
||||||
|
public static class WorkOrderBoardConstants
|
||||||
|
{
|
||||||
|
public const int ClientSideThreshold = 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs
Normal file
10
SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs
Normal 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);
|
||||||
|
}
|
||||||
20
SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs
Normal file
20
SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs
Normal 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);
|
||||||
|
}
|
||||||
9
SeaHaven.DataServices/Exceptions/BoardQueryException.cs
Normal file
9
SeaHaven.DataServices/Exceptions/BoardQueryException.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace SeaHaven.DataServices.Exceptions
|
||||||
|
{
|
||||||
|
public class BoardQueryException : Exception
|
||||||
|
{
|
||||||
|
public BoardQueryException(string message) : base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
Normal file
102
SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
138
SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs
Normal file
138
SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderBoardDataService
|
||||||
|
{
|
||||||
|
Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query);
|
||||||
|
Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public record PrimaryDispatchProjection(
|
||||||
|
int WorkOrderId,
|
||||||
|
int? DispatchId,
|
||||||
|
string? VendorName,
|
||||||
|
DateTime? ApptDate,
|
||||||
|
string? DispatchStatus);
|
||||||
|
}
|
||||||
60
SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
Normal file
60
SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
Normal 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);
|
||||||
|
}
|
||||||
8
SeaHaven.Services/Board/WorkOrderBoardConstants.cs
Normal file
8
SeaHaven.Services/Board/WorkOrderBoardConstants.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace SeaHaven.Services.Board
|
||||||
|
{
|
||||||
|
public static class WorkOrderBoardConstants
|
||||||
|
{
|
||||||
|
public const int MaxWindowDays = 90;
|
||||||
|
public const int ClientSideThreshold = 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
Normal file
73
SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
57
SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs
Normal file
57
SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,21 @@ namespace SeaHaven.Services.Helpers
|
||||||
return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today;
|
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>
|
/// <summary>
|
||||||
/// Sets initial lifecycle on create. New WOs start Incomplete unless auto-scheduled.
|
/// Sets initial lifecycle on create. New WOs start Incomplete unless auto-scheduled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -50,6 +65,35 @@ namespace SeaHaven.Services.Helpers
|
||||||
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
|
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
|
||||||
return true;
|
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) : "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
20
SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs
Normal file
20
SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
120
SeaHaven.Services/Implementation/WorkOrderBoardService.cs
Normal file
120
SeaHaven.Services/Implementation/WorkOrderBoardService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
10
SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs
Normal file
10
SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
210
SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs
Normal file
210
SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue