diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 9c3b181..71976b7 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -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 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 GetBoard( + [FromQuery] DateOnly weekStart, + [FromQuery] DateOnly? weekEnd = null, + [FromQuery] List? dispatchers = null, + [FromQuery] bool myWorkOrders = false, + [FromQuery] List? 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 GetDispatcherLookups() + { + var result = await _workOrderBoardService.GetDispatcherLookupsAsync(); + return Ok(result); + } + [HttpPost] [Route("AddWorkorder")] public async Task Addworkorder([FromForm] Workorder_DTO model) diff --git a/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs new file mode 100644 index 0000000..8b0d794 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public class Phase1_BoardIndexes : Migration + { + /// + 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 + """); + } + + /// + 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; + """); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 43e63e3..b4bdf21 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2035,6 +2035,8 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("PrimaryDispatchId"); + b.HasIndex("ScheduledDate"); + b.ToTable("workOrders"); }); diff --git a/SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs b/SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs new file mode 100644 index 0000000..bceff32 --- /dev/null +++ b/SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs @@ -0,0 +1,7 @@ +namespace SeaHaven.DataServices.Board +{ + public static class WorkOrderBoardConstants + { + public const int ClientSideThreshold = 300; + } +} diff --git a/SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs b/SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs new file mode 100644 index 0000000..6d5d1e0 --- /dev/null +++ b/SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs @@ -0,0 +1,10 @@ +namespace SeaHaven.DataServices.DTOs +{ + public record WorkOrderBoardQuery( + DateOnly ScheduledFrom, + DateOnly ScheduledTo, + string? Search, + IReadOnlyList? AssigneeIds, + IReadOnlyList? DbStatuses, + int? LocationId); +} diff --git a/SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs b/SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs new file mode 100644 index 0000000..a7eca60 --- /dev/null +++ b/SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs @@ -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); +} diff --git a/SeaHaven.DataServices/Exceptions/BoardQueryException.cs b/SeaHaven.DataServices/Exceptions/BoardQueryException.cs new file mode 100644 index 0000000..b659424 --- /dev/null +++ b/SeaHaven.DataServices/Exceptions/BoardQueryException.cs @@ -0,0 +1,9 @@ +namespace SeaHaven.DataServices.Exceptions +{ + public class BoardQueryException : Exception + { + public BoardQueryException(string message) : base(message) + { + } + } +} diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs new file mode 100644 index 0000000..f862e1e --- /dev/null +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -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> ProjectRowsAsync( + IQueryable 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(); + } + } +} diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs new file mode 100644 index 0000000..bb9919b --- /dev/null +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -0,0 +1,138 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Helpers +{ + public static class WorkOrderBoardQueryFilters + { + public static IQueryable ApplyBaseScope(IQueryable query) + => query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); + + public static IQueryable ApplyDispatcherFilter( + IQueryable query, + IReadOnlyList? 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 ApplyTypeFilter( + IQueryable query, + IReadOnlyList? types) + { + if (types == null || types.Count == 0) + return query; + + return query.Where(w => w.WorkOrderType != null && types.Contains(w.WorkOrderType.Value)); + } + + public static IQueryable ApplySiteFilter( + IQueryable query, + IReadOnlyList? 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 ApplyStatusFilter( + IQueryable query, + IReadOnlyList? statuses) + { + if (statuses == null || statuses.Count == 0) + return query; + + return query.Where(w => w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value)); + } + + public static IQueryable ApplyDocStatusFilter( + IQueryable query, + IReadOnlyList? docStatuses) + { + if (docStatuses == null || docStatuses.Count == 0) + return query; + + return query.Where(w => w.DocStatus != null && docStatuses.Contains(w.DocStatus.Value)); + } + + public static IQueryable ApplyVendorFilter( + IQueryable query, + IReadOnlyList? vendorIds) + { + if (vendorIds == null || vendorIds.Count == 0) + return query; + + return query.Where(w => + w.PrimaryDispatch != null && vendorIds.Contains(w.PrimaryDispatch.VendorId)); + } + + public static IQueryable ApplyPmTypeFilter( + IQueryable query, + IReadOnlyList? 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 ApplyDateRangeFilter( + IQueryable 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)); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs new file mode 100644 index 0000000..e2f1bac --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs @@ -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 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> 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 ApplyDispatcherFilter(IQueryable 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 ApplyTypeFilter(IQueryable 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 ApplySearchFilter(IQueryable 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> ProjectRows( + IQueryable 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(); + } + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs new file mode 100644 index 0000000..67f1459 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs @@ -0,0 +1,10 @@ +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderBoardDataService + { + Task GetBoardRowsAsync(WorkOrderBoardQuery query); + Task> GetDispatcherLookupsAsync(); + } +} diff --git a/SeaHaven.DataServices/Interfaces/PrimaryDispatchProjection.cs b/SeaHaven.DataServices/Interfaces/PrimaryDispatchProjection.cs new file mode 100644 index 0000000..ce6ebf6 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/PrimaryDispatchProjection.cs @@ -0,0 +1,9 @@ +namespace SeaHaven.DataServices.Interfaces +{ + public record PrimaryDispatchProjection( + int WorkOrderId, + int? DispatchId, + string? VendorName, + DateTime? ApptDate, + string? DispatchStatus); +} diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs new file mode 100644 index 0000000..4b96481 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -0,0 +1,60 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Interfaces +{ + public record WorkOrderBoardQuery( + DateOnly WeekStart, + DateOnly WeekEnd, + IReadOnlyList? Dispatchers, + bool MyWorkOrders, + IReadOnlyList? 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 ScheduledRows, + IReadOnlyList UnscheduledRows, + int ScheduledTotalBeforeSearch); + + public record DispatcherLookupRow( + string Id, + string FirstName, + string? LastName, + string? Initials, + string? Color); +} diff --git a/SeaHaven.Services/Board/WorkOrderBoardConstants.cs b/SeaHaven.Services/Board/WorkOrderBoardConstants.cs new file mode 100644 index 0000000..a00f3e2 --- /dev/null +++ b/SeaHaven.Services/Board/WorkOrderBoardConstants.cs @@ -0,0 +1,8 @@ +namespace SeaHaven.Services.Board +{ + public static class WorkOrderBoardConstants + { + public const int MaxWindowDays = 90; + public const int ClientSideThreshold = 300; + } +} diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs new file mode 100644 index 0000000..674097c --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -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? Dispatchers { get; set; } + public bool MyWorkOrders { get; set; } + public List? 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 Unscheduled { get; set; } = new(); + public List 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; } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs b/SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs new file mode 100644 index 0000000..f50949a --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs @@ -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); + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs index 873126d..cb64549 100644 --- a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs +++ b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs @@ -1,55 +1,99 @@ -using Data.SeaHavenIndustries; -using Data.SeaHavenIndustries.Enums; - -namespace SeaHaven.Services.Helpers -{ - public static class WorkOrderDerivedFields - { - /// - /// Past due overlay — never a WorkOrderType. Matches SHOC board design. - /// - 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; - } - - /// - /// Sets initial lifecycle on create. New WOs start Incomplete unless auto-scheduled. - /// - public static void SetInitialLifecycleStatus(WorkOrder workOrder) - { - workOrder.LifecycleStatus ??= LifecycleStatus.Incomplete; - ApplyAutoScheduleIfEligible(workOrder); - } - - /// - /// SHOC rule: specific scheduled date + assignee + Incomplete → Scheduled. - /// Week-only targets do not auto-schedule. - /// - 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; - } - } -} - \ No newline at end of file +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderDerivedFields + { + /// + /// Past due overlay — never a WorkOrderType. Matches SHOC board design. + /// + 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; + } + + /// + /// Sets initial lifecycle on create. New WOs start Incomplete unless auto-scheduled. + /// + public static void SetInitialLifecycleStatus(WorkOrder workOrder) + { + workOrder.LifecycleStatus ??= LifecycleStatus.Incomplete; + ApplyAutoScheduleIfEligible(workOrder); + } + + /// + /// SHOC rule: specific scheduled date + assignee + Incomplete → Scheduled. + /// Week-only targets do not auto-schedule. + /// + 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) : ""; + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs b/SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs new file mode 100644 index 0000000..5779a17 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs @@ -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}"; + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs new file mode 100644 index 0000000..a4b6001 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -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 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> 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(); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs b/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs new file mode 100644 index 0000000..d24e64a --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs @@ -0,0 +1,10 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderBoardService + { + Task GetBoardAsync(WorkOrderBoardQueryDto query, string? currentUserId); + Task> GetDispatcherLookupsAsync(); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs new file mode 100644 index 0000000..8b1b867 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -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() + .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() + .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(() => boardService.GetBoardAsync(new WorkOrderBoardQueryDto + { + WeekStart = new DateOnly(2026, 6, 22), + WeekEnd = new DateOnly(2026, 6, 15) + }, null)); + } +}