Compare commits

...

9 commits

Author SHA1 Message Date
Arthur Bassi
4e32c487d2 fix(migrations): discover Phase7, harden Phase4 indexes, align WO model
Make Phase7 EF-discoverable with idempotent DDL, shrink SiteCode and
InternalWONumber before Phase4 search indexes on this branch, align
MaxLength/Fluent indexes with the snapshot, and add the missing Events
calendar table migration so the model has no pending changes.
2026-07-17 11:10:59 -03:00
Arthur Bassi
a930d75ce0 docs(work-orders): add PR body index for split feature branches
Map ordered commits and feature branch tips to the PR descriptions
produced during staff review.
2026-07-17 10:40:40 -03:00
Arthur Bassi
f61aff06ff docs(work-orders): add Fase 2 staff review findings
Record adversarial review results and fixes for service notes, search,
paging, and migration hardening.
2026-07-17 10:37:25 -03:00
Arthur Bassi
16b31e5f28 feat(work-orders): allow comment edit and resolve author audit display names
Add PATCH comment for author/Admin, return authorName, and resolve
AssignTo audit values to user display names.
2026-07-17 10:37:25 -03:00
Arthur Bassi
1eaf9b7c84 feat(work-orders): enrich board search overdue filters and 0-based paging
Align week presets to Mon-Fri, treat types=Other as past-due, expand
text search, whitelist sortBy, and use 0-based paging for advanced
search without breaking 1-based PagedResult consumers.
2026-07-17 10:37:24 -03:00
Arthur Bassi
62572d8e03 feat(work-orders): add board flagColor with fixed palette
Support PATCH flagColor with FE-aligned #RRGGBB palette and expose the
value on board/detail rows.
2026-07-17 10:37:24 -03:00
Arthur Bassi
ecc1db3726 feat(work-orders): add structured service notes POC and vendor fields
Persist primary/extra services, service notes, free-text POC, and
tech/vendor notes on create/patch/board/detail instead of embedding
them in description.
2026-07-17 10:37:23 -03:00
Arthur Bassi
3983f7667d feat(locations): add sites options endpoint and vendor contactName
Expose GET /api/locations/sites for the WO create wizard and include
vendor contactName on dropdown responses.
2026-07-17 10:36:32 -03:00
Arthur Bassi
3e2952f8a0 fix(migrations): make Phase6 completion slide-over migration idempotent
Use IF EXISTS / IF COL_LENGTH guards and explicit EF migration metadata
so partial environments can apply Phase 6 safely.
2026-07-17 10:36:32 -03:00
54 changed files with 2897 additions and 268 deletions

View file

@ -0,0 +1,109 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class LocationControllerSitesTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static LocationController NewController(ApplicationDbContext ctx)
{
var dataService = new LocationDataService(ctx);
var service = new LocationService(
dataService,
Mock.Of<ICreateLocationValidation>(),
Mock.Of<IUpdateLocationValidation>());
var controller = new LocationController(service, dataService)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
return controller;
}
private static async Task SeedSitesAsync(ApplicationDbContext ctx)
{
ctx.Locations.AddRange(
new Locations { Id = 1, Name = "STL8", City = "St. Louis", State = "MO" },
new Locations { Id = 2, Name = "BK5", City = "Dallas", State = "TX" },
new Locations { Id = 3, Name = "IND9", City = "Indianapolis", State = "IN" },
new Locations { Id = 4, Name = "DFW8", City = "Dallas", State = "TX" });
await ctx.SaveChangesAsync();
}
[Fact]
public async Task GetSites_ReturnsIdCodeCityState_OrderedByCode()
{
await using var ctx = NewContext();
await SeedSitesAsync(ctx);
var controller = NewController(ctx);
var result = await controller.GetSites();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var data = ok.Value.Should().BeAssignableTo<IReadOnlyList<SiteOptionDTO>>().Subject;
data.Should().HaveCount(4);
data.Select(s => s.Code).Should().ContainInOrder("BK5", "DFW8", "IND9", "STL8");
data[0].Should().BeEquivalentTo(new SiteOptionDTO
{
Id = 2,
Code = "BK5",
City = "Dallas",
State = "TX"
});
}
[Fact]
public async Task GetSites_FiltersByCode()
{
await using var ctx = NewContext();
await SeedSitesAsync(ctx);
var controller = NewController(ctx);
var result = await controller.GetSites("bk5");
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var data = ok.Value.Should().BeAssignableTo<IReadOnlyList<SiteOptionDTO>>().Subject;
data.Should().ContainSingle();
data[0].Code.Should().Be("BK5");
}
[Fact]
public async Task GetSites_FiltersByCity()
{
await using var ctx = NewContext();
await SeedSitesAsync(ctx);
var controller = NewController(ctx);
var result = await controller.GetSites("Dallas");
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var data = ok.Value.Should().BeAssignableTo<IReadOnlyList<SiteOptionDTO>>().Subject;
data.Should().HaveCount(2);
data.Select(s => s.Code).Should().BeEquivalentTo("BK5", "DFW8");
}
}

View file

@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
@ -59,7 +60,11 @@ public class WorkOrderControllerSearchTests
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
advancedSearch);
advancedSearch,
Mock.Of<IWorkOrderDetailService>(),
Mock.Of<IWorkOrderCommentService>(),
Mock.Of<IWorkOrderCompletionService>(),
Mock.Of<IWorkOrderMediaService>());
controller.ControllerContext = new ControllerContext
{
@ -98,4 +103,61 @@ public class WorkOrderControllerSearchTests
body.Status.Should().Be("Error");
body.Message.Should().Contain("dateFrom");
}
[Fact]
public async Task GetBoard_WhenWeekEndBeforeWeekStart_ReturnsBadRequest()
{
using var ctx = NewContext();
var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(ctx));
var controller = NewController(ctx, Mock.Of<IWorkOrderAdvancedSearchService>());
// Replace board service - controller uses injected mock. Need different approach.
// Use real board service via manual controller setup
var advancedSearch = Mock.Of<IWorkOrderAdvancedSearchService>();
var controllerWithBoard = new WorkOrderController(
MockUserManager(),
Mock.Of<IWorkOrderService>(),
Mock.Of<IWorkOrderDataService>(),
Mock.Of<ICommentDataService>(),
Mock.Of<IQuotesDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<IDispatchDataService>(),
Mock.Of<IVendorDataService>(),
Mock.Of<ITaskListTemplateDataService>(),
ctx,
Mock.Of<IWebHostEnvironment>(),
Mock.Of<IHttpContextAccessor>(),
null!,
null!,
Mock.Of<IConfiguration>(),
boardService,
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
advancedSearch,
Mock.Of<IWorkOrderDetailService>(),
Mock.Of<IWorkOrderCommentService>(),
Mock.Of<IWorkOrderCompletionService>(),
Mock.Of<IWorkOrderMediaService>());
controllerWithBoard.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new System.Security.Claims.ClaimsPrincipal(
new System.Security.Claims.ClaimsIdentity(
new[] { new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, "test-user") },
"Test"))
}
};
var result = await controllerWithBoard.GetBoard(
new DateOnly(2026, 6, 22),
new DateOnly(2026, 6, 15));
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var body = badRequest.Value.Should().BeOfType<Response>().Subject;
body.Status.Should().Be("Error");
body.Message.Should().Contain("weekEnd");
}
}

View file

@ -26,6 +26,13 @@ namespace Api.SeaHavenIndustries.Controllers
_locationDataService = locationDataService;
}
[HttpGet("sites")]
public async Task<IActionResult> GetSites(string? search = null)
{
var data = await _locationService.GetSiteOptionsAsync(search);
return Ok(data);
}
[HttpGet("GetLocationList")]
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10)
{

View file

@ -174,6 +174,7 @@ namespace Api.SeaHavenIndustries.Controllers
{
v.Id,
v.CompanyName,
v.ContactName,
v.TradeSpecialties,
v.Address,
v.City,
@ -191,6 +192,7 @@ namespace Api.SeaHavenIndustries.Controllers
{
v.Id,
v.CompanyName,
v.ContactName,
v.TradeSpecialties,
address = string.Join(", ", addr),
distanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null

View file

@ -114,19 +114,26 @@ namespace Api.SeaHavenIndustries.Controllers
[FromQuery] List<WorkOrderType>? types = null,
[FromQuery] string? search = null)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var query = new WorkOrderBoardQueryDto
try
{
WeekStart = weekStart,
WeekEnd = weekEnd,
Dispatchers = dispatchers,
MyWorkOrders = myWorkOrders,
Types = types,
Search = search
};
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);
var result = await _workOrderBoardService.GetBoardAsync(query, userId);
return Ok(result);
}
catch (ArgumentException ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpGet("board/search")]
@ -278,6 +285,35 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
[HttpPatch("{id:int}/comments/{commentId:int}")]
public async Task<IActionResult> UpdateBoardComment(
int id,
int commentId,
[FromBody] WorkOrderCommentCreateDto request)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var isAdmin = User.IsInRole("Admin");
var comment = await _workOrderCommentService.UpdateCommentAsync(
id, commentId, request, actorId, isAdmin);
return Ok(comment);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden,
new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[HttpGet("completion-templates")]
public async Task<IActionResult> GetCompletionTemplates(
[FromQuery] string? serviceKey = null,

View file

@ -49,10 +49,18 @@ namespace Data.SeaHavenIndustries
.Property(w => w.RowVersion)
.IsRowVersion();
builder.Entity<WorkOrder>()
.Property(w => w.SiteCode)
.HasMaxLength(450);
builder.Entity<WorkOrder>()
.HasIndex(w => w.SiteCode)
.HasFilter("[istemplate] = 0");
builder.Entity<WorkOrder>()
.Property(w => w.InternalWONumber)
.HasMaxLength(450);
builder.Entity<WorkOrder>()
.HasIndex(w => w.InternalWONumber)
.HasFilter("[istemplate] = 0");
@ -61,6 +69,23 @@ namespace Data.SeaHavenIndustries
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
.HasFilter("[istemplate] = 0");
// Phase 1 board indexes (must stay in model to match snapshot / existing DBs).
builder.Entity<WorkOrder>()
.HasIndex(w => w.AssignTo);
builder.Entity<WorkOrder>()
.HasIndex(w => w.ScheduledDate);
builder.Entity<WorkOrder>()
.HasIndex(w => new { w.AssignTo, w.ScheduledDate });
builder.Entity<WorkOrder>()
.HasIndex(w => w.WorkOrderType);
builder.Entity<WorkOrder>()
.Property(w => w.Service)
.HasMaxLength(128);
builder.Entity<WorkOrder>()
.Property(w => w.ExternalWorkOrderId)
.HasMaxLength(450);

View file

@ -10,6 +10,27 @@ namespace Data.SeaHavenIndustries.Migrations
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// nvarchar(max) cannot be an index key; shrink before indexing.
migrationBuilder.AlterColumn<string>(
name: "SiteCode",
table: "workOrders",
type: "nvarchar(450)",
maxLength: 450,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "InternalWONumber",
table: "workOrders",
type: "nvarchar(450)",
maxLength: 450,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_workOrders_SiteCode",
table: "workOrders",
@ -43,6 +64,26 @@ namespace Data.SeaHavenIndustries.Migrations
migrationBuilder.DropIndex(
name: "IX_workOrders_LifecycleStatus_ScheduledDate",
table: "workOrders");
migrationBuilder.AlterColumn<string>(
name: "SiteCode",
table: "workOrders",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(450)",
oldMaxLength: 450,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "InternalWONumber",
table: "workOrders",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(450)",
oldMaxLength: 450,
oldNullable: true);
}
}
}

View file

