diff --git a/Data.SeaHavenIndustries/Migrations/20260715180000_WoFlagColor.cs b/Data.SeaHavenIndustries/Migrations/20260715180000_WoFlagColor.cs
new file mode 100644
index 0000000..e48600e
--- /dev/null
+++ b/Data.SeaHavenIndustries/Migrations/20260715180000_WoFlagColor.cs
@@ -0,0 +1,33 @@
+using Data.SeaHavenIndustries;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Data.SeaHavenIndustries.Migrations
+{
+ ///
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260715180000_WoFlagColor")]
+ public partial class WoFlagColor : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "FlagColor",
+ table: "workOrders",
+ type: "nvarchar(7)",
+ maxLength: 7,
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "FlagColor",
+ table: "workOrders");
+ }
+ }
+}
diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs
index 3da8116..608754d 100644
--- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -1975,6 +1975,10 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
+ b.Property("FlagColor")
+ .HasMaxLength(7)
+ .HasColumnType("nvarchar(7)");
+
b.Property("InternalWONumber")
.HasColumnType("nvarchar(max)");
diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs
index cb30f29..052c2d3 100644
--- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs
+++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs
@@ -70,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 71c417a..5520791 100644
--- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
+++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
@@ -66,6 +66,7 @@ namespace SeaHaven.DataServices.Helpers
w.ServiceNotes,
w.ExtraServices,
w.DocStatus,
+ w.FlagColor,
w.PrimaryDispatchId,
w.RowVersion,
DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null
@@ -115,6 +116,7 @@ namespace SeaHaven.DataServices.Helpers
w.ServiceNotes,
w.ExtraServices,
w.DocStatus,
+ w.FlagColor,
w.PrimaryDispatchId,
w.RowVersion,
w.DispatchRowVersion,
diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
index ce96697..c46ff55 100644
--- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
+++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
@@ -45,6 +45,7 @@ namespace SeaHaven.DataServices.Interfaces
string? ServiceNotes,
string? ExtraServices,
DocStatus? DocStatus,
+ string? FlagColor,
int? PrimaryDispatchId,
byte[]? RowVersion,
byte[]? DispatchRowVersion,
diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
index 1f1b549..f0b13f9 100644
--- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
+++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
@@ -54,6 +54,8 @@ namespace SeaHaven.Services.DTOs
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/Helpers/WorkOrderBoardFieldNames.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs
index 233090e..a76a7fe 100644
--- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs
+++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs
@@ -18,6 +18,7 @@ namespace SeaHaven.Services.Helpers
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)
@@ -44,7 +45,8 @@ namespace SeaHaven.Services.Helpers
DocStatus,
Pm,
ServiceNotes,
- ExtraServices
+ ExtraServices,
+ FlagColor
};
public static bool IsEditable(string field) => Canonicalize(field) != null;
@@ -84,6 +86,7 @@ namespace SeaHaven.Services.Helpers
Pm => "Trade",
ServiceNotes => "ServiceNotes",
ExtraServices => "ExtraServices",
+ FlagColor => "FlagColor",
_ => canonical
};
}
diff --git a/SeaHaven.Services/Helpers/WorkOrderFlagColors.cs b/SeaHaven.Services/Helpers/WorkOrderFlagColors.cs
new file mode 100644
index 0000000..6892bd8
--- /dev/null
+++ b/SeaHaven.Services/Helpers/WorkOrderFlagColors.cs
@@ -0,0 +1,50 @@
+namespace SeaHaven.Services.Helpers
+{
+ ///
+ /// Fixed board flag palette (must match FE FLAG_COLORS). Values stored as canonical #RRGGBB.
+ ///
+ public static class WorkOrderFlagColors
+ {
+ public static readonly HashSet Allowed = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "#EF4444",
+ "#F97316",
+ "#F59E0B",
+ "#EAB308",
+ "#22C55E",
+ "#14B8A6",
+ "#3B82F6",
+ "#8B5CF6"
+ };
+
+ ///
+ /// Normalizes to # + 6 uppercase hex digits, or null if empty.
+ /// Returns false if the value is non-empty but not a valid hex color.
+ ///
+ 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);
+ }
+}
diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs
index 076f090..56c576e 100644
--- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs
@@ -118,6 +118,7 @@ namespace SeaHaven.Services.Implementation
ServiceNotes = row.ServiceNotes,
ExtraServices = ParseExtraServices(row.ExtraServices),
DocStatus = row.DocStatus,
+ FlagColor = row.FlagColor,
PrimaryDispatchId = row.PrimaryDispatchId,
RowVersion = row.RowVersion,
DispatchRowVersion = row.DispatchRowVersion
diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
index 90b57fb..c924009 100644
--- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
@@ -161,6 +161,7 @@ namespace SeaHaven.Services.Implementation
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.")
};
}
@@ -548,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 0f7b5d0..849c5f6 100644
--- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs
@@ -114,6 +114,7 @@ namespace SeaHaven.Services.Implementation
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,
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
index 94ba33d..ecaba65 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
@@ -350,4 +350,157 @@ public class WorkOrderBoardUpdateServiceTests
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);
+ }
}