mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
Merge pull request #13 from Sea-Haven-Industries/feat/work-orders-phase-1-board
Feat/work orders phase 1 board
This commit is contained in:
commit
3bd0268129
21 changed files with 4347 additions and 56 deletions
|
|
@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Components.Forms;
|
|||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.VisualBasic;
|
||||
|
|
@ -17,6 +18,7 @@ using System.Security.Claims;
|
|||
using static System.Runtime.InteropServices.JavaScript.JSType;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
|
|
@ -42,6 +44,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 +61,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 +79,62 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
_sendMessage = sendMessage;
|
||||
_vendorTokens = vendorTokens;
|
||||
_config = config;
|
||||
_workOrderBoardService = workOrderBoardService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Weekly board payload.
|
||||
/// When <paramref name="myWorkOrders"/> is true, the authenticated user filter wins and
|
||||
/// <paramref name="dispatchers"/> is ignored (not AND-combined).
|
||||
/// Weekend WOs in a Mon–Sun window may have <c>dayGroup = null</c>; FE should handle that.
|
||||
/// </summary>
|
||||
[HttpGet("board")]
|
||||
public async Task<IActionResult> GetBoard(
|
||||
[FromQuery, BindRequired] 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 resolvedWeekEnd = weekEnd ?? weekStart.AddDays(4);
|
||||
var weekValidationError = WorkOrderOperationalWeek.ValidateWeekWindow(weekStart, resolvedWeekEnd);
|
||||
if (weekValidationError != null)
|
||||
return BadRequest(weekValidationError);
|
||||
|
||||
try
|
||||
{
|
||||
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);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatcher dropdown options. Currently returns all non-deleted users
|
||||
/// (role-filtered assignee list deferred until roles settle).
|
||||
/// </summary>
|
||||
[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)
|
||||
|
|
|
|||
21
Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs
Normal file
21
Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Filters
|
||||
{
|
||||
public class ArgumentExceptionFilter : IExceptionFilter
|
||||
{
|
||||
public void OnException(ExceptionContext context)
|
||||
{
|
||||
if (context.Exception is not ArgumentException ex) return;
|
||||
|
||||
context.Result = new BadRequestObjectResult(new
|
||||
{
|
||||
status = "Error",
|
||||
message = ex.Message,
|
||||
code = 400
|
||||
});
|
||||
context.ExceptionHandled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
|
|||
builder.Services.AddControllers(options =>
|
||||
{
|
||||
options.Filters.Add<Api.SeaHavenIndustries.Filters.ConcurrencyExceptionFilter>();
|
||||
options.Filters.Add<Api.SeaHavenIndustries.Filters.ArgumentExceptionFilter>();
|
||||
}).AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value
|
||||
|
|
|
|||
14
Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs
Normal file
14
Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
namespace Data.SeaHavenIndustries.Enums
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared terminal lifecycle statuses for EF-translatable filters and board rules.
|
||||
/// </summary>
|
||||
public static class LifecycleStatusSets
|
||||
{
|
||||
public static readonly LifecycleStatus?[] Terminal =
|
||||
{
|
||||
LifecycleStatus.Completed,
|
||||
LifecycleStatus.Canceled
|
||||
};
|
||||
}
|
||||
}
|
||||
2996
Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs
generated
Normal file
2996
Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,64 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial 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
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.indexes
|
||||
WHERE name = 'IX_workOrders_AssignTo_ScheduledDate'
|
||||
AND object_id = OBJECT_ID('workOrders'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_workOrders_AssignTo_ScheduledDate ON workOrders (AssignTo, ScheduledDate);
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.indexes
|
||||
WHERE name = 'IX_workOrders_WorkOrderType'
|
||||
AND object_id = OBJECT_ID('workOrders'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_workOrders_WorkOrderType ON workOrders (WorkOrderType);
|
||||
END
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM sys.indexes
|
||||
WHERE name = 'IX_workOrders_WorkOrderType'
|
||||
AND object_id = OBJECT_ID('workOrders'))
|
||||
DROP INDEX IX_workOrders_WorkOrderType ON workOrders;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM sys.indexes
|
||||
WHERE name = 'IX_workOrders_AssignTo_ScheduledDate'
|
||||
AND object_id = OBJECT_ID('workOrders'))
|
||||
DROP INDEX IX_workOrders_AssignTo_ScheduledDate ON workOrders;
|
||||
|
||||
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;
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2403,10 +2403,16 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("AssignTo");
|
||||
|
||||
b.HasIndex("AssignTo", "ScheduledDate");
|
||||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.HasIndex("PrimaryDispatchId");
|
||||
|
||||
b.HasIndex("ScheduledDate");
|
||||
|
||||
b.HasIndex("WorkOrderType");
|
||||
|
||||
b.ToTable("workOrders");
|
||||
});
|
||||
|
||||
|
|
|
|||
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,
|
||||
PhoneNumber = c.POC != null ? c.POC.PhoneNumber : null,
|
||||
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.Service,
|
||||
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.Service ?? w.Trade ?? w.Problem,
|
||||
w.DocStatus,
|
||||
w.RowVersion,
|
||||
isUnscheduled)).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
166
SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs
Normal file
166
SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
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));
|
||||
|
||||
/// <summary>
|
||||
/// Applies assignee filters. When <paramref name="myWorkOrders"/> is true and
|
||||
/// <paramref name="currentUserId"/> is set, that takes precedence and
|
||||
/// <paramref name="dispatchers"/> is ignored.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
#region Phase 2 filter helpers (not wired in Phase 1 board API)
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public static IQueryable<WorkOrder> ApplySearchFilter(IQueryable<WorkOrder> query, string? search)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(search))
|
||||
return query;
|
||||
|
||||
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.Service ?? "").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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Helpers;
|
||||
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 = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
|
||||
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types);
|
||||
|
||||
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
|
||||
&& !LifecycleStatusSets.Terminal.Contains(w.LifecycleStatus));
|
||||
|
||||
var scheduledTotal = await scheduledBase.CountAsync();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
scheduledBase = WorkOrderBoardQueryFilters.ApplySearchFilter(scheduledBase, query.Search);
|
||||
unscheduledBase = WorkOrderBoardQueryFilters.ApplySearchFilter(unscheduledBase, query.Search);
|
||||
}
|
||||
|
||||
var scheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(
|
||||
scheduledBase.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber),
|
||||
isUnscheduled: false);
|
||||
var unscheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(
|
||||
unscheduledBase.OrderBy(w => w.InternalWONumber),
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public interface IWorkOrderBoardDataService
|
||||
{
|
||||
Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query);
|
||||
Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync();
|
||||
}
|
||||
}
|
||||
59
SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
Normal file
59
SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
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? Service,
|
||||
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);
|
||||
}
|
||||
11
SeaHaven.Services/Board/WorkOrderBoardConstants.cs
Normal file
11
SeaHaven.Services/Board/WorkOrderBoardConstants.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
namespace SeaHaven.Services.Board
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared board constants for Phase 2+ features. Not referenced in Phase 1.
|
||||
/// </summary>
|
||||
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? Service { 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,55 +1,107 @@
|
|||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Board read-model past-due check. Phase 1 compares UTC calendar dates;
|
||||
/// business/location timezone support is planned for a later phase.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps scheduled date to Mon–Fri board column. Phase 1 uses the stored
|
||||
/// date's day-of-week (UTC); timezone-aware grouping is planned for a later phase.
|
||||
/// </summary>
|
||||
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) : "";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs
Normal file
35
SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
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}";
|
||||
|
||||
/// <summary>
|
||||
/// Returns a validation error message when the week window is invalid; otherwise null.
|
||||
/// </summary>
|
||||
public static string? ValidateWeekWindow(DateOnly weekStart, DateOnly weekEnd)
|
||||
{
|
||||
if (weekEnd < weekStart)
|
||||
return "weekEnd must be on or after weekStart.";
|
||||
|
||||
var spanDays = weekEnd.DayNumber - weekStart.DayNumber;
|
||||
if (spanDays > 6)
|
||||
return "Week window cannot exceed 7 days.";
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
114
SeaHaven.Services/Implementation/WorkOrderBoardService.cs
Normal file
114
SeaHaven.Services/Implementation/WorkOrderBoardService.cs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
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);
|
||||
|
||||
var validationError = WorkOrderOperationalWeek.ValidateWeekWindow(weekStart, weekEnd);
|
||||
if (validationError != null)
|
||||
throw new ArgumentException(validationError);
|
||||
|
||||
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 WorkOrderBoardRowDto MapRow(WorkOrderBoardRawRow row, DateTime utcNow)
|
||||
{
|
||||
// Appt column: date prefers dispatch appointment (vendor slot), then WO ScheduledDate.
|
||||
// Time prefers WO ScheduledStart/End (dispatcher window), then dispatch datetime.
|
||||
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),
|
||||
Service = row.Service,
|
||||
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));
|
||||
}
|
||||
}
|
||||
159
docs/work-orders/phase-1/README.md
Normal file
159
docs/work-orders/phase-1/README.md
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
# Fase 1 — Weekly Board (leitura)
|
||||
|
||||
**Programa:** Work Orders Board
|
||||
**Objetivo:** Endpoint window-based para carregar a visão semanal do SHOC (Mon–Fri + Unscheduled) com projeção das 14 colunas operacionais.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /api/workorders/board`
|
||||
|
||||
Retorna WOs agendadas na semana + seção **Unscheduled** em uma única chamada.
|
||||
|
||||
**Autenticação:** Bearer JWT (`[Authorize]`)
|
||||
|
||||
**Query params:**
|
||||
|
||||
| Param | Obrigatório | Descrição |
|
||||
|-------|-------------|-----------|
|
||||
| `weekStart` | Sim (`BindRequired`) | Segunda-feira da semana (`YYYY-MM-DD`). Omitir retorna **400**. |
|
||||
| `weekEnd` | Não | Default: `weekStart + 4 dias` (sexta). Janela pode ter até 7 dias. |
|
||||
| `dispatchers` | Não | Lista de GUIDs; use `__unassigned__` para não atribuídos. **Ignorado** quando `myWorkOrders=true`. |
|
||||
| `myWorkOrders` | Não | `true` → filtra `AssignTo == usuário logado` e tem **precedência** sobre `dispatchers` (não é AND). |
|
||||
| `types` | Não | Valores enum `WorkOrderType` (`PM`, `PO`, `Emergency`, etc.) |
|
||||
| `search` | Não | Busca contextual (site, WO#, location, dispatcher, trade, vendor, status) |
|
||||
|
||||
**Exemplo:**
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
"https://localhost:5001/api/workorders/board?weekStart=2026-06-22&myWorkOrders=true"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"weekStart": "2026-06-22",
|
||||
"weekEnd": "2026-06-26",
|
||||
"counts": { "returned": 42, "total": 58 },
|
||||
"unscheduled": [ /* WorkOrderBoardRowDto[] */ ],
|
||||
"scheduled": [ /* WorkOrderBoardRowDto[] — agrupar por dayGroup no FE */ ]
|
||||
}
|
||||
```
|
||||
|
||||
- `counts.total` — WOs agendadas na semana (filtros dispatcher/tipo, **sem** search)
|
||||
- `counts.returned` — WOs agendadas após aplicar search
|
||||
- `dayGroup` — `monday`…`friday` para dias úteis; **`null` no sábado/domingo** (a janela pode incluir fim de semana; o FE deve tratar `dayGroup` nulo)
|
||||
- Referência de dia / past-due: **UTC** nesta fase (timezone de negócio fica para fase posterior)
|
||||
|
||||
### `GET /api/workorders/lookups/dispatchers`
|
||||
|
||||
Lista opções para o filtro/dropdown de assignee do board.
|
||||
|
||||
**Escopo atual (placeholder):** todos os users com `IsDeleted != true` — ainda **sem** filtro por role. Quando roles de assignee estiverem definidos, este lookup será restrito.
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": "guid", "name": "Jane Doe", "initials": "JD", "color": "#4A90D9" }
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Matriz coluna → campo API
|
||||
|
||||
| Coluna board | Campos response |
|
||||
|--------------|-----------------|
|
||||
| WO# | `woNumber`, `rescheduleCount`, `carriedOver` |
|
||||
| Type | `workOrderType`, `isPastDue` |
|
||||
| Site | `siteCode`, `locationName`, `pocName`, `pocPhone`, `pocNotes` |
|
||||
| Status | `lifecycleStatus`, `lifecycleStatusLabel`, `legacyStatus` |
|
||||
| Assignee | `dispatcherId`, `dispatcherName`, `initials`, `color` |
|
||||
| Due | `dueDate` |
|
||||
| Scheduled | `scheduledDate`, `targetWeek`, `scheduleWeekOnly`, `dayGroup` |
|
||||
| Vendor | `vendorId`, `vendorName`, `techName`, `techPhone` |
|
||||
| Appt | `apptDate`, `apptTime` |
|
||||
| Past Due | `isPastDue` (derivado on-read) |
|
||||
| Carried | `carriedOver` |
|
||||
| Reschedule | `rescheduleCount` |
|
||||
| Doc | `docStatus` |
|
||||
| Actions | FE only |
|
||||
|
||||
---
|
||||
|
||||
## Regras de derivação
|
||||
|
||||
### isPastDue
|
||||
|
||||
```
|
||||
ScheduledDate < UTC hoje
|
||||
AND LifecycleStatus NOT IN (Complete, Canceled, Closed)
|
||||
```
|
||||
|
||||
Implementação: `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs`
|
||||
|
||||
### Vendor / Appt
|
||||
|
||||
Projeção read-only via `WorkOrder.PrimaryDispatchId` → `Dispatch` → `Vendor` ([ADR](../phase-0/adr-vendor-source-of-truth.md)).
|
||||
|
||||
### Janela semanal
|
||||
|
||||
WO entra em **scheduled** se:
|
||||
|
||||
- `ScheduledDate` entre `weekStart` e `weekEnd`, **ou**
|
||||
- `ScheduleWeekOnly == true` e `TargetWeek == weekStart`
|
||||
|
||||
**Unscheduled:** `ScheduledDate == null` e status não terminal (`LifecycleStatusSets.Terminal`).
|
||||
|
||||
---
|
||||
|
||||
## Código entregue
|
||||
|
||||
| Camada | Arquivo |
|
||||
|--------|---------|
|
||||
| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` |
|
||||
| Derived fields | `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs` |
|
||||
| Data | `SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs` |
|
||||
| Service | `SeaHaven.Services/Implementation/WorkOrderBoardService.cs` |
|
||||
| API | `Api.SeaHavenIndustries/Controllers/WorkOrderController.cs` (`board`, `lookups/dispatchers`) |
|
||||
| Migration | `Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs` |
|
||||
| Testes | `SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs`, `WorkOrderBoardServiceTests.cs` |
|
||||
|
||||
---
|
||||
|
||||
## Gates de aceite
|
||||
|
||||
### Desenvolvimento (concluído)
|
||||
|
||||
- [x] Endpoint `GET board` — semana + Unscheduled
|
||||
- [x] Projeção 12/14 colunas de dados
|
||||
- [x] `isPastDue` derivado on-read
|
||||
- [x] Vendor via dispatch primário
|
||||
- [x] Filtros dispatcher / My WOs / tipo / search
|
||||
- [x] Contador X of Y
|
||||
- [x] Índices Tier S
|
||||
- [x] 14 testes unitários board (+ 12 Fase 0 = 26 total)
|
||||
|
||||
### Staging / produção (pendente)
|
||||
|
||||
- [ ] Sign-off CTO/PO (herda gates Fase 0)
|
||||
- [ ] Dry-run migration Phase0 + Phase1 em staging
|
||||
- [ ] Tier volume confirmado em staging/prod
|
||||
- [ ] Smoke checklist Blazor / Sync / Portal
|
||||
- [ ] Gate Vendor Portal regression (endpoints dispatch inalterados)
|
||||
- [ ] Latência aceitável com volume real (Tier S: < 25k WOs)
|
||||
- [ ] Feature flag SHOC board em staging
|
||||
|
||||
---
|
||||
|
||||
## Coexistência
|
||||
|
||||
Endpoints legados (`GetWorkOrderList`, `GetWorkorderById`, etc.) **não foram alterados**. O board usa contrato novo em rotas separadas.
|
||||
|
||||
---
|
||||
|
||||
## Próximo passo
|
||||
|
||||
**Fase 2** — Inline edit + concorrência (dual RowVersion, audit por campo, PATCH granular).
|
||||
Loading…
Add table
Reference in a new issue