@ -1,4 +1,5 @@
using System;
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@ -6,45 +7,52 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260625120000_Phase6_CompletionSlideOver")]
public partial class Phase6_CompletionSlideOver : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Category",
table: "workOrderAttachments",
type: "int",
nullable: true);
migrationBuilder.Sql(@"
IF COL_LENGTH('workOrderAttachments', 'Category') IS NULL
BEGIN
ALTER TABLE workOrderAttachments ADD Category int NULL;
END
");
migrationBuilder.CreateTable(
name: "CompletionDocTemplates",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
ServiceKey = table.Column<string>(type: "nvarchar(450)", nullable: false),
WorkOrderType = table.Column<int>(type: "int", nullable: true),
TemplateUrl = table.Column<string>(type: "nvarchar(max)", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CompletionDocTemplates", x => x.Id);
});
migrationBuilder.Sql(@"
IF OBJECT_ID(N'dbo.CompletionDocTemplates', N'U') IS NULL
BEGIN
CREATE TABLE CompletionDocTemplates (
Id int NOT NULL IDENTITY(1,1),
Name nvarchar(max) NOT NULL,
ServiceKey nvarchar(450) NOT NULL,
WorkOrderType int NULL,
TemplateUrl nvarchar(max) NOT NULL,
IsActive bit NOT NULL,
IsDeleted bit NULL,
createdby nvarchar(max) NULL,
DeleterUserId nvarchar(max) NULL,
DeletionTime datetime2 NULL,
CreatedDate datetime2 NULL,
LastModificationTime datetime2 NULL,
LastModifierUserId int NULL,
CONSTRAINT PK_CompletionDocTemplates PRIMARY KEY (Id)
);
END
");
migrationBuilder.CreateIndex(
name: "IX_CompletionDocTemplates_ServiceKey_IsActive",
table: "CompletionDocTemplates",
columns: new[] { "ServiceKey", "IsActive" });
migrationBuilder.Sql(@"
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = N'IX_CompletionDocTemplates_ServiceKey_IsActive'
AND object_id = OBJECT_ID(N'dbo.CompletionDocTemplates'))
BEGIN
CREATE INDEX IX_CompletionDocTemplates_ServiceKey_IsActive
ON CompletionDocTemplates (ServiceKey, IsActive);
END
");
migrationBuilder.Sql(@"
UPDATE workOrders
@ -66,12 +74,15 @@ namespace Data.SeaHavenIndustries.Migrations
AND LTRIM(RTRIM(SignOffAttachment)) <> '';
");
migrationBuilder.DropTable(
name: "CompletionDocTemplates");
migrationBuilder.Sql(@"
IF OBJECT_ID(N'dbo.CompletionDocTemplates', N'U') IS NOT NULL
DROP TABLE CompletionDocTemplates;
");
migrationBuilder.DropColumn(
name: "Category",
table: "workOrderAttachments");
migrationBuilder.Sql(@"
IF COL_LENGTH('workOrderAttachments', 'Category') IS NOT NULL
ALTER TABLE workOrderAttachments DROP COLUMN Category;
");
}
}
}

View file

@ -1,3 +1,5 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@ -5,45 +7,57 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260713120000_Phase7_ExternalWorkOrderIdUnique")]
public partial class Phase7_ExternalWorkOrderIdUnique : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "ExternalWorkOrderId",
table: "workOrders",
type: "nvarchar(450)",
maxLength: 450,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldNullable: true);
// Idempotent: column may already be nvarchar(450) in some environments.
migrationBuilder.Sql(@"
IF COL_LENGTH('workOrders', 'ExternalWorkOrderId') IS NOT NULL
AND EXISTS (
SELECT 1
FROM sys.columns c
INNER JOIN sys.types t ON c.user_type_id = t.user_type_id
WHERE c.object_id = OBJECT_ID(N'dbo.workOrders')
AND c.name = N'ExternalWorkOrderId'
AND t.name = N'nvarchar'
AND c.max_length = -1)
BEGIN
ALTER TABLE workOrders ALTER COLUMN ExternalWorkOrderId nvarchar(450) NULL;
END
");
migrationBuilder.CreateIndex(
name: "IX_workOrders_ExternalWorkOrderId",
table: "workOrders",
column: "ExternalWorkOrderId",
unique: true,
filter: "[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
migrationBuilder.Sql(@"
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = N'IX_workOrders_ExternalWorkOrderId'
AND object_id = OBJECT_ID(N'dbo.workOrders'))
BEGIN
CREATE UNIQUE INDEX IX_workOrders_ExternalWorkOrderId
ON workOrders (ExternalWorkOrderId)
WHERE [ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> '';
END
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_workOrders_ExternalWorkOrderId",
table: "workOrders");
migrationBuilder.Sql(@"
IF EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = N'IX_workOrders_ExternalWorkOrderId'
AND object_id = OBJECT_ID(N'dbo.workOrders'))
DROP INDEX IX_workOrders_ExternalWorkOrderId ON workOrders;
");
migrationBuilder.AlterColumn<string>(
name: "ExternalWorkOrderId",
table: "workOrders",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(450)",
oldMaxLength: 450,
oldNullable: true);
migrationBuilder.Sql(@"
IF COL_LENGTH('workOrders', 'ExternalWorkOrderId') IS NOT NULL
ALTER TABLE workOrders ALTER COLUMN ExternalWorkOrderId nvarchar(max) NULL;
");
}
}
}

View file

@ -0,0 +1,121 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260715160000_WoServiceNotesStructuredFields")]
public partial class WoServiceNotesStructuredFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PocName",
table: "workOrders",
type: "nvarchar(200)",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PocPhone",
table: "workOrders",
type: "nvarchar(50)",
maxLength: 50,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PocNotes",
table: "workOrders",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExtraServices",
table: "workOrders",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ServiceNotes",
table: "workOrders",
type: "nvarchar(4000)",
maxLength: 4000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "TechPhone",
table: "workOrders",
type: "nvarchar(50)",
maxLength: 50,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "VendorNotes",
table: "workOrders",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "TechPhone",
table: "Dispatches",
type: "nvarchar(50)",
maxLength: 50,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "VendorNotes",
table: "Dispatches",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PocName",
table: "workOrders");
migrationBuilder.DropColumn(
name: "PocPhone",
table: "workOrders");
migrationBuilder.DropColumn(
name: "PocNotes",
table: "workOrders");
migrationBuilder.DropColumn(
name: "ExtraServices",
table: "workOrders");
migrationBuilder.DropColumn(
name: "ServiceNotes",
table: "workOrders");
migrationBuilder.DropColumn(
name: "TechPhone",
table: "workOrders");
migrationBuilder.DropColumn(
name: "VendorNotes",
table: "workOrders");
migrationBuilder.DropColumn(
name: "TechPhone",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "VendorNotes",
table: "Dispatches");
}
}
}

View file

@ -0,0 +1,33 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260715180000_WoFlagColor")]
public partial class WoFlagColor : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "FlagColor",
table: "workOrders",
type: "nvarchar(7)",
maxLength: 7,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FlagColor",
table: "workOrders");
}
}
}

View file

@ -0,0 +1,58 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260717140000_AddEventsCalendarTable")]
public partial class AddEventsCalendarTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Events entity existed in the model/DbSet without a discoverable migration.
migrationBuilder.Sql(@"
IF OBJECT_ID(N'dbo.Events', N'U') IS NULL
BEGIN
CREATE TABLE Events (
Id int NOT NULL IDENTITY(1,1),
Title nvarchar(100) NOT NULL,
Description nvarchar(max) NULL,
Location nvarchar(150) NULL,
RecordID int NULL,
RecordName nvarchar(max) NULL,
RecordTargetType nvarchar(max) NULL,
ReminderMinutes int NOT NULL,
EventColor nvarchar(20) NOT NULL,
StartDate datetime2 NOT NULL,
StartTime nvarchar(10) NULL,
EndDate datetime2 NOT NULL,
EndTime nvarchar(10) NULL,
AllDay bit NOT NULL,
IsDeleted bit NULL,
createdby nvarchar(max) NULL,
DeleterUserId nvarchar(max) NULL,
DeletionTime datetime2 NULL,
CreatedDate datetime2 NULL,
LastModificationTime datetime2 NULL,
LastModifierUserId int NULL,
CONSTRAINT PK_Events PRIMARY KEY (Id)
);
END
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
IF OBJECT_ID(N'dbo.Events', N'U') IS NOT NULL
DROP TABLE Events;
");
}
}
}

View file

@ -107,7 +107,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Accounts", (string)null);
b.ToTable("Accounts");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b =>
@ -175,7 +175,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("ContactId");
b.ToTable("Addresses", (string)null);
b.ToTable("Addresses");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
@ -334,7 +334,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("Assets", (string)null);
b.ToTable("Assets");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Category", b =>
@ -371,62 +371,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Categories", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ServiceKey")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("TemplateUrl")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int?>("WorkOrderType")
.HasColumnType("int");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("ServiceKey", "IsActive")
.HasDatabaseName("IX_CompletionDocTemplates_ServiceKey_IsActive");
b.ToTable("CompletionDocTemplates", (string)null);
b.ToTable("Categories");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b =>
@ -493,7 +438,61 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkerOrderId");
b.ToTable("Comments", (string)null);
b.ToTable("Comments");
});
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ServiceKey")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("TemplateUrl")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int?>("WorkOrderType")
.HasColumnType("int");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("ServiceKey", "IsActive");
b.ToTable("CompletionDocTemplates");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
@ -559,7 +558,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("ContactId");
b.ToTable("ContactDetails", (string)null);
b.ToTable("ContactDetails");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b =>
@ -651,7 +650,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("Contacts", (string)null);
b.ToTable("Contacts");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Department", b =>
@ -669,7 +668,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Departments", (string)null);
b.ToTable("Departments");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b =>
@ -739,9 +738,17 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Status")
.HasColumnType("nvarchar(max)");
b.Property<string>("TechPhone")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int>("VendorId")
.HasColumnType("int");
b.Property<string>("VendorNotes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<DateTime?>("VerifiedAt")
.HasColumnType("datetime2");
@ -760,7 +767,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("Dispatches", (string)null);
b.ToTable("Dispatches");
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b =>
@ -819,7 +826,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("DispatchChecklistItems", (string)null);
b.ToTable("DispatchChecklistItems");
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b =>
@ -873,7 +880,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("DispatchId");
b.ToTable("DispatchSignoffs", (string)null);
b.ToTable("DispatchSignoffs");
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b =>
@ -943,7 +950,7 @@ namespace Data.SeaHavenIndustries.Migrations
.IsUnique()
.HasFilter("[Status] = 'Pending'");
b.ToTable("DispatchUpliftRequests", (string)null);
b.ToTable("DispatchUpliftRequests");
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
@ -966,7 +973,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("DispatchWorkOrders", (string)null);
b.ToTable("DispatchWorkOrders");
});
modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b =>
@ -996,7 +1003,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("DropdownOptions", (string)null);
b.ToTable("DropdownOptions");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b =>
@ -1101,7 +1108,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("RegionId");
b.ToTable("Employees", (string)null);
b.ToTable("Employees");
});
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b =>
@ -1141,7 +1148,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId")
.IsUnique();
b.ToTable("EmployeeAddresses", (string)null);
b.ToTable("EmployeeAddresses");
});
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b =>
@ -1169,7 +1176,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId");
b.ToTable("EmployeeEmails", (string)null);
b.ToTable("EmployeeEmails");
});
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b =>
@ -1199,7 +1206,87 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId");
b.ToTable("EmployeePhones", (string)null);
b.ToTable("EmployeePhones");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Events", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("AllDay")
.HasColumnType("bit");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("EndDate")
.HasColumnType("datetime2");
b.Property<string>("EndTime")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("EventColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Location")
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<int?>("RecordID")
.HasColumnType("int");
b.Property<string>("RecordName")
.HasColumnType("nvarchar(max)");
b.Property<string>("RecordTargetType")
.HasColumnType("nvarchar(max)");
b.Property<int>("ReminderMinutes")
.HasColumnType("int");
b.Property<DateTime>("StartDate")
.HasColumnType("datetime2");
b.Property<string>("StartTime")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Events");
});
modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b =>
@ -1267,7 +1354,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("FollowUps", (string)null);
b.ToTable("FollowUps");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b =>
@ -1292,7 +1379,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("ForgetPasswordCodes", (string)null);
b.ToTable("ForgetPasswordCodes");
});
modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b =>
@ -1310,7 +1397,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("JobTitles", (string)null);
b.ToTable("JobTitles");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
@ -1380,7 +1467,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Locations", (string)null);
b.ToTable("Locations");
});
modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b =>
@ -1438,7 +1525,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("PMSchedules", (string)null);
b.ToTable("PMSchedules");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b =>
@ -1512,7 +1599,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("Quotes", (string)null);
b.ToTable("Quotes");
});
modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b =>
@ -1560,7 +1647,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("QuoteId");
b.ToTable("QuotesLines", (string)null);
b.ToTable("QuotesLines");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Region", b =>
@ -1578,7 +1665,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Regions", (string)null);
b.ToTable("Regions");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b =>
@ -1621,7 +1708,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("TaskListTemplates", (string)null);
b.ToTable("TaskListTemplates");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b =>
@ -1666,7 +1753,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("TaskListTemplateId");
b.ToTable("TaskListTemplateItems", (string)null);
b.ToTable("TaskListTemplateItems");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
@ -1768,7 +1855,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("Templates", (string)null);
b.ToTable("Templates");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
@ -1832,7 +1919,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Vendors", (string)null);
b.ToTable("Vendors");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b =>
@ -1891,7 +1978,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("VendorId");
b.ToTable("VendorAccessTokens", (string)null);
b.ToTable("VendorAccessTokens");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>
@ -1963,8 +2050,17 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.Property<string>("ExtraServices")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("FlagColor")
.HasMaxLength(7)
.HasColumnType("nvarchar(7)");
b.Property<string>("InternalWONumber")
.HasColumnType("nvarchar(max)");
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
@ -1996,6 +2092,18 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("PO")
.HasColumnType("nvarchar(max)");
b.Property<string>("PocName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("PocNotes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("PocPhone")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int?>("PrimaryDispatchId")
.HasColumnType("int");
@ -2029,6 +2137,10 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ServiceNotes")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
b.Property<string>("Severity")
.HasColumnType("nvarchar(max)");
@ -2042,7 +2154,8 @@ namespace Data.SeaHavenIndustries.Migrations
.HasColumnType("nvarchar(max)");
b.Property<string>("SiteCode")
.HasColumnType("nvarchar(max)");
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
b.Property<string>("Source")
.HasColumnType("nvarchar(max)");
@ -2062,12 +2175,20 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateOnly?>("TargetWeek")
.HasColumnType("date");
b.Property<string>("TechPhone")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Trade")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("VendorNTE")
.HasColumnType("decimal(18,2)");
b.Property<string>("VendorNotes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<int?>("WorkOrderType")
.HasColumnType("int");
@ -2087,8 +2208,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("AssignTo");
b.HasIndex("AssignTo", "ScheduledDate");
b.HasIndex("ExternalWorkOrderId")
.IsUnique()
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
@ -2096,9 +2215,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("InternalWONumber")
.HasFilter("[istemplate] = 0");
b.HasIndex("LifecycleStatus", "ScheduledDate")
.HasFilter("[istemplate] = 0");
b.HasIndex("LocationId");
b.HasIndex("PrimaryDispatchId");
@ -2110,7 +2226,12 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderType");
b.ToTable("workOrders", (string)null);
b.HasIndex("AssignTo", "ScheduledDate");
b.HasIndex("LifecycleStatus", "ScheduledDate")
.HasFilter("[istemplate] = 0");
b.ToTable("workOrders");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
@ -2155,7 +2276,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("workOrderAttachments", (string)null);
b.ToTable("workOrderAttachments");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b =>
@ -2205,7 +2326,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("WorkOrderAuditLogs", (string)null);
b.ToTable("WorkOrderAuditLogs");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b =>
@ -2249,7 +2370,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("workOrderCategories", (string)null);
b.ToTable("workOrderCategories");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b =>
@ -2296,7 +2417,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("WorkOrderContacts", (string)null);
b.ToTable("WorkOrderContacts");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
@ -2325,7 +2446,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId", "FieldName")
.IsUnique();
b.ToTable("WorkOrderFieldLocks", (string)null);
b.ToTable("WorkOrderFieldLocks");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
@ -2354,7 +2475,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId", "SourceWeekStart")
.IsUnique();
b.ToTable("WorkOrderWeekRolledLedgers", (string)null);
b.ToTable("WorkOrderWeekRolledLedgers");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
@ -2923,10 +3044,10 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
{
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
.WithMany()
.WithMany("FieldLocks")
.HasForeignKey("WorkOrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
@ -2934,10 +3055,10 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
{
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
.WithMany("FieldLocks")
.WithMany()
.HasForeignKey("WorkOrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();

View file

@ -28,6 +28,13 @@ namespace Data.SeaHavenIndustries
public string? VerifiedBy { get; set; }
public DateTime? VerifiedAt { get; set; }
/// <summary>Tech/crew phone for this dispatch (overrides Vendor.Phone on board).</summary>
[MaxLength(50)]
public string? TechPhone { get; set; }
/// <summary>Instructions for the vendor for this appointment/dispatch.</summary>
[MaxLength(2000)]
public string? VendorNotes { get; set; }
[Timestamp]
public byte[]? RowVersion { get; set; }

View file

@ -7,12 +7,15 @@ namespace Data.SeaHavenIndustries
public class WorkOrder : FullAuditEntity
{
// --- Core slice ---
[MaxLength(450)]
public string? InternalWONumber { get; set; }
[MaxLength(450)]
public string? ExternalWorkOrderId { get; set; }
public string? WorkerOrderNumber { get; set; }
public string? WorkerOrderTitle { get; set; }
public string? Description { get; set; }
public string? Customer { get; set; }
[MaxLength(450)]
public string? SiteCode { get; set; }
public string? Building { get; set; }
public string? Severity { get; set; }
@ -27,11 +30,31 @@ namespace Data.SeaHavenIndustries
public string? LegacyStatus { get; set; }
public WorkOrderType? WorkOrderType { get; set; }
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
[MaxLength(128)]
public string? Service { get; set; }
public int? PrimaryDispatchId { get; set; }
[ForeignKey(nameof(PrimaryDispatchId))]
public virtual Dispatch? PrimaryDispatch { get; set; }
// --- Service & Notes / POC (structured create) ---
[MaxLength(200)]
public string? PocName { get; set; }
[MaxLength(50)]
public string? PocPhone { get; set; }
[MaxLength(2000)]
public string? PocNotes { get; set; }
/// <summary>JSON array of extra service labels beyond primary (Trade).</summary>
[MaxLength(2000)]
public string? ExtraServices { get; set; }
[MaxLength(4000)]
public string? ServiceNotes { get; set; }
/// <summary>Tech/crew phone when no primary dispatch; otherwise prefer Dispatch.TechPhone.</summary>
[MaxLength(50)]
public string? TechPhone { get; set; }
/// <summary>Vendor instructions when no primary dispatch; otherwise prefer Dispatch.VendorNotes.</summary>
[MaxLength(2000)]
public string? VendorNotes { get; set; }
// --- Scheduling slice ---
public DateTime? ScheduledDate { get; set; }
public DateTime? ScheduledStart { get; set; }
@ -51,6 +74,10 @@ namespace Data.SeaHavenIndustries
// --- Completion slice ---
public DocStatus? DocStatus { get; set; }
/// <summary>Board flag color (#RRGGBB from fixed palette). Null = no flag.</summary>
[MaxLength(7)]
public string? FlagColor { get; set; }
// --- Legacy SQL columns (drift reconciliation) ---
public string? AvettaTask { get; set; }
public DateOnly? AssignDate { get; set; }

View file

@ -20,7 +20,10 @@ namespace SeaHaven.DataServices.Helpers
w.WorkOrderType,
w.SiteCode,
LocationName = w.Locations != null ? w.Locations.Name : null,
Poc = w.WorkOrderContacts!
WoPocName = w.PocName,
WoPocPhone = w.PocPhone,
WoPocNotes = w.PocNotes,
ContactPoc = w.WorkOrderContacts!
.OrderBy(c => c.Id)
.Select(c => new
{
@ -52,55 +55,73 @@ namespace SeaHaven.DataServices.Helpers
TechName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
? w.PrimaryDispatch.Vendor.ContactName
: null,
TechPhone = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
DispatchTechPhone = w.PrimaryDispatch != null ? w.PrimaryDispatch.TechPhone : null,
WoTechPhone = w.TechPhone,
VendorPhone = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
? w.PrimaryDispatch.Vendor.Phone
: null,
DispatchApptDate = w.PrimaryDispatch != null ? w.PrimaryDispatch.ScheduledDate : null,
w.Trade,
w.Problem,
w.ServiceNotes,
w.ExtraServices,
w.DocStatus,
w.FlagColor,
w.PrimaryDispatchId,
w.RowVersion,
DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null
})
.ToListAsync();
return rows.Select(w => new WorkOrderBoardRawRow(
w.Id,
w.InternalWONumber,
w.RescheduleCount,
w.CarriedOver,
w.WorkOrderType,
w.SiteCode,
w.LocationName,
w.Poc?.Name,
w.Poc?.PhoneNumber,
w.Poc?.Notes,
w.LifecycleStatus,
w.LegacyStatus ?? w.Status,
w.AssignTo,
w.FirstName,
w.LastName,
w.Initials,
w.Color,
w.DueDate,
w.ScheduledDate,
w.ScheduledStart,
w.ScheduledEnd,
w.TargetWeek,
w.ScheduleWeekOnly,
w.VendorId,
w.VendorName,
w.TechName,
w.TechPhone,
w.DispatchApptDate,
w.Trade ?? w.Problem,
w.Problem,
w.DocStatus,
w.PrimaryDispatchId,
w.RowVersion,
w.DispatchRowVersion,
isUnscheduled)).ToList();
return rows.Select(w =>
{
var pocName = !string.IsNullOrWhiteSpace(w.WoPocName) ? w.WoPocName : w.ContactPoc?.Name;
var pocPhone = !string.IsNullOrWhiteSpace(w.WoPocPhone) ? w.WoPocPhone : w.ContactPoc?.PhoneNumber;
var pocNotes = !string.IsNullOrWhiteSpace(w.WoPocNotes) ? w.WoPocNotes : w.ContactPoc?.Notes;
var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone)
? w.DispatchTechPhone
: (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone);
return new WorkOrderBoardRawRow(
w.Id,
w.InternalWONumber,
w.RescheduleCount,
w.CarriedOver,
w.WorkOrderType,
w.SiteCode,
w.LocationName,
pocName,
pocPhone,
pocNotes,
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,
techPhone,
w.DispatchApptDate,
w.Trade ?? w.Problem,
w.Problem,
w.ServiceNotes,
w.ExtraServices,
w.DocStatus,
w.FlagColor,
w.PrimaryDispatchId,
w.RowVersion,
w.DispatchRowVersion,
isUnscheduled);
}).ToList();
}
}
}

View file

@ -37,16 +37,49 @@ namespace SeaHaven.DataServices.Helpers
return query.Where(w => w.AssignTo != null && ids.Contains(w.AssignTo));
}
public static IQueryable<WorkOrder> ApplyTypeFilter(
/// <summary>
/// FE sends <see cref="WorkOrderType.Other"/> (99) as the overdue/isPastDue sentinel — not a real type filter.
/// </summary>
public static IQueryable<WorkOrder> ApplyTypeAndOverdueFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<WorkOrderType>? types)
IReadOnlyList<WorkOrderType>? types,
DateTime? utcNow = null)
{
if (types == null || types.Count == 0)
return query;
return query.Where(w => w.WorkOrderType != null && types.Contains(w.WorkOrderType.Value));
var today = (utcNow ?? DateTime.UtcNow).Date;
var includeOverdue = types.Contains(WorkOrderType.Other);
var typeFilters = types.Where(t => t != WorkOrderType.Other).ToList();
// Keep past-due predicate inline — EF cannot translate local method calls.
if (includeOverdue && typeFilters.Count == 0)
{
return query.Where(w =>
w.ScheduledDate != null
&& w.ScheduledDate.Value.Date < today
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled);
}
if (includeOverdue)
{
return query.Where(w =>
(w.ScheduledDate != null
&& w.ScheduledDate.Value.Date < today
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled)
|| (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value)));
}
return query.Where(w => w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value));
}
public static IQueryable<WorkOrder> ApplyTypeFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<WorkOrderType>? types)
=> ApplyTypeAndOverdueFilter(query, types);
public static IQueryable<WorkOrder> ApplySiteFilter(
IQueryable<WorkOrder> query,
IReadOnlyList<string>? sites)
@ -132,7 +165,10 @@ namespace SeaHaven.DataServices.Helpers
|| (w.ScheduleWeekOnly == true
&& w.TargetWeek != null
&& w.TargetWeek >= dateFrom
&& w.TargetWeek <= dateTo));
&& w.TargetWeek <= dateTo)
|| (w.ScheduledDate == null
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled));
}
}
}

