mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
Merge 75969a1e3d into e5902e1df3
This commit is contained in:
commit
c18fbfa490
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.Hosting;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.VisualBasic;
|
using Microsoft.VisualBasic;
|
||||||
|
|
@ -17,6 +18,7 @@ using System.Security.Claims;
|
||||||
using static System.Runtime.InteropServices.JavaScript.JSType;
|
using static System.Runtime.InteropServices.JavaScript.JSType;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
@ -42,6 +44,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
private readonly Helper.SendMessage _sendMessage;
|
private readonly Helper.SendMessage _sendMessage;
|
||||||
private readonly Helper.VendorPortalTokenService _vendorTokens;
|
private readonly Helper.VendorPortalTokenService _vendorTokens;
|
||||||
private readonly IConfiguration _config;
|
private readonly IConfiguration _config;
|
||||||
|
private readonly IWorkOrderBoardService _workOrderBoardService;
|
||||||
|
|
||||||
public WorkOrderController(
|
public WorkOrderController(
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
|
|
@ -58,7 +61,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
IHttpContextAccessor httpContext,
|
IHttpContextAccessor httpContext,
|
||||||
Helper.SendMessage sendMessage,
|
Helper.SendMessage sendMessage,
|
||||||
Helper.VendorPortalTokenService vendorTokens,
|
Helper.VendorPortalTokenService vendorTokens,
|
||||||
IConfiguration config)
|
IConfiguration config,
|
||||||
|
IWorkOrderBoardService workOrderBoardService)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_workOrderService = workOrderService;
|
_workOrderService = workOrderService;
|
||||||
|
|
@ -75,7 +79,62 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
_sendMessage = sendMessage;
|
_sendMessage = sendMessage;
|
||||||
_vendorTokens = vendorTokens;
|
_vendorTokens = vendorTokens;
|
||||||
_config = config;
|
_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]
|
[HttpPost]
|
||||||
[Route("AddWorkorder")]
|
[Route("AddWorkorder")]
|
||||||
public async Task<IActionResult> Addworkorder([FromForm] Workorder_DTO model)
|
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 =>
|
builder.Services.AddControllers(options =>
|
||||||
{
|
{
|
||||||
options.Filters.Add<Api.SeaHavenIndustries.Filters.ConcurrencyExceptionFilter>();
|
options.Filters.Add<Api.SeaHavenIndustries.Filters.ConcurrencyExceptionFilter>();
|
||||||
|
options.Filters.Add<Api.SeaHavenIndustries.Filters.ArgumentExceptionFilter>();
|
||||||
}).AddJsonOptions(options =>
|
}).AddJsonOptions(options =>
|
||||||
{
|
{
|
||||||
options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value
|
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;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2031,10 +2031,16 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
|
||||||
b.HasIndex("AssignTo");
|
b.HasIndex("AssignTo");
|
||||||
|
|
||||||
|
b.HasIndex("AssignTo", "ScheduledDate");
|
||||||
|
|
||||||
b.HasIndex("LocationId");
|
b.HasIndex("LocationId");
|
||||||
|
|
||||||
b.HasIndex("PrimaryDispatchId");
|
b.HasIndex("PrimaryDispatchId");
|
||||||
|
|
||||||
|
b.HasIndex("ScheduledDate");
|
||||||
|
|
||||||
|
b.HasIndex("WorkOrderType");
|
||||||
|
|
||||||
b.ToTable("workOrders");
|
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;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
namespace SeaHaven.Services.Helpers
|
namespace SeaHaven.Services.Helpers
|
||||||
{
|
{
|
||||||
public static class WorkOrderDerivedFields
|
public static class WorkOrderDerivedFields
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Past due overlay — never a WorkOrderType. Matches SHOC board design.
|
/// Past due overlay — never a WorkOrderType. Matches SHOC board design.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null)
|
public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null)
|
||||||
{
|
{
|
||||||
var today = asOf ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
var today = asOf ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
if (workOrder.ScheduledDate == null)
|
if (workOrder.ScheduledDate == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if (LifecycleStatusMapper.IsTerminal(workOrder.LifecycleStatus))
|
if (LifecycleStatusMapper.IsTerminal(workOrder.LifecycleStatus))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today;
|
return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets initial lifecycle on create. New WOs start Incomplete unless auto-scheduled.
|
/// Board read-model past-due check. Phase 1 compares UTC calendar dates;
|
||||||
/// </summary>
|
/// business/location timezone support is planned for a later phase.
|
||||||
public static void SetInitialLifecycleStatus(WorkOrder workOrder)
|
/// </summary>
|
||||||
{
|
public static bool IsPastDue(
|
||||||
workOrder.LifecycleStatus ??= LifecycleStatus.Incomplete;
|
DateTime? scheduledDate,
|
||||||
ApplyAutoScheduleIfEligible(workOrder);
|
LifecycleStatus? lifecycleStatus,
|
||||||
}
|
DateTime? utcNow = null)
|
||||||
|
{
|
||||||
/// <summary>
|
if (!scheduledDate.HasValue)
|
||||||
/// SHOC rule: specific scheduled date + assignee + Incomplete → Scheduled.
|
return false;
|
||||||
/// Week-only targets do not auto-schedule.
|
|
||||||
/// </summary>
|
if (LifecycleStatusMapper.IsTerminal(lifecycleStatus))
|
||||||
public static bool ApplyAutoScheduleIfEligible(WorkOrder workOrder)
|
return false;
|
||||||
{
|
|
||||||
if (workOrder.LifecycleStatus != LifecycleStatus.Incomplete)
|
var today = (utcNow ?? DateTime.UtcNow).Date;
|
||||||
return false;
|
return scheduledDate.Value.Date < today;
|
||||||
|
}
|
||||||
if (workOrder.ScheduleWeekOnly == true)
|
|
||||||
return false;
|
/// <summary>
|
||||||
|
/// Sets initial lifecycle on create. New WOs start Incomplete unless auto-scheduled.
|
||||||
if (workOrder.ScheduledDate == null)
|
/// </summary>
|
||||||
return false;
|
public static void SetInitialLifecycleStatus(WorkOrder workOrder)
|
||||||
|
{
|
||||||
if (string.IsNullOrWhiteSpace(workOrder.AssignTo))
|
workOrder.LifecycleStatus ??= LifecycleStatus.Incomplete;
|
||||||
return false;
|
ApplyAutoScheduleIfEligible(workOrder);
|
||||||
|
}
|
||||||
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
|
|
||||||
return true;
|
/// <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