diff --git a/Data.SeaHavenIndustries/Migrations/20260715160000_WoServiceNotesStructuredFields.cs b/Data.SeaHavenIndustries/Migrations/20260715160000_WoServiceNotesStructuredFields.cs
new file mode 100644
index 0000000..e49532f
--- /dev/null
+++ b/Data.SeaHavenIndustries/Migrations/20260715160000_WoServiceNotesStructuredFields.cs
@@ -0,0 +1,121 @@
+using Data.SeaHavenIndustries;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Data.SeaHavenIndustries.Migrations
+{
+ ///
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260715160000_WoServiceNotesStructuredFields")]
+ public partial class WoServiceNotesStructuredFields : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "PocName",
+ table: "workOrders",
+ type: "nvarchar(200)",
+ maxLength: 200,
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "PocPhone",
+ table: "workOrders",
+ type: "nvarchar(50)",
+ maxLength: 50,
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "PocNotes",
+ table: "workOrders",
+ type: "nvarchar(2000)",
+ maxLength: 2000,
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "ExtraServices",
+ table: "workOrders",
+ type: "nvarchar(2000)",
+ maxLength: 2000,
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "ServiceNotes",
+ table: "workOrders",
+ type: "nvarchar(4000)",
+ maxLength: 4000,
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "TechPhone",
+ table: "workOrders",
+ type: "nvarchar(50)",
+ maxLength: 50,
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "VendorNotes",
+ table: "workOrders",
+ type: "nvarchar(2000)",
+ maxLength: 2000,
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "TechPhone",
+ table: "Dispatches",
+ type: "nvarchar(50)",
+ maxLength: 50,
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "VendorNotes",
+ table: "Dispatches",
+ type: "nvarchar(2000)",
+ maxLength: 2000,
+ nullable: true);
+ }
+
+ ///
+ 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");
+ }
+ }
+}
diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs
index d97ab9b..608754d 100644
--- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -739,9 +739,17 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property("Status")
.HasColumnType("nvarchar(max)");
+ b.Property("TechPhone")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
b.Property("VendorId")
.HasColumnType("int");
+ b.Property("VendorNotes")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
b.Property("VerifiedAt")
.HasColumnType("datetime2");
@@ -1963,6 +1971,14 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(450)
.HasColumnType("nvarchar(450)");
+ b.Property("ExtraServices")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("FlagColor")
+ .HasMaxLength(7)
+ .HasColumnType("nvarchar(7)");
+
b.Property("InternalWONumber")
.HasColumnType("nvarchar(max)");
@@ -1996,6 +2012,18 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property("PO")
.HasColumnType("nvarchar(max)");
+ b.Property("PocName")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("PocNotes")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("PocPhone")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
b.Property("PrimaryDispatchId")
.HasColumnType("int");
@@ -2029,6 +2057,10 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
+ b.Property("ServiceNotes")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
b.Property("Severity")
.HasColumnType("nvarchar(max)");
@@ -2062,12 +2094,20 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property("TargetWeek")
.HasColumnType("date");
+ b.Property("TechPhone")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
b.Property("Trade")
.HasColumnType("nvarchar(max)");
b.Property("VendorNTE")
.HasColumnType("decimal(18,2)");
+ b.Property("VendorNotes")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
b.Property("WorkOrderType")
.HasColumnType("int");
diff --git a/Data.SeaHavenIndustries/Models/Dispatch.cs b/Data.SeaHavenIndustries/Models/Dispatch.cs
index 831cc17..7fe14f3 100644
--- a/Data.SeaHavenIndustries/Models/Dispatch.cs
+++ b/Data.SeaHavenIndustries/Models/Dispatch.cs
@@ -28,6 +28,13 @@ namespace Data.SeaHavenIndustries
public string? VerifiedBy { get; set; }
public DateTime? VerifiedAt { get; set; }
+ /// Tech/crew phone for this dispatch (overrides Vendor.Phone on board).
+ [MaxLength(50)]
+ public string? TechPhone { get; set; }
+ /// Instructions for the vendor for this appointment/dispatch.
+ [MaxLength(2000)]
+ public string? VendorNotes { get; set; }
+
[Timestamp]
public byte[]? RowVersion { get; set; }
diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs
index 18e51bf..052c2d3 100644
--- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs
+++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs
@@ -32,6 +32,25 @@ namespace Data.SeaHavenIndustries
[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; }
+ /// JSON array of extra service labels beyond primary (Trade).
+ [MaxLength(2000)]
+ public string? ExtraServices { get; set; }
+ [MaxLength(4000)]
+ public string? ServiceNotes { get; set; }
+ /// Tech/crew phone when no primary dispatch; otherwise prefer Dispatch.TechPhone.
+ [MaxLength(50)]
+ public string? TechPhone { get; set; }
+ /// Vendor instructions when no primary dispatch; otherwise prefer Dispatch.VendorNotes.
+ [MaxLength(2000)]
+ public string? VendorNotes { get; set; }
+
// --- Scheduling slice ---
public DateTime? ScheduledDate { get; set; }
public DateTime? ScheduledStart { get; set; }
@@ -51,6 +70,10 @@ namespace Data.SeaHavenIndustries
// --- Completion slice ---
public DocStatus? DocStatus { get; set; }
+ /// Board flag color (#RRGGBB from fixed palette). Null = no flag.
+ [MaxLength(7)]
+ public string? FlagColor { get; set; }
+
// --- Legacy SQL columns (drift reconciliation) ---
public string? AvettaTask { get; set; }
public DateOnly? AssignDate { get; set; }
diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
index e2ad8d7..5520791 100644
--- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
+++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
@@ -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();
}
}
}
diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs
index 8ea9358..e2526ab 100644
--- a/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs
+++ b/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs
@@ -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();
}
diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
index 0173d07..c46ff55 100644
--- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
+++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
@@ -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,
diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs
index cab956b..433b080 100644
--- a/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs
+++ b/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs
@@ -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,
diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
index b35c303..f0b13f9 100644
--- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
+++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
@@ -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? ExtraServices { get; set; }
public DocStatus? DocStatus { get; set; }
+ /// Board flag color (#RRGGBB). Null = no flag. Distinct from Color (dispatcher avatar).
+ public string? FlagColor { get; set; }
public int? PrimaryDispatchId { get; set; }
public byte[]? RowVersion { get; set; }
public byte[]? DispatchRowVersion { get; set; }
diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
index 31fae59..b78be76 100644
--- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
+++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
@@ -3,14 +3,25 @@ using SeaHaven.Services.Helpers;
namespace SeaHaven.Services.DTOs
{
- /// Payload for creating a board work order (SHOC create wizard).
+ ///
+ /// 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.
+ ///
public class WorkOrderBoardCreateRequestDto
{
public string? WoNumber { get; set; }
public WorkOrderType WorkOrderType { get; set; }
public string? SiteCode { get; set; }
public string? Description { get; set; }
+ /// Legacy alias for primary service; prefer .
public string? Trade { get; set; }
+ /// Primary service label; persisted as Trade / board pm.
+ public string? PrimaryService { get; set; }
+ public List? 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; }
}
/// Advanced search / filter query for the board.
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; }
diff --git a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs
index 7ac1437..105e112 100644
--- a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs
+++ b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs
@@ -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; }
diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs
index cf6e4e8..a76a7fe 100644
--- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs
+++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs
@@ -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 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
};
}
diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
index 77bdb27..7f75baa 100644
--- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
@@ -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
@@ -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? extras)
+ {
+ if (extras == null || extras.Count == 0)
+ return null;
+
+ var normalized = new List();
+ var seen = new HashSet(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 ResolveWoNumberAsync(string? requested)
{
if (!string.IsNullOrWhiteSpace(requested))
diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs
index 0a82f76..56c576e 100644
--- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs
@@ -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? ParseExtraServices(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return null;
+
+ try
+ {
+ var list = System.Text.Json.JsonSerializer.Deserialize>(json);
+ return list is { Count: > 0 } ? list : null;
+ }
+ catch (System.Text.Json.JsonException)
+ {
+ return null;
+ }
+ }
}
}
diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
index 98239bb..c924009 100644
--- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
@@ -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 { ApplyApptDate(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptTime => new List { ApplyApptTime(workOrder, dispatch!, value, auditField) },
WorkOrderBoardFieldNames.DocStatus => new List { ApplyDocStatus(workOrder, value, auditField) },
+ WorkOrderBoardFieldNames.Pm => new List { ApplyStringField(value, auditField, v => workOrder.Trade = v, () => workOrder.Trade) },
+ WorkOrderBoardFieldNames.ServiceNotes => new List { ApplyServiceNotes(workOrder, value, auditField) },
+ WorkOrderBoardFieldNames.ExtraServices => new List { ApplyExtraServices(workOrder, value, auditField) },
+ WorkOrderBoardFieldNames.FlagColor => new List { 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? parsed;
+ try
+ {
+ parsed = JsonSerializer.Deserialize>(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 NormalizeExtraServiceList(List? extras)
+ {
+ var normalized = new List();
+ if (extras == null || extras.Count == 0)
+ return normalized;
+
+ var seen = new HashSet(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 ApplyAutoScheduleSideEffects(WorkOrder workOrder)
{
var changes = new List();
diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs
index 34aa8ac..849c5f6 100644
--- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs
@@ -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 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()
: 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> MapAuditAsync(IReadOnlyList 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? ParseExtraServices(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return null;
+
+ try
+ {
+ var list = System.Text.Json.JsonSerializer.Deserialize>(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,
diff --git a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs
index 3b00bed..8d0564d 100644
--- a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs
+++ b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs
@@ -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);
}
}
}
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs
index 6910f7a..b3171f2 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs
@@ -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 { "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>(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(() =>
+ service.CreateAsync(new WorkOrderBoardCreateRequestDto
+ {
+ WorkOrderType = WorkOrderType.PM,
+ SiteCode = "BK5",
+ ExtraServices = new List { "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(() =>
+ service.CreateAsync(new WorkOrderBoardCreateRequestDto
+ {
+ WorkOrderType = WorkOrderType.PM,
+ SiteCode = "BK5",
+ VendorId = 999
+ }, "actor-1"));
+
+ Assert.Equal("VendorNotFound", ex.Code);
+ }
}
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
index 59b180c..ecaba65 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
@@ -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(() =>
+ 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(() =>
+ 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(() =>
+ 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(() =>
+ 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);
+ }
}