View file

@ -1,3 +1,5 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
@ -5,6 +7,10 @@ namespace SeaHaven.DataServices.Helpers
{
public static class WorkOrderBoardSearchFilter
{
private static readonly Regex PartialDatePattern = new(
@"^\d{1,2}/\d{1,2}$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
public static string? NormalizeSearch(string? search)
{
if (string.IsNullOrWhiteSpace(search))
@ -21,10 +27,23 @@ namespace SeaHaven.DataServices.Helpers
return query;
var s = normalized.ToLower();
var today = DateTime.UtcNow.Date;
var exactDate = TryParseExactDate(normalized);
var hasExactDate = exactDate.HasValue;
var exactDateValue = exactDate?.Date ?? default;
var partialMonthDay = TryParsePartialMonthDay(normalized);
var hasPartialMonthDay = partialMonthDay.HasValue;
var partialMonth = partialMonthDay?.Month ?? 0;
var partialDay = partialMonthDay?.Day ?? 0;
// Keep predicates inline — EF Core cannot translate local method calls.
return query.Where(w =>
(w.SiteCode ?? "").ToLower().Contains(s)
|| (w.InternalWONumber ?? "").ToLower().Contains(s)
|| (w.WorkerOrderNumber ?? "").ToLower().Contains(s)
|| (w.WorkerOrderTitle ?? "").ToLower().Contains(s)
|| (w.Description ?? "").ToLower().Contains(s)
|| (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s))
|| (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s))
|| (w.Trade ?? "").ToLower().Contains(s)
@ -32,6 +51,19 @@ namespace SeaHaven.DataServices.Helpers
|| (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s))
|| (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.ContactName ?? "").ToLower().Contains(s))
|| (w.Status ?? "").ToLower().Contains(s)
|| (w.WorkOrderType == WorkOrderType.PO && "po".Contains(s))
|| (w.WorkOrderType == WorkOrderType.PM && "pm".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Emergency && "emergency".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Project && "project".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Inspection && "inspection".Contains(s))
|| (w.WorkOrderType == WorkOrderType.Reactive && "reactive".Contains(s))
|| (w.WorkOrderType == WorkOrderType.AddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|| (w.WorkOrderType == WorkOrderType.Other && "other".Contains(s))
|| ("overdue".Contains(s)
&& w.ScheduledDate != null
&& w.ScheduledDate.Value.Date < today
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled)
|| (w.LifecycleStatus == LifecycleStatus.Incomplete && "incomplete".Contains(s))
|| (w.LifecycleStatus == LifecycleStatus.Pending && ("pending".Contains(s) || "on hold".Contains(s) || "onhold".Contains(s)))
|| (w.LifecycleStatus == LifecycleStatus.Scheduled && "scheduled".Contains(s))
@ -41,7 +73,61 @@ namespace SeaHaven.DataServices.Helpers
|| (w.LifecycleStatus == LifecycleStatus.Completed && ("completed".Contains(s) || "complete".Contains(s) || "done".Contains(s) || "closed".Contains(s)))
|| (w.LifecycleStatus == LifecycleStatus.Rescheduled && "rescheduled".Contains(s))
|| (w.LifecycleStatus == LifecycleStatus.Canceled && ("canceled".Contains(s) || "cancelled".Contains(s)))
|| (w.LifecycleStatus == LifecycleStatus.PendingQuote && ("pending quote".Contains(s) || "pendingquote".Contains(s) || "pending approval".Contains(s) || "pendingapproval".Contains(s))));
|| (w.LifecycleStatus == LifecycleStatus.PendingQuote && ("pending quote".Contains(s) || "pendingquote".Contains(s) || "pending approval".Contains(s) || "pendingapproval".Contains(s)))
|| (hasExactDate
&& ((w.ScheduledDate != null && w.ScheduledDate.Value.Date == exactDateValue)
|| (w.DueDate != null && w.DueDate.Value.Date == exactDateValue)))
|| (hasPartialMonthDay
&& ((w.ScheduledDate != null
&& w.ScheduledDate.Value.Month == partialMonth
&& w.ScheduledDate.Value.Day == partialDay)
|| (w.DueDate != null
&& w.DueDate.Value.Month == partialMonth
&& w.DueDate.Value.Day == partialDay))));
}
internal static DateTime? TryParseExactDate(string search)
{
if (PartialDatePattern.IsMatch(search))
return null;
var formats = new[]
{
"yyyy-MM-dd",
"MM/dd/yyyy",
"M/d/yyyy",
"MM/dd/yy",
"M/d/yy"
};
if (DateTime.TryParseExact(search, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
return parsed.Date;
if (DateTime.TryParse(search, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed))
return parsed.Date;
return null;
}
internal static (int Month, int Day)? TryParsePartialMonthDay(string search)
{
if (!PartialDatePattern.IsMatch(search))
return null;
var parts = search.Split('/');
if (parts.Length != 2)
return null;
if (!int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var month))
return null;
if (!int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var day))
return null;
if (month is < 1 or > 12 || day is < 1 or > 31)
return null;
return (month, day);
}
}
}

View file

@ -60,6 +60,24 @@ namespace SeaHaven.DataServices.Implementation
return (items, totalCount);
}
public async Task<IReadOnlyList<SiteOptionRow>> GetSiteOptionsAsync(string? search = null)
{
var query = _context.Locations.AsNoTracking();
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.ToLower();
query = query.Where(l =>
(l.Name ?? "").ToLower().Contains(s) ||
(l.City ?? "").ToLower().Contains(s));
}
return await query
.OrderBy(l => l.Name)
.Select(l => new SiteOptionRow(l.Id, l.Name, l.City, l.State))
.ToListAsync();
}
public async Task<Locations> AddAsync(Locations location)
{
location.CreatedDate = DateTime.Now;

View file

@ -33,5 +33,27 @@ namespace SeaHaven.DataServices.Implementation
{
return await _context.Users.AnyAsync(u => u.Id == id);
}
public async Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids)
{
var idList = ids
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.Ordinal)
.ToList();
if (idList.Count == 0)
return new Dictionary<string, string>(StringComparer.Ordinal);
var users = await _context.Users
.AsNoTracking()
.Where(u => idList.Contains(u.Id))
.Select(u => new { u.Id, u.FirstName, u.LastName })
.ToListAsync();
return users.ToDictionary(
u => u.Id,
u => $"{u.FirstName ?? ""} {u.LastName ?? ""}".Trim(),
StringComparer.Ordinal);
}
}
}

View file

@ -35,7 +35,7 @@ namespace SeaHaven.DataServices.Implementation
var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir);
var paged = sorted
.Skip((query.Page - 1) * query.PageSize)
.Skip(query.Page * query.PageSize)
.Take(query.PageSize);
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(paged, isUnscheduled: false);

