diff --git a/Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs b/Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs
new file mode 100644
index 0000000..3405792
--- /dev/null
+++ b/Data.SeaHavenIndustries/Migrations/20260813193000_SH121_BoardSearchFacets.cs
@@ -0,0 +1,25 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Data.SeaHavenIndustries.Migrations
+{
+ ///
+ ///
+ /// SH-121 originally added IsAddOn for search facets. Column + legacy backfill
+ /// now live in 20260730150000_WoIsAddOn (SH-184). This migration is a no-op so
+ /// both PRs can land without a duplicate column.
+ ///
+ public partial class SH121_BoardSearchFacets : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ }
+ }
+}
diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs
index 3ce4cc9..b2d1989 100644
--- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs
+++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs
@@ -80,7 +80,7 @@ namespace Data.SeaHavenIndustries
// --- Analytics slice ---
public int RescheduleCount { get; set; }
public int CarriedOver { get; set; }
- /// Recalculated on schedule set/change/clear (SH-184); create hint when unscheduled.
+ /// Recalculated on schedule set/change/clear (SH-184); create hint when unscheduled. Board/search facet.
public bool IsAddOn { get; set; }
public OperationalFlags OperationalFlags { get; set; }
diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs
new file mode 100644
index 0000000..08b2c2d
--- /dev/null
+++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFacetFilters.cs
@@ -0,0 +1,166 @@
+using Data.SeaHavenIndustries;
+using Data.SeaHavenIndustries.Enums;
+using Microsoft.EntityFrameworkCore;
+
+namespace SeaHaven.DataServices.Helpers
+{
+ ///
+ /// SH-121/SH-196 advanced search facets (severity, indicators, flag, internal WO#, uplift).
+ ///
+ public static class WorkOrderBoardSearchFacetFilters
+ {
+ private static readonly WorkOrderType[] SeverityEligibleTypes =
+ {
+ WorkOrderType.Emergency,
+ WorkOrderType.Reactive,
+ };
+
+ private static readonly string[] RevokedUpliftStatuses =
+ {
+ "Withdrawn",
+ "Cancelled",
+ };
+
+ public static IQueryable ApplySeverityFilter(
+ IQueryable query,
+ IReadOnlyList? severities)
+ {
+ if (severities == null || severities.Count == 0)
+ return query;
+
+ var allowed = severities
+ .Where(s => s is >= 1 and <= 5)
+ .Select(s => s.ToString())
+ .Distinct()
+ .ToList();
+
+ if (allowed.Count == 0)
+ return query;
+
+ return query.Where(w =>
+ w.WorkOrderType != null
+ && SeverityEligibleTypes.Contains(w.WorkOrderType.Value)
+ && w.Severity != null
+ && allowed.Contains(w.Severity));
+ }
+
+ public static IQueryable ApplyRescheduledFilter(
+ IQueryable query,
+ bool rescheduled)
+ {
+ if (!rescheduled)
+ return query;
+
+ return query.Where(w => w.RescheduleCount >= 2);
+ }
+
+ public static IQueryable ApplyCarriedOverFilter(
+ IQueryable query,
+ bool carriedOver)
+ {
+ if (!carriedOver)
+ return query;
+
+ return query.Where(w => w.CarriedOver >= 2);
+ }
+
+ public static IQueryable ApplyAddOnFilter(
+ IQueryable query,
+ bool addOn)
+ {
+ if (!addOn)
+ return query;
+
+ return query.Where(w => w.IsAddOn);
+ }
+
+ public static IQueryable ApplyFlagColorFilter(
+ IQueryable query,
+ IReadOnlyList? flagColors)
+ {
+ if (flagColors == null || flagColors.Count == 0)
+ return query;
+
+ var normalized = flagColors
+ .Where(c => !string.IsNullOrWhiteSpace(c))
+ .Select(c => c.Trim().ToUpperInvariant())
+ .Distinct()
+ .ToList();
+
+ if (normalized.Count == 0)
+ return query;
+
+ return query.Where(w =>
+ w.FlagColor != null && normalized.Contains(w.FlagColor.ToUpper()));
+ }
+
+ public static IQueryable ApplyInternalOnlyFilter(
+ IQueryable query,
+ bool internalOnly)
+ {
+ if (!internalOnly)
+ return query;
+
+ return query.Where(w =>
+ w.InternalWONumber != null
+ && EF.Functions.Like(w.InternalWONumber, "SH%"));
+ }
+
+ public static IQueryable ApplyUpliftFilter(
+ IQueryable query,
+ ApplicationDbContext context,
+ bool hasUplift,
+ IReadOnlyList? upliftStatuses)
+ {
+ if (!hasUplift)
+ return query;
+
+ var mappedStatuses = MapUpliftStatuses(upliftStatuses);
+
+ return query.Where(w => context.DispatchUpliftRequests.Any(u =>
+ (u.IsDeleted == null || u.IsDeleted == false)
+ && u.Status != "Expired"
+ && !RevokedUpliftStatuses.Contains(u.Status)
+ && u.Dispatch != null
+ && (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
+ && (
+ u.Dispatch.WorkOrderId == w.Id
+ || u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))
+ && (mappedStatuses.Count == 0 || mappedStatuses.Contains(u.Status))));
+ }
+
+ internal static List MapUpliftStatuses(IReadOnlyList? upliftStatuses)
+ {
+ if (upliftStatuses == null || upliftStatuses.Count == 0)
+ return new List();
+
+ var mapped = new List();
+ foreach (var raw in upliftStatuses)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ continue;
+
+ switch (raw.Trim().ToLowerInvariant())
+ {
+ case "pending":
+ mapped.Add("Pending");
+ mapped.Add("ChangesRequested");
+ break;
+ case "approved":
+ mapped.Add("Approved");
+ break;
+ case "auto_approved":
+ mapped.Add("NoApprovalRequired");
+ break;
+ case "rejected":
+ mapped.Add("Rejected");
+ mapped.Add("Denied");
+ break;
+ }
+ }
+
+ return mapped.Distinct(StringComparer.Ordinal).ToList();
+ }
+ }
+
+}
diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs
index de1f4d2..1d4892d 100644
--- a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs
+++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs
@@ -28,6 +28,14 @@ namespace SeaHaven.DataServices.Implementation
baseQuery = WorkOrderBoardQueryFilters.ApplyPmTypeFilter(baseQuery, query.PmTypes);
baseQuery = WorkOrderBoardQueryFilters.ApplyVendorFilter(baseQuery, query.VendorIds);
baseQuery = WorkOrderBoardQueryFilters.ApplyDocStatusFilter(baseQuery, query.DocStatuses);
+ baseQuery = WorkOrderBoardSearchFacetFilters.ApplySeverityFilter(baseQuery, query.Severities);
+ baseQuery = WorkOrderBoardSearchFacetFilters.ApplyRescheduledFilter(baseQuery, query.Rescheduled);
+ baseQuery = WorkOrderBoardSearchFacetFilters.ApplyCarriedOverFilter(baseQuery, query.CarriedOver);
+ baseQuery = WorkOrderBoardSearchFacetFilters.ApplyAddOnFilter(baseQuery, query.AddOn);
+ baseQuery = WorkOrderBoardSearchFacetFilters.ApplyFlagColorFilter(baseQuery, query.FlagColors);
+ baseQuery = WorkOrderBoardSearchFacetFilters.ApplyInternalOnlyFilter(baseQuery, query.InternalOnly);
+ baseQuery = WorkOrderBoardSearchFacetFilters.ApplyUpliftFilter(
+ baseQuery, _context, query.HasUplift, query.UpliftStatuses);
var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search);
if (normalizedSearch != null)
diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs
index d1ef77f..b965e86 100644
--- a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs
+++ b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs
@@ -15,6 +15,14 @@ namespace SeaHaven.DataServices.Interfaces
IReadOnlyList? PmTypes,
IReadOnlyList? VendorIds,
IReadOnlyList? DocStatuses,
+ IReadOnlyList? Severities,
+ bool Rescheduled,
+ bool CarriedOver,
+ bool AddOn,
+ IReadOnlyList? FlagColors,
+ bool InternalOnly,
+ bool HasUplift,
+ IReadOnlyList? UpliftStatuses,
bool MyWorkOrders,
string? CurrentUserId,
int Page,
diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
index 5f1bbb9..0f7d976 100644
--- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
+++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
@@ -68,6 +68,14 @@ namespace SeaHaven.Services.DTOs
public List? PmTypes { get; set; }
public List? VendorIds { get; set; }
public List? DocStatuses { get; set; }
+ public List? Severities { get; set; }
+ public bool Rescheduled { get; set; }
+ public bool CarriedOver { get; set; }
+ public bool AddOn { get; set; }
+ public List? FlagColors { get; set; }
+ public bool InternalOnly { get; set; }
+ public bool HasUplift { get; set; }
+ public List? UpliftStatuses { get; set; }
public bool MyWorkOrders { get; set; }
public string? SortBy { get; set; }
public string? SortDir { get; set; }
diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs
index a4d332d..f1e79cf 100644
--- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs
@@ -51,6 +51,14 @@ namespace SeaHaven.Services.Implementation
query.PmTypes,
query.VendorIds,
query.DocStatuses,
+ query.Severities,
+ query.Rescheduled,
+ query.CarriedOver,
+ query.AddOn,
+ query.FlagColors,
+ query.InternalOnly,
+ query.HasUplift,
+ query.UpliftStatuses,
query.MyWorkOrders,
currentUserId,
page,
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs
index 79b6f7c..04aa6c0 100644
--- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs
@@ -669,4 +669,164 @@ public class WorkOrderAdvancedSearchServiceTests
Assert.Contains(result.Items, i => i.Id == 2 && i.IsAddOn && i.WorkOrderType == WorkOrderType.PM);
Assert.DoesNotContain(result.Items, i => i.Id == 3);
}
+
+ [Fact]
+ public async Task SearchAsync_SeverityFilter_ReturnsEmergencyReactiveOnly()
+ {
+ await using var context = CreateContext();
+ var weekStart = new DateOnly(2026, 6, 22);
+
+ context.workOrders.AddRange(
+ new WorkOrder
+ {
+ Id = 1,
+ WorkOrderType = WorkOrderType.Emergency,
+ Severity = "1",
+ ScheduledDate = new DateTime(2026, 6, 23),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ InternalWONumber = "10000000001",
+ },
+ new WorkOrder
+ {
+ Id = 2,
+ WorkOrderType = WorkOrderType.Reactive,
+ Severity = "2",
+ ScheduledDate = new DateTime(2026, 6, 24),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ InternalWONumber = "10000000002",
+ },
+ new WorkOrder
+ {
+ Id = 3,
+ WorkOrderType = WorkOrderType.PM,
+ Severity = "1",
+ ScheduledDate = new DateTime(2026, 6, 25),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ InternalWONumber = "10000000003",
+ });
+
+ await context.SaveChangesAsync();
+
+ var dataService = new WorkOrderAdvancedSearchDataService(context);
+ var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
+ dataService,
+ WorkOrderAccountTestHelpers.Resolver(context));
+
+ var result = await service.SearchAsync(
+ new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
+ {
+ DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
+ DateFrom = weekStart,
+ DateTo = weekStart.AddDays(6),
+ Severities = new List { 1 },
+ },
+ WorkOrderAccountTestHelpers.OrgWideAdmin(),
+ null);
+
+ Assert.Equal(1, result.TotalCount);
+ Assert.Equal(1, result.Items.Single().Id);
+ }
+
+ [Fact]
+ public async Task SearchAsync_InternalOnly_ReturnsShPrefixRows()
+ {
+ await using var context = CreateContext();
+ var weekStart = new DateOnly(2026, 6, 22);
+
+ context.workOrders.AddRange(
+ new WorkOrder
+ {
+ Id = 1,
+ InternalWONumber = "SH-1001",
+ ScheduledDate = new DateTime(2026, 6, 23),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ },
+ new WorkOrder
+ {
+ Id = 2,
+ InternalWONumber = "10000000002",
+ ScheduledDate = new DateTime(2026, 6, 24),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ });
+
+ await context.SaveChangesAsync();
+
+ var dataService = new WorkOrderAdvancedSearchDataService(context);
+ var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
+ dataService,
+ WorkOrderAccountTestHelpers.Resolver(context));
+
+ var result = await service.SearchAsync(
+ new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
+ {
+ DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
+ DateFrom = weekStart,
+ DateTo = weekStart.AddDays(6),
+ InternalOnly = true,
+ },
+ WorkOrderAccountTestHelpers.OrgWideAdmin(),
+ null);
+
+ Assert.Equal(1, result.TotalCount);
+ Assert.Equal("SH-1001", result.Items.Single().WoNumber);
+ }
+
+ [Fact]
+ public async Task SearchAsync_HasUpliftWithPendingStatus_FiltersRows()
+ {
+ await using var context = CreateContext();
+ var weekStart = new DateOnly(2026, 6, 22);
+
+ context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
+ context.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1, WorkOrderId = 1 });
+ context.Dispatches.Add(new Dispatch { Id = 11, VendorId = 1, WorkOrderId = 2 });
+ context.workOrders.AddRange(
+ new WorkOrder
+ {
+ Id = 1,
+ InternalWONumber = "10000000001",
+ ScheduledDate = new DateTime(2026, 6, 23),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ PrimaryDispatchId = 10,
+ },
+ new WorkOrder
+ {
+ Id = 2,
+ InternalWONumber = "10000000002",
+ ScheduledDate = new DateTime(2026, 6, 24),
+ LifecycleStatus = LifecycleStatus.Scheduled,
+ PrimaryDispatchId = 11,
+ });
+ context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
+ {
+ Id = 100,
+ DispatchId = 10,
+ RequestedNTE = 1500m,
+ Status = "Pending",
+ RequiredTier = 1,
+ NotificationStatus = "Pending",
+ });
+
+ await context.SaveChangesAsync();
+
+ var dataService = new WorkOrderAdvancedSearchDataService(context);
+ var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
+ dataService,
+ WorkOrderAccountTestHelpers.Resolver(context));
+
+ var result = await service.SearchAsync(
+ new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
+ {
+ DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
+ DateFrom = weekStart,
+ DateTo = weekStart.AddDays(6),
+ HasUplift = true,
+ UpliftStatuses = new List { "pending" },
+ },
+ WorkOrderAccountTestHelpers.OrgWideAdmin(),
+ null);
+
+ Assert.Equal(1, result.TotalCount);
+ Assert.Equal(1, result.Items.Single().Id);
+ }
}