View file

@ -32,7 +32,12 @@ namespace SeaHaven.DataServices.Implementation
w.CompletedDate,
w.SignOffName,
w.SignOffAttachment,
w.SignOffSignature))
w.SignOffSignature,
w.ExtraServices,
w.ServiceNotes,
w.PrimaryDispatch != null && w.PrimaryDispatch.VendorNotes != null
? w.PrimaryDispatch.VendorNotes
: w.VendorNotes))
.FirstOrDefaultAsync();
}

View file

@ -2,6 +2,8 @@ using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces
{
public record SiteOptionRow(int Id, string? Code, string? City, string? State);
public interface ILocationDataService
{
Task<Locations?> GetByIdAsync(int id);
@ -15,6 +17,8 @@ namespace SeaHaven.DataServices.Interfaces
// For addressbook endpoint - returns simplified DTO
Task<(IEnumerable<object> Items, int TotalCount)> GetAddressbookPagedAsync(int page, int pageSize, string? search = null);
Task<IReadOnlyList<SiteOptionRow>> GetSiteOptionsAsync(string? search = null);
Task<Locations> AddAsync(Locations location);
Task UpdateAsync(Locations location);
Task DeleteAsync(int id);

View file

@ -8,5 +8,6 @@ namespace SeaHaven.DataServices.Interfaces
Task<IEnumerable<ApplicationUser>> GetAllAsync();
Task<ApplicationUser?> GetByEmailAsync(string email);
Task<bool> ExistsAsync(string id);
Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids);
}
}

View file

@ -42,7 +42,10 @@ namespace SeaHaven.DataServices.Interfaces
DateTime? DispatchApptDate,
string? Trade,
string? Problem,
string? ServiceNotes,
string? ExtraServices,
DocStatus? DocStatus,
string? FlagColor,
int? PrimaryDispatchId,
byte[]? RowVersion,
byte[]? DispatchRowVersion,

View file

@ -12,7 +12,10 @@ namespace SeaHaven.DataServices.Interfaces
DateTime? CompletedDate,
string? SignOffName,
string? SignOffAttachment,
string? SignOffSignature);
string? SignOffSignature,
string? ExtraServices,
string? ServiceNotes,
string? VendorNotes);
public record DispatchSignoffRow(
int DispatchId,

View file

@ -8,8 +8,9 @@ public class PagedResultTests
{
[Theory]
[InlineData(0, 10, 0)]
[InlineData(1, 10, 1)]
[InlineData(10, 10, 1)]
[InlineData(25, 10, 3)]
[InlineData(11, 10, 2)]
[InlineData(100, 25, 4)]
public void TotalPages_IsCeilingOfCountOverPageSize(int totalCount, int pageSize, int expectedPages)
{
@ -19,16 +20,32 @@ public class PagedResultTests
}
[Fact]
public void HasPrevious_IsFalseOnFirstPage_TrueAfterward()
public void HasPrevious_IsFalseOnFirstPage_TrueAfterward_OneBased()
{
new PagedResult<string> { Page = 1, PageSize = 10, TotalCount = 30 }.HasPrevious.Should().BeFalse();
new PagedResult<string> { Page = 2, PageSize = 10, TotalCount = 30 }.HasPrevious.Should().BeTrue();
}
[Fact]
public void HasNext_IsTrueBeforeLastPage_FalseOnLastPage()
public void HasNext_IsTrueBeforeLastPage_FalseOnLastPage_OneBased()
{
new PagedResult<string> { Page = 2, PageSize = 10, TotalCount = 30 }.HasNext.Should().BeTrue();
new PagedResult<string> { Page = 3, PageSize = 10, TotalCount = 30 }.HasNext.Should().BeFalse();
}
[Fact]
public void HasPrevious_And_HasNext_ZeroBased_WhenPageBaseIsZero()
{
var first = new PagedResult<string> { Page = 0, PageSize = 10, TotalCount = 30, PageBase = 0 };
first.HasPrevious.Should().BeFalse();
first.HasNext.Should().BeTrue();
var middle = new PagedResult<string> { Page = 1, PageSize = 10, TotalCount = 30, PageBase = 0 };
middle.HasPrevious.Should().BeTrue();
middle.HasNext.Should().BeTrue();
var last = new PagedResult<string> { Page = 2, PageSize = 10, TotalCount = 30, PageBase = 0 };
last.HasPrevious.Should().BeTrue();
last.HasNext.Should().BeFalse();
}
}

View file

@ -48,8 +48,17 @@ namespace SeaHaven.Services.DTOs
public int TotalCount { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
public bool HasPrevious => Page > 1;
public bool HasNext => Page < TotalPages;
/// <summary>
/// Page numbering base for <see cref="HasPrevious"/> / <see cref="HasNext"/>.
/// Default <c>1</c> (classic APIs). Work-order advanced search sets <c>0</c>.
/// </summary>
public int PageBase { get; set; } = 1;
public int TotalPages => PageSize <= 0 ? 0 : (int)Math.Ceiling(TotalCount / (double)PageSize);
public bool HasPrevious => PageBase <= 0 ? Page > 0 : Page > 1;
public bool HasNext => PageBase <= 0 ? Page + 1 < TotalPages : Page < TotalPages;
}
}

View file

@ -36,4 +36,12 @@ namespace SeaHaven.Services.DTOs
public string? Zipcode { get; set; }
public int? AccountId { get; set; }
}
public class SiteOptionDTO
{
public int Id { get; set; }
public string? Code { get; set; }
public string? City { get; set; }
public string? State { get; set; }
}
}

View file

@ -51,7 +51,11 @@ namespace SeaHaven.Services.DTOs
public DateTime? ApptDate { get; set; }
public string? ApptTime { get; set; }
public string? Pm { get; set; }
public string? ServiceNotes { get; set; }
public List<string>? ExtraServices { get; set; }
public DocStatus? DocStatus { get; set; }
/// <summary>Board flag color (#RRGGBB). Null = no flag. Distinct from Color (dispatcher avatar).</summary>
public string? FlagColor { get; set; }
public int? PrimaryDispatchId { get; set; }
public byte[]? RowVersion { get; set; }
public byte[]? DispatchRowVersion { get; set; }

View file

@ -3,14 +3,25 @@ using SeaHaven.Services.Helpers;
namespace SeaHaven.Services.DTOs
{
/// <summary>Payload for creating a board work order (SHOC create wizard).</summary>
/// <summary>
/// Payload for creating a board work order (SHOC create wizard).
/// FE follow-up: extend WorkOrderBoardCreatePayload + mapper with PrimaryService,
/// ExtraServices, ServiceNotes, PocName, PocPhone, VendorNotes, TechPhone; stop embedding
/// POC/vendor/service notes in description (pocNotes already exists). Prefer structured
/// fields; keep Trade+Description temporarily for legacy clients.
/// </summary>
public class WorkOrderBoardCreateRequestDto
{
public string? WoNumber { get; set; }
public WorkOrderType WorkOrderType { get; set; }
public string? SiteCode { get; set; }
public string? Description { get; set; }
/// <summary>Legacy alias for primary service; prefer <see cref="PrimaryService"/>.</summary>
public string? Trade { get; set; }
/// <summary>Primary service label; persisted as Trade / board pm.</summary>
public string? PrimaryService { get; set; }
public List<string>? ExtraServices { get; set; }
public string? ServiceNotes { get; set; }
public int? LocationId { get; set; }
public DateTime? DueDate { get; set; }
public string? AssignTo { get; set; }
@ -22,14 +33,18 @@ namespace SeaHaven.Services.DTOs
public DateTime? ApptDate { get; set; }
public string? ApptTime { get; set; }
public int? PocContactId { get; set; }
public string? PocName { get; set; }
public string? PocPhone { get; set; }
public string? PocNotes { get; set; }
public string? TechPhone { get; set; }
public string? VendorNotes { get; set; }
}
/// <summary>Advanced search / filter query for the board.</summary>
public class WorkOrderAdvancedSearchQueryDto
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 50;
public int Page { get; set; } = 0;
public int PageSize { get; set; } = 100;
public string? Search { get; set; }
public WorkOrderAdvancedSearchDatePreset? DatePreset { get; set; }
public DateOnly? DateFrom { get; set; }

View file

@ -20,6 +20,7 @@ namespace SeaHaven.Services.DTOs
public DateOnly? OriginalWeek { get; set; }
public DateOnly? OriginalDate { get; set; }
public DateTime? CompletedDate { get; set; }
public string? VendorNotes { get; set; }
}
public class CompletionDocTemplateDto
@ -63,6 +64,7 @@ namespace SeaHaven.Services.DTOs
{
public int Id { get; set; }
public string? AuthorId { get; set; }
public string? AuthorName { get; set; }
public string Text { get; set; } = "";
public string Time { get; set; } = "";
public string? Documents { get; set; }

View file

@ -5,23 +5,69 @@ namespace SeaHaven.Services.Helpers
{
public static class WorkOrderAuditProjection
{
public static WorkOrderAuditEntryDto ToDto(WorkOrderAuditLog log)
private const string UnassignedLabel = "Unassigned";
public static WorkOrderAuditEntryDto ToDto(
WorkOrderAuditLog log,
IReadOnlyDictionary<string, string>? assignToDisplayNames = null)
{
var oldValue = log.OldValue ?? "";
var newValue = log.NewValue ?? "";
if (IsAssignToField(log.FieldName))
{
oldValue = ResolveAssignToDisplay(oldValue, assignToDisplayNames);
newValue = ResolveAssignToDisplay(newValue, assignToDisplayNames);
}
return new WorkOrderAuditEntryDto
{
Type = MapEventType(log.EventType),
DispatcherId = log.UserId,
Action = log.Action ?? "",
FieldName = log.FieldName,
OldValue = log.OldValue ?? "",
NewValue = log.NewValue ?? "",
OldValue = oldValue,
NewValue = newValue,
Time = log.CreatedAt.ToString("o"),
DispatchId = log.DispatchId
};
}
public static List<WorkOrderAuditEntryDto> ToDtos(IEnumerable<WorkOrderAuditLog> logs)
=> logs.Select(ToDto).ToList();
public static List<WorkOrderAuditEntryDto> ToDtos(
IEnumerable<WorkOrderAuditLog> logs,
IReadOnlyDictionary<string, string>? assignToDisplayNames = null)
=> logs.Select(log => ToDto(log, assignToDisplayNames)).ToList();
public static IEnumerable<string> CollectAssignToUserIds(IEnumerable<WorkOrderAuditLog> logs)
{
return logs
.Where(l => IsAssignToField(l.FieldName))
.SelectMany(l => new[] { l.OldValue, l.NewValue })
.Where(v => !string.IsNullOrWhiteSpace(v))
.Select(v => v!)
.Distinct(StringComparer.Ordinal);
}
internal static bool IsAssignToField(string? fieldName)
=> string.Equals(fieldName, "AssignTo", StringComparison.OrdinalIgnoreCase)
|| string.Equals(fieldName, "AssignedTo", StringComparison.OrdinalIgnoreCase);
private static string ResolveAssignToDisplay(
string value,
IReadOnlyDictionary<string, string>? assignToDisplayNames)
{
if (string.IsNullOrWhiteSpace(value))
return UnassignedLabel;
if (assignToDisplayNames != null
&& assignToDisplayNames.TryGetValue(value, out var name)
&& !string.IsNullOrWhiteSpace(name))
{
return name;
}
return value;
}
private static string MapEventType(string? eventType)
{

View file

@ -15,6 +15,10 @@ namespace SeaHaven.Services.Helpers
public const string ApptDate = "apptDate";
public const string ApptTime = "apptTime";
public const string DocStatus = "docStatus";
public const string Pm = "pm";
public const string ServiceNotes = "serviceNotes";
public const string ExtraServices = "extraServices";
public const string FlagColor = "flagColor";
public const string RescheduleCount = "RescheduleCount";
private static readonly HashSet<string> DispatchFields = new(StringComparer.OrdinalIgnoreCase)
@ -38,7 +42,11 @@ namespace SeaHaven.Services.Helpers
VendorId,
ApptDate,
ApptTime,
DocStatus
DocStatus,
Pm,
ServiceNotes,
ExtraServices,
FlagColor
};
public static bool IsEditable(string field) => Canonicalize(field) != null;
@ -75,6 +83,10 @@ namespace SeaHaven.Services.Helpers
ApptDate => "ApptDate",
ApptTime => "ApptTime",
DocStatus => "DocStatus",
Pm => "Trade",
ServiceNotes => "ServiceNotes",
ExtraServices => "ExtraServices",
FlagColor => "FlagColor",
_ => canonical
};
}

View file

@ -0,0 +1,29 @@
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderBoardSortFields
{
public static readonly IReadOnlySet<string> Allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"scheduledDate",
"woNumber",
"dueDate"
};
public static string NormalizeOrThrow(string? sortBy)
{
var field = (sortBy ?? "scheduledDate").Trim();
if (!Allowed.Contains(field))
{
throw new ArgumentException(
"sortBy must be one of: scheduledDate, woNumber, dueDate.");
}
return field.ToLowerInvariant() switch
{
"wonumber" => "woNumber",
"duedate" => "dueDate",
_ => "scheduledDate"
};
}
}
}

View file

@ -5,19 +5,48 @@ namespace SeaHaven.Services.Helpers
{
public static class WorkOrderCommentProjection
{
public static WorkOrderCommentDto ToDto(Comments comment)
public static WorkOrderCommentDto ToDto(
Comments comment,
IReadOnlyDictionary<string, string>? displayNames = null)
{
return new WorkOrderCommentDto
{
Id = comment.Id,
AuthorId = comment.UserId,
AuthorName = ResolveAuthorName(comment, displayNames),
Text = comment.Commenttext ?? "",
Time = (comment.CreatedDate ?? DateTime.UtcNow).ToUniversalTime().ToString("o"),
Documents = comment.Documents
};
}
public static List<WorkOrderCommentDto> ToDtos(IEnumerable<Comments> comments)
=> comments.Select(ToDto).ToList();
public static List<WorkOrderCommentDto> ToDtos(
IEnumerable<Comments> comments,
IReadOnlyDictionary<string, string>? displayNames = null)
=> comments.Select(c => ToDto(c, displayNames)).ToList();
public static IEnumerable<string> CollectAuthorUserIds(IEnumerable<Comments> comments)
=> comments
.Where(c => !string.IsNullOrWhiteSpace(c.UserId))
.Select(c => c.UserId!)
.Distinct(StringComparer.Ordinal);
private static string? ResolveAuthorName(
Comments comment,
IReadOnlyDictionary<string, string>? displayNames)
{
if (!string.IsNullOrWhiteSpace(comment.UserId)
&& displayNames != null
&& displayNames.TryGetValue(comment.UserId, out var name)
&& !string.IsNullOrWhiteSpace(name))
{
return name;
}
if (!string.IsNullOrWhiteSpace(comment.Commenter))
return comment.Commenter.Trim();
return null;
}
}
}

View file

@ -0,0 +1,50 @@
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Fixed board flag palette (must match FE FLAG_COLORS). Values stored as canonical #RRGGBB.
/// </summary>
public static class WorkOrderFlagColors
{
public static readonly HashSet<string> Allowed = new(StringComparer.OrdinalIgnoreCase)
{
"#EF4444",
"#F97316",
"#F59E0B",
"#EAB308",
"#22C55E",
"#14B8A6",
"#3B82F6",
"#8B5CF6"
};
/// <summary>
/// Normalizes to # + 6 uppercase hex digits, or null if empty.
/// Returns false if the value is non-empty but not a valid hex color.
/// </summary>
public static bool TryNormalize(string? value, out string? canonical)
{
canonical = null;
if (string.IsNullOrWhiteSpace(value))
return true;
var trimmed = value.Trim();
if (trimmed.Length == 6 && trimmed[0] != '#')
trimmed = "#" + trimmed;
if (trimmed.Length != 7 || trimmed[0] != '#')
return false;
for (var i = 1; i < 7; i++)
{
if (!Char.IsAsciiHexDigit(trimmed[i]))
return false;
}
canonical = "#" + trimmed[1..].ToUpperInvariant();
return true;
}
public static bool IsAllowed(string? canonical)
=> canonical != null && Allowed.Contains(canonical);
}
}

View file

@ -56,19 +56,19 @@ namespace SeaHaven.Services.Helpers
private static WorkOrderSearchDateRange ThisIsoWeek(DateOnly today)
{
var start = GetIsoWeekStart(today);
return new WorkOrderSearchDateRange(start, start.AddDays(6));
return new WorkOrderSearchDateRange(start, start.AddDays(4));
}
private static WorkOrderSearchDateRange LastIsoWeek(DateOnly today)
{
var start = GetIsoWeekStart(today).AddDays(-7);
return new WorkOrderSearchDateRange(start, start.AddDays(6));
return new WorkOrderSearchDateRange(start, start.AddDays(4));
}
private static WorkOrderSearchDateRange NextIsoWeek(DateOnly today)
{
var start = GetIsoWeekStart(today).AddDays(7);
return new WorkOrderSearchDateRange(start, start.AddDays(6));
return new WorkOrderSearchDateRange(start, start.AddDays(4));
}
private static WorkOrderSearchDateRange ThisMonth(DateOnly today)

View file

@ -64,6 +64,20 @@ namespace SeaHaven.Services.Implementation
};
}
public async Task<IReadOnlyList<SiteOptionDTO>> GetSiteOptionsAsync(string? search = null)
{
var rows = await _locationDataService.GetSiteOptionsAsync(search);
return rows
.Select(r => new SiteOptionDTO
{
Id = r.Id,
Code = r.Code,
City = r.City,
State = r.State
})
.ToList();
}
public async Task<LocationDTO> CreateLocationAsync(CreateLocationDTO dto, string userId)
{
var validationResult = await _createValidator.ValidateAsync(dto);

View file

@ -8,7 +8,7 @@ namespace SeaHaven.Services.Implementation
{
public class WorkOrderAdvancedSearchService : IWorkOrderAdvancedSearchService
{
private const int DefaultPageSize = 50;
private const int DefaultPageSize = 100;
private const int MaxPageSize = 100;
private readonly IWorkOrderAdvancedSearchDataService _searchDataService;
@ -22,7 +22,7 @@ namespace SeaHaven.Services.Implementation
WorkOrderAdvancedSearchQueryDto query,
string? currentUserId)
{
var page = query.Page < 1 ? 1 : query.Page;
var page = query.Page < 0 ? 0 : query.Page;
var pageSize = query.PageSize < 1 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize);
var dateRange = WorkOrderSearchDateRangeResolver.Resolve(
@ -30,6 +30,8 @@ namespace SeaHaven.Services.Implementation
query.DateFrom,
query.DateTo);
var sortBy = WorkOrderBoardSortFields.NormalizeOrThrow(query.SortBy);
var dataQuery = new WorkOrderAdvancedSearchQuery(
WorkOrderBoardSearchFilter.NormalizeSearch(query.Search),
dateRange.From,
@ -45,7 +47,7 @@ namespace SeaHaven.Services.Implementation
currentUserId,
page,
pageSize,
query.SortBy ?? "scheduledDate",
sortBy,
query.SortDir ?? "asc");
var result = await _searchDataService.SearchAsync(dataQuery);
@ -56,7 +58,8 @@ namespace SeaHaven.Services.Implementation
Items = result.Rows.Select(r => WorkOrderBoardService.MapRawRow(r, utcNow)).ToList(),
TotalCount = result.TotalCount,
Page = page,
PageSize = pageSize
PageSize = pageSize,
PageBase = 0
};
}
}

View file

@ -1,3 +1,4 @@
using System.Text.Json;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentValidation;
@ -41,6 +42,24 @@ namespace SeaHaven.Services.Implementation
var woNumber = await ResolveWoNumberAsync(request.WoNumber);
var siteCode = request.SiteCode!.Trim();
var primaryService = ResolvePrimaryService(request);
var extraServicesJson = SerializeExtraServices(request.ExtraServices);
var serviceNotes = TrimOrNull(request.ServiceNotes);
var pocName = TrimOrNull(request.PocName);
var pocPhone = TrimOrNull(request.PocPhone);
var pocNotes = TrimOrNull(request.PocNotes);
var techPhone = TrimOrNull(request.TechPhone);
var vendorNotes = TrimOrNull(request.VendorNotes);
var hasVendorDispatch = request.VendorId.HasValue
|| request.ApptDate.HasValue
|| !string.IsNullOrWhiteSpace(request.ApptTime);
if (request.VendorId.HasValue && request.VendorId.Value > 0)
{
var vendorExists = await _context.Vendors.AnyAsync(v => v.Id == request.VendorId.Value && v.IsDeleted != true);
if (!vendorExists)
throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist.");
}
var workOrder = new WorkOrder
{
@ -49,7 +68,12 @@ namespace SeaHaven.Services.Implementation
WorkOrderType = request.WorkOrderType,
SiteCode = siteCode,
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Trade = string.IsNullOrWhiteSpace(request.Trade) ? null : request.Trade.Trim(),
Trade = primaryService,
ExtraServices = extraServicesJson,
ServiceNotes = serviceNotes,
PocName = pocName,
PocPhone = pocPhone,
PocNotes = pocNotes,
LocationId = request.LocationId,
DueDate = request.DueDate?.Date,
AssignTo = string.IsNullOrWhiteSpace(request.AssignTo) ? null : request.AssignTo.Trim(),
@ -61,6 +85,12 @@ namespace SeaHaven.Services.Implementation
istemplate = false
};
if (!hasVendorDispatch)
{
workOrder.TechPhone = techPhone;
workOrder.VendorNotes = vendorNotes;
}
WorkOrderBoardFieldMutations.SetInitialLifecycleStatus(workOrder);
var changes = new List<WorkOrderBoardFieldMutations.BoardFieldChange>
@ -75,6 +105,20 @@ namespace SeaHaven.Services.Implementation
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Description", "", workOrder.Description));
if (workOrder.Trade != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Trade", "", workOrder.Trade));
if (workOrder.ExtraServices != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ExtraServices", "", workOrder.ExtraServices));
if (workOrder.ServiceNotes != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ServiceNotes", "", workOrder.ServiceNotes));
if (workOrder.PocName != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocName", "", workOrder.PocName));
if (workOrder.PocPhone != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocPhone", "", workOrder.PocPhone));
if (workOrder.PocNotes != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocNotes", "", workOrder.PocNotes));
if (workOrder.TechPhone != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TechPhone", "", workOrder.TechPhone));
if (workOrder.VendorNotes != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorNotes", "", workOrder.VendorNotes));
if (workOrder.LocationId.HasValue)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("LocationId", "", workOrder.LocationId.Value.ToString()));
if (workOrder.DueDate.HasValue)
@ -95,7 +139,7 @@ namespace SeaHaven.Services.Implementation
changes.AddRange(WorkOrderBoardFieldMutations.ApplyScheduledDate(workOrder, request.ScheduledDate.Value.Date));
Dispatch? dispatch = null;
if (request.VendorId.HasValue || request.ApptDate.HasValue || !string.IsNullOrWhiteSpace(request.ApptTime))
if (hasVendorDispatch)
{
if (!request.VendorId.HasValue || request.VendorId.Value <= 0)
throw new WorkOrderBoardValidationException("DispatchRequired", "vendorId is required when setting appointment fields.");
@ -105,7 +149,9 @@ namespace SeaHaven.Services.Implementation
VendorId = request.VendorId.Value,
Status = "Pending",
CreatedDate = DateTime.UtcNow,
ScheduledDate = request.ApptDate?.Date
ScheduledDate = request.ApptDate?.Date,
TechPhone = techPhone,
VendorNotes = vendorNotes
};
_context.Dispatches.Add(dispatch);
workOrder.PrimaryDispatch = dispatch;
@ -120,6 +166,11 @@ namespace SeaHaven.Services.Implementation
if (apptChange.HasChanged)
changes.Add(apptChange);
}
if (techPhone != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TechPhone", "", techPhone));
if (vendorNotes != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorNotes", "", vendorNotes));
}
_context.workOrders.Add(workOrder);
@ -140,7 +191,7 @@ namespace SeaHaven.Services.Implementation
{
WorkorderId = workOrder.Id,
ContactId = request.PocContactId,
Notes = string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim()
Notes = pocNotes
});
}
@ -150,6 +201,51 @@ namespace SeaHaven.Services.Implementation
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
}
private static string? ResolvePrimaryService(WorkOrderBoardCreateRequestDto request)
{
if (!string.IsNullOrWhiteSpace(request.PrimaryService))
return request.PrimaryService.Trim();
if (!string.IsNullOrWhiteSpace(request.Trade))
return request.Trade.Trim();
return null;
}
private static string? SerializeExtraServices(List<string>? extras)
{
if (extras == null || extras.Count == 0)
return null;
var normalized = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in extras)
{
if (string.IsNullOrWhiteSpace(item))
continue;
var trimmed = item.Trim();
if (trimmed.Length > 128)
throw new WorkOrderBoardValidationException(
"InvalidValue",
"Each extra service must be at most 128 characters.");
if (!seen.Add(trimmed))
continue;
normalized.Add(trimmed);
}
if (normalized.Count == 0)
return null;
var json = JsonSerializer.Serialize(normalized);
if (json.Length > 2000)
throw new WorkOrderBoardValidationException(
"InvalidValue",
"extraServices must serialize to at most 2000 characters.");
return json;
}
private static string? TrimOrNull(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private async Task<string> ResolveWoNumberAsync(string? requested)
{
if (!string.IsNullOrWhiteSpace(requested))

View file

@ -115,7 +115,10 @@ namespace SeaHaven.Services.Implementation
ApptDate = row.DispatchApptDate ?? row.ScheduledDate,
ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd),
Pm = row.Trade ?? row.Problem,
ServiceNotes = row.ServiceNotes,
ExtraServices = ParseExtraServices(row.ExtraServices),
DocStatus = row.DocStatus,
FlagColor = row.FlagColor,
PrimaryDispatchId = row.PrimaryDispatchId,
RowVersion = row.RowVersion,
DispatchRowVersion = row.DispatchRowVersion
@ -124,5 +127,21 @@ namespace SeaHaven.Services.Implementation
private static string FormatName(string? firstName, string? lastName)
=> $"{firstName ?? ""} {lastName ?? ""}".Trim();
private static List<string>? ParseExtraServices(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
return list is { Count: > 0 } ? list : null;
}
catch (System.Text.Json.JsonException)
{
return null;
}
}
}
}

View file

@ -1,3 +1,4 @@
using System.Text.Json;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
@ -157,6 +158,10 @@ namespace SeaHaven.Services.Implementation
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptTime => new List<FieldChange> { ApplyApptTime(workOrder, dispatch!, value, auditField) },
WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { ApplyDocStatus(workOrder, value, auditField) },
WorkOrderBoardFieldNames.Pm => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.Trade = v, () => workOrder.Trade) },
WorkOrderBoardFieldNames.ServiceNotes => new List<FieldChange> { ApplyServiceNotes(workOrder, value, auditField) },
WorkOrderBoardFieldNames.ExtraServices => new List<FieldChange> { ApplyExtraServices(workOrder, value, auditField) },
WorkOrderBoardFieldNames.FlagColor => new List<FieldChange> { ApplyFlagColor(workOrder, value, auditField) },
_ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.")
};
}
@ -222,6 +227,11 @@ namespace SeaHaven.Services.Implementation
if (string.IsNullOrWhiteSpace(value))
throw new WorkOrderBoardValidationException("InvalidValue", "workOrderType value is required.");
if (string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase))
throw new WorkOrderBoardValidationException(
"InvalidValue",
"Overdue is not a work order type; it is derived from scheduledDate (isPastDue).");
if (!WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
@ -248,6 +258,89 @@ namespace SeaHaven.Services.Implementation
return FieldChange.ForField(auditField, old, normalized);
}
private static FieldChange ApplyServiceNotes(WorkOrder workOrder, string? value, string auditField)
{
var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
if (normalized != null && normalized.Length > 4000)
throw new WorkOrderBoardValidationException("InvalidValue", "serviceNotes must be at most 4000 characters.");
var old = workOrder.ServiceNotes;
if (old == normalized)
return FieldChange.Unchanged(auditField);
workOrder.ServiceNotes = normalized;
return FieldChange.ForField(auditField, old, normalized);
}
private static FieldChange ApplyExtraServices(WorkOrder workOrder, string? value, string auditField)
{
string? normalizedJson = null;
if (!string.IsNullOrWhiteSpace(value))
{
List<string>? parsed;
try
{
parsed = JsonSerializer.Deserialize<List<string>>(value.Trim());
}
catch (JsonException)
{
throw new WorkOrderBoardValidationException("InvalidValue", "extraServices must be a JSON string array.");
}
var items = NormalizeExtraServiceList(parsed);
if (items.Count > 0)
{
if (string.IsNullOrWhiteSpace(workOrder.Trade))
{
throw new WorkOrderBoardValidationException(
"InvalidValue",
"primaryService (pm/trade) is required when extraServices is provided.");
}
items = items
.Where(s => !string.Equals(s, workOrder.Trade, StringComparison.OrdinalIgnoreCase))
.ToList();
normalizedJson = items.Count > 0 ? JsonSerializer.Serialize(items) : null;
if (normalizedJson != null && normalizedJson.Length > 2000)
{
throw new WorkOrderBoardValidationException(
"InvalidValue",
"extraServices must serialize to at most 2000 characters.");
}
}
}
var old = workOrder.ExtraServices;
if (old == normalizedJson)
return FieldChange.Unchanged(auditField);
workOrder.ExtraServices = normalizedJson;
return FieldChange.ForField(auditField, old, normalizedJson);
}
private static List<string> NormalizeExtraServiceList(List<string>? extras)
{
var normalized = new List<string>();
if (extras == null || extras.Count == 0)
return normalized;
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in extras)
{
if (string.IsNullOrWhiteSpace(item))
continue;
var trimmed = item.Trim();
if (trimmed.Length > 128)
throw new WorkOrderBoardValidationException("InvalidValue", "Each extra service must be at most 128 characters.");
if (!seen.Add(trimmed))
continue;
normalized.Add(trimmed);
}
return normalized;
}
private static FieldChange ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField)
{
var parsed = LifecycleStatusMapper.ParseLifecycleStatus(value);
@ -456,6 +549,24 @@ namespace SeaHaven.Services.Implementation
return FieldChange.ForField(auditField, old, parsed.ToString());
}
private static FieldChange ApplyFlagColor(WorkOrder workOrder, string? value, string auditField)
{
if (!WorkOrderFlagColors.TryNormalize(value, out var canonical))
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid flagColor: {value}");
if (canonical != null && !WorkOrderFlagColors.IsAllowed(canonical))
throw new WorkOrderBoardValidationException(
"InvalidValue",
$"flagColor must be one of the allowed palette values or empty to clear.");
var old = workOrder.FlagColor;
if (string.Equals(old, canonical, StringComparison.OrdinalIgnoreCase))
return FieldChange.Unchanged(auditField);
workOrder.FlagColor = canonical;
return FieldChange.ForField(auditField, old, canonical);
}
private static List<FieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
{
var changes = new List<FieldChange>();

View file

@ -11,13 +11,16 @@ namespace SeaHaven.Services.Implementation
{
private readonly IWorkOrderDetailDataService _detailData;
private readonly ICommentDataService _commentData;
private readonly IUserDataService _userDataService;
public WorkOrderCommentService(
IWorkOrderDetailDataService detailData,
ICommentDataService commentData)
ICommentDataService commentData,
IUserDataService userDataService)
{
_detailData = detailData;
_commentData = commentData;
_userDataService = userDataService;
}
public async Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId)
@ -26,7 +29,8 @@ namespace SeaHaven.Services.Implementation
return null;
var comments = await _detailData.GetCommentsAsync(workOrderId);
return WorkOrderCommentProjection.ToDtos(comments);
var authorNames = await ResolveAuthorNamesAsync(comments);
return WorkOrderCommentProjection.ToDtos(comments, authorNames);
}
public async Task<WorkOrderCommentDto> AddCommentAsync(
@ -53,7 +57,58 @@ namespace SeaHaven.Services.Implementation
};
var saved = await _commentData.AddAsync(comment);
return WorkOrderCommentProjection.ToDto(saved);
var authorNames = await ResolveAuthorNamesAsync(new[] { saved });
return WorkOrderCommentProjection.ToDto(saved, authorNames);
}
public async Task<WorkOrderCommentDto> UpdateCommentAsync(
int workOrderId,
int commentId,
WorkOrderCommentCreateDto request,
string? actorId,
bool isAdmin)
{
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId);
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
if (string.IsNullOrWhiteSpace(request.Text))
throw new WorkOrderBoardValidationException("InvalidValue", "Comment text is required.");
var comment = await _commentData.GetByIdAsync(commentId);
if (comment == null || comment.WorkerOrderId != workOrderId || comment.IsDeleted == true)
throw new WorkOrderBoardValidationException("NotFound", "Comment not found.");
if (!IsBoardEditableComment(comment))
throw new WorkOrderBoardValidationException(
"NotEditable",
"Only board comments (CommentType General) can be edited via this endpoint.");
var isAuthor = !string.IsNullOrWhiteSpace(actorId)
&& string.Equals(comment.UserId, actorId, StringComparison.Ordinal);
if (!isAuthor && !isAdmin)
throw new WorkOrderBoardValidationException("Forbidden", "You are not allowed to edit this comment.");
comment.Commenttext = request.Text.Trim();
comment.LastModificationTime = DateTime.UtcNow;
await _commentData.UpdateAsync(comment);
var authorNames = await ResolveAuthorNamesAsync(new[] { comment });
return WorkOrderCommentProjection.ToDto(comment, authorNames);
}
private static bool IsBoardEditableComment(Comments comment)
=> string.Equals(comment.CommentType, "General", StringComparison.OrdinalIgnoreCase)
&& string.Equals(comment.RecordType, "WorkOrder", StringComparison.OrdinalIgnoreCase);
private async Task<IReadOnlyDictionary<string, string>> ResolveAuthorNamesAsync(
IEnumerable<Comments> comments)
{
var authorIds = WorkOrderCommentProjection.CollectAuthorUserIds(comments);
return await _userDataService.GetDisplayNamesByIdsAsync(authorIds);
}
}
}

View file

@ -10,15 +10,18 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderBoardService _boardService;
private readonly IWorkOrderDetailDataService _detailData;
private readonly ICompletionDocTemplateDataService _templateData;
private readonly IUserDataService _userDataService;
public WorkOrderDetailService(
IWorkOrderBoardService boardService,
IWorkOrderDetailDataService detailData,
ICompletionDocTemplateDataService templateData)
ICompletionDocTemplateDataService templateData,
IUserDataService userDataService)
{
_boardService = boardService;
_detailData = detailData;
_templateData = templateData;
_userDataService = userDataService;
}
public async Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId)
@ -41,12 +44,15 @@ namespace SeaHaven.Services.Implementation
extended?.Trade ?? boardRow.Pm,
boardRow.WorkOrderType);
var authorIds = WorkOrderCommentProjection.CollectAuthorUserIds(comments);
var authorNames = await _userDataService.GetDisplayNamesByIdsAsync(authorIds);
return new WorkOrderDetailDto
{
Info = MapInfo(boardRow, extended),
Completion = MapCompletion(extended, boardRow.DocStatus, template, signoffs),
Comments = WorkOrderCommentProjection.ToDtos(comments),
Audit = WorkOrderAuditProjection.ToDtos(audit),
Comments = WorkOrderCommentProjection.ToDtos(comments, authorNames),
Audit = await MapAuditAsync(audit),
Media = workOrder == null
? new List<WorkOrderMediaFileDto>()
: WorkOrderMediaProjection.ProjectAll(workOrder, attachments)
@ -59,7 +65,14 @@ namespace SeaHaven.Services.Implementation
return null;
var logs = await _detailData.GetAuditLogsAsync(workOrderId, limit);
return WorkOrderAuditProjection.ToDtos(logs);
return await MapAuditAsync(logs);
}
private async Task<List<WorkOrderAuditEntryDto>> MapAuditAsync(IReadOnlyList<Data.SeaHavenIndustries.WorkOrderAuditLog> logs)
{
var userIds = WorkOrderAuditProjection.CollectAssignToUserIds(logs);
var displayNames = await _userDataService.GetDisplayNamesByIdsAsync(userIds);
return WorkOrderAuditProjection.ToDtos(logs, displayNames);
}
private static WorkOrderDetailInfoDto MapInfo(
@ -98,7 +111,10 @@ namespace SeaHaven.Services.Implementation
ApptDate = row.ApptDate,
ApptTime = row.ApptTime,
Pm = row.Pm,
ServiceNotes = extended?.ServiceNotes ?? row.ServiceNotes,
ExtraServices = ParseExtraServices(extended?.ExtraServices) ?? row.ExtraServices,
DocStatus = row.DocStatus,
FlagColor = row.FlagColor,
PrimaryDispatchId = row.PrimaryDispatchId,
RowVersion = row.RowVersion,
DispatchRowVersion = row.DispatchRowVersion,
@ -108,10 +124,27 @@ namespace SeaHaven.Services.Implementation
SubTrade = extended?.SubTrade,
OriginalWeek = extended?.OriginalWeek,
OriginalDate = extended?.OriginalDate,
CompletedDate = extended?.CompletedDate
CompletedDate = extended?.CompletedDate,
VendorNotes = extended?.VendorNotes
};
}
private static List<string>? ParseExtraServices(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
return list is { Count: > 0 } ? list : null;
}
catch (System.Text.Json.JsonException)
{
return null;
}
}
private static WorkOrderCompletionDto MapCompletion(
WorkOrderDetailExtendedFields? extended,
Data.SeaHavenIndustries.Enums.DocStatus? docStatus,

View file

@ -9,6 +9,7 @@ namespace SeaHaven.Services.Interfaces
Task<IEnumerable<LocationDTO>> GetAllLocationsAsync();
Task<IEnumerable<LocationDTO>> GetLocationsByAccountIdAsync(int accountId);
Task<PagedResult<LocationDTO>> GetLocationsPagedAsync(int page, int pageSize, string? search = null);
Task<IReadOnlyList<SiteOptionDTO>> GetSiteOptionsAsync(string? search = null);
Task<LocationDTO> CreateLocationAsync(CreateLocationDTO dto, string userId);
Task<LocationDTO> UpdateLocationAsync(int id, UpdateLocationDTO dto, string userId);
Task DeleteLocationAsync(int id, string userId);

View file

@ -7,5 +7,11 @@ namespace SeaHaven.Services.Interfaces
{
Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId);
Task<WorkOrderCommentDto> AddCommentAsync(int workOrderId, WorkOrderCommentCreateDto request, string? actorId, string? documentUrl = null);
Task<WorkOrderCommentDto> UpdateCommentAsync(
int workOrderId,
int commentId,
WorkOrderCommentCreateDto request,
string? actorId,
bool isAdmin);
}
}

View file

@ -30,6 +30,34 @@ namespace SeaHaven.Services.Validation
.MaximumLength(128)
.When(x => !string.IsNullOrEmpty(x.Trade));
RuleFor(x => x.PrimaryService)
.MaximumLength(128)
.When(x => !string.IsNullOrEmpty(x.PrimaryService));
RuleFor(x => x.ServiceNotes)
.MaximumLength(4000)
.When(x => !string.IsNullOrEmpty(x.ServiceNotes));
RuleFor(x => x.PocName)
.MaximumLength(200)
.When(x => !string.IsNullOrEmpty(x.PocName));
RuleFor(x => x.PocPhone)
.MaximumLength(50)
.When(x => !string.IsNullOrEmpty(x.PocPhone));
RuleFor(x => x.PocNotes)
.MaximumLength(2000)
.When(x => !string.IsNullOrEmpty(x.PocNotes));
RuleFor(x => x.TechPhone)
.MaximumLength(50)
.When(x => !string.IsNullOrEmpty(x.TechPhone));
RuleFor(x => x.VendorNotes)
.MaximumLength(2000)
.When(x => !string.IsNullOrEmpty(x.VendorNotes));
RuleFor(x => x.LocationId)
.GreaterThan(0)
.When(x => x.LocationId.HasValue);
@ -41,6 +69,26 @@ namespace SeaHaven.Services.Validation
RuleFor(x => x.PocContactId)
.GreaterThan(0)
.When(x => x.PocContactId.HasValue);
RuleFor(x => x)
.Must(HasPrimaryWhenExtras)
.WithMessage("primaryService (or trade) is required when extraServices is provided.")
.WithName("extraServices");
RuleForEach(x => x.ExtraServices)
.MaximumLength(128)
.When(x => x.ExtraServices != null);
}
private static bool HasPrimaryWhenExtras(WorkOrderBoardCreateRequestDto request)
{
var hasExtras = request.ExtraServices != null
&& request.ExtraServices.Any(s => !string.IsNullOrWhiteSpace(s));
if (!hasExtras)
return true;
return !string.IsNullOrWhiteSpace(request.PrimaryService)
|| !string.IsNullOrWhiteSpace(request.Trade);
}
}
}

View file

@ -180,4 +180,173 @@ public class WorkOrderBoardCreateServiceTests
Assert.All(logs, l => Assert.Equal(result.Id, l.WorkOrderId));
Assert.True(result.Id > 0);
}
[Fact]
public async Task Create_StructuredServiceNotes_PersistsAndReturnsPm()
{
var (context, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
PrimaryService = "HVAC PM",
ExtraServices = new List<string> { "Filter change", "Coil clean", "Filter change" },
ServiceNotes = "Unit on roof"
}, "actor-1");
Assert.Equal("HVAC PM", result.Pm);
Assert.Equal("Unit on roof", result.ServiceNotes);
Assert.Equal(new[] { "Filter change", "Coil clean" }, result.ExtraServices);
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
Assert.Equal("HVAC PM", wo.Trade);
Assert.Equal("Unit on roof", wo.ServiceNotes);
Assert.Contains("Filter change", wo.ExtraServices);
Assert.Contains("Coil clean", wo.ExtraServices);
Assert.Equal(1, System.Text.Json.JsonSerializer.Deserialize<List<string>>(wo.ExtraServices!)!
.Count(s => s == "Filter change"));
}
[Fact]
public async Task Create_PrimaryServiceTakesPrecedenceOverTrade()
{
var (_, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Reactive,
SiteCode = "CHI",
Trade = "Legacy Trade",
PrimaryService = "Plumbing"
}, "actor-1");
Assert.Equal("Plumbing", result.Pm);
}
[Fact]
public async Task Create_ExtraServicesWithoutPrimary_ThrowsValidation()
{
var (_, service) = CreateSut();
await Assert.ThrowsAsync<FluentValidation.ValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
ExtraServices = new List<string> { "Filter change" }
}, "actor-1"));
}
[Fact]
public async Task Create_PocFreeText_WithoutContact_AppearsOnBoardRow()
{
var (context, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.Reactive,
SiteCode = "BK5",
PocName = "Jane Site Lead",
PocPhone = "+1 555-0100",
PocNotes = "Call 30 min before"
}, "actor-1");
Assert.Equal("Jane Site Lead", result.PocName);
Assert.Equal("+1 555-0100", result.PocPhone);
Assert.Equal("Call 30 min before", result.PocNotes);
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
Assert.Equal("Jane Site Lead", wo.PocName);
Assert.Empty(context.WorkOrderContacts);
}
[Fact]
public async Task Create_WithoutVendor_PersistsTechPhoneAndVendorNotesOnWorkOrder()
{
var (context, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
TechPhone = "+1 555-0199",
VendorNotes = "Gate code 4421"
}, "actor-1");
Assert.Equal("+1 555-0199", result.TechPhone);
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
Assert.Equal("+1 555-0199", wo.TechPhone);
Assert.Equal("Gate code 4421", wo.VendorNotes);
Assert.Null(wo.PrimaryDispatchId);
}
[Fact]
public async Task Create_WithVendor_PersistsTechPhoneAndVendorNotesOnDispatch()
{
var (context, service) = CreateSut();
context.Vendors.Add(new Vendor { Id = 55, CompanyName = "Acme", Phone = "+1 555-0000" });
await context.SaveChangesAsync();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
VendorId = 55,
ApptDate = new DateTime(2026, 7, 18),
TechPhone = "+1 555-0199",
VendorNotes = "Park behind dock"
}, "actor-1");
Assert.Equal("+1 555-0199", result.TechPhone);
Assert.Equal(55, result.VendorId);
var wo = await context.workOrders.Include(w => w.PrimaryDispatch).SingleAsync(w => w.Id == result.Id);
Assert.Null(wo.TechPhone);
Assert.Null(wo.VendorNotes);
Assert.NotNull(wo.PrimaryDispatch);
Assert.Equal("+1 555-0199", wo.PrimaryDispatch!.TechPhone);
Assert.Equal("Park behind dock", wo.PrimaryDispatch.VendorNotes);
}
[Fact]
public async Task Create_LegacyTradeAndDescription_StillWorks()
{
var (context, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
Trade = "HVAC",
Description = "pmNote\nPOC: Jane · +1 555\nVendor notes: gate"
}, "actor-1");
Assert.Equal("HVAC", result.Pm);
Assert.Null(result.PocName);
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
Assert.Equal("HVAC", wo.Trade);
Assert.Contains("Vendor notes:", wo.Description);
Assert.Null(wo.PocName);
Assert.Null(wo.ExtraServices);
Assert.Null(wo.ServiceNotes);
}
[Fact]
public async Task Create_UnknownVendorId_Throws()
{
var (_, service) = CreateSut();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
VendorId = 999
}, "actor-1"));
Assert.Equal("VendorNotFound", ex.Code);
}
}

View file

@ -22,6 +22,9 @@ public class WorkOrderBoardSearchFilterTests
[InlineData("HVAC Tech", 1)]
[InlineData("Acme HVAC", 1)]
[InlineData("sched", 1)]
[InlineData("Leak repair", 1)]
[InlineData("Reactive", 1)]
[InlineData("6/23", 1)]
public async Task Apply_MatchesExpectedField(string search, int expectedId)
{
await using var context = CreateContext();
@ -73,6 +76,9 @@ public class WorkOrderBoardSearchFilterTests
Id = 1,
SiteCode = "BK5",
InternalWONumber = "10000000001",
WorkerOrderTitle = "Leak repair",
WorkOrderType = WorkOrderType.Reactive,
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10
});
@ -152,7 +158,7 @@ public class WorkOrderSearchDateRangeResolverTests
reference);
Assert.Equal(new DateOnly(2026, 6, 22), range.From);
Assert.Equal(new DateOnly(2026, 6, 28), range.To);
Assert.Equal(new DateOnly(2026, 6, 26), range.To);
}
[Fact]
@ -213,7 +219,7 @@ public class WorkOrderAdvancedSearchServiceTests
DateFrom = weekStart,
DateTo = weekStart.AddDays(6),
Sites = new List<string> { "BK5" },
Page = 1,
Page = 0,
PageSize = 50
}, null);
@ -237,4 +243,144 @@ public class WorkOrderAdvancedSearchServiceTests
Assert.Equal(100, result.PageSize);
}
[Fact]
public async Task SearchAsync_PageZero_ReturnsFirstPage()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
SiteCode = "BK5",
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
SiteCode = "BK6",
ScheduledDate = new DateTime(2026, 6, 24),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
});
await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = weekStart,
DateTo = weekStart.AddDays(4),
Page = 0,
PageSize = 1
}, null);
Assert.Equal(2, result.TotalCount);
Assert.Single(result.Items);
Assert.Equal(0, result.Page);
}
[Fact]
public async Task SearchAsync_OverdueTypeSentinel_FiltersPastDueOnly()
{
await using var context = CreateContext();
var today = DateTime.UtcNow.Date;
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
WorkOrderType = WorkOrderType.PM,
ScheduledDate = today.AddDays(-3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
WorkOrderType = WorkOrderType.Other,
ScheduledDate = today.AddDays(3),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000002"
});
await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Types = new List<WorkOrderType> { WorkOrderType.Other }
}, null);
Assert.Equal(1, result.TotalCount);
Assert.Equal(1, result.Items.First().Id);
Assert.True(result.Items.First().IsPastDue);
}
[Fact]
public async Task SearchAsync_IncludesUnscheduledRows()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
InternalWONumber = "10000000001"
},
new WorkOrder
{
Id = 2,
ScheduledDate = null,
LifecycleStatus = LifecycleStatus.Incomplete,
InternalWONumber = "10000000002"
});
await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = weekStart,
DateTo = weekStart.AddDays(4)
}, null);
Assert.Equal(2, result.TotalCount);
Assert.Contains(result.Items, i => i.Id == 2);
}
[Fact]
public async Task SearchAsync_InvalidSortBy_Throws()
{
await using var context = CreateContext();
var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
var ex = await Assert.ThrowsAsync<ArgumentException>(() => service.SearchAsync(
new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek,
SortBy = "invalid"
}, null));
Assert.Contains("sortBy", ex.Message);
}
}

View file

@ -105,6 +105,33 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal("PastDueStatusBlocked", ex.Code);
}
[Fact]
public async Task PatchField_RejectsOverdueAsWorkOrderType()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
WorkOrderType = WorkOrderType.Reactive,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WorkOrderType,
Value = "Overdue",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("InvalidValue", ex.Code);
Assert.Contains("isPastDue", ex.Message, StringComparison.Ordinal);
Assert.Contains("scheduledDate", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task PatchField_BlocksEditWhenCanceled()
{
@ -224,4 +251,256 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal("DuplicateWoNumber", ex.Code);
}
[Fact]
public async Task PatchField_UpdatesPmTrade()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
Trade = "HVAC",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.Pm,
Value = "Plumbing",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal("Plumbing", result.Pm);
Assert.Equal("Plumbing", (await context.workOrders.FindAsync(1))!.Trade);
}
[Fact]
public async Task PatchField_UpdatesServiceNotesAndReturnsOnBoardRow()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
Trade = "Backflow Preventers",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ServiceNotes,
Value = "service notes",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal("service notes", result.ServiceNotes);
Assert.Equal("service notes", (await context.workOrders.FindAsync(1))!.ServiceNotes);
}
[Fact]
public async Task PatchField_UpdatesExtraServices_WhenPrimaryExists()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
Trade = "Backflow Preventers",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ExtraServices,
Value = "[\"Filter change\",\"Coil clean\"]",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(new[] { "Filter change", "Coil clean" }, result.ExtraServices);
Assert.Contains("Filter change", (await context.workOrders.FindAsync(1))!.ExtraServices);
}
[Fact]
public async Task PatchField_ExtraServicesWithoutPrimary_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ExtraServices,
Value = "[\"Filter change\"]",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("InvalidValue", ex.Code);
}
[Fact]
public async Task PatchField_SetsFlagColor_CanonicalUppercase()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.FlagColor,
Value = "#ef4444",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal("#EF4444", result.FlagColor);
Assert.Equal("#EF4444", (await context.workOrders.FindAsync(1))!.FlagColor);
}
[Fact]
public async Task PatchField_ClearsFlagColor_WithNullOrEmpty()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
FlagColor = "#EF4444",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var cleared = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.FlagColor,
Value = "",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Null(cleared.FlagColor);
Assert.Null((await context.workOrders.FindAsync(1))!.FlagColor);
}
[Fact]
public async Task PatchField_ClearsFlagColor_WithNullValue()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
FlagColor = "#3B82F6",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var cleared = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.FlagColor,
Value = null,
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Null(cleared.FlagColor);
Assert.Null((await context.workOrders.FindAsync(1))!.FlagColor);
}
[Fact]
public async Task PatchField_FlagColorOutsidePalette_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.FlagColor,
Value = "#000000",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("InvalidValue", ex.Code);
}
[Fact]
public async Task PatchField_FlagColorMalformed_Throws()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.FlagColor,
Value = "not-a-color",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("InvalidValue", ex.Code);
}
[Fact]
public async Task PatchField_FlagColor_DoesNotRequireDispatchVersion()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
PrimaryDispatchId = 10,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.Dispatches.Add(new Dispatch
{
Id = 10,
WorkOrderId = 1,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
});
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.FlagColor,
Value = "#8B5CF6",
WorkOrderVersion = ToVersion(wo)
// no DispatchVersion
}, "actor-1");
Assert.Equal("#8B5CF6", result.FlagColor);
}
}

View file

@ -92,7 +92,8 @@ public class WorkOrderDetailServiceTests
var boardService = new WorkOrderBoardService(boardData);
var detailData = new WorkOrderDetailDataService(context);
var templateData = new CompletionDocTemplateDataService(context);
var service = new WorkOrderDetailService(boardService, detailData, templateData);
var userData = new UserDataService(context);
var service = new WorkOrderDetailService(boardService, detailData, templateData, userData);
return (context, service);
}
@ -131,6 +132,13 @@ public class WorkOrderDetailServiceTests
NewValue = "1",
CreatedAt = DateTime.UtcNow
});
context.Users.Add(new ApplicationUser
{
Id = "user-1",
UserName = "alice",
FirstName = "Alice",
LastName = "Dispatcher"
});
context.Comments.Add(new Comments
{
WorkerOrderId = 1,
@ -150,11 +158,75 @@ public class WorkOrderDetailServiceTests
Assert.Equal("HVAC PM Completion", detail.Completion.Template!.Name);
Assert.Single(detail.Comments);
Assert.Equal("user-1", detail.Comments[0].AuthorId);
Assert.Equal("Alice Dispatcher", detail.Comments[0].AuthorName);
Assert.Equal("Called vendor", detail.Comments[0].Text);
Assert.Single(detail.Audit);
Assert.Equal("system", detail.Audit[0].Type);
Assert.Equal("WeekRolled", detail.Audit[0].Action);
}
[Fact]
public async Task GetAudit_ResolvesAssignToUserIdsToDisplayNames()
{
var (context, service) = CreateSut();
const string oldUserId = "a1000001-0001-4000-8000-000000000002";
const string newUserId = "b5e356d5-d926-4f92-8673-ee13cddeff0f";
context.Users.AddRange(
new ApplicationUser
{
Id = oldUserId,
UserName = "alice",
FirstName = "Alice",
LastName = "Dispatcher"
},
new ApplicationUser
{
Id = newUserId,
UserName = "bob",
FirstName = "Bob",
LastName = "Tech"
});
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "00000000001",
LifecycleStatus = LifecycleStatus.Pending,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = 1,
UserId = "actor-1",
EventType = "Manual",
Action = "AssignmentChanged",
FieldName = "AssignTo",
OldValue = oldUserId,
NewValue = newUserId,
CreatedAt = DateTime.UtcNow
});
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = 1,
UserId = "actor-1",
EventType = "Manual",
Action = "AssignmentChanged",
FieldName = "AssignTo",
OldValue = newUserId,
NewValue = "",
CreatedAt = DateTime.UtcNow.AddMinutes(1)
});
await context.SaveChangesAsync();
var audit = await service.GetAuditAsync(1);
Assert.NotNull(audit);
Assert.Equal(2, audit!.Count);
var unassigned = audit.Single(a => a.NewValue == "Unassigned");
Assert.Equal("Bob Tech", unassigned.OldValue);
var reassigned = audit.Single(a => a.NewValue == "Bob Tech");
Assert.Equal("Alice Dispatcher", reassigned.OldValue);
}
}
public class WorkOrderCompletionServiceTests
@ -306,18 +378,197 @@ public class WorkOrderCommentServiceTests
.Options;
var context = new ApplicationDbContext(options);
context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled });
context.Users.Add(new ApplicationUser
{
Id = "user-abc",
UserName = "bob",
FirstName = "Bob",
LastName = "Tech"
});
await context.SaveChangesAsync();
var detailData = new WorkOrderDetailDataService(context);
var commentData = new CommentDataService(context);
var service = new WorkOrderCommentService(detailData, commentData);
var userData = new UserDataService(context);
var service = new WorkOrderCommentService(detailData, commentData, userData);
var result = await service.AddCommentAsync(1, new WorkOrderCommentCreateDto { Text = "Note" }, "user-abc");
Assert.Equal("user-abc", result.AuthorId);
Assert.Equal("Bob Tech", result.AuthorName);
Assert.Equal("Note", result.Text);
Assert.False(string.IsNullOrWhiteSpace(result.Time));
}
[Fact]
public async Task GetComments_FallsBackToCommenterWhenUserIdMissing()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled });
context.Comments.Add(new Comments
{
WorkerOrderId = 1,
Commenter = "Legacy Sync Author",
Commenttext = "Synced note",
CreatedDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var detailData = new WorkOrderDetailDataService(context);
var commentData = new CommentDataService(context);
var userData = new UserDataService(context);
var service = new WorkOrderCommentService(detailData, commentData, userData);
var comments = await service.GetCommentsAsync(1);
Assert.NotNull(comments);
Assert.Single(comments!);
Assert.Null(comments[0].AuthorId);
Assert.Equal("Legacy Sync Author", comments[0].AuthorName);
Assert.Equal("Synced note", comments[0].Text);
}
private static async Task<(ApplicationDbContext Context, WorkOrderCommentService Service, Comments Comment)> SeedEditableCommentAsync(
LifecycleStatus status = LifecycleStatus.Scheduled,
string authorId = "user-author",
string commentType = "General",
string recordType = "WorkOrder",
int workOrderId = 1)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
context.workOrders.Add(new WorkOrder { Id = workOrderId, LifecycleStatus = status });
context.Users.Add(new ApplicationUser
{
Id = authorId,
UserName = "author",
FirstName = "Alice",
LastName = "Dispatcher"
});
var comment = new Comments
{
WorkerOrderId = workOrderId,
UserId = authorId,
Commenttext = "Original",
CommentType = commentType,
RecordType = recordType,
CreatedDate = DateTime.UtcNow
};
context.Comments.Add(comment);
await context.SaveChangesAsync();
var service = new WorkOrderCommentService(
new WorkOrderDetailDataService(context),
new CommentDataService(context),
new UserDataService(context));
return (context, service, comment);
}
[Fact]
public async Task UpdateComment_Author_UpdatesText()
{
var (_, service, comment) = await SeedEditableCommentAsync();
var result = await service.UpdateCommentAsync(
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = " Updated note " },
"user-author",
isAdmin: false);
Assert.Equal("Updated note", result.Text);
Assert.Equal("user-author", result.AuthorId);
Assert.Equal("Alice Dispatcher", result.AuthorName);
}
[Fact]
public async Task UpdateComment_Admin_CanEditOthers()
{
var (_, service, comment) = await SeedEditableCommentAsync();
var result = await service.UpdateCommentAsync(
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Admin edit" },
"admin-user",
isAdmin: true);
Assert.Equal("Admin edit", result.Text);
Assert.Equal("user-author", result.AuthorId);
}
[Fact]
public async Task UpdateComment_NonAuthorNonAdmin_ThrowsForbidden()
{
var (_, service, comment) = await SeedEditableCommentAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateCommentAsync(
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Nope" },
"other-user",
isAdmin: false));
Assert.Equal("Forbidden", ex.Code);
}
[Fact]
public async Task UpdateComment_LegacyVendorComment_ThrowsNotEditable()
{
var (_, service, comment) = await SeedEditableCommentAsync(
commentType: "vendor",
recordType: "WorkOrder");
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateCommentAsync(
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Nope" },
"user-author",
isAdmin: false));
Assert.Equal("NotEditable", ex.Code);
}
[Fact]
public async Task UpdateComment_ReadOnlyWorkOrder_Throws()
{
var (_, service, comment) = await SeedEditableCommentAsync(LifecycleStatus.Canceled);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateCommentAsync(
1,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Nope" },
"user-author",
isAdmin: false));
Assert.Equal("ReadOnly", ex.Code);
}
[Fact]
public async Task UpdateComment_WrongWorkOrder_ThrowsNotFound()
{
var (context, service, comment) = await SeedEditableCommentAsync(workOrderId: 1);
context.workOrders.Add(new WorkOrder { Id = 2, LifecycleStatus = LifecycleStatus.Scheduled });
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateCommentAsync(
2,
comment.Id,
new WorkOrderCommentCreateDto { Text = "Nope" },
"user-author",
isAdmin: false));
Assert.Equal("NotFound", ex.Code);
}
}
public class WorkOrderMediaServiceTests

View file

@ -0,0 +1,28 @@
# PR bodies (copy-paste)
Full English PR descriptions are in the plan appendix and summarized below by branch tip.
| PR | Branch | Tip commit | Depends on |
|----|--------|------------|------------|
| 1 | `feat/work-orders-phase-4` | `cbcfd9a` Phase4 index shrink | existing Phase 4 PR |
| 2 | `feat/work-orders-completion-slideover-and-rollout` | `3e2952f` Phase6 idempotent (then stacked features) | Phase 6 base |
| 3 | `feature/wo-structured-service-notes` | `ecc1db3` | after F2+sites on slideover stack |
| 4 | `feature/wo-flag-color` | `62572d8` | after service notes |
| 5 | `feature/wo-board-search-paging` | `1eaf9b7` | after flag (or notes) |
| 6 | `feature/wo-comments-author-edit` | `16b31e5` | after search (controller mixed) |
| 7 | `feature/location-sites-vendor-contact` | `3983f76` | after F2 |
Stacked history on `feat/work-orders-completion-slideover-and-rollout` (`f61aff0`, ahead 7):
1. fix Phase6 migration
2. feat locations/sites + vendor contactName
3. feat structured service notes
4. feat flagColor
5. feat search/paging
6. feat comment edit / display names
7. docs staff review findings
Phase4 hardening lives only on `feat/work-orders-phase-4`.
Full PR markdown: see conversation / plan Apêndice B.
Staff findings: [staff-review-findings.md](../staff-review-findings.md)

View file

@ -0,0 +1,276 @@
# Staff Review Findings — Work Orders (Fase 2)
Adversarial reviews per delivery. Do not treat preliminary inventário notes as final.
---
## Entrega A — Structured Service Notes / POC / Tech-Vendor
### How it works
1. **Create** `POST /api/workorders/board`: FluentValidation → resolve `primaryService`/`trade` → serialize `extraServices` JSON → persist POC/service/tech/vendor on `WorkOrder`; if vendor/appt present, create `Dispatch` and store tech/vendor notes there instead of WO.
2. **Patch** `PATCH .../board` with `pm` | `serviceNotes` | `extraServices`.
3. **Read**: board projection prefers WO POC fields over `WorkOrderContacts`; tech phone = Dispatch.TechPhone → WO.TechPhone → Vendor.Phone. Detail exposes `serviceNotes`, `extraServices`, `vendorNotes`.
### Domain invariants
- Extra services require a primary service on create.
- Tech/vendor notes live on dispatch when a vendor dispatch exists; otherwise on WO.
- Board POC free-text overrides linked contact projection.
### Half-done
- No board PATCH for `pocName` / `pocPhone` / `pocNotes` / `techPhone` / `vendorNotes` (create-only for those).
- `ParseExtraServices` duplicated in board + detail services.
- Create validation file has excessive blank lines (noise).
- Test `PatchField_ExtraServicesWithoutPrimary_Throws` disagrees with promotion logic in `ApplyExtraServices`.
### Findings
#### A1 — ExtraServices without primary: test vs promotion
- **Severity:** High
- **File:** `SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs` (~294–298); test `WorkOrderBoardUpdateServiceTests.cs` (~331)
- **Explanation:** Create requires primary when extras exist; PATCH promotes first extra to `Trade` without auditing Trade. Test expects `InvalidValue`.
- **Scenario:** Legacy WO with empty Trade; FE patches `extraServices` only.
- **Repro:** PATCH `extraServices=["Filter change"]` on WO with null Trade.
- **Fix:** Reject when Trade empty (align create + test); do not promote silently.
- **When:** **Now** (blocks green CI / domain consistency).
#### A2 — ExtraServices JSON can exceed nvarchar(2000)
- **Severity:** High
- **File:** Create `SerializeExtraServices`; Update `ApplyExtraServices`
- **Explanation:** Per-item max 128; no check on serialized JSON length vs column 2000.
- **Scenario:** Many extras → `DbUpdateException` 500.
- **Repro:** PATCH/create with ~20×128-char labels.
- **Fix:** After serialize, throw `InvalidValue` if length > 2000.
- **When:** **Now**.
#### A3 — Create does not cap ExtraServices JSON length / per-item in serializer
- **Severity:** Medium
- **File:** `WorkOrderBoardCreateService.SerializeExtraServices`
- **Explanation:** FluentValidation caps each item at 128 but create serializer does not re-validate length or total JSON; update path has item length check, create does not.
- **Fix:** Share normalize helper; enforce item + JSON caps on create.
- **When:** **Now** (with A2).
#### A4 — POC / tech / vendor notes not patchable on board
- **Severity:** Medium
- **File:** `WorkOrderBoardFieldNames.cs` / UpdateService
- **Explanation:** Create persists structured POC/tech/vendor; no PATCH fields → FE cannot edit after create without another API.
- **When:** **Later** if FE has another edit path; otherwise before FE ship.
#### A5 — Dual POC sources can diverge
- **Severity:** Low
- **File:** CreateService + Projection
- **Explanation:** `PocContactId` + free-text both allowed; board prefers free-text; contact row may hold different notes.
- **When:** Later (document or clear contact notes when free-text set).
#### A6 — Duplicate ParseExtraServices
- **Severity:** Low
- **File:** `WorkOrderBoardService.cs`, `WorkOrderDetailService.cs`
- **When:** Later (extract helper).
### Verdict A
**Merge blocked** until A1–A3 fixed. A4 is a product gap for FE edit flows.
---
## Entrega B — Flag Color
### How it works
PATCH `flagColor` → normalize `#RRGGBB` → whitelist palette → persist on WO → project on board/detail. Distinct from dispatcher avatar `color`.
### Half-done
- None material. Migration + model + PATCH + tests present.
### Findings
#### B1 — No finding Critical/High
Palette enforcement and clear semantics are consistent with tests.
#### B2 — Snapshot/model coupled with Entrega A
- **Severity:** Low (process)
- **Explanation:** Same `WorkerOrder` / snapshot file as service notes; branch split needs careful ordering.
- **When:** During organize.
### Verdict B
**OK to merge** after A schema lands (or same branch ordered commits). No code blockers unique to B.
---
## Entrega C — Search / overdue / paging
### How it works
Advanced search: normalize page (≥0), pageSize≤100, resolve Mon–Fri presets, whitelist sortBy, apply type/overdue/unscheduled filters, project board rows. Board GET maps `ArgumentException` → 400.
### Half-done / risks
- Shared `PagedResult` flipped to 0-based helpers while Location/Contact/etc. still use 1-based `page` defaults.
### Findings
#### C1 — PagedResult 0-based breaks 1-based APIs
- **Severity:** Critical
- **File:** `SeaHaven.Services/DTOs/ContactDTOs.cs`
- **Explanation:** `HasPrevious`/`HasNext` assume 0-based; Location etc. pass `page=1` on first page → `HasPrevious=true` incorrectly; last-page `HasNext` wrong for 0-based search too if mixed.
- **Scenario:** Any paged Location/Contact/Vendor list UI using helpers.
- **Fix:** Restore 1-based helpers by default; opt-in 0-based for WO search only (`PageBase` or dedicated DTO).
- **When:** **Now**.
#### C2 — Unscheduled always included in date range
- **Severity:** High
- **File:** `WorkOrderBoardQueryFilters.ApplyDateRangeFilter`
- **Explanation:** Every advanced search with a date range also returns all unscheduled non-terminal WOs → unbounded result growth.
- **Scenario:** Narrow custom range + large backlog of undated WOs.
- **Fix:** Explicit `includeUnscheduled` or cap; confirm FE intent.
- **When:** Confirm with FE; fix before prod if unintentional.
#### C3 — WorkOrderType.Other = overdue sentinel
- **Severity:** High (domain)
- **File:** `WorkOrderBoardQueryFilters.ApplyTypeAndOverdueFilter`
- **Explanation:** Cannot filter real `Other` types via `types=99`.
- **When:** Document now; long-term separate `isPastDue` query param.
#### C4 — Label search `label.Contains(s)` matches short tokens broadly
- **Severity:** Medium
- **File:** `WorkOrderBoardSearchFilter`
- **When:** Later / monitor perf.
#### C5 — Overdue uses UTC date vs possibly local scheduled dates
- **Severity:** Medium
- **File:** QueryFilters / search filter
- **When:** Later if US sites report off-by-one day.
#### C6 — Search helper methods not EF-translatable (FIXED)
- **Severity:** Critical
- **File:** `WorkOrderBoardSearchFilter.Apply`
- **Explanation:** Refactor extracted `Matches*` private methods; EF Core InMemory/SQL cannot translate them → all text searches throw at runtime.
- **Repro:** Any `search` with length ≥ 2 on board/search.
- **Fix:** Inline predicates in the `Where` expression (applied).
- **When:** **Now** — fixed during Fase 2.
### Verdict C
**Merge blocked** on C1 (fixed via `PageBase`) and C6 (fixed via inline predicates). C2 needs product confirmation. Prefer separate PR.
---
## Entrega D — Comments edit + display names
### How it works
`GetDisplayNamesByIdsAsync` → comment/audit projections. PATCH comment: author or Admin; General+WorkOrder only; read-only WO blocked.
### Half-done
- Comment edit not written to audit log.
### Findings
#### D1 — Comment updates leave no audit trail
- **Severity:** Medium
- **File:** `WorkOrderCommentService.UpdateCommentAsync`
- **When:** Later if compliance requires it; note in PR.
#### D2 — No max length on comment text on update
- **Severity:** Low (pre-existing on create)
- **When:** Later.
### Verdict D
**OK to merge** with D1 noted. No Critical/High code defects found.
---
## Entrega E — Sites + Vendor ContactName
### How it works
`GET /api/locations/sites?search=` → Id/Code/City/State (`Code` ← `Locations.Name`). Vendor Dropdown adds `contactName`.
### Findings
#### E1 — GetSites unbounded
- **Severity:** Medium
- **File:** `LocationDataService.GetSiteOptionsAsync`
- **Explanation:** No Take/limit; large Locations table → heavy payload.
- **When:** Later or add Take(500) if volume unknown.
#### E2 — No soft-delete filter
- **Severity:** Low
- **Explanation:** Locations inherit audit entity; if soft-delete used elsewhere, sites may include deleted.
- **When:** Later if soft-delete applies to Locations.
### Verdict E
**OK to merge** independently.
---
## Entrega F1 — Phase4 migration hardening
### How it works
Before creating indexes, alter `SiteCode`/`InternalWONumber` to nvarchar(450).
### Findings
#### F1-1 — Editing already-shipped migration
- **Severity:** High (ops)
- **File:** `20260624200000_Phase4_SearchIndexes.cs`
- **Explanation:** Environments that already applied old `Up` will not re-run. Failed partial applies need manual repair or a **new** migration.
- **When:** Verify `__EFMigrationsHistory` per environment **before** merge. Prefer new corrective migration if any env already applied.
### Verdict F1
**Conditional merge** after env check. Apply on `feat/work-orders-phase-4` only.
---
## Entrega F2 — Phase6 migration hardening
### How it works
Idempotent SQL for Category column, CompletionDocTemplates, index; EF attributes for discovery.
### Findings
#### F2-1 — Same applied-migration caveat as F1
- **Severity:** High (ops)
- **When:** Verify env history; idempotent SQL helps partial DBs if migration re-run manually, not if EF skips applied migrations.
### Verdict F2
**Conditional merge** on slideover branch after env check.
---
## Cross-cutting mandatory fixes before organize
1. A1 — ExtraServices without primary throws
2. A2/A3 — JSON length ≤ 2000
3. C1 — PagedResult 1-based default + WO search 0-based opt-in